Compare commits
106 Commits
f9f6f8f747
...
2d98e1a6d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d98e1a6d5 | |||
| 73ffcbef09 | |||
| 6a4701f052 | |||
| 075f2a0cab | |||
| 2d45e11120 | |||
| ea2ad4506c | |||
| d9f63acbe2 | |||
| 5ebb86e2a8 | |||
| 4d8f63e9b2 | |||
| dbdce3b661 | |||
| 160b595e2f | |||
| 1bb7ca68da | |||
| 7c2f11ef06 | |||
| cd8d574ff8 | |||
| 196f0ebb07 | |||
| cc88785e7c | |||
| 913f19c360 | |||
| d3b2ede09d | |||
| 8167b3e70b | |||
| f6bd33bbcf | |||
| 4375ac04ed | |||
| d14a49d629 | |||
| 35e063b17b | |||
| 931b5f42e7 | |||
| 8b2f2d6489 | |||
| 740399ee6f | |||
| 28433dc544 | |||
| 545fdce70d | |||
| 20184ad35a | |||
| b11a0de52e | |||
| 869b7183ab | |||
| f4ee23c10d | |||
| 7db006544c | |||
| c040cafc93 | |||
| ed73a845e8 | |||
| be8f3d9300 | |||
| dffd90b1b8 | |||
| 879cea84c4 | |||
| 2970c2dcfe | |||
| 9f571a5c02 | |||
| 4081a1c1ed | |||
| 573c9a356f | |||
| 818e0493b2 | |||
| 9aa9e3c709 | |||
| 977c68aee8 | |||
| 44cfaca931 | |||
| 64689afdc9 | |||
| bcc5c9a7b9 | |||
| dd471eb5b5 | |||
| a383bc9d13 | |||
| 9d8e939704 | |||
| ccc8b77cf5 | |||
| 1f26fbc421 | |||
| 64f4f19ae9 | |||
| 60f8a55bb9 | |||
| babaee3608 | |||
| 503c56a19e | |||
| 7641365e37 | |||
| 7c2f6d9fd1 | |||
| e84eb1a35e | |||
| a651e5ca7f | |||
| 08ce71c0a2 | |||
| e924e697a2 | |||
| e5f19d6fef | |||
| 4c411e446d | |||
| 1e7c258631 | |||
| 277f1a6c80 | |||
| e20f64ff1f | |||
| b172c776a8 | |||
| f5e8f152ad | |||
| e37b6af984 | |||
| f3f1f14fb1 | |||
| 92cbb52b73 | |||
| 4936180e45 | |||
| d656efe0dc | |||
| 0b43d45865 | |||
| 304a77619e | |||
| 50ae5b5c6f | |||
| b8decaadc5 | |||
| 9affdd86a4 | |||
| 75ed8a7f13 | |||
| b8f5cee0b8 | |||
| 72232731d1 | |||
| ceb063dfdc | |||
| 4f10fcc91a | |||
| 7b3eb62925 | |||
| 8afff106da | |||
| b2eded808e | |||
| e65f970e9a | |||
| ab63ef532d | |||
| fb71c444f6 | |||
| 8d4ffb6550 | |||
| 263fecc19a | |||
| 5898162b7d | |||
| 906b467510 | |||
| 1572ddda19 | |||
| 74803a3355 | |||
| cd32491bbb | |||
| 729afb3da7 | |||
| dcd1ec9ed5 | |||
| 8593a35a59 | |||
| 92243fccb6 | |||
| b0a2a7014b | |||
| 6a0a3e159e | |||
| fdd219660b | |||
| e2dc8f90fb |
+17
@@ -0,0 +1,17 @@
|
||||
.dryforge/
|
||||
Library
|
||||
Temp
|
||||
Obj
|
||||
obj
|
||||
Logs
|
||||
UserSettings
|
||||
Build
|
||||
Builds
|
||||
artifacts/
|
||||
.utmp
|
||||
.vs/
|
||||
*.csproj
|
||||
*.sln
|
||||
*.slnx
|
||||
*.user
|
||||
__pycache__
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"components": [
|
||||
"Microsoft.VisualStudio.Workload.ManagedGame"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# MagnetismHeroes
|
||||
|
||||
5~7세가 구 자석을 직접 모으고 세고 합체·분리하는 모바일 3D 카툰 퍼즐이다.
|
||||
현재 한국어·Android·가로 화면·오프라인 체험판이며 30개 스테이지와 성공 후 자유 놀이를 제공한다.
|
||||
Unity 6의 URP·Input System·uGUI·TMP를 사용하고 수학 규칙은 순수 C#으로 분리한다.
|
||||
|
||||
## 프로젝트 안내
|
||||
|
||||
```text
|
||||
MagnetismHeroes/
|
||||
├── AGENTS.md → 작업 시작과 핵심 경계
|
||||
├── CLAUDE.md → 동일한 작업 시작 지침
|
||||
├── docs/
|
||||
│ ├── architecture.md → 구성과 데이터 흐름
|
||||
│ ├── business-rules.md → 학습·조작·진행의 판정
|
||||
│ ├── security.md → 로컬 데이터·제작 자산 보호
|
||||
│ ├── standards.md → 변경·참조·검증 기준
|
||||
│ ├── engineering-notes.md → 실제 실패 원인과 대응
|
||||
│ ├── operations.md → 에디터 실행·빌드·제작 절차
|
||||
│ ├── contracts.md → JSON·명령행 입출력 계약
|
||||
│ └── tracking/
|
||||
│ ├── status.md → 구현·검증과 후속 범위
|
||||
│ ├── findings.md → 해결되지 않은 재현 문제
|
||||
│ └── decisions/
|
||||
│ ├── index.md → 설계 결정 목록
|
||||
│ ├── 0001-rules-boundary.md → 규칙과 Unity 분리
|
||||
│ ├── 0002-stage-authoring.md → 고정 스테이지 제작
|
||||
│ ├── 0003-local-progress.md → 로컬 이어하기
|
||||
│ ├── 0004-prebaked-voice.md → 파일 기반 학습 음성
|
||||
│ └── 0005-hybrid-assets.md → 혼합 자산 제작
|
||||
├── Assets/MagnetismHeroes/
|
||||
│ ├── Core/AGENTS.md → 순수 규칙·진행·저장 검증
|
||||
│ ├── UnityRuntime/AGENTS.md → 입력·표현·파일 연결
|
||||
│ └── Editor/AGENTS.md → 자산 제작·Android 빌드
|
||||
├── SourceAssets/AGENTS.md → 원본·출처·채택 이력
|
||||
└── tools/
|
||||
├── AGENTS.md → 검증 진입점
|
||||
├── android/AGENTS.md → 실제 앱 입력·저장·PCM 검사
|
||||
├── audio/AGENTS.md → 로컬 음성 생성·후보 내보내기
|
||||
└── models/AGENTS.md → Blender 소품 제작
|
||||
```
|
||||
|
||||
## 핵심 경계
|
||||
|
||||
- 합체·분리·이동으로 구의 ID와 총량을 잃거나 복제하지 않는다. 숫자와 실제 소속 구 수를 일치시킨다.
|
||||
- 모양·위치·풀이 순서를 새로운 정답 조건으로 만들지 않는다. 선택한 구가 떨어져 있어도 두 친구로 나눈다.
|
||||
- 완료 기록·퍼즐별 미완료 상태·최근 자유 놀이를 혼합하지 않는다. 성공의 기록 변경은 한 단위로 확정한다.
|
||||
- Unity 입력·연출이 순수 수학 규칙을 소유하지 않는다. 미리보기·애니메이션 중간값을 확정 상태로 저장하지 않는다.
|
||||
- 실제로 평가되지 않은 검사를 통과로 기록하지 않는다. 설치 APK와 실행 증거를 같은 소스·파일 해시로 연결한다.
|
||||
|
||||
## 작업 전에
|
||||
|
||||
`docs/standards.md`, `docs/engineering-notes.md`와 대상 모듈의 AGENTS.md를 읽는다. 수학·진행·힌트 변경은 `docs/business-rules.md`의 수집/계수·목표·재플레이 경계를, 저장 변경은 `docs/contracts.md`의 필드·손상 복구와 `docs/security.md`의 데이터 보존을 확인한다. 입력·씬·자산 작업은 `docs/architecture.md`의 상태 소유와 `docs/operations.md`의 초기 builder 주의사항을 확인한다. 음성 변경은 승인 파일·원본 이력과 제작/런타임 분리를 먼저 확인한다.
|
||||
|
||||
요구된 아이의 행동과 합격 결과를 먼저 정하고 최소 변경으로 구현한다. 실제 기기·iOS 등 현재와 후속의 구분은 `docs/tracking/status.md`에서 확인한다. 검사·Editor·ADB·녹음의 실행 소유자를 한 명으로 유지한다.
|
||||
|
||||
## 문제 대응
|
||||
|
||||
구 소실·복제나 잘못된 숫자 판정, 완료/이어하기 손실·덮어쓰기, 미승인 자산 채택, 요청하지 않은 런타임 외부 전송이 발견되면 관련 변경을 멈추고 증거와 영향을 사용자에게 즉시 알린다. 다른 문제는 먼저 재현·원인 확인·수정을 시도하고 실제로 해결할 수 없는 경우에만 이유와 영향·가능한 대응을 `docs/tracking/findings.md`에 남긴다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 052faaac586de48259a63d0c4782560b
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3}
|
||||
generateWrapperCode: 0
|
||||
wrapperCodePath:
|
||||
wrapperClassName:
|
||||
wrapperCodeNamespace:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 944782f304ac00540aa14b9451c7458b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 431363d771451094da26ab23050c6b00
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,9 @@
|
||||
# 순수 규칙과 진행 제어
|
||||
|
||||
이 어셈블리는 구·묶음·목표·계수, 진행 화면, 저장 DTO 검증과 `IProgressStore`를 소유한다. Unity 입력·Transform·물리·UI·파일 읽기와 제작 도구는 소유하지 않는다. `noEngineReferences: true`와 빈 참조를 유지한다.
|
||||
|
||||
변경은 BoardRules의 확정 조작과 GameSession의 진행 전이를 통해 전달한다. 거부된 조작은 상태를 바꾸지 않으며 호출자가 명시적 거부 사유를 받는다. Presenter에 상태를 복제하거나 애니메이션 프레임을 정답 근거로 전달하지 않는다.
|
||||
|
||||
합체·분리의 구 ID 집합 보존, 정확히 한 묶음 소속, 모든 비어 있지 않은 진부분집합 분리, 순서 없는 목표 다중집합을 검사한다. 계수는 수집·단일 묶음 조건과 중복 ID를 검사한다. 진행 수정에는 재플레이 완료 기록 유지, 두 미완료 슬롯과 한 자유 놀이, 성공의 단일 저장 갱신을 포함한다.
|
||||
|
||||
DTO lowerCamelCase는 파일 계약이다. 자유 놀이에 계수 필드를 추가하지 않는다. 저장 검증 수정은 누락·중복·비유한 위치·무효 quaternion·구 소속·잘못된 source와 unsupported 버전을 실제 사례로 확인한다. Tests/EditMode의 규칙·세션·저장 검사를 실행하며 결과를 화면 연결 검증으로 확대하지 않는다.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37d15ecaf57860c48bf907ae302ccaa5
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1 @@
|
||||
[assembly: System.Reflection.AssemblyTitle("MagnetismHeroes.Core")]
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74b92a7d90707d64790feba3a7cb08b8
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MagnetismHeroes.Core
|
||||
{
|
||||
public enum HintKind { Collect, Count, Merge, Split }
|
||||
public sealed class BoardHint
|
||||
{
|
||||
public HintKind Kind { get; }
|
||||
public string[] BallIds { get; }
|
||||
public string FirstGroupId { get; }
|
||||
public string SecondGroupId { get; }
|
||||
public int GroupSize { get; }
|
||||
public int SelectionCount => BallIds.Length;
|
||||
public string VoiceId => Kind == HintKind.Collect ? "guide_find" : Kind == HintKind.Count ? "guide_uncounted"
|
||||
: Kind == HintKind.Merge ? "guide_merge" : $"guide_split_{GroupSize}_{SelectionCount}";
|
||||
internal BoardHint(HintKind kind, GroupState first, string[] balls, GroupState second = null)
|
||||
{ Kind = kind; FirstGroupId = first.id; SecondGroupId = second?.id; GroupSize = first.ballIds.Length; BallIds = balls; }
|
||||
|
||||
public static BoardHint Find(BoardRules board, StageDefinition definition)
|
||||
{
|
||||
if (board == null || board.Mode == BoardMode.FreePlay || board.IsSolved) return null;
|
||||
var groups = board.Groups;
|
||||
if (board.Mode == BoardMode.Counting)
|
||||
{
|
||||
var collectedGroups = groups.Where(group => group.ballIds.All(id => board.CollectedBallIds.Contains(id))).ToArray();
|
||||
if (collectedGroups.Length > 1) return Merge(collectedGroups[0], collectedGroups[1]);
|
||||
var uncollected = board.Balls.FirstOrDefault(ball => !board.CollectedBallIds.Contains(ball.id));
|
||||
if (uncollected != null) return new BoardHint(HintKind.Collect, groups.First(group => group.ballIds.Contains(uncollected.id)), new[] { uncollected.id });
|
||||
if (groups.Length > 1) return Merge(groups[0], groups[1]);
|
||||
return new BoardHint(HintKind.Count, groups[0], new[] { groups[0].ballIds.First(id => !board.CountedBallIds.Contains(id)) });
|
||||
}
|
||||
// Integer partitions of at most ten balls are a small, finite undirected graph.
|
||||
// Distances from the target make every suggested live operation shorten a valid solution.
|
||||
var distance = new Dictionary<string, int>(); var queue = new Queue<int[]>();
|
||||
var target = definition.TargetGroups.OrderBy(size => size).ToArray();
|
||||
distance.Add(Key(target), 0); queue.Enqueue(target);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var sizes = queue.Dequeue(); int nextDistance = distance[Key(sizes)] + 1;
|
||||
foreach (var next in Neighbors(sizes))
|
||||
if (!distance.ContainsKey(Key(next))) { distance.Add(Key(next), nextDistance); queue.Enqueue(next); }
|
||||
}
|
||||
var current = groups.Select(group => group.ballIds.Length).ToArray(); int desired = distance[Key(current)] - 1;
|
||||
for (int i = 0; i < groups.Length; i++)
|
||||
for (int j = i + 1; j < groups.Length; j++)
|
||||
if (distance[Key(Merged(current, i, j))] == desired) return Merge(groups[i], groups[j]);
|
||||
for (int i = 0; i < groups.Length; i++)
|
||||
for (int count = 1; count <= current[i] / 2; count++)
|
||||
if (distance[Key(Split(current, i, count))] == desired)
|
||||
return new BoardHint(HintKind.Split, groups[i], groups[i].ballIds.Take(count).ToArray());
|
||||
throw new InvalidOperationException("A valid unsolved partition must have a shorter neighbor.");
|
||||
}
|
||||
private static BoardHint Merge(GroupState first, GroupState second) => new BoardHint(HintKind.Merge, first, first.ballIds.Concat(second.ballIds).ToArray(), second);
|
||||
private static string Key(IEnumerable<int> sizes) => string.Join(",", sizes.OrderBy(size => size));
|
||||
private static int[] Merged(int[] sizes, int i, int j) => sizes.Where((size, index) => index != i && index != j).Concat(new[] { sizes[i] + sizes[j] }).ToArray();
|
||||
private static int[] Split(int[] sizes, int i, int count) => sizes.Where((size, index) => index != i).Concat(new[] { count, sizes[i] - count }).ToArray();
|
||||
private static IEnumerable<int[]> Neighbors(int[] sizes)
|
||||
{
|
||||
for (int i = 0; i < sizes.Length; i++)
|
||||
{
|
||||
for (int j = i + 1; j < sizes.Length; j++) yield return Merged(sizes, i, j);
|
||||
for (int count = 1; count <= sizes[i] / 2; count++) yield return Split(sizes, i, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c5d298887e9598459597d508e8a1bb5
|
||||
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MagnetismHeroes.Core
|
||||
{
|
||||
public sealed class BoardRules
|
||||
{
|
||||
private readonly Dictionary<string, Position3> _balls = new Dictionary<string, Position3>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<string>> _groups = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _collected = new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _counted = new HashSet<string>(StringComparer.Ordinal);
|
||||
private readonly StageDefinition _definition;
|
||||
|
||||
public BoardMode Mode { get; }
|
||||
public int StageId { get; }
|
||||
public int BallCount => _balls.Count;
|
||||
public int CountedCount => _counted.Count;
|
||||
public bool CanCount => Mode == BoardMode.Counting && _collected.Count == _balls.Count && _groups.Count == 1;
|
||||
public bool IsSolved
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Mode == BoardMode.FreePlay) return false;
|
||||
if (Mode == BoardMode.Counting) return CanCount && _counted.Count == _definition.Total;
|
||||
return _groups.Values.Select(ids => ids.Count).OrderBy(size => size)
|
||||
.SequenceEqual(_definition.TargetGroups.OrderBy(size => size));
|
||||
}
|
||||
}
|
||||
// Detached snapshots keep callers from changing membership or committed positions.
|
||||
public BallState[] Balls => _balls.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new BallState { id = pair.Key, position = pair.Value }).ToArray();
|
||||
public GroupState[] Groups => _groups.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => new GroupState { id = pair.Key, ballIds = pair.Value.ToArray() }).ToArray();
|
||||
public string[] CollectedBallIds => _collected.OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
||||
public string[] CountedBallIds => _counted.OrderBy(id => id, StringComparer.Ordinal).ToArray();
|
||||
|
||||
public BoardRules(StageDefinition definition, PuzzleState restored = null)
|
||||
{
|
||||
_definition = definition ?? throw new ArgumentNullException(nameof(definition));
|
||||
StageId = definition.Id;
|
||||
Mode = definition.Kind == StageKind.Counting ? BoardMode.Counting : BoardMode.Composition;
|
||||
if (restored != null)
|
||||
{
|
||||
if (!SaveValidator.TryValidatePuzzle(restored, definition, out var error)) throw new ArgumentException(error, nameof(restored));
|
||||
CopyBoard(restored.balls, restored.groups);
|
||||
_collected.UnionWith(restored.collectedBallIds);
|
||||
_counted.UnionWith(restored.countedBallIds);
|
||||
return;
|
||||
}
|
||||
var ballIndex = 0;
|
||||
for (var groupIndex = 0; groupIndex < definition.InitialGroups.Count; groupIndex++)
|
||||
{
|
||||
var group = new List<string>();
|
||||
for (var i = 0; i < definition.InitialGroups[groupIndex]; i++)
|
||||
{
|
||||
var id = $"s{StageId:D2}-b{ballIndex + 1:D2}";
|
||||
_balls.Add(id, definition.BallPositions[ballIndex++]);
|
||||
group.Add(id);
|
||||
}
|
||||
_groups.Add($"s{StageId:D2}-g{groupIndex + 1:D2}", group);
|
||||
}
|
||||
}
|
||||
|
||||
private BoardRules(FreePlayState state)
|
||||
{
|
||||
if (!SaveValidator.TryValidateFreePlay(state, out var error)) throw new ArgumentException(error, nameof(state));
|
||||
Mode = BoardMode.FreePlay;
|
||||
StageId = state.sourceStageId;
|
||||
CopyBoard(state.balls, state.groups);
|
||||
}
|
||||
|
||||
public static BoardRules CreateFreePlay(FreePlayState state) => new BoardRules(state);
|
||||
|
||||
public RuleResult TryMoveGroup(string groupId, Position3 delta)
|
||||
{
|
||||
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId);
|
||||
if (!delta.IsValid) return RuleResult.Reject(RejectionReason.InvalidPosition);
|
||||
var positions = new Position3[members.Count];
|
||||
var moved = false;
|
||||
for (var i = 0; i < members.Count; i++)
|
||||
{
|
||||
var current = _balls[members[i]];
|
||||
positions[i] = new Position3(current.x + delta.x, current.y + delta.y, current.z + delta.z);
|
||||
if (!positions[i].IsValid) return RuleResult.Reject(RejectionReason.InvalidPosition);
|
||||
moved |= positions[i].x != current.x || positions[i].y != current.y || positions[i].z != current.z;
|
||||
}
|
||||
if (!moved) return RuleResult.Reject(RejectionReason.NoChange);
|
||||
for (var i = 0; i < members.Count; i++) _balls[members[i]] = positions[i];
|
||||
if (Mode == BoardMode.Counting) _collected.UnionWith(members);
|
||||
return RuleResult.Success();
|
||||
}
|
||||
|
||||
// Both groups participated in a committed drop, so all their balls are collected.
|
||||
public RuleResult TryMoveAndMerge(string groupId, Position3 delta, string targetGroupId)
|
||||
{
|
||||
if (!ValidId(groupId) || !_groups.ContainsKey(groupId) || !ValidId(targetGroupId) || !_groups.ContainsKey(targetGroupId))
|
||||
return RuleResult.Reject(RejectionReason.UnknownId);
|
||||
if (groupId == targetGroupId) return RuleResult.Reject(RejectionReason.InvalidGroup);
|
||||
var move = TryMoveGroup(groupId, delta);
|
||||
if (!move.Accepted) return move;
|
||||
return TryMerge(groupId, targetGroupId);
|
||||
}
|
||||
|
||||
public BallState[] PreviewSplit(string groupId, IReadOnlyList<string> selectedIds, Position3 delta)
|
||||
{
|
||||
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members) || selectedIds == null ||
|
||||
selectedIds.Count == 0 || selectedIds.Count >= members.Count || !delta.IsValid ||
|
||||
(delta.x == 0 && delta.y == 0 && delta.z == 0)) return null;
|
||||
var selected = new HashSet<string>(selectedIds, StringComparer.Ordinal);
|
||||
if (selected.Count != selectedIds.Count || selected.Any(id => !members.Contains(id))) return null;
|
||||
var chosen = members.Where(selected.Contains).ToArray(); var rest = members.Where(id => !selected.Contains(id)).ToArray();
|
||||
var origin = Center(rest); var destination = Center(chosen);
|
||||
destination = new Position3(destination.x + delta.x, destination.y + delta.y, destination.z + delta.z);
|
||||
double dx = destination.x - origin.x, dy = destination.y - origin.y, dz = destination.z - origin.z;
|
||||
double distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
// Compact both subsets into connected rows; disconnected taps still yield exactly two friends.
|
||||
double clearance = (chosen.Length + rest.Length) * 0.5 + 1.1;
|
||||
if (distance < clearance)
|
||||
{
|
||||
if (distance < 0.0001) { dx = delta.x; dy = delta.y; dz = delta.z; distance = Math.Sqrt(dx * dx + dy * dy + dz * dz); }
|
||||
destination = new Position3(origin.x + (float)(dx / distance * clearance), origin.y + (float)(dy / distance * clearance), origin.z + (float)(dz / distance * clearance));
|
||||
}
|
||||
var result = new List<BallState>();
|
||||
Arrange(rest, origin, result); Arrange(chosen, destination, result);
|
||||
return result.All(ball => ball.position.IsValid) ? result.ToArray() : null;
|
||||
}
|
||||
|
||||
public RuleResult TrySplitAndMove(string groupId, IReadOnlyList<string> selectedIds, string newGroupId, Position3 delta)
|
||||
{
|
||||
if (!ValidId(newGroupId) || _groups.ContainsKey(newGroupId)) return RuleResult.Reject(RejectionReason.DuplicateId);
|
||||
var positions = PreviewSplit(groupId, selectedIds, delta);
|
||||
if (positions == null) return RuleResult.Reject(RejectionReason.InvalidSelection);
|
||||
var split = TrySplit(groupId, selectedIds, newGroupId);
|
||||
if (!split.Accepted) return split;
|
||||
foreach (var ball in positions) _balls[ball.id] = ball.position;
|
||||
return split;
|
||||
}
|
||||
|
||||
private Position3 Center(string[] ids)
|
||||
{
|
||||
double x = 0, y = 0, z = 0;
|
||||
foreach (var id in ids) { var point = _balls[id]; x += point.x; y += point.y; z += point.z; }
|
||||
return new Position3((float)(x / ids.Length), (float)(y / ids.Length), (float)(z / ids.Length));
|
||||
}
|
||||
private static void Arrange(string[] ids, Position3 center, List<BallState> result)
|
||||
{
|
||||
for (int index = 0; index < ids.Length; index++) result.Add(new BallState { id = ids[index],
|
||||
position = new Position3(center.x + index - (ids.Length - 1) * 0.5f, center.y, center.z) });
|
||||
}
|
||||
|
||||
public RuleResult TryMerge(string firstGroupId, string secondGroupId)
|
||||
{
|
||||
if (!ValidId(firstGroupId) || !ValidId(secondGroupId) || !_groups.TryGetValue(firstGroupId, out var first)
|
||||
|| !_groups.TryGetValue(secondGroupId, out var second)) return RuleResult.Reject(RejectionReason.UnknownId);
|
||||
if (firstGroupId == secondGroupId) return RuleResult.Reject(RejectionReason.InvalidGroup);
|
||||
first.AddRange(second);
|
||||
_groups.Remove(secondGroupId);
|
||||
if (Mode == BoardMode.Counting) _collected.UnionWith(first);
|
||||
return RuleResult.Success();
|
||||
}
|
||||
|
||||
public RuleResult TrySplit(string groupId, IReadOnlyList<string> selectedBallIds, string newGroupId)
|
||||
{
|
||||
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId);
|
||||
if (!ValidId(newGroupId) || _groups.ContainsKey(newGroupId)) return RuleResult.Reject(RejectionReason.DuplicateId);
|
||||
if (selectedBallIds == null || selectedBallIds.Count == 0 || selectedBallIds.Count >= members.Count)
|
||||
return RuleResult.Reject(RejectionReason.InvalidSelection);
|
||||
var selected = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var id in selectedBallIds)
|
||||
if (!ValidId(id) || !members.Contains(id) || !selected.Add(id)) return RuleResult.Reject(RejectionReason.InvalidSelection);
|
||||
// Connectivity does not enter the rule: exactly selected/rest, even for distant balls.
|
||||
var selectedMembers = members.Where(selected.Contains).ToList();
|
||||
var remaining = members.Where(id => !selected.Contains(id)).ToList();
|
||||
_groups[groupId] = remaining;
|
||||
_groups.Add(newGroupId, selectedMembers);
|
||||
return RuleResult.Success();
|
||||
}
|
||||
|
||||
public RuleResult TryCount(string ballId)
|
||||
{
|
||||
if (!ValidId(ballId) || !_balls.ContainsKey(ballId)) return RuleResult.Reject(RejectionReason.UnknownId);
|
||||
if (!CanCount) return RuleResult.Reject(RejectionReason.NotCollected);
|
||||
if (!_counted.Add(ballId)) return RuleResult.Reject(RejectionReason.AlreadyCounted);
|
||||
return RuleResult.Success(_counted.Count);
|
||||
}
|
||||
|
||||
public RuleResult TrySpawn(string ballId, string groupId, Position3 position)
|
||||
{
|
||||
if (Mode != BoardMode.FreePlay) return RuleResult.Reject(RejectionReason.PuzzleSupplyLocked);
|
||||
if (_balls.Count >= 10) return RuleResult.Reject(RejectionReason.CapacityReached);
|
||||
if (!ValidId(ballId) || !ValidId(groupId) || _balls.ContainsKey(ballId) || _groups.ContainsKey(groupId)) return RuleResult.Reject(RejectionReason.DuplicateId);
|
||||
if (!position.IsValid) return RuleResult.Reject(RejectionReason.InvalidPosition);
|
||||
_balls.Add(ballId, position);
|
||||
_groups.Add(groupId, new List<string> { ballId });
|
||||
return RuleResult.Success();
|
||||
}
|
||||
|
||||
public RuleResult TryReturn(string groupId)
|
||||
{
|
||||
if (Mode != BoardMode.FreePlay) return RuleResult.Reject(RejectionReason.PuzzleSupplyLocked);
|
||||
if (!ValidId(groupId) || !_groups.TryGetValue(groupId, out var members)) return RuleResult.Reject(RejectionReason.UnknownId);
|
||||
foreach (var id in members) _balls.Remove(id);
|
||||
_groups.Remove(groupId);
|
||||
return RuleResult.Success();
|
||||
}
|
||||
|
||||
public PuzzleState ToPuzzleState(CameraState camera)
|
||||
{
|
||||
if (Mode == BoardMode.FreePlay) throw new InvalidOperationException("Free play cannot be saved as a puzzle.");
|
||||
return new PuzzleState { stageId = StageId, balls = Balls, groups = Groups,
|
||||
collectedBallIds = CollectedBallIds, countedBallIds = CountedBallIds, camera = CopyCamera(camera) };
|
||||
}
|
||||
|
||||
// Used for the successful puzzle -> free play boundary; count markers are intentionally absent.
|
||||
public FreePlayState ToFreePlayState(CameraState camera) => new FreePlayState
|
||||
{ sourceStageId = StageId, balls = Balls, groups = Groups, camera = CopyCamera(camera) };
|
||||
|
||||
private void CopyBoard(BallState[] balls, GroupState[] groups)
|
||||
{
|
||||
foreach (var ball in balls) _balls.Add(ball.id, ball.position);
|
||||
foreach (var group in groups) _groups.Add(group.id, new List<string>(group.ballIds));
|
||||
}
|
||||
|
||||
private static CameraState CopyCamera(CameraState camera)
|
||||
{
|
||||
if (!SaveValidator.ValidCamera(camera)) throw new ArgumentException("Invalid camera.", nameof(camera));
|
||||
return new CameraState { target = camera.target, rotation = camera.rotation, distance = camera.distance };
|
||||
}
|
||||
|
||||
internal static bool ValidId(string id) => !string.IsNullOrWhiteSpace(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a13e5eea59bf5145a3098f5d20ac58e
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MagnetismHeroes.Core
|
||||
{
|
||||
public enum StageKind { Counting, Composition }
|
||||
public enum BoardMode { Counting, Composition, FreePlay }
|
||||
public enum RejectionReason { None, UnknownId, InvalidSelection, DuplicateId, InvalidPosition, NotCollected, AlreadyCounted, PuzzleSupplyLocked, CapacityReached, InvalidGroup, NoChange }
|
||||
|
||||
public readonly struct RuleResult
|
||||
{
|
||||
public bool Accepted { get; }
|
||||
public RejectionReason Reason { get; }
|
||||
public int Count { get; }
|
||||
private RuleResult(bool accepted, RejectionReason reason, int count) { Accepted = accepted; Reason = reason; Count = count; }
|
||||
public static RuleResult Success(int count = 0) => new RuleResult(true, RejectionReason.None, count);
|
||||
public static RuleResult Reject(RejectionReason reason) => new RuleResult(false, reason, 0);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Position3
|
||||
{
|
||||
public float x, y, z;
|
||||
public Position3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
|
||||
public bool IsValid => Finite(x) && Finite(y) && Finite(z);
|
||||
internal static bool Finite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct Rotation4
|
||||
{
|
||||
public float x, y, z, w;
|
||||
public Rotation4(float x, float y, float z, float w) { this.x = x; this.y = y; this.z = z; this.w = w; }
|
||||
public bool IsValid => Position3.Finite(x) && Position3.Finite(y) && Position3.Finite(z) && Position3.Finite(w)
|
||||
&& Math.Abs((double)x * x + (double)y * y + (double)z * z + (double)w * w - 1d) < 0.001d;
|
||||
}
|
||||
|
||||
// Lower camel case fields are the versioned JSON wire contract, also usable by JsonUtility.
|
||||
[Serializable] public sealed class BallState { public string id; public Position3 position; }
|
||||
[Serializable] public sealed class GroupState { public string id; public string[] ballIds = Array.Empty<string>(); }
|
||||
[Serializable] public sealed class CameraState
|
||||
{
|
||||
public Position3 target;
|
||||
public Rotation4 rotation = new Rotation4(0, 0, 0, 1);
|
||||
public float distance = 8;
|
||||
}
|
||||
[Serializable] public sealed class PuzzleState
|
||||
{
|
||||
public int stageId;
|
||||
public BallState[] balls = Array.Empty<BallState>();
|
||||
public GroupState[] groups = Array.Empty<GroupState>();
|
||||
public string[] collectedBallIds = Array.Empty<string>();
|
||||
public string[] countedBallIds = Array.Empty<string>();
|
||||
public CameraState camera = new CameraState();
|
||||
}
|
||||
[Serializable] public sealed class FreePlayState
|
||||
{
|
||||
public int sourceStageId;
|
||||
public BallState[] balls = Array.Empty<BallState>();
|
||||
public GroupState[] groups = Array.Empty<GroupState>();
|
||||
public CameraState camera = new CameraState();
|
||||
}
|
||||
[Serializable] public sealed class ProgressSave
|
||||
{
|
||||
public int schemaVersion = 1;
|
||||
public int[] completedStageIds = Array.Empty<int>();
|
||||
public string[] seenTutorialIds = Array.Empty<string>();
|
||||
public PuzzleState[] puzzleStates = Array.Empty<PuzzleState>();
|
||||
public FreePlayState latestFreePlay;
|
||||
}
|
||||
|
||||
public sealed class DecorationPlacement
|
||||
{
|
||||
public string AssetKey { get; }
|
||||
public Position3 Position { get; }
|
||||
public Rotation4 Rotation { get; }
|
||||
public Position3 Scale { get; }
|
||||
|
||||
public DecorationPlacement(string assetKey, Position3 position, Rotation4 rotation, Position3 scale)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetKey) || !position.IsValid || !rotation.IsValid || !scale.IsValid
|
||||
|| scale.x <= 0 || scale.y <= 0 || scale.z <= 0) throw new ArgumentException("Invalid decoration key or transform.");
|
||||
AssetKey = assetKey;
|
||||
Position = position;
|
||||
Rotation = rotation;
|
||||
Scale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StageLayout
|
||||
{
|
||||
public IReadOnlyList<Position3> BallPositions { get; }
|
||||
public IReadOnlyList<DecorationPlacement> Decorations { get; }
|
||||
|
||||
public StageLayout(Position3[] ballPositions, DecorationPlacement[] decorations = null)
|
||||
{
|
||||
if (ballPositions == null) throw new ArgumentException("Ball layout is required.");
|
||||
var positions = (Position3[])ballPositions.Clone();
|
||||
foreach (var position in positions) if (!position.IsValid) throw new ArgumentException("Layout positions must be finite.");
|
||||
var placements = decorations == null ? Array.Empty<DecorationPlacement>() : (DecorationPlacement[])decorations.Clone();
|
||||
foreach (var placement in placements) if (placement == null) throw new ArgumentException("Decoration placements cannot be null.");
|
||||
BallPositions = Array.AsReadOnly(positions);
|
||||
Decorations = Array.AsReadOnly(placements);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StageDefinition
|
||||
{
|
||||
public int Id { get; }
|
||||
public StageKind Kind { get; }
|
||||
public IReadOnlyList<int> InitialGroups { get; }
|
||||
public IReadOnlyList<int> TargetGroups { get; }
|
||||
public StageLayout Layout { get; }
|
||||
public IReadOnlyList<Position3> BallPositions => Layout.BallPositions;
|
||||
public int Total { get; }
|
||||
|
||||
public StageDefinition(int id, StageKind kind, int[] initialGroups, int[] targetGroups, Position3[] ballPositions = null,
|
||||
DecorationPlacement[] decorations = null)
|
||||
{
|
||||
if (id < 1 || id > 30 || (kind != StageKind.Counting && kind != StageKind.Composition))
|
||||
throw new ArgumentException("Invalid stage ID or kind.");
|
||||
var initial = CopyGroups(initialGroups);
|
||||
var target = CopyGroups(targetGroups);
|
||||
var total = 0;
|
||||
foreach (var size in initial) total += size;
|
||||
var targetTotal = 0;
|
||||
foreach (var size in target) targetTotal += size;
|
||||
if (total != targetTotal || total > 10) throw new ArgumentException("Stage totals must match and be within 1..10.");
|
||||
if (kind == StageKind.Counting)
|
||||
{
|
||||
foreach (var size in initial) if (size != 1) throw new ArgumentException("Counting starts with individual balls.");
|
||||
if (target.Length != 1) throw new ArgumentException("Counting has one target group.");
|
||||
}
|
||||
if (ballPositions != null && ballPositions.Length != total) throw new ArgumentException("Layout must position every ball.");
|
||||
var positions = ballPositions == null ? new Position3[total] : (Position3[])ballPositions.Clone();
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
if (ballPositions == null) positions[i] = new Position3(i * 1.4f, 0, 0);
|
||||
if (!positions[i].IsValid) throw new ArgumentException("Layout positions must be finite.");
|
||||
}
|
||||
Id = id;
|
||||
Kind = kind;
|
||||
Total = total;
|
||||
InitialGroups = Array.AsReadOnly(initial);
|
||||
TargetGroups = Array.AsReadOnly(target);
|
||||
Layout = new StageLayout(positions, decorations);
|
||||
}
|
||||
|
||||
private static int[] CopyGroups(int[] groups)
|
||||
{
|
||||
if (groups == null || groups.Length == 0 || groups.Length > 10) throw new ArgumentException("Groups must be nonempty.");
|
||||
var copy = (int[])groups.Clone();
|
||||
foreach (var size in copy) if (size < 1 || size > 10) throw new ArgumentException("Group size must be within 1..10.");
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IProgressStore
|
||||
{
|
||||
bool TryLoad(out ProgressSave save, out string error);
|
||||
bool TrySave(ProgressSave save, out string error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47bdee7aa8035b14cb5d993b0b1f5e5d
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MagnetismHeroes.Core
|
||||
{
|
||||
public enum SessionScreen { Home, StageSelection, Puzzle, FreePlay }
|
||||
|
||||
// Owns committed game state. A gesture preview never reaches this class.
|
||||
public sealed class GameSession
|
||||
{
|
||||
private readonly StageDefinition[] _definitions;
|
||||
private readonly IProgressStore _store;
|
||||
private ProgressSave _progress;
|
||||
public SessionScreen Screen { get; private set; } = SessionScreen.Home;
|
||||
public BoardRules Board { get; private set; }
|
||||
public CameraState Camera { get; private set; } = DefaultCamera();
|
||||
public string LastSaveError { get; private set; }
|
||||
public event Action<string> SaveFinished;
|
||||
public bool HasFreePlay => _progress.latestFreePlay != null;
|
||||
public int[] CompletedStageIds => (int[])_progress.completedStageIds.Clone();
|
||||
public IReadOnlyList<StageDefinition> Definitions => Array.AsReadOnly(_definitions);
|
||||
public bool HasUnfinishedStage(int stageId) => _progress.puzzleStates.Any(state => state.stageId == stageId);
|
||||
public bool HasSeenTutorial(string id) => _progress.seenTutorialIds.Contains(id);
|
||||
public BoardHint Hint => BoardHint.Find(Board, _definitions.FirstOrDefault(stage => stage.Id == Board?.StageId));
|
||||
public bool HasNextStage => Screen == SessionScreen.FreePlay && _definitions.Any(stage => stage.Id == Board.StageId + 1) && IsUnlocked(Board.StageId + 1);
|
||||
public bool StartNextStage() => HasNextStage && StartStage(Board.StageId + 1);
|
||||
|
||||
public bool MarkTutorialSeen(string id)
|
||||
{
|
||||
if ((id != "merge" && id != "split") || HasSeenTutorial(id)) return false;
|
||||
_progress.seenTutorialIds = _progress.seenTutorialIds.Concat(new[] { id }).ToArray();
|
||||
SaveCurrent(); return true;
|
||||
}
|
||||
|
||||
public GameSession(IEnumerable<StageDefinition> definitions, IProgressStore store)
|
||||
{
|
||||
_definitions = definitions.ToArray();
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
if (!_store.TryLoad(out var loaded, out var error)) loaded = new ProgressSave();
|
||||
LastSaveError = error;
|
||||
if (!SaveValidator.TryValidate(loaded, _definitions, out error))
|
||||
{ loaded = new ProgressSave(); LastSaveError = error; }
|
||||
_progress = loaded;
|
||||
}
|
||||
|
||||
public bool IsUnlocked(int stageId) => stageId == 1 || _progress.completedStageIds.Contains(stageId)
|
||||
|| (stageId >= 2 && stageId <= 30 && _progress.completedStageIds.Contains(stageId - 1));
|
||||
|
||||
public void ShowSelection() { SaveCurrent(); Screen = SessionScreen.StageSelection; Board = null; }
|
||||
public void ShowHome() { SaveCurrent(); Screen = SessionScreen.Home; Board = null; }
|
||||
|
||||
public bool StartStage(int stageId)
|
||||
{
|
||||
var definition = _definitions.FirstOrDefault(value => value.Id == stageId);
|
||||
if (definition == null || !IsUnlocked(stageId)) return false;
|
||||
SaveCurrent();
|
||||
var restored = _progress.puzzleStates.FirstOrDefault(state => state.stageId == stageId);
|
||||
Board = new BoardRules(definition, restored);
|
||||
Camera = restored == null ? DefaultCamera() : CopyCamera(restored.camera);
|
||||
Screen = SessionScreen.Puzzle;
|
||||
SaveCurrent();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ContinueFreePlay()
|
||||
{
|
||||
if (_progress.latestFreePlay == null) return false;
|
||||
SaveCurrent();
|
||||
Board = BoardRules.CreateFreePlay(_progress.latestFreePlay);
|
||||
Camera = CopyCamera(_progress.latestFreePlay.camera);
|
||||
Screen = SessionScreen.FreePlay;
|
||||
return true;
|
||||
}
|
||||
|
||||
public RuleResult MoveGroup(string groupId, Position3 delta) => Commit(() => Board.TryMoveGroup(groupId, delta));
|
||||
public RuleResult Merge(string first, string second) => Commit(() => Board.TryMerge(first, second));
|
||||
public RuleResult MoveAndMerge(string groupId, Position3 delta, string targetGroupId) => Commit(() => Board.TryMoveAndMerge(groupId, delta, targetGroupId));
|
||||
public RuleResult SplitAndMove(string groupId, IReadOnlyList<string> selectedIds, string newGroupId, Position3 delta) =>
|
||||
Commit(() => Board.TrySplitAndMove(groupId, selectedIds, newGroupId, delta));
|
||||
public RuleResult Count(string ballId) => Commit(() => Board.TryCount(ballId));
|
||||
public RuleResult ReturnGroup(string groupId) => Commit(() => Board.TryReturn(groupId));
|
||||
public RuleResult Spawn(Position3 position)
|
||||
{
|
||||
if (Board == null) return RuleResult.Reject(RejectionReason.InvalidGroup);
|
||||
int index = 1;
|
||||
while (Board.Balls.Any(ball => ball.id == $"free-b{index:D2}") || Board.Groups.Any(group => group.id == $"free-g{index:D2}")) index++;
|
||||
return Commit(() => Board.TrySpawn($"free-b{index:D2}", $"free-g{index:D2}", position));
|
||||
}
|
||||
|
||||
public void CommitCamera(CameraState camera)
|
||||
{
|
||||
// Board snapshot methods validate all camera coordinates and rotation.
|
||||
Camera = Board == null ? CopyCamera(camera) : Board.ToFreePlayState(camera).camera;
|
||||
SaveCurrent();
|
||||
}
|
||||
|
||||
private RuleResult Commit(Func<RuleResult> mutation)
|
||||
{
|
||||
if (Board == null) return RuleResult.Reject(RejectionReason.InvalidGroup);
|
||||
var result = mutation();
|
||||
if (!result.Accepted) return result;
|
||||
if (Screen == SessionScreen.Puzzle && Board.IsSolved)
|
||||
{
|
||||
// Completion, removing that unfinished puzzle, and creating free play are one saved update.
|
||||
_progress.completedStageIds = _progress.completedStageIds.Concat(new[] { Board.StageId }).Distinct().OrderBy(id => id).ToArray();
|
||||
_progress.puzzleStates = _progress.puzzleStates.Where(state => state.stageId != Board.StageId).ToArray();
|
||||
_progress.latestFreePlay = Board.ToFreePlayState(Camera);
|
||||
Board = BoardRules.CreateFreePlay(_progress.latestFreePlay);
|
||||
Screen = SessionScreen.FreePlay;
|
||||
}
|
||||
SaveCurrent();
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool SaveCurrent()
|
||||
{
|
||||
if (Board != null)
|
||||
{
|
||||
if (Screen == SessionScreen.Puzzle)
|
||||
_progress.puzzleStates = _progress.puzzleStates.Where(state => state.stageId != Board.StageId)
|
||||
.Concat(new[] { Board.ToPuzzleState(Camera) }).OrderBy(state => state.stageId).ToArray();
|
||||
else if (Screen == SessionScreen.FreePlay) _progress.latestFreePlay = Board.ToFreePlayState(Camera);
|
||||
}
|
||||
var success = _store.TrySave(_progress, out var error);
|
||||
LastSaveError = error;
|
||||
SaveFinished?.Invoke(error);
|
||||
return success;
|
||||
}
|
||||
|
||||
public static CameraState DefaultCamera() => new CameraState {
|
||||
target = new Position3(0, 0.6f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 8 };
|
||||
private static CameraState CopyCamera(CameraState camera) => new CameraState {
|
||||
target = camera.target, rotation = camera.rotation, distance = camera.distance };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85fdaf062aa4ff042b6d37c003441435
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "MagnetismHeroes.Core",
|
||||
"rootNamespace": "MagnetismHeroes.Core",
|
||||
"references": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b4abf8566b6a7b49a19bc260542ab85
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace MagnetismHeroes.Core
|
||||
{
|
||||
public static class SaveValidator
|
||||
{
|
||||
public static bool TryValidate(ProgressSave save, IReadOnlyList<StageDefinition> definitions, out string error)
|
||||
{
|
||||
if (save == null || save.schemaVersion != 1) return Fail("Missing save or unsupported schema version.", out error);
|
||||
if (definitions == null || definitions.Any(definition => definition == null)
|
||||
|| definitions.Select(definition => definition.Id).Distinct().Count() != definitions.Count)
|
||||
return Fail("Invalid definition catalog.", out error);
|
||||
if (save.completedStageIds == null || save.completedStageIds.Any(id => id < 1 || id > 30)
|
||||
|| save.completedStageIds.Distinct().Count() != save.completedStageIds.Length)
|
||||
return Fail("Invalid completed stage IDs.", out error);
|
||||
if (save.seenTutorialIds == null || save.seenTutorialIds.Any(id => id != "merge" && id != "split")
|
||||
|| save.seenTutorialIds.Distinct().Count() != save.seenTutorialIds.Length)
|
||||
return Fail("Invalid tutorial IDs.", out error);
|
||||
if (save.puzzleStates == null || save.puzzleStates.Length > 30 || save.puzzleStates.Any(state => state == null)
|
||||
|| save.puzzleStates.Select(state => state.stageId).Distinct().Count() != save.puzzleStates.Length)
|
||||
return Fail("Invalid puzzle state list.", out error);
|
||||
foreach (var state in save.puzzleStates)
|
||||
{
|
||||
var definition = definitions.FirstOrDefault(candidate => candidate.Id == state.stageId);
|
||||
if (!TryValidatePuzzle(state, definition, out error)) return false;
|
||||
}
|
||||
if (save.latestFreePlay != null)
|
||||
{
|
||||
if (!save.completedStageIds.Contains(save.latestFreePlay.sourceStageId)) return Fail("Free play source was not completed.", out error);
|
||||
if (!TryValidateFreePlay(save.latestFreePlay, out error)) return false;
|
||||
}
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateUpdate(ProgressSave previous, ProgressSave next, IReadOnlyList<StageDefinition> definitions, out string error)
|
||||
{
|
||||
if (!TryValidate(previous, definitions, out error) || !TryValidate(next, definitions, out error)) return false;
|
||||
if (previous.completedStageIds.Except(next.completedStageIds).Any()) return Fail("Completed stage IDs cannot decrease.", out error);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidatePuzzle(PuzzleState state, StageDefinition definition, out string error)
|
||||
{
|
||||
if (state == null || definition == null || state.stageId != definition.Id) return Fail("Puzzle stage has no matching definition.", out error);
|
||||
if (!ValidateBoard(state.balls, state.groups, state.camera, out var ballIds, out error)) return false;
|
||||
if (state.balls.Length != definition.Total) return Fail("Puzzle total differs from its definition.", out error);
|
||||
if (!ValidateSubset(state.collectedBallIds, ballIds, out error)) return false;
|
||||
if (!ValidateSubset(state.countedBallIds, new HashSet<string>(state.collectedBallIds, StringComparer.Ordinal), out error)) return false;
|
||||
if (definition.Kind == StageKind.Composition && (state.collectedBallIds.Length != 0 || state.countedBallIds.Length != 0))
|
||||
return Fail("Composition puzzles cannot contain counting records.", out error);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateFreePlay(FreePlayState state, out string error)
|
||||
{
|
||||
if (state == null || state.sourceStageId < 1 || state.sourceStageId > 30) return Fail("Invalid free play source.", out error);
|
||||
return ValidateBoard(state.balls, state.groups, state.camera, out _, out error);
|
||||
}
|
||||
|
||||
private static bool ValidateBoard(BallState[] balls, GroupState[] groups, CameraState camera, out HashSet<string> ballIds, out string error)
|
||||
{
|
||||
ballIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (balls == null || groups == null || balls.Length > 10 || !ValidCamera(camera)) return Fail("Invalid board collections or camera.", out error);
|
||||
foreach (var ball in balls)
|
||||
if (ball == null || !BoardRules.ValidId(ball.id) || !ballIds.Add(ball.id) || !ball.position.IsValid)
|
||||
return Fail("Invalid or duplicate ball ID or position.", out error);
|
||||
var groupIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var membership = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group == null || !BoardRules.ValidId(group.id) || !groupIds.Add(group.id) || group.ballIds == null || group.ballIds.Length == 0)
|
||||
return Fail("Invalid, duplicate, or empty group.", out error);
|
||||
foreach (var id in group.ballIds)
|
||||
if (!BoardRules.ValidId(id) || !ballIds.Contains(id) || !membership.Add(id)) return Fail("Invalid or duplicate group membership.", out error);
|
||||
}
|
||||
if (!membership.SetEquals(ballIds)) return Fail("Every ball must belong to exactly one group.", out error);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateSubset(string[] ids, HashSet<string> allowed, out string error)
|
||||
{
|
||||
if (ids == null || ids.Any(id => !BoardRules.ValidId(id) || !allowed.Contains(id)) || ids.Distinct().Count() != ids.Length)
|
||||
return Fail("Counting IDs must be unique subsets of their parent set.", out error);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool ValidCamera(CameraState camera) => camera != null && camera.target.IsValid && camera.rotation.IsValid
|
||||
&& Position3.Finite(camera.distance) && camera.distance > 0;
|
||||
|
||||
private static bool Fail(string message, out string error) { error = message; return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0beb15120d13ace43aa57346895685ab
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 75997e84f9c140949a3de4010efa7746
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,9 @@
|
||||
# Unity 제작과 Android 빌드
|
||||
|
||||
이 Editor 전용 어셈블리는 자산 생성·연결·표현 증거와 Android 빌드를 소유한다. 게임의 런타임 규칙·이어하기 파일·사용자 청취 승인은 소유하지 않는다. 런타임이 이 어셈블리를 참조하지 않게 유지한다.
|
||||
|
||||
현재 완성 Main 씬은 `Assets/MagnetismHeroes/Scenes/Main.unity`다. 초기 Prepare/ConnectApp 및 단계별 Assets.Build는 기존 씬·프리팹·스테이지·음성 참조를 덮을 수 있으므로 일반 실행 전 준비 명령으로 사용하지 않는다. 특정 제작 수정의 대상·기존 참조·생성 diff를 확인하고 필요한 경로만 갱신한다.
|
||||
|
||||
씬·프리팹·ScriptableObject·메타데이터는 Editor API로 만들고 Undo/dirty/save 및 필수 참조를 처리한다. URP의 임시 생성 설정 변화는 원본과 정확히 일치하는 알려진 변환인지 확인한 경우만 정리한다. 모델 root 회전·아치 구멍의 MeshCollider·TMP static glyph와 승인 WAV GUID를 보존한다.
|
||||
|
||||
Android 빌드는 Main만 포함하고 ARM64 IL2CPP release APK를 만든다. 출력 경로는 `MAGNETISM_APK_PATH`를 사용한다. 개발 모델·캐시·QA 녹음 도구를 포함하지 않는다. 빌드 수정은 실제 AndroidBuild와 APK inspection, 표현·연결 변경은 Edit/Play와 실제 화면으로 검증한다. 파일 생성 성공을 설치·재생 성공으로 기록하지 않는다.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a30bcfd019000c042a757d4ddac8e03a
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Android;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public sealed class AndroidBuildSanitizer : IPreprocessBuildWithReport, IPostGenerateGradleAndroidProject
|
||||
{
|
||||
public int callbackOrder => int.MaxValue;
|
||||
|
||||
public void OnPreprocessBuild(BuildReport report)
|
||||
{
|
||||
if (report.summary.platform != BuildTarget.Android || (report.summary.options & BuildOptions.IncludeTestAssemblies) != 0) return;
|
||||
foreach (string path in new[] { "Assets/Resources/PerformanceTestRunInfo.json", "Assets/Resources/PerformanceTestRunSettings.json" })
|
||||
if (File.Exists(path)) AssetDatabase.DeleteAsset(path);
|
||||
if (Directory.Exists("Assets/Resources") && !Directory.EnumerateFileSystemEntries("Assets/Resources").Any())
|
||||
AssetDatabase.DeleteAsset("Assets/Resources");
|
||||
Debug.Log("[AndroidBuild] Generated performance-test metadata excluded from release assets.");
|
||||
}
|
||||
|
||||
public void OnPostGenerateGradleAndroidProject(string path)
|
||||
{
|
||||
string manifestPath = Path.Combine(path, "src/main/AndroidManifest.xml");
|
||||
var manifest = new XmlDocument(); manifest.Load(manifestPath);
|
||||
var namespaces = new XmlNamespaceManager(manifest.NameTable);
|
||||
namespaces.AddNamespace("android", "http://schemas.android.com/apk/res/android");
|
||||
foreach (XmlNode permission in manifest.SelectNodes("/manifest/uses-permission[@android:name='android.permission.INTERNET']", namespaces))
|
||||
permission.ParentNode.RemoveChild(permission);
|
||||
manifest.Save(manifestPath);
|
||||
Debug.Log("[AndroidBuild] Offline manifest: generated INTERNET permission removed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c950257c624bf444a68a989a14c7118
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class CompositionAssets
|
||||
{
|
||||
private const string Presentation = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
|
||||
private const string Glyphs = "0123456789 자석 영웅 숫자 친구와 입체 놀이터 친구 찾기 자유 놀이 이어하기 숫자 친구를 만나 볼까? 처음으로 숫자 1 친구 찾기 친구를 찾아 끌어와 볼까? 센 친구 0 / 1 빈 곳을 쓸면 회전 · 두 손가락으로 확대 이전 기록으로 이어서 시작할게요. 기록을 읽지 못해 처음부터 시작할게요. 친구를 먼저 끌어와 볼까? 자유 놀이터 해냈어! 친구와 자유롭게 놀아 보자. 모두 모았네! 하나씩 눌러 세어 보자. 붙이거나 나누어 목표 친구를 만들어 보자. 선택 구를 골라 끌어내 보자. 나누기 취소 친구 만들기 합체 분리 둘과 둘";
|
||||
[MenuItem("Magnetism Heroes/Build Composition Assets")]
|
||||
public static void Build()
|
||||
{
|
||||
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(Presentation + "Fonts/NotoSansCJKkr-FirstSlice.asset");
|
||||
string needed = new string(Glyphs.Where(character => !font.HasCharacter(character)).Distinct().ToArray());
|
||||
if (needed.Length > 0)
|
||||
{
|
||||
typeof(TMP_FontAsset).GetProperty("SourceFont_EditorRef", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(font, AssetDatabase.LoadAssetAtPath<Font>(Presentation + "Fonts/NotoSansCJKkr-Regular.otf"));
|
||||
font.atlasPopulationMode = AtlasPopulationMode.Dynamic;
|
||||
try { if (!font.TryAddCharacters(Glyphs, out var missing)) throw new InvalidOperationException("Missing composition glyphs: " + missing); }
|
||||
finally { font.atlasPopulationMode = AtlasPopulationMode.Static; }
|
||||
}
|
||||
font.atlasPopulationMode = AtlasPopulationMode.Static; EditorUtility.SetDirty(font);
|
||||
var stages = new[] {
|
||||
AssetDatabase.LoadAssetAtPath<StageAsset>(FirstSliceAssets.StageOnePath),
|
||||
Stage(6, new[] { 1, 1 }, new[] { 2 }, new[] { new Vector3(-1.2f, .8f, 0), new Vector3(1.2f, .8f, 0) }),
|
||||
Stage(10, new[] { 2 }, new[] { 1, 1 }, new[] { new Vector3(-.5f, .8f, 0), new Vector3(.5f, .8f, 0) }),
|
||||
Stage(14, new[] { 3, 1 }, new[] { 2, 2 }, new[] { new Vector3(-2, .8f, 0), new Vector3(-1, .8f, 0), new Vector3(0, .8f, 0), new Vector3(2, .8f, 0) }) };
|
||||
var root = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
|
||||
try
|
||||
{
|
||||
var app = root.GetComponent<GameApp>(); var serialized = new SerializedObject(app);
|
||||
var stageField = serialized.FindProperty("_stages"); stageField.arraySize = stages.Length;
|
||||
for (int index = 0; index < stages.Length; index++) stageField.GetArrayElementAtIndex(index).objectReferenceValue = stages[index];
|
||||
var selection = root.transform.Find("Interface/SafeArea/StageSelection");
|
||||
var template = selection.Find("Stage01Button").gameObject;
|
||||
((RectTransform)template.transform).sizeDelta = new Vector2(280, 90);
|
||||
var buttons = serialized.FindProperty("_compositionButtons"); buttons.arraySize = 3;
|
||||
var anchors = new[] { new Vector2(.2f, .48f), new Vector2(.8f, .48f), new Vector2(.5f, .64f) };
|
||||
for (int index = 0; index < 3; index++)
|
||||
{
|
||||
int id = stages[index + 1].StageId; string name = $"Stage{id:D2}Button";
|
||||
var existing = selection.Find(name);
|
||||
var button = existing == null ? UnityEngine.Object.Instantiate(template, selection) : existing.gameObject;
|
||||
button.name = name; var rect = (RectTransform)button.transform;
|
||||
rect.anchorMin = rect.anchorMax = anchors[index]; rect.anchoredPosition = Vector2.zero;
|
||||
button.GetComponentInChildren<TMP_Text>().text = $"{id} 친구 만들기";
|
||||
buttons.GetArrayElementAtIndex(index).objectReferenceValue = button.GetComponent<UnityEngine.UI.Button>();
|
||||
}
|
||||
var play = root.transform.Find("Interface/SafeArea/Play");
|
||||
var buttonTemplate = play.Find("HomeButton").gameObject;
|
||||
foreach (var spec in new[] { ("SplitButton", "나누기", "_splitButton"), ("CancelSplitButton", "취소", "_cancelSplitButton") })
|
||||
{
|
||||
var existing = play.Find(spec.Item1);
|
||||
var button = existing == null ? UnityEngine.Object.Instantiate(buttonTemplate, play) : existing.gameObject;
|
||||
button.name = spec.Item1; var rect = (RectTransform)button.transform;
|
||||
rect.anchorMin = rect.anchorMax = new Vector2(.13f, .23f); rect.anchoredPosition = Vector2.zero; rect.sizeDelta = new Vector2(200, 62);
|
||||
var text = button.GetComponentInChildren<TMP_Text>(); text.text = spec.Item2; text.rectTransform.sizeDelta = rect.sizeDelta;
|
||||
serialized.FindProperty(spec.Item3).objectReferenceValue = button.GetComponent<UnityEngine.UI.Button>();
|
||||
button.SetActive(spec.Item1 == "SplitButton");
|
||||
}
|
||||
var voice = root.GetComponent<VoicePlayer>(); var voiceObject = new SerializedObject(voice); var clips = voiceObject.FindProperty("_clips");
|
||||
foreach (string id in new[] { "guide_merge", "guide_split", "demo_composition" })
|
||||
{
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(Presentation + "Audio/Voice/" + id + ".wav");
|
||||
if (clip == null) throw new InvalidOperationException("New draft voice absent: " + id);
|
||||
var importer = (AudioImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip));
|
||||
var settings = importer.defaultSampleSettings; settings.loadType = AudioClipLoadType.DecompressOnLoad; settings.compressionFormat = AudioCompressionFormat.PCM;
|
||||
settings.preloadAudioData = true; importer.defaultSampleSettings = settings; importer.SaveAndReimport();
|
||||
int index = Enumerable.Range(0, clips.arraySize).Where(i => clips.GetArrayElementAtIndex(i).FindPropertyRelative("id").stringValue == id).DefaultIfEmpty(-1).First();
|
||||
if (index == -1) { index = clips.arraySize; clips.arraySize++; }
|
||||
var entry = clips.GetArrayElementAtIndex(index); entry.FindPropertyRelative("id").stringValue = id; entry.FindPropertyRelative("clip").objectReferenceValue = clip;
|
||||
}
|
||||
voiceObject.ApplyModifiedPropertiesWithoutUndo(); serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
PrefabUtility.SaveAsPrefabAsset(root, FirstSliceAssets.AppPrefabPath);
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(root); }
|
||||
var badgeRoot = PrefabUtility.LoadPrefabContents(Presentation + "Prefabs/GroupBadge.prefab");
|
||||
try
|
||||
{
|
||||
var cape = badgeRoot.transform.Find("RibbonCape");
|
||||
if (cape == null)
|
||||
{
|
||||
cape = new GameObject("RibbonCape", typeof(LineRenderer)).transform; cape.SetParent(badgeRoot.transform, false);
|
||||
var line = cape.GetComponent<LineRenderer>(); line.sharedMaterial = AssetDatabase.LoadAssetAtPath<Material>(Presentation + "Materials/CountGold.mat");
|
||||
line.useWorldSpace = false; line.widthMultiplier = .09f; line.positionCount = 4;
|
||||
line.SetPositions(new[] { new Vector3(-.4f, -.1f, .55f), new Vector3(-.55f, -.48f, .6f), new Vector3(-.28f, -.7f, .6f), new Vector3(.1f, -.45f, .6f) });
|
||||
}
|
||||
var badge = new SerializedObject(badgeRoot.GetComponent<GroupBadge>()); badge.FindProperty("_cape").objectReferenceValue = cape;
|
||||
badge.ApplyModifiedPropertiesWithoutUndo(); PrefabUtility.SaveAsPrefabAsset(badgeRoot, Presentation + "Prefabs/GroupBadge.prefab");
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(badgeRoot); }
|
||||
AssetDatabase.SaveAssets(); Debug.Log("[Composition] Stage06/10/14, selection/split controls, contact/selection presentation and draft guides wired. Main remains root-owned.");
|
||||
}
|
||||
private static StageAsset Stage(int id, int[] initial, int[] target, Vector3[] positions)
|
||||
{
|
||||
string path = $"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset";
|
||||
var asset = AssetDatabase.LoadAssetAtPath<StageAsset>(path);
|
||||
if (asset == null) { asset = ScriptableObject.CreateInstance<StageAsset>(); AssetDatabase.CreateAsset(asset, path); }
|
||||
var serialized = new SerializedObject(asset); serialized.FindProperty("_stageId").intValue = id;
|
||||
serialized.FindProperty("_kind").enumValueIndex = (int)StageKind.Composition;
|
||||
foreach (var spec in new[] { ("_initialGroups", initial), ("_targetGroups", target) })
|
||||
{ var field = serialized.FindProperty(spec.Item1); field.arraySize = spec.Item2.Length; for (int index = 0; index < spec.Item2.Length; index++) field.GetArrayElementAtIndex(index).intValue = spec.Item2[index]; }
|
||||
var layout = serialized.FindProperty("_ballPositions"); layout.arraySize = positions.Length;
|
||||
for (int index = 0; index < positions.Length; index++) layout.GetArrayElementAtIndex(index).vector3Value = positions[index];
|
||||
serialized.ApplyModifiedPropertiesWithoutUndo(); return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3716099c561b9a84a952934d7120cdec
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class ContentAssets
|
||||
{
|
||||
public static void InspectModels()
|
||||
{
|
||||
foreach (string path in Directory.GetFiles(Presentation + "Prefabs/Scenery", "*.prefab"))
|
||||
{
|
||||
var root=UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(path));
|
||||
foreach (var mesh in root.GetComponentsInChildren<MeshFilter>()) Debug.Log($"[ModelBounds] {path} rootRotation={root.transform.rotation} childRotation={mesh.transform.rotation} scale={mesh.transform.lossyScale} mesh={mesh.sharedMesh.bounds} renderer={mesh.GetComponent<MeshRenderer>().bounds}");
|
||||
UnityEngine.Object.DestroyImmediate(root);
|
||||
}
|
||||
}
|
||||
private const string Presentation = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
|
||||
private static readonly int[][] Initial = {
|
||||
new[] {1,1}, new[] {2,1}, new[] {3,1}, new[] {2,3}, new[] {2},
|
||||
new[] {3}, new[] {4}, new[] {5}, new[] {3,1}, new[] {4,1},
|
||||
new[] {5,1}, new[] {3,4}, new[] {4,4}, new[] {5,5}, new[] {6},
|
||||
new[] {7}, new[] {9}, new[] {10}, new[] {5,3}, new[] {6,4} };
|
||||
private static readonly int[][] Target = {
|
||||
new[] {2}, new[] {3}, new[] {4}, new[] {5}, new[] {1,1},
|
||||
new[] {2,1}, new[] {2,2}, new[] {3,2}, new[] {2,2}, new[] {3,2},
|
||||
new[] {6}, new[] {7}, new[] {8}, new[] {10}, new[] {5,1},
|
||||
new[] {4,3}, new[] {5,4}, new[] {5,5}, new[] {4,4}, new[] {5,5} };
|
||||
[MenuItem("Magnetism Heroes/Build Content Assets")]
|
||||
public static void Build()
|
||||
{
|
||||
foreach (string folder in new[] { "Models", "Materials", "Prefabs/Scenery" }) Directory.CreateDirectory(Presentation + folder);
|
||||
AssetDatabase.Refresh();
|
||||
string[] modelNames = { "Island", "BlockArch", "ToySteps", "Pebbles", "GardenFlower", "GardenMushroom", "GardenLeaf", "CloudPuff", "CloudRainbow", "CloudMoon", "WorkshopStar", "WorkshopGear", "WorkshopCrystals" };
|
||||
var props = modelNames.ToDictionary(name => name, Prop);
|
||||
var stages = Enumerable.Range(1, 30).Select(id => Stage(id, props)).ToArray();
|
||||
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(Presentation + "Fonts/NotoSansCJKkr-FirstSlice.asset");
|
||||
const string glyphs = "0123456789 · 잠금 완료 이어하기 시작 도움 다음 시범 나누기 → 하나 선택 끌어내기 친구를 끌어와 붙여 보자 이 친구를 찾아 끌어와 볼까? 아직 세지 않은 이 구를 눌러 볼까? 빛나는 두 친구를 가까이 끌어와 붙여 볼까? 빛나는 친구에서 나누기를 누르고 개를 골라 끌어내 보자.";
|
||||
if (glyphs.Any(character => !font.HasCharacter(character)))
|
||||
{
|
||||
typeof(TMP_FontAsset).GetProperty("SourceFont_EditorRef", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(font, AssetDatabase.LoadAssetAtPath<Font>(Presentation + "Fonts/NotoSansCJKkr-Regular.otf"));
|
||||
font.atlasPopulationMode = AtlasPopulationMode.Dynamic;
|
||||
try { if (!font.TryAddCharacters(glyphs, out var missing)) throw new InvalidOperationException("Missing content glyphs: " + missing); }
|
||||
finally { font.atlasPopulationMode = AtlasPopulationMode.Static; }
|
||||
EditorUtility.SetDirty(font);
|
||||
}
|
||||
var root = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
|
||||
try
|
||||
{
|
||||
var app = new SerializedObject(root.GetComponent<GameApp>());
|
||||
var catalog = app.FindProperty("_stages"); catalog.arraySize = stages.Length;
|
||||
for (int i = 0; i < stages.Length; i++) catalog.GetArrayElementAtIndex(i).objectReferenceValue = stages[i];
|
||||
// These representative controls are now members of the complete ordered catalog.
|
||||
app.FindProperty("_compositionButtons").arraySize = 3;
|
||||
var selection = root.transform.Find("Interface/SafeArea/StageSelection");
|
||||
var template = selection.Find("Stage01Button").gameObject;
|
||||
var buttons = app.FindProperty("_stageButtons"); buttons.arraySize = 30;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var button = Button(selection, template, $"Stage{i + 1:D2}Button", $"{i + 1} · 잠금",
|
||||
new Vector2(.12f + i % 6 * .152f, .76f - i / 6 * .123f), new Vector2(165, 66), 24);
|
||||
buttons.GetArrayElementAtIndex(i).objectReferenceValue = button;
|
||||
}
|
||||
for (int i = 0; i < 3; i++) app.FindProperty("_compositionButtons").GetArrayElementAtIndex(i).objectReferenceValue = selection.Find($"Stage{new[] { 6, 10, 14 }[i]:D2}Button").GetComponent<UnityEngine.UI.Button>();
|
||||
var title = (RectTransform)selection.Find("SelectionTitle"); title.anchorMin = title.anchorMax = new Vector2(.5f, .92f); title.sizeDelta = new Vector2(820, 65);
|
||||
var back = (RectTransform)selection.Find("SelectionHomeButton"); back.anchorMin = back.anchorMax = new Vector2(.5f, .11f); back.sizeDelta = new Vector2(230, 60);
|
||||
var play = root.transform.Find("Interface/SafeArea/Play"); var playTemplate = play.Find("HomeButton").gameObject;
|
||||
app.FindProperty("_helpButton").objectReferenceValue = Button(play, playTemplate, "HelpButton", "도움", new Vector2(.13f, .34f), new Vector2(170, 62), 28);
|
||||
app.FindProperty("_nextButton").objectReferenceValue = Button(play, playTemplate, "NextButton", "다음", new Vector2(.86f, .4f), new Vector2(170, 62), 28);
|
||||
var world = root.transform.Find("Playground");
|
||||
foreach (string name in new[] { "ToyStep", "ToyArchPost" })
|
||||
{ var old = world.Find(name); if (old != null) UnityEngine.Object.DestroyImmediate(old.gameObject); }
|
||||
var floor=world.Find("PlaygroundIsland");
|
||||
if (floor==null) { floor=new GameObject("PlaygroundIsland",typeof(MeshFilter),typeof(MeshRenderer),typeof(MeshCollider)).transform; floor.SetParent(world,false); }
|
||||
var island=props["Island"].GetComponentInChildren<MeshFilter>();
|
||||
floor.localPosition=new Vector3(0,-.45f,0); floor.localRotation=island.transform.localRotation; floor.localScale=Vector3.one;
|
||||
floor.GetComponent<MeshFilter>().sharedMesh=island.sharedMesh;
|
||||
floor.GetComponent<MeshRenderer>().sharedMaterial=island.GetComponent<MeshRenderer>().sharedMaterial;
|
||||
floor.GetComponent<MeshCollider>().sharedMesh=island.sharedMesh;
|
||||
var environment = world.Find("Environment");
|
||||
if (environment == null) { environment = new GameObject("Environment", typeof(BoardEnvironment)).transform; environment.SetParent(world, false); }
|
||||
app.FindProperty("_environment").objectReferenceValue = environment.GetComponent<BoardEnvironment>();
|
||||
var ghost = play.Find("GhostDemonstration");
|
||||
if (ghost == null)
|
||||
{
|
||||
ghost = new GameObject("GhostDemonstration", typeof(RectTransform), typeof(UnityEngine.UI.Image), typeof(GhostDemonstration)).transform;
|
||||
ghost.SetParent(play, false); var rect = (RectTransform)ghost; rect.anchorMin = rect.anchorMax = new Vector2(.22f, .58f); rect.sizeDelta = new Vector2(330, 160);
|
||||
var background = ghost.GetComponent<UnityEngine.UI.Image>(); background.color = new Color(.12f, .2f, .31f, .92f); background.raycastTarget = false;
|
||||
var serialized = new SerializedObject(ghost.GetComponent<GhostDemonstration>());
|
||||
foreach (string field in new[] { "_first", "_second" })
|
||||
{
|
||||
var ball = new GameObject(field, typeof(RectTransform), typeof(UnityEngine.UI.Image)); ball.transform.SetParent(ghost, false);
|
||||
var ballRect = (RectTransform)ball.transform; ballRect.sizeDelta = new Vector2(54, 54);
|
||||
var image = ball.GetComponent<UnityEngine.UI.Image>(); image.raycastTarget = false;
|
||||
image.sprite = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/Knob.psd");
|
||||
serialized.FindProperty(field).objectReferenceValue = ballRect;
|
||||
}
|
||||
var caption = new GameObject("Caption", typeof(RectTransform), typeof(TextMeshProUGUI)); caption.transform.SetParent(ghost, false);
|
||||
var text = caption.GetComponent<TextMeshProUGUI>(); text.font = font; text.fontSize = 22; text.color = Color.white; text.alignment = TextAlignmentOptions.Center; text.raycastTarget = false;
|
||||
text.rectTransform.anchorMin = text.rectTransform.anchorMax = new Vector2(.5f, .17f); text.rectTransform.sizeDelta = new Vector2(310, 55);
|
||||
serialized.FindProperty("_caption").objectReferenceValue = text; serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
}
|
||||
ghost.gameObject.SetActive(false); app.FindProperty("_demonstration").objectReferenceValue = ghost.GetComponent<GhostDemonstration>();
|
||||
app.ApplyModifiedPropertiesWithoutUndo();
|
||||
var voice = new SerializedObject(root.GetComponent<VoicePlayer>()); var clips = voice.FindProperty("_clips");
|
||||
foreach (string path in Directory.GetFiles(Presentation + "Audio/Voice", "*.wav"))
|
||||
{
|
||||
string id = Path.GetFileNameWithoutExtension(path);
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AudioClip>(path.Replace('\\', '/'));
|
||||
var importer = (AudioImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip)); var settings = importer.defaultSampleSettings;
|
||||
settings.loadType = AudioClipLoadType.DecompressOnLoad; settings.compressionFormat = AudioCompressionFormat.PCM; settings.preloadAudioData = true;
|
||||
importer.defaultSampleSettings = settings; importer.SaveAndReimport();
|
||||
int index = Enumerable.Range(0, clips.arraySize).Where(i => clips.GetArrayElementAtIndex(i).FindPropertyRelative("id").stringValue == id).DefaultIfEmpty(-1).First();
|
||||
if (index < 0) { index = clips.arraySize; clips.arraySize++; }
|
||||
clips.GetArrayElementAtIndex(index).FindPropertyRelative("id").stringValue = id;
|
||||
clips.GetArrayElementAtIndex(index).FindPropertyRelative("clip").objectReferenceValue = clip;
|
||||
}
|
||||
voice.ApplyModifiedPropertiesWithoutUndo(); PrefabUtility.SaveAsPrefabAsset(root, FirstSliceAssets.AppPrefabPath);
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(root); }
|
||||
AssetDatabase.SaveAssets(); Debug.Log("[Content] All 30 fixed assets, three Blender scenery themes, ordered controls, live help, next and ghost demonstrations built.");
|
||||
}
|
||||
private static GameObject Prop(string name)
|
||||
{
|
||||
string modelPath = Presentation + "Models/" + name + ".fbx";
|
||||
var importer = (ModelImporter)AssetImporter.GetAtPath(modelPath);
|
||||
if (importer == null) throw new InvalidOperationException("Blender export missing: " + modelPath);
|
||||
importer.globalScale = 1; importer.useFileScale = true; importer.bakeAxisConversion = true; importer.importCameras = false; importer.importLights = false; importer.importAnimation = false;
|
||||
importer.SaveAndReimport();
|
||||
var model = AssetDatabase.LoadAssetAtPath<GameObject>(modelPath);
|
||||
var instance = new GameObject(name);
|
||||
var modelInstance=(GameObject)PrefabUtility.InstantiatePrefab(model); modelInstance.transform.SetParent(instance.transform,false);
|
||||
try
|
||||
{
|
||||
var color = name.StartsWith("Garden") ? new Color(.96f,.56f,.48f) : name.StartsWith("Cloud") ? new Color(.8f,.88f,1) : name.StartsWith("Workshop") ? new Color(1,.79f,.27f) : name == "Island" ? new Color(.61f,.83f,.71f) : new Color(.61f,.69f,.92f);
|
||||
string materialPath = Presentation + "Materials/" + name + ".mat";
|
||||
var material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
|
||||
if (material == null) { material = new Material(Shader.Find("Universal Render Pipeline/Lit")); AssetDatabase.CreateAsset(material, materialPath); }
|
||||
material.color = color; EditorUtility.SetDirty(material);
|
||||
foreach (var renderer in instance.GetComponentsInChildren<MeshRenderer>()) renderer.sharedMaterial = material;
|
||||
foreach (var filter in instance.GetComponentsInChildren<MeshFilter>())
|
||||
{ var collider = filter.gameObject.GetComponent<MeshCollider>(); if (collider == null) collider = filter.gameObject.AddComponent<MeshCollider>(); collider.sharedMesh = filter.sharedMesh; collider.convex = false; }
|
||||
return PrefabUtility.SaveAsPrefabAsset(instance, Presentation + "Prefabs/Scenery/" + name + ".prefab");
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(instance); }
|
||||
}
|
||||
private static StageAsset Stage(int id, System.Collections.Generic.Dictionary<string, GameObject> props)
|
||||
{
|
||||
bool counting = id <= 5 || id >= 16 && id <= 20;
|
||||
int n = id <= 5 ? id : id - 10; int index = id <= 15 ? id - 6 : id - 11;
|
||||
int[] initial = counting ? Enumerable.Repeat(1, n).ToArray() : Initial[index];
|
||||
int[] target = counting ? new[] { n } : Target[index];
|
||||
string path = $"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset";
|
||||
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>(path);
|
||||
bool preserveLayout = stage != null;
|
||||
if (stage == null) { stage = ScriptableObject.CreateInstance<StageAsset>(); AssetDatabase.CreateAsset(stage, path); }
|
||||
var data = new SerializedObject(stage); data.FindProperty("_stageId").intValue = id;
|
||||
data.FindProperty("_kind").enumValueIndex = (int)(counting ? StageKind.Counting : StageKind.Composition);
|
||||
foreach (var spec in new[] { ("_initialGroups", initial), ("_targetGroups", target) })
|
||||
{ var field = data.FindProperty(spec.Item1); field.arraySize = spec.Item2.Length; for (int i = 0; i < spec.Item2.Length; i++) field.GetArrayElementAtIndex(i).intValue = spec.Item2[i]; }
|
||||
if (!preserveLayout)
|
||||
{
|
||||
var positions = data.FindProperty("_ballPositions"); positions.arraySize = initial.Sum(); int ball = 0;
|
||||
if (counting)
|
||||
{
|
||||
var layout = new[] { new Vector3(-2,.8f,0), new Vector3(2,.8f,0), new Vector3(0,2.3f,0), new Vector3(-3.4f,.8f,2.8f), new Vector3(3.2f,2.7f,1.5f), new Vector3(0,.8f,2.5f), new Vector3(-2.2f,2.7f,-1.5f), new Vector3(2.2f,.8f,-1.8f), new Vector3(-.5f,2.2f,2.5f), new Vector3(3.4f,.8f,2.8f) };
|
||||
for (int i = 0; i < positions.arraySize; i++) positions.GetArrayElementAtIndex(i).vector3Value = layout[i];
|
||||
}
|
||||
else for (int group = 0; group < initial.Length; group++)
|
||||
for (int i = 0; i < initial[group]; i++)
|
||||
positions.GetArrayElementAtIndex(ball++).vector3Value = new Vector3(i - (initial[group] - 1) * .5f, .8f, initial.Length == 1 ? 0 : group == 0 ? -1.2f : 1.2f);
|
||||
}
|
||||
string theme = id <= 10 ? "Garden" : id <= 20 ? "Cloud" : "Workshop"; data.FindProperty("_theme").stringValue = theme;
|
||||
string[] themed = theme == "Garden" ? new[] { "GardenFlower", "GardenMushroom", "GardenLeaf" } : theme == "Cloud" ? new[] { "CloudPuff", "CloudRainbow", "CloudMoon" } : new[] { "WorkshopStar", "WorkshopGear", "WorkshopCrystals" };
|
||||
string[] names = new[] { "Island", "BlockArch", "ToySteps", "Pebbles" }.Concat(themed).ToArray();
|
||||
var locations = new[] { new Vector3(0,-.45f,0), new Vector3(-4,0,1.6f), new Vector3(4,0,1.7f), new Vector3(3.8f,0,-2.6f), new Vector3(-4.1f,0,-1.8f), new Vector3(-3.4f,0,1.4f), new Vector3(3.7f,0,3) };
|
||||
var decorations = data.FindProperty("_decorations"); decorations.arraySize = names.Length;
|
||||
for (int i = 0; i < names.Length; i++)
|
||||
{
|
||||
var placement = decorations.GetArrayElementAtIndex(i); placement.FindPropertyRelative("prefab").objectReferenceValue = props[names[i]];
|
||||
placement.FindPropertyRelative("position").vector3Value = locations[i]; placement.FindPropertyRelative("eulerAngles").vector3Value = Vector3.zero; placement.FindPropertyRelative("scale").vector3Value = Vector3.one;
|
||||
}
|
||||
data.ApplyModifiedPropertiesWithoutUndo(); return stage;
|
||||
}
|
||||
private static UnityEngine.UI.Button Button(Transform parent, GameObject template, string name, string label, Vector2 anchor, Vector2 size, float fontSize)
|
||||
{
|
||||
var existing = parent.Find(name); var button = existing == null ? UnityEngine.Object.Instantiate(template, parent) : existing.gameObject;
|
||||
button.name = name; var rect = (RectTransform)button.transform; rect.anchorMin = rect.anchorMax = anchor; rect.anchoredPosition = Vector2.zero; rect.sizeDelta = size;
|
||||
var text = button.GetComponentInChildren<TMP_Text>(); text.text = label; text.fontSize = fontSize; text.rectTransform.sizeDelta = size;
|
||||
return button.GetComponent<UnityEngine.UI.Button>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1023d9efc73aff743bf77a461d6d5ce6
|
||||
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Interaction;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem.UI;
|
||||
using UnityEngine.TextCore.LowLevel;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class FirstSliceAssets
|
||||
{
|
||||
public const string AppPrefabPath = "Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab";
|
||||
public const string StageOnePath = "Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage01.asset";
|
||||
private const string Presentation = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
|
||||
private static readonly Color Ink = new Color(0.12f, 0.2f, 0.31f);
|
||||
private static TMP_FontAsset _font;
|
||||
|
||||
[MenuItem("Magnetism Heroes/Build First Slice Assets")]
|
||||
public static void Build()
|
||||
{
|
||||
foreach (var directory in new[] { "Prefabs", "Materials", "Fonts", "Audio" }) Directory.CreateDirectory(Presentation + directory);
|
||||
AssetDatabase.Refresh();
|
||||
if (TMP_Settings.instance == null)
|
||||
{
|
||||
var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(TMP_Settings).Assembly);
|
||||
// uGUI 2.6's canonical helper does not resolve registry PackageCache paths.
|
||||
UnityEditor.AssetPackage.Package.Import(Path.Combine(package.resolvedPath, "Package Resources/TMP Essential Resources.unitypackage"), false);
|
||||
EditorApplication.wantsToQuit += WaitForResources;
|
||||
EditorApplication.update += ContinueAfterResources;
|
||||
return;
|
||||
}
|
||||
AssetDatabase.Refresh();
|
||||
_font = FontAsset();
|
||||
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>(StageOnePath);
|
||||
if (stage == null) { stage = ScriptableObject.CreateInstance<StageAsset>(); AssetDatabase.CreateAsset(stage, StageOnePath); }
|
||||
var sphereMaterial = Material("MagnetBlue", new Color(0.15f, 0.68f, 0.92f));
|
||||
var markerMaterial = Material("CountGold", new Color(1, 0.76f, 0.14f));
|
||||
var faceMaterial = Material("FaceInk", Ink);
|
||||
var baseMaterial = Material("PlaygroundMint", new Color(0.65f, 0.88f, 0.73f));
|
||||
var propMaterial = Material("ToyCoral", new Color(1, 0.57f, 0.43f));
|
||||
var ballPrefab = BallPrefab(sphereMaterial, markerMaterial);
|
||||
var badgePrefab = BadgePrefab(faceMaterial);
|
||||
|
||||
var root = new GameObject("MagnetismHeroesApp");
|
||||
var app = root.AddComponent<GameApp>();
|
||||
var world = new GameObject("Playground"); world.transform.SetParent(root.transform);
|
||||
var cameraObject = new GameObject("GameCamera", typeof(Camera), typeof(AudioListener)); cameraObject.transform.SetParent(root.transform);
|
||||
var camera = cameraObject.GetComponent<Camera>(); camera.tag = "MainCamera"; camera.backgroundColor = new Color(0.84f, 0.94f, 1);
|
||||
camera.clearFlags = CameraClearFlags.SolidColor; camera.fieldOfView = 46;
|
||||
var light = new GameObject("Sun", typeof(Light)); light.transform.SetParent(root.transform);
|
||||
light.transform.rotation = Quaternion.Euler(45, -25, 0); light.GetComponent<Light>().type = LightType.Directional;
|
||||
light.GetComponent<Light>().intensity = 1.3f;
|
||||
var island = Prop("PlaygroundIsland", PrimitiveType.Cylinder, world.transform, new Vector3(0, -0.7f, 0), new Vector3(6, 0.35f, 4.5f), baseMaterial);
|
||||
// The primitive capsule grows to the widest scaled radius and hides the floating sphere.
|
||||
UnityEngine.Object.DestroyImmediate(island.GetComponent<Collider>());
|
||||
island.AddComponent<MeshCollider>().sharedMesh = island.GetComponent<MeshFilter>().sharedMesh;
|
||||
Prop("ToyStep", PrimitiveType.Cube, world.transform, new Vector3(1.8f, -0.1f, 1.1f), new Vector3(1, 0.5f, 1), propMaterial);
|
||||
Prop("ToyArchPost", PrimitiveType.Cylinder, world.transform, new Vector3(-2.4f, -0.05f, 1), new Vector3(0.45f, 0.65f, 0.45f), propMaterial);
|
||||
var boardObject = new GameObject("Friends"); boardObject.transform.SetParent(world.transform);
|
||||
var presenter = boardObject.AddComponent<BoardPresenter>();
|
||||
Set(presenter, "_ballPrefab", ballPrefab); Set(presenter, "_badgePrefab", badgePrefab); Set(presenter, "_camera", camera);
|
||||
var orbit = root.AddComponent<OrbitCamera>(); Set(orbit, "_camera", camera);
|
||||
var input = root.AddComponent<BoardInput>(); Set(input, "_app", app); Set(input, "_presenter", presenter); Set(input, "_orbit", orbit);
|
||||
var voice = root.AddComponent<VoicePlayer>();
|
||||
var source = root.AddComponent<AudioSource>(); source.playOnAwake = false; source.spatialBlend = 0;
|
||||
Set(voice, "_source", source); ConfigureVoices(voice);
|
||||
source.outputAudioMixerGroup = Mixer().FindMatchingGroups("Voice").Single();
|
||||
|
||||
var events = new GameObject("EventSystem", typeof(EventSystem), typeof(InputSystemUIInputModule)); events.transform.SetParent(root.transform);
|
||||
events.GetComponent<InputSystemUIInputModule>().AssignDefaultActions();
|
||||
var canvasObject = new GameObject("Interface", typeof(RectTransform), typeof(Canvas), typeof(UnityEngine.UI.CanvasScaler), typeof(UnityEngine.UI.GraphicRaycaster));
|
||||
canvasObject.transform.SetParent(root.transform); canvasObject.GetComponent<Canvas>().renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasObject.GetComponent<UnityEngine.UI.CanvasScaler>(); scaler.uiScaleMode = UnityEngine.UI.CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1280, 720); scaler.matchWidthOrHeight = 0.5f;
|
||||
var safe = Panel("SafeArea", canvasObject.transform); safe.AddComponent<SafeAreaPanel>();
|
||||
var home = Panel("Home", safe.transform); var selection = Panel("StageSelection", safe.transform); var play = Panel("Play", safe.transform);
|
||||
Label("Title", home.transform, "자석 영웅", new Vector2(0.5f, 0.78f), new Vector2(660, 110), 66);
|
||||
Label("Subtitle", home.transform, "숫자 친구와 입체 놀이터", new Vector2(0.5f, 0.63f), new Vector2(720, 70), 30);
|
||||
var playButton = Button("PlayButton", home.transform, "친구 찾기", new Vector2(0.5f, 0.43f));
|
||||
var continueButton = Button("ContinueButton", home.transform, "자유 놀이 이어하기", new Vector2(0.5f, 0.25f));
|
||||
var saveNotice = Label("SaveNotice", home.transform, "", new Vector2(0.5f, 0.07f), new Vector2(1040, 55), 24);
|
||||
Label("SelectionTitle", selection.transform, "숫자 친구를 만나 볼까?", new Vector2(0.5f, 0.78f), new Vector2(820, 90), 42);
|
||||
var stageButton = Button("Stage01Button", selection.transform, "1 친구 찾기", new Vector2(0.5f, 0.48f));
|
||||
var selectionHomeButton = Button("SelectionHomeButton", selection.transform, "처음으로", new Vector2(0.5f, 0.24f));
|
||||
var stageTitle = Label("StageTitle", play.transform, "숫자 1 친구 찾기", new Vector2(0.5f, 0.92f), new Vector2(580, 60), 34);
|
||||
var instruction = Label("Instruction", play.transform, "친구를 찾아 끌어와 볼까?", new Vector2(0.5f, 0.12f), new Vector2(960, 70), 32);
|
||||
var counter = Label("Counter", play.transform, "센 친구 0 / 1", new Vector2(0.83f, 0.9f), new Vector2(330, 70), 28);
|
||||
var homeButton = Button("HomeButton", play.transform, "처음으로", new Vector2(0.13f, 0.9f), new Vector2(230, 72), 28);
|
||||
Label("CameraHelp", play.transform, "빈 곳을 쓸면 회전 · 두 손가락으로 확대", new Vector2(0.5f, 0.035f), new Vector2(1010, 36), 23);
|
||||
Set(app, "_homePanel", home); Set(app, "_selectionPanel", selection); Set(app, "_playPanel", play); Set(app, "_world", world);
|
||||
Set(app, "_instruction", instruction); Set(app, "_counter", counter); Set(app, "_stageTitle", stageTitle); Set(app, "_saveNotice", saveNotice);
|
||||
Set(app, "_playButton", playButton); Set(app, "_continueButton", continueButton); Set(app, "_stageOneButton", stageButton);
|
||||
Set(app, "_homeButton", homeButton); Set(app, "_selectionHomeButton", selectionHomeButton);
|
||||
Set(app, "_presenter", presenter); Set(app, "_orbit", orbit); Set(app, "_input", input); Set(app, "_voice", voice);
|
||||
var serialized = new SerializedObject(app); var stages = serialized.FindProperty("_stages"); stages.arraySize = 1;
|
||||
stages.GetArrayElementAtIndex(0).objectReferenceValue = stage; serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
home.SetActive(true); selection.SetActive(false); play.SetActive(false); world.SetActive(false);
|
||||
PrefabUtility.SaveAsPrefabAsset(root, AppPrefabPath); UnityEngine.Object.DestroyImmediate(root);
|
||||
AssetDatabase.SaveAssets(); Debug.Log("[FirstSlice] Real Stage01 and GameApp assets created. Main scene remains root-owned.");
|
||||
}
|
||||
|
||||
private static bool WaitForResources() => false;
|
||||
private static void ContinueAfterResources()
|
||||
{
|
||||
if (Resources.Load<TMP_Settings>("TMP Settings") == null || EditorApplication.isUpdating) return;
|
||||
EditorApplication.update -= ContinueAfterResources; EditorApplication.wantsToQuit -= WaitForResources;
|
||||
try { Build(); if (Application.isBatchMode) EditorApplication.Exit(0); }
|
||||
catch (Exception exception) { Debug.LogException(exception); if (Application.isBatchMode) EditorApplication.Exit(1); }
|
||||
}
|
||||
|
||||
private static TMP_FontAsset FontAsset()
|
||||
{
|
||||
const string path = Presentation + "Fonts/NotoSansCJKkr-FirstSlice.asset";
|
||||
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(path);
|
||||
bool created = font == null;
|
||||
if (created)
|
||||
{
|
||||
var source = AssetDatabase.LoadAssetAtPath<Font>(Presentation + "Fonts/NotoSansCJKkr-Regular.otf");
|
||||
font = TMP_FontAsset.CreateFontAsset(source, 48, 5, GlyphRenderMode.SDFAA, 1024, 1024, AtlasPopulationMode.Dynamic, false);
|
||||
}
|
||||
if (font == null) throw new InvalidOperationException("Could not create Korean font.");
|
||||
font.name = "NotoSansCJKkr-FirstSlice";
|
||||
var characters = "0123456789 자석 영웅 숫자 친구와 입체 놀이터 친구 찾기 자유 놀이 이어하기 숫자 친구를 만나 볼까? 처음으로 숫자 1 친구 찾기 친구를 찾아 끌어와 볼까? 센 친구 0 / 1 빈 곳을 쓸면 회전 · 두 손가락으로 확대 이전 기록으로 이어서 시작할게요. 기록을 읽지 못해 처음부터 시작할게요. 친구를 먼저 끌어와 볼까? 자유 놀이터 해냈어! 친구와 자유롭게 놀아 보자. 모두 모았네! 하나씩 눌러 세어 보자.";
|
||||
typeof(TMP_FontAsset).GetProperty("SourceFont_EditorRef", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(font, AssetDatabase.LoadAssetAtPath<Font>(Presentation + "Fonts/NotoSansCJKkr-Regular.otf"));
|
||||
font.atlasPopulationMode = AtlasPopulationMode.Dynamic;
|
||||
var serializedFont = new SerializedObject(font);
|
||||
serializedFont.FindProperty("m_SourceFontFilePath").stringValue = Presentation + "Fonts/NotoSansCJKkr-Regular.otf";
|
||||
serializedFont.ApplyModifiedPropertiesWithoutUndo();
|
||||
if (!font.TryAddCharacters(characters, out var missing)) throw new InvalidOperationException("Missing font glyphs: " + missing);
|
||||
font.atlasPopulationMode = AtlasPopulationMode.Static; font.faceInfo = SetFaceScale(font.faceInfo);
|
||||
if (created)
|
||||
{
|
||||
AssetDatabase.CreateAsset(font, path); AssetDatabase.AddObjectToAsset(font.material, font);
|
||||
foreach (var atlas in font.atlasTextures) AssetDatabase.AddObjectToAsset(atlas, font);
|
||||
}
|
||||
EditorUtility.SetDirty(font);
|
||||
return font;
|
||||
}
|
||||
private static UnityEngine.TextCore.FaceInfo SetFaceScale(UnityEngine.TextCore.FaceInfo face) { face.scale = 1; return face; }
|
||||
private static Material Material(string name, Color color)
|
||||
{
|
||||
string path = Presentation + "Materials/" + name + ".mat";
|
||||
var material = AssetDatabase.LoadAssetAtPath<Material>(path);
|
||||
if (material == null) { material = new Material(Shader.Find("Universal Render Pipeline/Simple Lit")); AssetDatabase.CreateAsset(material, path); }
|
||||
material.color = color; return material;
|
||||
}
|
||||
private static BallView BallPrefab(Material body, Material marker)
|
||||
{
|
||||
var root = GameObject.CreatePrimitive(PrimitiveType.Sphere); root.name = "MagnetFriend";
|
||||
root.GetComponent<Renderer>().sharedMaterial = body;
|
||||
var view = root.AddComponent<BallView>(); Set(view, "_body", root.GetComponent<Renderer>());
|
||||
var counted = Prop("CountMarker", PrimitiveType.Cube, root.transform, new Vector3(0, 0.55f, 0), new Vector3(0.24f, 0.08f, 0.24f), marker);
|
||||
UnityEngine.Object.DestroyImmediate(counted.GetComponent<Collider>()); counted.SetActive(false); Set(view, "_countMarker", counted);
|
||||
var prefab = PrefabUtility.SaveAsPrefabAsset(root, Presentation + "Prefabs/MagnetFriend.prefab"); UnityEngine.Object.DestroyImmediate(root);
|
||||
return prefab.GetComponent<BallView>();
|
||||
}
|
||||
private static GroupBadge BadgePrefab(Material ink)
|
||||
{
|
||||
var root = new GameObject("GroupBadge"); var badge = root.AddComponent<GroupBadge>();
|
||||
var label = new GameObject("Number", typeof(TextMeshPro)); label.transform.SetParent(root.transform);
|
||||
var text = label.GetComponent<TextMeshPro>(); text.font = _font; text.fontSize = 5; text.alignment = TextAlignmentOptions.Center;
|
||||
text.color = Ink; text.rectTransform.sizeDelta = new Vector2(2, 0.7f); text.text = "1"; text.enableAutoSizing = false;
|
||||
var face = new GameObject("RepresentativeFace"); face.transform.SetParent(root.transform);
|
||||
var left = Prop("LeftEye", PrimitiveType.Quad, face.transform, new Vector3(-0.15f, 0.05f, 0), new Vector3(0.08f, 0.11f, 1), ink);
|
||||
var right = Prop("RightEye", PrimitiveType.Quad, face.transform, new Vector3(0.15f, 0.05f, 0), new Vector3(0.08f, 0.11f, 1), ink);
|
||||
UnityEngine.Object.DestroyImmediate(left.GetComponent<Collider>()); UnityEngine.Object.DestroyImmediate(right.GetComponent<Collider>());
|
||||
var smile = new GameObject("Smile", typeof(LineRenderer)); smile.transform.SetParent(face.transform); smile.transform.localPosition = Vector3.zero;
|
||||
var line = smile.GetComponent<LineRenderer>(); line.sharedMaterial = ink; line.useWorldSpace = false; line.positionCount = 9; line.widthMultiplier = 0.035f;
|
||||
for (int i = 0; i < 9; i++) { float x = (i - 4) * 0.035f; line.SetPosition(i, new Vector3(x, x * x * 3 - 0.14f, 0)); }
|
||||
Set(badge, "_number", text); Set(badge, "_face", face.transform);
|
||||
var prefab = PrefabUtility.SaveAsPrefabAsset(root, Presentation + "Prefabs/GroupBadge.prefab"); UnityEngine.Object.DestroyImmediate(root);
|
||||
return prefab.GetComponent<GroupBadge>();
|
||||
}
|
||||
private static GameObject Prop(string name, PrimitiveType primitive, Transform parent, Vector3 position, Vector3 scale, Material material)
|
||||
{
|
||||
var prop = GameObject.CreatePrimitive(primitive); prop.name = name; prop.transform.SetParent(parent);
|
||||
prop.transform.localPosition = position; prop.transform.localScale = scale; prop.GetComponent<Renderer>().sharedMaterial = material; return prop;
|
||||
}
|
||||
private static GameObject Panel(string name, Transform parent)
|
||||
{
|
||||
var panel = new GameObject(name, typeof(RectTransform)); panel.transform.SetParent(parent, false);
|
||||
var rect = (RectTransform)panel.transform; rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.offsetMin = rect.offsetMax = Vector2.zero; return panel;
|
||||
}
|
||||
private static TMP_Text Label(string name, Transform parent, string content, Vector2 anchor, Vector2 size, int fontSize)
|
||||
{
|
||||
var label = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI)); label.transform.SetParent(parent, false);
|
||||
var text = label.GetComponent<TextMeshProUGUI>(); text.font = _font; text.fontSize = fontSize; text.text = content;
|
||||
text.color = Ink; text.alignment = TextAlignmentOptions.Center; text.raycastTarget = false; text.enableAutoSizing = false;
|
||||
var rect = (RectTransform)label.transform; rect.anchorMin = rect.anchorMax = anchor; rect.anchoredPosition = Vector2.zero; rect.sizeDelta = size; return text;
|
||||
}
|
||||
private static UnityEngine.UI.Button Button(string name, Transform parent, string content, Vector2 anchor, Vector2? size = null, int fontSize = 34)
|
||||
{
|
||||
var buttonObject = new GameObject(name, typeof(RectTransform), typeof(UnityEngine.UI.Image), typeof(UnityEngine.UI.Button)); buttonObject.transform.SetParent(parent, false);
|
||||
var rect = (RectTransform)buttonObject.transform; rect.anchorMin = rect.anchorMax = anchor; rect.sizeDelta = size ?? new Vector2(460, 90); rect.anchoredPosition = Vector2.zero;
|
||||
var image = buttonObject.GetComponent<UnityEngine.UI.Image>(); image.color = new Color(1, 0.8f, 0.3f); image.raycastTarget = true;
|
||||
var button = buttonObject.GetComponent<UnityEngine.UI.Button>(); button.targetGraphic = image;
|
||||
var text = Label("Label", buttonObject.transform, content, new Vector2(0.5f, 0.5f), rect.sizeDelta, fontSize); return button;
|
||||
}
|
||||
private static void Set(UnityEngine.Object target, string field, UnityEngine.Object value)
|
||||
{ var serialized = new SerializedObject(target); serialized.FindProperty(field).objectReferenceValue = value; serialized.ApplyModifiedPropertiesWithoutUndo(); }
|
||||
private static void ConfigureVoices(VoicePlayer voice)
|
||||
{
|
||||
var clips = AssetDatabase.FindAssets("t:AudioClip", new[] { Presentation + "Audio" })
|
||||
.Select(guid => AssetDatabase.LoadAssetAtPath<AudioClip>(AssetDatabase.GUIDToAssetPath(guid))).Where(clip => clip != null).ToArray();
|
||||
foreach (var clip in clips)
|
||||
{
|
||||
var importer = (AudioImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip));
|
||||
var settings = importer.defaultSampleSettings; settings.loadType = AudioClipLoadType.DecompressOnLoad;
|
||||
settings.compressionFormat = AudioCompressionFormat.PCM; settings.preloadAudioData = true;
|
||||
importer.defaultSampleSettings = settings; importer.forceToMono = true; importer.SaveAndReimport();
|
||||
}
|
||||
var serialized = new SerializedObject(voice); var property = serialized.FindProperty("_clips"); property.arraySize = clips.Length;
|
||||
for (int i = 0; i < clips.Length; i++)
|
||||
{ var entry = property.GetArrayElementAtIndex(i); entry.FindPropertyRelative("id").stringValue = clips[i].name; entry.FindPropertyRelative("clip").objectReferenceValue = clips[i]; }
|
||||
serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||
}
|
||||
private static AudioMixer Mixer()
|
||||
{
|
||||
const string path = Presentation + "Audio/GameAudio.mixer";
|
||||
var mixer = AssetDatabase.LoadAssetAtPath<AudioMixer>(path);
|
||||
var controllerType = typeof(UnityEditor.Editor).Assembly.GetType("UnityEditor.Audio.AudioMixerController");
|
||||
var flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
if (mixer == null) mixer = (AudioMixer)controllerType.GetMethod("CreateMixerControllerAtPath", flags).Invoke(null, new object[] { path });
|
||||
var instanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
var master = controllerType.GetProperty("masterGroup", instanceFlags).GetValue(mixer);
|
||||
var childrenProperty = master.GetType().GetProperty("children", instanceFlags);
|
||||
var children = (Array)childrenProperty.GetValue(master);
|
||||
var newChildren = children.Cast<object>().ToList();
|
||||
foreach (var groupName in new[] { "Voice", "SFX", "Music" })
|
||||
{
|
||||
if (mixer.FindMatchingGroups(groupName).Length != 0) continue;
|
||||
var group = controllerType.GetMethod("CreateNewGroup", instanceFlags).Invoke(mixer, new object[] { groupName, false });
|
||||
newChildren.Add(group);
|
||||
}
|
||||
var array = Array.CreateInstance(master.GetType(), newChildren.Count);
|
||||
for (int index = 0; index < newChildren.Count; index++) array.SetValue(newChildren[index], index);
|
||||
childrenProperty.SetValue(master, array); EditorUtility.SetDirty(mixer); EditorUtility.SetDirty((UnityEngine.Object)master);
|
||||
return mixer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c21a9cea2d948bd4185448d90d4da2e8
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "MagnetismHeroes.Editor",
|
||||
"rootNamespace": "MagnetismHeroes.Editor",
|
||||
"references": ["MagnetismHeroes.Core", "MagnetismHeroes.UnityRuntime", "Unity.InputSystem", "Unity.ugui", "Unity.TextMeshPro", "Unity.RenderPipelines.Universal.Runtime"],
|
||||
"includePlatforms": ["Editor"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37bb79c14a20939429efb5ee98503060
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.PackageManager;
|
||||
using UnityEditor.PackageManager.Requests;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class PackageInstaller
|
||||
{
|
||||
private static AddAndRemoveRequest _request;
|
||||
private static double _deadline;
|
||||
|
||||
public static void Install()
|
||||
{
|
||||
var installed = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages().Select(p => p.name).ToArray();
|
||||
var remove = new[] { "com.unity.ai.navigation", "com.unity.collab-proxy", "com.unity.visualscripting", "com.unity.timeline", "com.unity.ide.rider" }
|
||||
.Where(id => installed.Contains(id)).ToArray();
|
||||
_request = Client.AddAndRemove(
|
||||
new[] { "com.unity.inputsystem@1.20.0", "com.unity.render-pipelines.universal@17.6.0", "com.unity.test-framework@1.8.0", "com.unity.ugui@2.6.0", "com.unity.nuget.newtonsoft-json@3.2.2" },
|
||||
remove);
|
||||
_deadline = EditorApplication.timeSinceStartup + 600;
|
||||
EditorApplication.update += Poll;
|
||||
}
|
||||
|
||||
private static void Poll()
|
||||
{
|
||||
if (!_request.IsCompleted && EditorApplication.timeSinceStartup < _deadline) return;
|
||||
EditorApplication.update -= Poll;
|
||||
if (_request.Status == StatusCode.Success)
|
||||
{
|
||||
Debug.Log("[Packages] " + string.Join(", ", _request.Result.Select(p => p.name + "@" + p.version)));
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[Packages] " + (_request.Error?.message ?? "Resolution timed out."));
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4038b181695431f4499ade8a0d0efe90
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class PersistenceAssets
|
||||
{
|
||||
private const string Presentation = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
|
||||
[MenuItem("Magnetism Heroes/Build Persistence Assets")]
|
||||
public static void Build()
|
||||
{
|
||||
var font = AssetDatabase.LoadAssetAtPath<TMP_FontAsset>(Presentation + "Fonts/NotoSansCJKkr-FirstSlice.asset");
|
||||
const string glyphs = "2 하나 꺼내기 친구 반납 지금은 저장하지 못했어요. 다시 해볼게요. 빈 곳이 보이도록 화면을 움직여 볼까?";
|
||||
if (glyphs.Any(character => !font.HasCharacter(character)))
|
||||
{
|
||||
typeof(TMP_FontAsset).GetProperty("SourceFont_EditorRef", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(font, AssetDatabase.LoadAssetAtPath<Font>(Presentation + "Fonts/NotoSansCJKkr-Regular.otf"));
|
||||
font.atlasPopulationMode = AtlasPopulationMode.Dynamic;
|
||||
try { if (!font.TryAddCharacters(glyphs, out var missing)) throw new InvalidOperationException("Missing persistence glyphs: " + missing); }
|
||||
finally { font.atlasPopulationMode = AtlasPopulationMode.Static; }
|
||||
EditorUtility.SetDirty(font);
|
||||
}
|
||||
const string stagePath = "Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage02.asset";
|
||||
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>(stagePath);
|
||||
if (stage == null) { stage = ScriptableObject.CreateInstance<StageAsset>(); AssetDatabase.CreateAsset(stage, stagePath); }
|
||||
var stageFields = new SerializedObject(stage); stageFields.FindProperty("_stageId").intValue = 2;
|
||||
stageFields.FindProperty("_kind").enumValueIndex = (int)StageKind.Counting;
|
||||
var initial = stageFields.FindProperty("_initialGroups"); initial.arraySize = 2;
|
||||
initial.GetArrayElementAtIndex(0).intValue = initial.GetArrayElementAtIndex(1).intValue = 1;
|
||||
var target = stageFields.FindProperty("_targetGroups"); target.arraySize = 1; target.GetArrayElementAtIndex(0).intValue = 2;
|
||||
var positions = stageFields.FindProperty("_ballPositions"); positions.arraySize = 2;
|
||||
positions.GetArrayElementAtIndex(0).vector3Value = new Vector3(-1.2f, .8f, 0);
|
||||
positions.GetArrayElementAtIndex(1).vector3Value = new Vector3(1.2f, .8f, 0); stageFields.ApplyModifiedPropertiesWithoutUndo();
|
||||
var root = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
|
||||
try
|
||||
{
|
||||
var app = new SerializedObject(root.GetComponent<GameApp>());
|
||||
var stages = app.FindProperty("_stages"); var catalog = Enumerable.Range(0, stages.arraySize)
|
||||
.Select(index => (StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).Where(value => value.StageId != 2)
|
||||
.Concat(new[] { stage }).OrderBy(value => value.StageId).ToArray(); stages.arraySize = catalog.Length;
|
||||
for (int index = 0; index < catalog.Length; index++) stages.GetArrayElementAtIndex(index).objectReferenceValue = catalog[index];
|
||||
var safe = root.transform.Find("Interface/SafeArea"); var selection = safe.Find("StageSelection");
|
||||
var template = selection.Find("Stage01Button").gameObject;
|
||||
var stageButton = Button(selection, template, "Stage02Button", "2 친구 찾기", new Vector2(.5f, .36f), new Vector2(280, 72));
|
||||
app.FindProperty("_stageTwoButton").objectReferenceValue = stageButton;
|
||||
var play = safe.Find("Play"); var playTemplate = play.Find("HomeButton").gameObject;
|
||||
app.FindProperty("_spawnButton").objectReferenceValue = Button(play, playTemplate, "SpawnButton", "하나 꺼내기", new Vector2(.86f, .3f), new Vector2(240, 62));
|
||||
app.FindProperty("_returnButton").objectReferenceValue = Button(play, playTemplate, "ReturnButton", "친구 반납", new Vector2(.86f, .2f), new Vector2(240, 62));
|
||||
var notice = (TMP_Text)app.FindProperty("_saveNotice").objectReferenceValue;
|
||||
notice.transform.SetParent(safe, false); notice.transform.SetAsLastSibling();
|
||||
notice.rectTransform.anchorMin = notice.rectTransform.anchorMax = new Vector2(.5f, .07f);
|
||||
notice.rectTransform.anchoredPosition = Vector2.zero;
|
||||
app.ApplyModifiedPropertiesWithoutUndo(); PrefabUtility.SaveAsPrefabAsset(root, FirstSliceAssets.AppPrefabPath);
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(root); }
|
||||
AssetDatabase.SaveAssets(); Debug.Log("[Persistence] Stage02, locked selection, free supply/whole-friend return and shared save notice wired. Main unchanged.");
|
||||
}
|
||||
private static UnityEngine.UI.Button Button(Transform parent, GameObject template, string name, string label, Vector2 anchor, Vector2 size)
|
||||
{
|
||||
var existing = parent.Find(name); var button = existing == null ? UnityEngine.Object.Instantiate(template, parent) : existing.gameObject;
|
||||
button.name = name; var rect = (RectTransform)button.transform; rect.anchorMin = rect.anchorMax = anchor;
|
||||
rect.anchoredPosition = Vector2.zero; rect.sizeDelta = size;
|
||||
var text = button.GetComponentInChildren<TMP_Text>(); text.text = label; text.rectTransform.sizeDelta = size;
|
||||
return button.GetComponent<UnityEngine.UI.Button>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 60c8eab85ee35ad40884c25ab21276c9
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class PresentationAssets
|
||||
{
|
||||
private const string Root = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
|
||||
public static void Build()
|
||||
{
|
||||
AssetDatabase.Refresh();
|
||||
string atlasPath = Root + "Textures/FaceExpressions.png";
|
||||
var importer = (TextureImporter)AssetImporter.GetAtPath(atlasPath);
|
||||
importer.textureType = TextureImporterType.Default;
|
||||
importer.alphaSource = TextureImporterAlphaSource.FromInput; importer.alphaIsTransparency = true;
|
||||
importer.mipmapEnabled = false; importer.maxTextureSize = 2048;
|
||||
importer.textureCompression = TextureImporterCompression.Uncompressed; importer.SaveAndReimport();
|
||||
var atlas = AssetDatabase.LoadAssetAtPath<Texture2D>(atlasPath);
|
||||
Directory.CreateDirectory(Root + "Sprites"); AssetDatabase.Refresh();
|
||||
var expressions = new Sprite[6];
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
string path = Root + $"Sprites/Face{i}.asset";
|
||||
expressions[i] = AssetDatabase.LoadAssetAtPath<Sprite>(path);
|
||||
var pivot = new Vector2(.5f, i < 3 ? .5f : .68f);
|
||||
var sprite = Sprite.Create(atlas, new Rect(i % 3 * atlas.width / 3, (1 - i / 3) * atlas.height / 2, atlas.width / 3, atlas.height / 2), pivot, 640, 0, SpriteMeshType.FullRect);
|
||||
sprite.name = new[] { "Neutral", "Interested", "Focused", "Laughing", "Encouraging", "Celebrating" }[i];
|
||||
if (expressions[i] == null)
|
||||
{
|
||||
expressions[i] = sprite;
|
||||
AssetDatabase.CreateAsset(expressions[i], path);
|
||||
}
|
||||
else { EditorUtility.CopySerialized(sprite, expressions[i]); EditorUtility.SetDirty(expressions[i]); UnityEngine.Object.DestroyImmediate(sprite); }
|
||||
}
|
||||
var ribbonMaterial = AssetDatabase.LoadAssetAtPath<Material>(Root + "Materials/LightRibbon.mat");
|
||||
if (ribbonMaterial == null)
|
||||
{
|
||||
ribbonMaterial = new Material(Shader.Find("Sprites/Default")); ribbonMaterial.color = new Color(.3f, .75f, 1, .6f);
|
||||
AssetDatabase.CreateAsset(ribbonMaterial, Root + "Materials/LightRibbon.mat");
|
||||
}
|
||||
ribbonMaterial.color = Color.white; EditorUtility.SetDirty(ribbonMaterial);
|
||||
var badge = PrefabUtility.LoadPrefabContents(Root + "Prefabs/GroupBadge.prefab");
|
||||
try
|
||||
{
|
||||
var face = badge.transform.Find("RepresentativeFace");
|
||||
foreach (Transform child in face.Cast<Transform>().ToArray()) UnityEngine.Object.DestroyImmediate(child.gameObject);
|
||||
var renderer = face.GetComponent<SpriteRenderer>(); if (renderer == null) renderer = face.gameObject.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = expressions[0]; renderer.sortingOrder = 1;
|
||||
face.localScale = new Vector3(.9f, .9f, 1);
|
||||
var data = new SerializedObject(badge.GetComponent<GroupBadge>());
|
||||
data.FindProperty("_expression").objectReferenceValue = renderer;
|
||||
var stem = badge.transform.Find("NumberStem");
|
||||
if (stem != null) UnityEngine.Object.DestroyImmediate(stem.gameObject);
|
||||
var frames = data.FindProperty("_expressions"); frames.arraySize = 6;
|
||||
for (int i = 0; i < 6; i++) frames.GetArrayElementAtIndex(i).objectReferenceValue = expressions[i];
|
||||
var cape = badge.transform.Find("RibbonCape");
|
||||
var line = cape.GetComponent<LineRenderer>(); line.sharedMaterial = ribbonMaterial;
|
||||
line.useWorldSpace = false; line.positionCount = 10; line.widthMultiplier = .065f;
|
||||
line.startColor = new Color(.8f, .15f, .12f, .9f); line.endColor = new Color(.1f, .25f, .75f, .85f);
|
||||
data.ApplyModifiedPropertiesWithoutUndo();
|
||||
var number = badge.GetComponentInChildren<TextMeshPro>(); number.fontSize = 3.5f; number.rectTransform.sizeDelta = new Vector2(1, .6f);
|
||||
var numberMaterial = AssetDatabase.LoadAssetAtPath<Material>(Root + "Materials/NumberOutline.mat");
|
||||
if (numberMaterial == null)
|
||||
{
|
||||
numberMaterial = new Material(number.font.material);
|
||||
numberMaterial.SetFloat("_OutlineWidth", .12f); numberMaterial.SetColor("_OutlineColor", Color.white);
|
||||
AssetDatabase.CreateAsset(numberMaterial, Root + "Materials/NumberOutline.mat");
|
||||
}
|
||||
numberMaterial.shader = Shader.Find("TextMeshPro/Mobile/Distance Field Overlay"); numberMaterial.EnableKeyword("OUTLINE_ON"); EditorUtility.SetDirty(numberMaterial);
|
||||
number.fontSharedMaterial = numberMaterial;
|
||||
PrefabUtility.SaveAsPrefabAsset(badge, Root + "Prefabs/GroupBadge.prefab");
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(badge); }
|
||||
var ball = PrefabUtility.LoadPrefabContents(Root + "Prefabs/MagnetFriend.prefab");
|
||||
try
|
||||
{
|
||||
var rim = ball.transform.Find("LightOutline");
|
||||
if (rim == null) { rim = new GameObject("LightOutline", typeof(LineRenderer)).transform; rim.SetParent(ball.transform, false); }
|
||||
var line = rim.GetComponent<LineRenderer>(); line.sharedMaterial = ribbonMaterial;
|
||||
line.useWorldSpace = false; line.loop = true; line.positionCount = 40; line.widthMultiplier = .035f; line.enabled = false;
|
||||
for (int i = 0; i < 40; i++) { float angle = i * Mathf.PI * 2 / 40; line.SetPosition(i, new Vector3(Mathf.Cos(angle) * .51f, Mathf.Sin(angle) * .51f, -.08f)); }
|
||||
Set(ball.GetComponent<BallView>(), "_outline", line);
|
||||
var contact = ball.transform.Find("ContactLight");
|
||||
if (contact == null) { contact = new GameObject("ContactLight", typeof(LineRenderer)).transform; contact.SetParent(ball.transform, false); }
|
||||
var ring = contact.GetComponent<LineRenderer>(); ring.sharedMaterial = ribbonMaterial;
|
||||
ring.useWorldSpace = false; ring.loop = true; ring.positionCount = 32; ring.widthMultiplier = .035f; ring.enabled = false;
|
||||
for (int i = 0; i < 32; i++) { float angle = i * Mathf.PI * 2 / 32; ring.SetPosition(i, new Vector3(Mathf.Cos(angle) * .19f, Mathf.Sin(angle) * .19f, 0)); }
|
||||
Set(ball.GetComponent<BallView>(), "_contactRing", ring);
|
||||
PrefabUtility.SaveAsPrefabAsset(ball, Root + "Prefabs/MagnetFriend.prefab");
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(ball); }
|
||||
var app = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
|
||||
try
|
||||
{
|
||||
var voice = app.GetComponent<VoicePlayer>(); Set(app.GetComponentInChildren<BoardPresenter>(true), "_voice", voice);
|
||||
app.transform.Find("GameCamera").GetComponent<Camera>().fieldOfView = 62;
|
||||
var sound = app.GetComponent<GameSound>(); if (sound == null) sound = app.AddComponent<GameSound>();
|
||||
var mixer = AssetDatabase.LoadAssetAtPath<AudioMixer>(Root + "Audio/GameAudio.mixer");
|
||||
Set(sound, "_voice", voice);
|
||||
foreach (var spec in new[] { ("SFX", "_sfx", false), ("Music", "_music", true) })
|
||||
{
|
||||
var child = app.transform.Find(spec.Item1);
|
||||
if (child == null) { child = new GameObject(spec.Item1, typeof(AudioSource)).transform; child.SetParent(app.transform, false); }
|
||||
var source = child.GetComponent<AudioSource>(); source.playOnAwake = false; source.loop = spec.Item3; source.spatialBlend = 0;
|
||||
source.outputAudioMixerGroup = mixer.FindMatchingGroups(spec.Item1).Single(); Set(sound, spec.Item2, source);
|
||||
}
|
||||
Set(app.GetComponent<GameApp>(), "_sound", sound);
|
||||
var play = app.transform.Find("Interface/SafeArea/Play");
|
||||
var spoken = play.Find("SpokenNumber");
|
||||
if (spoken == null) { spoken = new GameObject("SpokenNumber", typeof(RectTransform), typeof(TextMeshProUGUI)).transform; spoken.SetParent(play, false); }
|
||||
var spokenText = spoken.GetComponent<TMP_Text>(); spokenText.font = play.Find("Counter").GetComponent<TMP_Text>().font;
|
||||
spokenText.fontSize = 23; spokenText.color = new Color(.12f, .2f, .31f); spokenText.alignment = TextAlignmentOptions.Center;
|
||||
spokenText.raycastTarget = false; spokenText.enableAutoSizing = false; spokenText.text = "";
|
||||
Rect(spoken, new Vector2(.76f, .83f), new Vector2(.98f, .83f), new Vector2(-8, 36)); Set(voice, "_spokenNumber", spokenText);
|
||||
Rect(play.Find("HomeButton"), new Vector2(.02f, .9f), new Vector2(.18f, .9f), new Vector2(-8, 62));
|
||||
Rect(play.Find("StageTitle"), new Vector2(.21f, .92f), new Vector2(.73f, .92f), new Vector2(-8, 60));
|
||||
Rect(play.Find("Counter"), new Vector2(.76f, .9f), new Vector2(.98f, .9f), new Vector2(-8, 62));
|
||||
foreach (var text in play.GetComponentsInChildren<TMP_Text>(true))
|
||||
{ text.enableAutoSizing = false; if (text.transform.name == "Counter" || text.transform.name == "StageTitle") text.fontSize = 27; }
|
||||
foreach (var button in app.GetComponentsInChildren<UnityEngine.UI.Button>(true))
|
||||
{
|
||||
var image = button.GetComponent<UnityEngine.UI.Image>();
|
||||
image.sprite = AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UISprite.psd"); image.type = UnityEngine.UI.Image.Type.Sliced;
|
||||
}
|
||||
var selection = app.transform.Find("Interface/SafeArea/StageSelection");
|
||||
for (int i = 0; i < 30; i++)
|
||||
Rect(selection.Find($"Stage{i + 1:D2}Button"), new Vector2(.045f + i % 6 * .153f, .76f - i / 6 * .123f),
|
||||
new Vector2(.18f + i % 6 * .153f, .76f - i / 6 * .123f), new Vector2(0, 62));
|
||||
PrefabUtility.SaveAsPrefabAsset(app, FirstSliceAssets.AppPrefabPath);
|
||||
}
|
||||
finally { PrefabUtility.UnloadPrefabContents(app); }
|
||||
AssetDatabase.SaveAssets(); Debug.Log("[Presentation] Six face decals, light ribbons/outlines, responsive controls and existing mixer routing built without scene edits.");
|
||||
}
|
||||
private static void Rect(Transform target, Vector2 min, Vector2 max, Vector2 size)
|
||||
{
|
||||
var rect = (RectTransform)target; rect.anchorMin = min; rect.anchorMax = max; rect.anchoredPosition = Vector2.zero; rect.sizeDelta = size;
|
||||
foreach (var text in target.GetComponentsInChildren<TMP_Text>())
|
||||
if (text.transform != target) { text.rectTransform.anchorMin = Vector2.zero; text.rectTransform.anchorMax = Vector2.one; text.rectTransform.offsetMin = text.rectTransform.offsetMax = Vector2.zero; }
|
||||
}
|
||||
private static void Set(UnityEngine.Object target, string field, UnityEngine.Object value)
|
||||
{ var data = new SerializedObject(target); data.FindProperty(field).objectReferenceValue = value; data.ApplyModifiedPropertiesWithoutUndo(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e4a866a5fd5323444b0aa424910818ad
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Profiling;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class PresentationProof
|
||||
{
|
||||
private sealed class ProofStore : IProgressStore
|
||||
{
|
||||
public bool TryLoad(out ProgressSave save, out string error)
|
||||
{ save = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } }; error = null; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error) { error = null; return true; }
|
||||
}
|
||||
public static GameApp App => UnityEngine.Object.FindFirstObjectByType<GameApp>();
|
||||
public static void Initialize() { App.Initialize(new ProofStore()); App.StartStage(20); App.Voice.Clear(); }
|
||||
public static void Supply(int stage)
|
||||
{
|
||||
Stage(stage);
|
||||
for (int step = 0; App.Session.Screen == SessionScreen.Puzzle && step < 40; step++)
|
||||
{
|
||||
var hint = App.Session.Hint;
|
||||
if (hint.Kind == HintKind.Collect) App.CommitMove(hint.FirstGroupId, new Position3(.1f, 0, 0));
|
||||
else if (hint.Kind == HintKind.Merge) App.CommitMove(hint.FirstGroupId, new Position3(.1f, 0, 0), hint.SecondGroupId);
|
||||
else if (hint.Kind == HintKind.Count) App.CountBall(hint.BallIds[0]);
|
||||
else App.CommitSplit(hint.FirstGroupId, hint.BallIds, new Position3(1, 0, 0));
|
||||
}
|
||||
if (App.Session.Screen != SessionScreen.FreePlay) throw new InvalidOperationException("Proof stage did not solve.");
|
||||
foreach (var group in App.Session.Board.Groups) App.Session.ReturnGroup(group.id);
|
||||
App.ContinueFreePlay();
|
||||
App.Orbit.Apply(new CameraState { target = new Position3(0, .6f, 0), distance = 11, rotation = new Rotation4(0, 0, 0, 1) });
|
||||
for (int i = 0; i < 10; i++) App.SpawnFriend();
|
||||
if (App.Session.Board.BallCount != 10) throw new InvalidOperationException("Ten initial supply friends are not reachable.");
|
||||
App.Voice.Clear();
|
||||
}
|
||||
public static void Fixture(string layout, int count = 10)
|
||||
{
|
||||
App.StartStage(layout == "Garden" ? 9 : layout == "Cloud" ? 20 : 28); App.Voice.Clear();
|
||||
var balls = Enumerable.Range(0, count).Select(i => new BallState { id = "proof-" + i,
|
||||
position = layout == "Line" ? new Position3(i - (count - 1) * .5f, .8f, 0)
|
||||
: layout == "Volume" ? new Position3(i % 2 - .5f, .8f + i / 4, (i / 2) % 2 - .5f)
|
||||
: new Position3(i % 5 - 2, .8f + i / 5 * (layout == "Garden" || layout == "Cloud" || layout == "Workshop" ? 1.8f : 1), 0) }).ToArray();
|
||||
var groups = layout == "FivePairs" ? new[] { new GroupState { id = "first", ballIds = balls.Take(5).Select(ball => ball.id).ToArray() },
|
||||
new GroupState { id = "second", ballIds = balls.Skip(5).Select(ball => ball.id).ToArray() } }
|
||||
: new[] { new GroupState { id = "proof", ballIds = balls.Select(ball => ball.id).ToArray() } };
|
||||
if (layout == "Garden" || layout == "Cloud" || layout == "Workshop")
|
||||
groups = balls.Select(ball => new GroupState { id = ball.id, ballIds = new[] { ball.id } }).ToArray();
|
||||
BoundBoard = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = 28, balls = balls, groups = groups });
|
||||
App.Presenter.Render(BoundBoard);
|
||||
}
|
||||
private static BoardRules BoundBoard;
|
||||
[Serializable] private sealed class CaptureRecord
|
||||
{
|
||||
public string sourceCommit, name, mode, utc, board, boardOrigin;
|
||||
public bool sourceDirty;
|
||||
public int width, height, groups, balls, faces, numbers, capes, editorUnityStatsDrawCalls, expressionOverride;
|
||||
public long allocatedMemory, textureMemory, voiceMemory;
|
||||
public float cameraRenderMilliseconds;
|
||||
public Vector3 cameraPosition, fixedVisualPivot = Vector3.up * .65f; public Quaternion cameraRotation; public float fieldOfView;
|
||||
public CameraState rawCameraState;
|
||||
public Rect safeArea;
|
||||
public Rect actualCameraPixelRect;
|
||||
public Vector2 liveScreenSize, canvasRectSize;
|
||||
}
|
||||
public static void Capture(string name, int width = 1280, int height = 720, float inset = 0, int expression = -1)
|
||||
{
|
||||
string folder = "artifacts/verification/T7/Implementation/editor-play"; Directory.CreateDirectory(folder);
|
||||
var app = App; var camera = app.Presenter.Camera; var canvas = app.GetComponentInChildren<Canvas>(true);
|
||||
var safe = app.GetComponentInChildren<SafeAreaPanel>(true); var safeRect = (RectTransform)safe.transform;
|
||||
var oldMin = safeRect.anchorMin; var oldMax = safeRect.anchorMax; bool oldEnabled = safe.enabled;
|
||||
var oldMode = canvas.renderMode; var oldCamera = canvas.worldCamera; float oldPlane = canvas.planeDistance;
|
||||
var oldTarget = camera.targetTexture; var oldActive = RenderTexture.active; float oldAspect = camera.aspect;
|
||||
var target = new RenderTexture(width, height, 24); var image = new Texture2D(width, height, TextureFormat.RGB24, false);
|
||||
try
|
||||
{
|
||||
camera.targetTexture = target; camera.aspect = (float)width / height;
|
||||
canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = .5f;
|
||||
safe.enabled = false; safeRect.anchorMin = new Vector2(inset / width, 0); safeRect.anchorMax = new Vector2(1 - inset / width, 1);
|
||||
foreach (var ball in app.Presenter.Balls.Values) ball.SendMessage("Update");
|
||||
app.Presenter.SendMessage("LateUpdate"); Canvas.ForceUpdateCanvases();
|
||||
if (expression >= 0)
|
||||
foreach (var face in app.GetComponentsInChildren<SpriteRenderer>()) face.sprite = AssetDatabase.LoadAssetAtPath<Sprite>($"Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face{expression}.asset");
|
||||
var timer = System.Diagnostics.Stopwatch.StartNew(); camera.Render(); timer.Stop();
|
||||
RenderTexture.active = target; image.ReadPixels(new Rect(0, 0, width, height), 0, 0); image.Apply();
|
||||
File.WriteAllBytes(folder + "/" + name + ".png", image.EncodeToPNG());
|
||||
var board = BoundBoard ?? app.Session.Board;
|
||||
var record = new CaptureRecord { name = name, sourceCommit = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_COMMIT"), mode = "Editor Camera.Render RT with temporary ScreenSpaceCamera canvas; simulated viewport/safe area, not Android device capture",
|
||||
sourceDirty = Environment.GetEnvironmentVariable("MAGNETISM_PROOF_DIRTY") == "true",
|
||||
boardOrigin = BoundBoard != null ? "Validated synthetic FreePlay render fixture; session/UI remain the underlying stage, not a puzzle success" : "Actual GameApp.Session.Board",
|
||||
utc = DateTime.UtcNow.ToString("o"), width = width, height = height, expressionOverride = expression, safeArea = new Rect(inset, 0, width - inset * 2, height),
|
||||
balls = app.Presenter.Balls.Count, groups = app.GetComponentsInChildren<GroupBadge>().Length,
|
||||
faces = app.GetComponentsInChildren<SpriteRenderer>().Length, numbers = app.GetComponentsInChildren<TextMeshPro>().Length,
|
||||
capes = app.GetComponentsInChildren<GroupBadge>().Count(badge => badge.transform.Find("RibbonCape") != null),
|
||||
cameraPosition = camera.transform.position, cameraRotation = camera.transform.rotation, fieldOfView = camera.fieldOfView,
|
||||
rawCameraState = app.Orbit.Snapshot(),
|
||||
liveScreenSize = new Vector2(Screen.width, Screen.height), actualCameraPixelRect = camera.pixelRect,
|
||||
canvasRectSize = ((RectTransform)canvas.transform).rect.size,
|
||||
board = JsonUtility.ToJson(board.Mode == BoardMode.FreePlay ? (object)board.ToFreePlayState(app.Orbit.Snapshot()) : board.ToPuzzleState(app.Orbit.Snapshot())),
|
||||
allocatedMemory = Profiler.GetTotalAllocatedMemoryLong(), textureMemory = UnityEngine.Object.FindObjectsByType<Texture>(FindObjectsSortMode.None).Sum(texture => Profiler.GetRuntimeMemorySizeLong(texture)),
|
||||
voiceMemory = Resources.FindObjectsOfTypeAll<AudioClip>().Sum(clip => Profiler.GetRuntimeMemorySizeLong(clip)),
|
||||
editorUnityStatsDrawCalls = UnityEditor.UnityStats.drawCalls, cameraRenderMilliseconds = (float)timer.Elapsed.TotalMilliseconds };
|
||||
File.WriteAllText(folder + "/" + name + ".json", JsonUtility.ToJson(record, true));
|
||||
}
|
||||
finally
|
||||
{
|
||||
camera.targetTexture = oldTarget; camera.aspect = oldAspect; RenderTexture.active = oldActive;
|
||||
canvas.renderMode = oldMode; canvas.worldCamera = oldCamera; canvas.planeDistance = oldPlane;
|
||||
safeRect.anchorMin = oldMin; safeRect.anchorMax = oldMax; safe.enabled = oldEnabled;
|
||||
UnityEngine.Object.DestroyImmediate(image); target.Release(); UnityEngine.Object.DestroyImmediate(target);
|
||||
app.Presenter.SendMessage("LateUpdate"); Canvas.ForceUpdateCanvases();
|
||||
}
|
||||
}
|
||||
public static void Stage(int id) { BoundBoard = null; App.StartStage(id); App.Voice.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ab6b61155a1d5a41b63cf3bdc580b0b
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace MagnetismHeroes.Editor
|
||||
{
|
||||
public static class ProjectBootstrap
|
||||
{
|
||||
public const string MainScenePath = "Assets/MagnetismHeroes/Scenes/Main.unity";
|
||||
|
||||
public static void Prepare()
|
||||
{
|
||||
foreach (string path in new[] {
|
||||
"Assets/MagnetismHeroes/UnityRuntime/Interaction", "Assets/MagnetismHeroes/UnityRuntime/Presentation",
|
||||
"Assets/MagnetismHeroes/UnityRuntime/StageAssets", "Assets/MagnetismHeroes/UnityRuntime/Persistence",
|
||||
"Assets/MagnetismHeroes/UnityRuntime/Bootstrap", "Assets/MagnetismHeroes/Scenes",
|
||||
"Assets/MagnetismHeroes/Tests/EditMode/Core" }) Directory.CreateDirectory(path);
|
||||
AssetDatabase.Refresh();
|
||||
PlayerSettings.companyName = "MagnetismHeroes";
|
||||
PlayerSettings.productName = "MagnetismHeroes";
|
||||
PlayerSettings.SetApplicationIdentifier(UnityEditor.Build.NamedBuildTarget.Android, "com.magnetismheroes.game");
|
||||
PlayerSettings.defaultInterfaceOrientation = UIOrientation.LandscapeLeft;
|
||||
PlayerSettings.Android.minSdkVersion = AndroidSdkVersions.AndroidApiLevel26;
|
||||
PlayerSettings.Android.targetSdkVersion = AndroidSdkVersions.AndroidApiLevelAuto;
|
||||
PlayerSettings.Android.forceInternetPermission = false;
|
||||
PlayerSettings.runInBackground = false;
|
||||
PlayerSettings.colorSpace = ColorSpace.Linear;
|
||||
var mobilePipeline = AssetDatabase.LoadAssetAtPath<RenderPipelineAsset>("Assets/Settings/Mobile_RPAsset.asset");
|
||||
if (mobilePipeline == null) throw new InvalidOperationException("Mobile URP asset missing.");
|
||||
GraphicsSettings.defaultRenderPipeline = mobilePipeline;
|
||||
QualitySettings.renderPipeline = mobilePipeline;
|
||||
if (!File.Exists(MainScenePath))
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
EditorSceneManager.SaveScene(scene, MainScenePath);
|
||||
}
|
||||
EditorBuildSettings.scenes = new[] { new EditorBuildSettingsScene(MainScenePath, true) };
|
||||
AssetDatabase.DeleteAsset("Assets/TutorialInfo");
|
||||
AssetDatabase.DeleteAsset("Assets/Readme.asset");
|
||||
AssetDatabase.DeleteAsset("Assets/Scenes");
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("[Scaffold] Unity project prepared.");
|
||||
}
|
||||
|
||||
public static void ConnectApp()
|
||||
{
|
||||
const string prefabPath = "Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab";
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (prefab == null) throw new InvalidOperationException("GameApp prefab missing.");
|
||||
var scene = EditorSceneManager.OpenScene(MainScenePath, OpenSceneMode.Single);
|
||||
var instances = scene.GetRootGameObjects().Where(root =>
|
||||
PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(root) == prefabPath).ToArray();
|
||||
if (instances.Length > 1) throw new InvalidOperationException("Main contains duplicate GameApp instances.");
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
{
|
||||
if ((root.name == "Main Camera" && root.GetComponent<Camera>() != null)
|
||||
|| (root.name == "Directional Light" && root.GetComponent<Light>() != null))
|
||||
UnityEngine.Object.DestroyImmediate(root);
|
||||
}
|
||||
if (instances.Length == 0) PrefabUtility.InstantiatePrefab(prefab, scene);
|
||||
EditorSceneManager.SaveScene(scene, MainScenePath);
|
||||
Debug.Log("[Integration] Main connected to GameApp.");
|
||||
}
|
||||
|
||||
public static void ConfigureAndroid()
|
||||
{
|
||||
PlayerSettings.SetScriptingBackend(UnityEditor.Build.NamedBuildTarget.Android, ScriptingImplementation.IL2CPP);
|
||||
PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64;
|
||||
PlayerSettings.Android.buildApkPerCpuArchitecture = false;
|
||||
PlayerSettings.defaultInterfaceOrientation = UIOrientation.LandscapeLeft;
|
||||
PlayerSettings.Android.forceInternetPermission = false;
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("[AndroidConfiguration] ARM64 IL2CPP, landscape, no forced Internet permission.");
|
||||
}
|
||||
|
||||
public static void BuildAndroid()
|
||||
{
|
||||
string apkPath = Environment.GetEnvironmentVariable("MAGNETISM_APK_PATH");
|
||||
if (string.IsNullOrEmpty(apkPath)) throw new InvalidOperationException("MAGNETISM_APK_PATH is required.");
|
||||
EditorUserBuildSettings.buildAppBundle = false;
|
||||
EditorUserBuildSettings.development = false;
|
||||
BuildReport report = BuildPipeline.BuildPlayer(new BuildPlayerOptions {
|
||||
scenes = new[] { MainScenePath }, locationPathName = apkPath,
|
||||
target = BuildTarget.Android, options = BuildOptions.None
|
||||
});
|
||||
Debug.Log($"[AndroidBuild] {report.summary.result}: {report.summary.totalSize} bytes, {report.summary.totalErrors} errors.");
|
||||
if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException("Android build did not succeed.");
|
||||
var packedPaths = report.packedAssets.SelectMany(asset => asset.contents)
|
||||
.Select(asset => asset.sourceAssetPath).Distinct().OrderBy(path => path).ToArray();
|
||||
File.WriteAllLines(apkPath + ".packed-assets.txt", packedPaths);
|
||||
if (packedPaths.Any(path => path.Contains("PerformanceTestRun")))
|
||||
throw new InvalidOperationException("Generated performance-test metadata entered the release APK.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a3e27420d503594ebe8f2243f118081
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98fbd46e6e73fc24c8f35e6b9ed686f4
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,183 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!29 &1
|
||||
OcclusionCullingSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_OcclusionBakeSettings:
|
||||
smallestOccluder: 5
|
||||
smallestHole: 0.25
|
||||
backfaceThreshold: 100
|
||||
m_SceneGUID: 00000000000000000000000000000000
|
||||
m_OcclusionCullingData: {fileID: 0}
|
||||
--- !u!104 &2
|
||||
RenderSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 10
|
||||
m_Fog: 0
|
||||
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
|
||||
m_FogMode: 3
|
||||
m_FogDensity: 0.01
|
||||
m_LinearFogStart: 0
|
||||
m_LinearFogEnd: 300
|
||||
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
|
||||
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
|
||||
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
|
||||
m_AmbientIntensity: 1
|
||||
m_AmbientMode: 0
|
||||
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
|
||||
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_HaloStrength: 0.5
|
||||
m_FlareStrength: 1
|
||||
m_FlareFadeSpeed: 3
|
||||
m_HaloTexture: {fileID: 0}
|
||||
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
|
||||
m_DefaultReflectionMode: 0
|
||||
m_DefaultReflectionResolution: 128
|
||||
m_ReflectionBounces: 1
|
||||
m_ReflectionIntensity: 1
|
||||
m_CustomReflection: {fileID: 0}
|
||||
m_Sun: {fileID: 0}
|
||||
m_UseRadianceAmbientProbe: 0
|
||||
--- !u!157 &3
|
||||
LightmapSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 13
|
||||
m_BakeOnSceneLoad: 0
|
||||
m_GISettings:
|
||||
serializedVersion: 2
|
||||
m_BounceScale: 1
|
||||
m_IndirectOutputScale: 1
|
||||
m_AlbedoBoost: 1
|
||||
m_EnvironmentLightingMode: 0
|
||||
m_EnableBakedLightmaps: 1
|
||||
m_EnableRealtimeLightmaps: 0
|
||||
m_LightmapEditorSettings:
|
||||
serializedVersion: 12
|
||||
m_Resolution: 2
|
||||
m_BakeResolution: 40
|
||||
m_AtlasSize: 1024
|
||||
m_AO: 0
|
||||
m_AOMaxDistance: 1
|
||||
m_CompAOExponent: 1
|
||||
m_CompAOExponentDirect: 0
|
||||
m_ExtractAmbientOcclusion: 0
|
||||
m_Padding: 2
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_LightmapsBakeMode: 1
|
||||
m_TextureCompression: 1
|
||||
m_ReflectionCompression: 2
|
||||
m_MixedBakeMode: 2
|
||||
m_BakeBackend: 2
|
||||
m_PVRSampling: 1
|
||||
m_PVRDirectSampleCount: 32
|
||||
m_PVRSampleCount: 512
|
||||
m_PVRBounces: 2
|
||||
m_PVREnvironmentSampleCount: 256
|
||||
m_PVREnvironmentReferencePointCount: 2048
|
||||
m_PVRFilteringMode: 1
|
||||
m_PVRDenoiserTypeDirect: 2
|
||||
m_PVRDenoiserTypeIndirect: 2
|
||||
m_PVRDenoiserTypeAO: 2
|
||||
m_PVRFilterTypeDirect: 0
|
||||
m_PVRFilterTypeIndirect: 0
|
||||
m_PVRFilterTypeAO: 0
|
||||
m_PVREnvironmentMIS: 1
|
||||
m_PVRCulling: 1
|
||||
m_PVRFilteringGaussRadiusDirect: 1
|
||||
m_PVRFilteringGaussRadiusIndirect: 1
|
||||
m_PVRFilteringGaussRadiusAO: 1
|
||||
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
|
||||
m_PVRFilteringAtrousPositionSigmaIndirect: 2
|
||||
m_PVRFilteringAtrousPositionSigmaAO: 1
|
||||
m_ExportTrainingData: 0
|
||||
m_TrainingDataDestination: TrainingData
|
||||
m_LightProbeSampleCountMultiplier: 4
|
||||
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_LightingSettings: {fileID: 0}
|
||||
--- !u!196 &4
|
||||
NavMeshSettings:
|
||||
serializedVersion: 2
|
||||
m_ObjectHideFlags: 0
|
||||
m_BuildSettings:
|
||||
serializedVersion: 3
|
||||
agentTypeID: 0
|
||||
agentRadius: 0.5
|
||||
agentHeight: 2
|
||||
agentSlope: 45
|
||||
agentClimb: 0.4
|
||||
ledgeDropHeight: 0
|
||||
maxJumpAcrossDistance: 0
|
||||
minRegionArea: 2
|
||||
manualCellSize: 0
|
||||
cellSize: 0.16666667
|
||||
manualTileSize: 0
|
||||
tileSize: 256
|
||||
buildHeightMesh: 0
|
||||
maxJobWorkers: 0
|
||||
preserveTilesOutsideBounds: 0
|
||||
debug:
|
||||
m_Flags: 0
|
||||
m_NavMeshData: {fileID: 0}
|
||||
--- !u!1001 &330293461
|
||||
PrefabInstance:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_Modification:
|
||||
serializedVersion: 3
|
||||
m_TransformParent: {fileID: 0}
|
||||
m_Modifications:
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalPosition.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalPosition.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalPosition.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalRotation.w
|
||||
value: 1
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalRotation.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalRotation.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalRotation.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6696913924934804788, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 7158186905691569963, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
propertyPath: m_Name
|
||||
value: GameApp
|
||||
objectReference: {fileID: 0}
|
||||
m_RemovedComponents: []
|
||||
m_RemovedGameObjects: []
|
||||
m_AddedGameObjects: []
|
||||
m_AddedComponents: []
|
||||
m_SourcePrefab: {fileID: 100100000, guid: 73c3215bc432df740b907a1edefa9ea1, type: 3}
|
||||
--- !u!1660057539 &9223372036854775807
|
||||
SceneRoots:
|
||||
m_ObjectHideFlags: 0
|
||||
m_Roots:
|
||||
- {fileID: 330293461}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 576bda39cc02c344a84d8b6d4e35dca5
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c4443f6d61b810498e35f94b7b43b3e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fba2c93eae84df742a3b0efacedf6eb3
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class CompositionAssetTests
|
||||
{
|
||||
[TestCase(6, new[] { 1, 1 }, new[] { 2 })]
|
||||
[TestCase(10, new[] { 2 }, new[] { 1, 1 })]
|
||||
[TestCase(14, new[] { 3, 1 }, new[] { 2, 2 })]
|
||||
public void InspectorStageMatchesRepresentativeRule(int id, int[] initial, int[] target)
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
|
||||
Assert.That(asset, Is.Not.Null); var definition = asset.ToDefinition();
|
||||
Assert.That(definition.Id, Is.EqualTo(id)); Assert.That(definition.Kind, Is.EqualTo(StageKind.Composition));
|
||||
Assert.That(definition.InitialGroups, Is.EqualTo(initial)); Assert.That(definition.TargetGroups, Is.EqualTo(target));
|
||||
Assert.That(definition.BallPositions.Count, Is.EqualTo(initial.Sum()));
|
||||
}
|
||||
[Test] public void AppCatalogControlsAndDraftGuidesAreConnectedWithoutUnlockBypass()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||
var serialized = new SerializedObject(prefab.GetComponent<GameApp>()); var stages = serialized.FindProperty("_stages");
|
||||
Assert.That(stages.arraySize, Is.EqualTo(30)); var definitions = Enumerable.Range(0, stages.arraySize)
|
||||
.Select(index => ((StageAsset)stages.GetArrayElementAtIndex(index).objectReferenceValue).ToDefinition()).ToArray();
|
||||
Assert.That(definitions.Select(stage => stage.Id), Is.EqualTo(Enumerable.Range(1, 30)));
|
||||
var session = new GameSession(definitions, new EmptyStore()); Assert.That(session.IsUnlocked(6), Is.False);
|
||||
Assert.That(session.StartStage(6), Is.False); Assert.That(session.IsUnlocked(10), Is.False); Assert.That(session.IsUnlocked(14), Is.False);
|
||||
Assert.That(serialized.FindProperty("_compositionButtons").arraySize, Is.EqualTo(3));
|
||||
foreach (string field in new[] { "_splitButton", "_cancelSplitButton" }) Assert.That(serialized.FindProperty(field).objectReferenceValue, Is.Not.Null);
|
||||
}
|
||||
private sealed class EmptyStore : IProgressStore
|
||||
{
|
||||
public bool TryLoad(out ProgressSave save, out string error) { save = new ProgressSave(); error = null; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error) { error = null; return true; }
|
||||
}
|
||||
[Test] public void IdenticalRowsCannotHideEachOthersUnitSpheresAfterContactPlacement()
|
||||
{
|
||||
var row = new[] { new Vector3(-.5f, 0, 0), new Vector3(.5f, 0, 0) };
|
||||
var shifted = new[] { row[0] - Vector3.right, row[1] - Vector3.right };
|
||||
float correction = ContactPlacement.Clearance(shifted, row, Vector3.left);
|
||||
Assert.That(correction, Is.EqualTo(1).Within(.0001f));
|
||||
foreach (var moving in shifted) foreach (var target in row)
|
||||
Assert.That(Vector3.Distance(moving + Vector3.left * correction, target), Is.GreaterThanOrEqualTo(.9999f));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82f243b65ae87844cbc4a8fcd1b00d4a
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class CompositionSessionTests
|
||||
{
|
||||
private sealed class SpyStore : IProgressStore
|
||||
{
|
||||
public ProgressSave Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 13).ToArray() };
|
||||
public int Writes;
|
||||
public bool TryLoad(out ProgressSave save, out string error) { save = Saved; error = null; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error) { Saved = save; Writes++; error = null; return true; }
|
||||
}
|
||||
[Test] public void MoveAndMergeCompleteWithOneWriteAndPreserveEveryIdentity()
|
||||
{
|
||||
var store = new SpyStore { Saved = new ProgressSave { completedStageIds = Enumerable.Range(1, 5).ToArray() } };
|
||||
var session = new GameSession(new[] { new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) }, store);
|
||||
session.StartStage(6); var groups = session.Board.Groups; var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
|
||||
var writes = store.Writes;
|
||||
var result = session.MoveAndMerge(groups[0].id, new Position3(0.4f, 0.2f, 1), groups[1].id);
|
||||
Assert.That(result.Accepted, Is.True); Assert.That(store.Writes, Is.EqualTo(writes + 1));
|
||||
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(session.Board.Groups.Single().ballIds, Is.EquivalentTo(ids));
|
||||
Assert.That(store.Saved.puzzleStates, Is.Empty); Assert.That(store.Saved.latestFreePlay, Is.Not.Null);
|
||||
Assert.That(store.Saved.completedStageIds, Is.EqualTo(Enumerable.Range(1, 6).ToArray()));
|
||||
}
|
||||
[Test] public void UnknownMergeTargetDoesNotMoveOrWrite()
|
||||
{
|
||||
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) }, store);
|
||||
session.StartStage(6); var original = session.Board.Balls; var writes = store.Writes;
|
||||
Assert.That(session.MoveAndMerge(session.Board.Groups[0].id, new Position3(1, 0, 0), "missing").Accepted, Is.False);
|
||||
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
|
||||
Assert.That(store.Writes, Is.EqualTo(writes));
|
||||
}
|
||||
[TestCase(0)] [TestCase(3)] public void EmptyAndEntireSplitKeepStateAndSave(int count)
|
||||
{
|
||||
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
|
||||
session.StartStage(14); var group = session.Board.Groups[0]; var writes = store.Writes; var original = session.Board.Balls;
|
||||
Assert.That(session.SplitAndMove(group.id, group.ballIds.Take(count).ToArray(), "new", new Position3(1, 1, 0)).Accepted, Is.False);
|
||||
Assert.That(store.Writes, Is.EqualTo(writes)); Assert.That(session.Board.Groups.Length, Is.EqualTo(2));
|
||||
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
|
||||
}
|
||||
[Test] public void DistantSelectionProducesExactlyTwoFriendsAndFinalCompositionIgnoresHistory()
|
||||
{
|
||||
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
|
||||
session.StartStage(14); var groups = session.Board.Groups;
|
||||
session.MoveAndMerge(groups[0].id, new Position3(0.1f, 0, 0), groups[1].id);
|
||||
var all = session.Board.Groups.Single(); var ids = all.ballIds.ToArray(); var writes = store.Writes;
|
||||
Assert.That(session.SplitAndMove(all.id, new[] { ids[0], ids[3] }, "split-result", new Position3(3, 0, 0)).Accepted, Is.True);
|
||||
Assert.That(store.Writes, Is.EqualTo(writes + 1)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 2, 2 }));
|
||||
Assert.That(session.Board.Groups.SelectMany(group => group.ballIds), Is.EquivalentTo(ids));
|
||||
}
|
||||
[Test] public void SplitAndMergeKeepCollectedAndCountedIdsThroughPuzzleRestore()
|
||||
{
|
||||
var definition = new StageDefinition(1, StageKind.Counting, new[] { 1, 1, 1 }, new[] { 3 });
|
||||
var store = new SpyStore(); var session = new GameSession(new[] { definition }, store); session.StartStage(1);
|
||||
var groups = session.Board.Groups; var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
|
||||
foreach (var group in groups) session.MoveGroup(group.id, new Position3(0, 1, 0));
|
||||
session.Merge(groups[0].id, groups[1].id); session.Merge(groups[0].id, groups[2].id); session.Count(ids[0]);
|
||||
var writes = store.Writes;
|
||||
Assert.That(session.SplitAndMove(groups[0].id, new[] { ids[0], ids[2] }, "detached", new Position3(2, 0, 0)).Accepted, Is.True);
|
||||
Assert.That(store.Writes, Is.EqualTo(writes + 1)); Assert.That(session.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] }));
|
||||
var restored = new GameSession(new[] { definition }, store); restored.StartStage(1);
|
||||
Assert.That(restored.Board.CollectedBallIds, Is.EquivalentTo(ids)); Assert.That(restored.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] }));
|
||||
Assert.That(restored.Board.Groups.Length, Is.EqualTo(2));
|
||||
Assert.That(restored.MoveAndMerge("detached", new Position3(-1, 0, 0), groups[0].id).Accepted, Is.True);
|
||||
Assert.That(restored.Board.CountedBallIds, Is.EqualTo(new[] { ids[0] })); Assert.That(restored.Board.BallCount, Is.EqualTo(3));
|
||||
}
|
||||
[Test] public void InvalidCoordinatesNeverPartiallySplitOrMerge()
|
||||
{
|
||||
var store = new SpyStore(); var session = new GameSession(new[] { new StageDefinition(14, StageKind.Composition, new[] { 3, 1 }, new[] { 2, 2 }) }, store);
|
||||
session.StartStage(14); var groups = session.Board.Groups; var original = session.Board.Balls; var writes = store.Writes;
|
||||
Assert.That(session.MoveAndMerge(groups[0].id, new Position3(float.NaN, 0, 0), groups[1].id).Accepted, Is.False);
|
||||
Assert.That(session.SplitAndMove(groups[0].id, new[] { groups[0].ballIds[0] }, "invalid", new Position3(float.PositiveInfinity, 0, 0)).Accepted, Is.False);
|
||||
Assert.That(store.Writes, Is.EqualTo(writes)); Assert.That(session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 3, 1 }));
|
||||
Assert.That(session.Board.Balls.Select(ball => ball.position), Is.EqualTo(original.Select(ball => ball.position)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9ca4f319c077954381f2f4da39614e0
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class ContentAssetTests
|
||||
{
|
||||
[Test] public void EveryFixedStageHasExactlyOneAsset()
|
||||
{
|
||||
var assets = AssetDatabase.FindAssets("t:StageAsset", new[] { "Assets/MagnetismHeroes/UnityRuntime/StageAssets" })
|
||||
.Select(guid => AssetDatabase.LoadAssetAtPath<StageAsset>(AssetDatabase.GUIDToAssetPath(guid))).ToArray();
|
||||
Assert.That(assets.Length, Is.EqualTo(30));
|
||||
Assert.That(assets.Select(asset => asset.StageId).OrderBy(id => id), Is.EqualTo(Enumerable.Range(1, 30)));
|
||||
}
|
||||
[Test] public void FixedOrderMatchesEveryR07InitialTargetAndKind()
|
||||
{
|
||||
string[] expected = { "1>1", "1,1>2", "1,1,1>3", "1,1,1,1>4", "1,1,1,1,1>5",
|
||||
"1,1>2", "2,1>3", "3,1>4", "2,3>5", "2>1,1", "3>2,1", "4>2,2", "5>3,2", "3,1>2,2", "4,1>3,2",
|
||||
"1,1,1,1,1,1>6", "1,1,1,1,1,1,1>7", "1,1,1,1,1,1,1,1>8", "1,1,1,1,1,1,1,1,1>9", "1,1,1,1,1,1,1,1,1,1>10",
|
||||
"5,1>6", "3,4>7", "4,4>8", "5,5>10", "6>5,1", "7>4,3", "9>5,4", "10>5,5", "5,3>4,4", "6,4>5,5" };
|
||||
for (int id=1; id<=30; id++)
|
||||
{
|
||||
var asset=AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
|
||||
var definition=asset.ToDefinition();
|
||||
Assert.That(string.Join(",",definition.InitialGroups)+">"+string.Join(",",definition.TargetGroups), Is.EqualTo(expected[id-1]), $"Stage {id}");
|
||||
Assert.That(definition.Kind, Is.EqualTo(id<=5 || id>=16 && id<=20 ? StageKind.Counting : StageKind.Composition));
|
||||
Assert.That(definition.Total, Is.EqualTo(definition.TargetGroups.Sum())); Assert.That(definition.Total, Is.InRange(1,10));
|
||||
Assert.That(definition.BallPositions.Count, Is.EqualTo(definition.Total));
|
||||
Assert.That(asset.Theme, Is.EqualTo(id<=10 ? "Garden" : id<=20 ? "Cloud" : "Workshop"));
|
||||
Assert.That(asset.Decorations.Count, Is.EqualTo(7));
|
||||
foreach (var placement in asset.Decorations) Assert.That(placement.prefab, Is.Not.Null);
|
||||
string before=EditorJsonUtility.ToJson(asset); var board=new BoardRules(definition);
|
||||
board.TryMoveGroup(board.Groups[0].id,new Position3(1,0,0));
|
||||
Assert.That(EditorJsonUtility.ToJson(asset), Is.EqualTo(before), "Runtime rules never mutate their source asset.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae9003f830dded844a056f200eb4b634
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class ContentSessionTests
|
||||
{
|
||||
private sealed class Store : IProgressStore
|
||||
{
|
||||
public ProgressSave Saved = new ProgressSave();
|
||||
public bool TryLoad(out ProgressSave save, out string error) { save = Saved; error = null; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error) { Saved = save; error = null; return true; }
|
||||
}
|
||||
private static StageDefinition[] Definitions() => Enumerable.Range(1,30).Select(id =>
|
||||
AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset").ToDefinition()).ToArray();
|
||||
private static void Apply(GameSession session, BoardHint hint)
|
||||
{
|
||||
Assert.That(hint, Is.Not.Null);
|
||||
RuleResult result;
|
||||
switch (hint.Kind)
|
||||
{
|
||||
case HintKind.Collect: result = session.MoveGroup(hint.FirstGroupId, new Position3(.1f,0,0)); break;
|
||||
case HintKind.Count: result = session.Count(hint.BallIds[0]); break;
|
||||
case HintKind.Merge: result = session.MoveAndMerge(hint.FirstGroupId, new Position3(.1f,0,0), hint.SecondGroupId); break;
|
||||
default: result = session.SplitAndMove(hint.FirstGroupId, hint.BallIds, "hint-split-" + Guid.NewGuid().ToString("N"), new Position3(1,0,0)); break;
|
||||
}
|
||||
Assert.That(result.Accepted, Is.True, result.Reason.ToString());
|
||||
}
|
||||
[Test] public void FreshSaveSolvesAllThirtyThroughActualCoreCallsUnlocksNextAndNeverThirtyOne()
|
||||
{
|
||||
var definitions = Definitions(); var store = new Store(); var session = new GameSession(definitions, store);
|
||||
Assert.That(Enumerable.Range(1,30).Where(session.IsUnlocked), Is.EqualTo(new[] {1}));
|
||||
for (int id = 1; id <= 30; id++)
|
||||
{
|
||||
if (id == 1) Assert.That(session.StartStage(id), Is.True);
|
||||
else Assert.That(session.StartNextStage(), Is.True);
|
||||
Assert.That(session.Board.StageId, Is.EqualTo(id));
|
||||
for (int step = 0; session.Screen == SessionScreen.Puzzle && step < 40; step++)
|
||||
{
|
||||
var before = UnityEngine.JsonUtility.ToJson(store.Saved); var ids = session.Board.Balls.Select(ball => ball.id).ToArray();
|
||||
var hint = session.Hint;
|
||||
Assert.That(UnityEngine.JsonUtility.ToJson(store.Saved), Is.EqualTo(before), "Help is read-only.");
|
||||
Apply(session, hint); Assert.That(session.Board.Balls.Select(ball => ball.id), Is.EqualTo(ids));
|
||||
}
|
||||
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay), $"Stage {id}");
|
||||
Assert.That(store.Saved.completedStageIds, Is.EqualTo(Enumerable.Range(1,id)));
|
||||
Assert.That(session.HasNextStage, Is.EqualTo(id < 30));
|
||||
Assert.That(store.Saved.latestFreePlay.sourceStageId, Is.EqualTo(id));
|
||||
session = new GameSession(definitions, store); Assert.That(session.ContinueFreePlay(), Is.True);
|
||||
}
|
||||
Assert.That(session.IsUnlocked(31), Is.False); Assert.That(session.StartNextStage(), Is.False); Assert.That(session.StartStage(31), Is.False);
|
||||
Assert.That(session.StartStage(6), Is.True); session.MoveGroup(session.Board.Groups[0].id, new Position3(.2f,0,0)); session.ShowHome();
|
||||
Assert.That(store.Saved.completedStageIds, Is.EqualTo(Enumerable.Range(1,30))); Assert.That(session.IsUnlocked(30), Is.True);
|
||||
Assert.That(session.StartStage(6), Is.True); Assert.That(session.Board.Balls[0].position.x, Is.EqualTo(definitions[5].BallPositions[0].x+.2f));
|
||||
}
|
||||
[Test] public void NextPrefersSavedPuzzleAndHelpRecoversFromAlternativeDisconnectedPartitions()
|
||||
{
|
||||
var definitions = Definitions(); var store = new Store { Saved = new ProgressSave { completedStageIds = Enumerable.Range(1,29).ToArray() } };
|
||||
var session = new GameSession(definitions,store); Assert.That(session.StartStage(30), Is.True);
|
||||
var group = session.Board.Groups[0]; session.SplitAndMove(group.id,new[] {group.ballIds[0],group.ballIds[2]},"alternative",new Position3(0,2,0));
|
||||
session.CommitCamera(new CameraState { distance=12 }); session.ShowHome();
|
||||
Assert.That(session.StartStage(29), Is.True);
|
||||
for (int i=0; session.Screen==SessionScreen.Puzzle && i<30; i++) Apply(session,session.Hint);
|
||||
Assert.That(session.StartNextStage(), Is.True); Assert.That(session.Camera.distance, Is.EqualTo(12));
|
||||
Assert.That(session.Board.Groups.Any(value=>value.id=="alternative"), Is.True);
|
||||
for (int i=0; session.Screen==SessionScreen.Puzzle && i<30; i++) Apply(session,session.Hint);
|
||||
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(session.Board.Groups.Select(value=>value.ballIds.Length).OrderBy(size=>size), Is.EqualTo(new[] {5,5}));
|
||||
}
|
||||
[Test] public void AllIntegerPartitionsHaveLiveHintsThatReachEachCompositionTarget()
|
||||
{
|
||||
foreach (var definition in Definitions().Where(stage=>stage.Kind==StageKind.Composition))
|
||||
foreach (var sizes in Partitions(definition.Total,1))
|
||||
{
|
||||
var board = new BoardRules(new StageDefinition(definition.Id,StageKind.Composition,sizes,definition.TargetGroups.ToArray()));
|
||||
for (int i=0; !board.IsSolved && i<20; i++)
|
||||
{
|
||||
var hint=BoardHint.Find(board,definition); Assert.That(hint, Is.Not.Null);
|
||||
var result=hint.Kind==HintKind.Merge ? board.TryMerge(hint.FirstGroupId,hint.SecondGroupId)
|
||||
: board.TrySplit(hint.FirstGroupId,hint.BallIds,"split-"+i);
|
||||
Assert.That(result.Accepted, Is.True);
|
||||
}
|
||||
Assert.That(board.IsSolved, Is.True, $"{definition.Id}: {string.Join(",",sizes)}");
|
||||
}
|
||||
}
|
||||
private static System.Collections.Generic.IEnumerable<int[]> Partitions(int total,int minimum)
|
||||
{
|
||||
if (total==0) { yield return Array.Empty<int>(); yield break; }
|
||||
for (int first=minimum; first<=total; first++)
|
||||
foreach (var rest in Partitions(total-first,first)) yield return new[] {first}.Concat(rest).ToArray();
|
||||
}
|
||||
[Test] public void CountingHelpRejoinsCollectedSplitBeforeFindingOtherFriendsThenCountsOnlyUncountedId()
|
||||
{
|
||||
var definition=new StageDefinition(4,StageKind.Counting,new[] {1,1,1,1},new[] {4}); var board=new BoardRules(definition);
|
||||
board.TryMerge(board.Groups[0].id,board.Groups[1].id); var collected=board.Groups.First(group=>group.ballIds.Length==2);
|
||||
board.TrySplit(collected.id,new[] {collected.ballIds[0]},"resplit");
|
||||
var hint=BoardHint.Find(board,definition); Assert.That(hint.Kind, Is.EqualTo(HintKind.Merge));
|
||||
Assert.That(hint.BallIds.All(board.CollectedBallIds.Contains), Is.True); board.TryMerge(hint.FirstGroupId,hint.SecondGroupId);
|
||||
Assert.That(BoardHint.Find(board,definition).Kind, Is.EqualTo(HintKind.Collect));
|
||||
foreach (var group in board.Groups) board.TryMoveGroup(group.id,new Position3(.1f,0,0));
|
||||
while (board.Groups.Length>1) board.TryMerge(board.Groups[0].id,board.Groups[1].id);
|
||||
board.TryCount(board.Balls[0].id); hint=BoardHint.Find(board,definition);
|
||||
Assert.That(hint.Kind, Is.EqualTo(HintKind.Count)); Assert.That(board.CountedBallIds, Does.Not.Contain(hint.BallIds[0]));
|
||||
Assert.That(board.TryCount(hint.BallIds[0]).Accepted, Is.True);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f65031cb242c4e746b894204a7e7c4a1
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class ContentVoiceTests
|
||||
{
|
||||
[Test] public void EveryLiveHelpPairAndBothActualGhostScriptsHaveOfflinePcmClips()
|
||||
{
|
||||
var prefab=AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||
var clips=new SerializedObject(prefab.GetComponent<VoicePlayer>()).FindProperty("_clips");
|
||||
var ids=Enumerable.Range(0,clips.arraySize).Select(i=>clips.GetArrayElementAtIndex(i).FindPropertyRelative("id").stringValue).ToArray();
|
||||
Assert.That(ids.Length, Is.EqualTo(44)); Assert.That(ids.Distinct().Count(), Is.EqualTo(44));
|
||||
foreach (string id in new[] {"guide_find","guide_uncounted","demo_merge6","demo_split10"}.Concat(
|
||||
Enumerable.Range(2,9).SelectMany(size=>Enumerable.Range(1,size/2).Select(count=>$"guide_split_{size}_{count}"))))
|
||||
{
|
||||
Assert.That(ids, Does.Contain(id));
|
||||
int index=System.Array.IndexOf(ids,id); var clip=(AudioClip)clips.GetArrayElementAtIndex(index).FindPropertyRelative("clip").objectReferenceValue;
|
||||
Assert.That(clip, Is.Not.Null,id); Assert.That(clip.frequency, Is.EqualTo(44100)); Assert.That(clip.channels, Is.EqualTo(1)); Assert.That(clip.samples, Is.GreaterThan(0));
|
||||
var importer=(AudioImporter)AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(clip));
|
||||
Assert.That(importer.defaultSampleSettings.compressionFormat, Is.EqualTo(AudioCompressionFormat.PCM));
|
||||
Assert.That(importer.defaultSampleSettings.loadType, Is.EqualTo(AudioClipLoadType.DecompressOnLoad));
|
||||
}
|
||||
Assert.That(ids.Count(id=>id.StartsWith("guide_split_")), Is.EqualTo(25));
|
||||
}
|
||||
[TestCase("count_01","13A882CE1620140D447635E14F2D4758045D5E348960C17386DF1C720050C100")]
|
||||
[TestCase("guide_collect","62E15C9E38C36F4094A6C7E9C5148510141290B5F41DB5AAD9DC1D8F1A158594")]
|
||||
[TestCase("guide_count","DEBE29B89F3E2856F2BF4C5E01707A5F0A0657E5768C03BE385EC88FA2328B40")]
|
||||
[TestCase("count_08","FA8034948F6AC3E289E8E7E9FB447943760D4ED5AC577C8AB0C961C608FE49C2")]
|
||||
public void UserApprovedVoiceBytesRemainExact(string id,string hash)
|
||||
{
|
||||
using (var sha=SHA256.Create()) Assert.That(System.BitConverter.ToString(sha.ComputeHash(File.ReadAllBytes($"Assets/MagnetismHeroes/UnityRuntime/Presentation/Audio/Voice/{id}.wav"))).Replace("-",""), Is.EqualTo(hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2e9d6608639d744397aad7855886e4b
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2adb284c6a46ee445b2628178ab5132f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests.EditMode.Core
|
||||
{
|
||||
public sealed class BoardRulesTests
|
||||
{
|
||||
private static StageDefinition Composition(int[] initial, int[] target) => new StageDefinition(6, StageKind.Composition, initial, target);
|
||||
private static StageDefinition Counting(int total) => new StageDefinition(total, StageKind.Counting, Enumerable.Repeat(1, total).ToArray(), new[] { total });
|
||||
private static string Signature(BoardRules board) => string.Join(";", board.Groups.Select(g => g.id + ":" + string.Join(",", g.ballIds)))
|
||||
+ "|" + string.Join(";", board.Balls.Select(b => b.id + ":" + b.position.x + "," + b.position.y + "," + b.position.z))
|
||||
+ "|" + string.Join(",", board.CollectedBallIds) + "|" + string.Join(",", board.CountedBallIds);
|
||||
|
||||
[TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)]
|
||||
[TestCase(6)] [TestCase(7)] [TestCase(8)] [TestCase(9)] [TestCase(10)]
|
||||
public void EveryProperSubsetProducesExactlyTwoGroupsAndPreservesAllIds(int total)
|
||||
{
|
||||
for (var mask = 1; mask < (1 << total) - 1; mask++)
|
||||
{
|
||||
var board = new BoardRules(Composition(new[] { total }, new[] { total }));
|
||||
var ids = board.Balls.Select(b => b.id).ToArray();
|
||||
var selected = ids.Where((id, index) => (mask & (1 << index)) != 0).ToArray();
|
||||
var result = board.TrySplit(board.Groups[0].id, selected, "selected");
|
||||
Assert.That(result.Accepted, Is.True, $"N={total}, mask={mask}");
|
||||
Assert.That(board.Groups.Length, Is.EqualTo(2));
|
||||
Assert.That(board.Groups.Single(g => g.id == "selected").ballIds, Is.EquivalentTo(selected));
|
||||
Assert.That(board.Groups.Single(g => g.id != "selected").ballIds, Is.EquivalentTo(ids.Except(selected)));
|
||||
Assert.That(board.Groups.SelectMany(g => g.ballIds), Is.EquivalentTo(ids));
|
||||
Assert.That(board.Balls.Select(b => b.id), Is.EquivalentTo(ids));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidSplitsNeverMutateBoard()
|
||||
{
|
||||
var board = new BoardRules(Composition(new[] { 3, 1 }, new[] { 2, 2 }));
|
||||
var group = board.Groups[0];
|
||||
var other = board.Groups[1];
|
||||
var before = Signature(board);
|
||||
var attempts = new[] { Array.Empty<string>(), group.ballIds, new[] { group.ballIds[0], group.ballIds[0] },
|
||||
new[] { other.ballIds[0] }, new[] { "missing" } };
|
||||
foreach (var selection in attempts)
|
||||
{
|
||||
Assert.That(board.TrySplit(group.id, selection, "new").Accepted, Is.False);
|
||||
Assert.That(Signature(board), Is.EqualTo(before));
|
||||
}
|
||||
Assert.That(board.TrySplit(group.id, null, "new").Accepted, Is.False);
|
||||
Assert.That(board.TrySplit(group.id, new[] { group.ballIds[0] }, other.id).Accepted, Is.False);
|
||||
Assert.That(board.TrySplit(other.id, other.ballIds, "new").Accepted, Is.False);
|
||||
Assert.That(Signature(board), Is.EqualTo(before));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GoalUsesAllGroupsAsMultisetAndIgnoresShape()
|
||||
{
|
||||
var board = new BoardRules(Composition(new[] { 4 }, new[] { 2, 2 }));
|
||||
Assert.That(board.IsSolved, Is.False);
|
||||
var ids = board.Balls.Select(b => b.id).ToArray();
|
||||
Assert.That(board.TrySplit(board.Groups[0].id, new[] { ids[0], ids[3] }, "two").Accepted, Is.True);
|
||||
Assert.That(board.IsSolved, Is.True);
|
||||
board.TryMoveGroup("two", new Position3(5, -3, 10));
|
||||
Assert.That(board.IsSolved, Is.True);
|
||||
board.TrySplit("two", new[] { ids[0] }, "one");
|
||||
Assert.That(board.IsSolved, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MergePreservesIdsAndRejectsSelfOrUnknownGroups()
|
||||
{
|
||||
var board = new BoardRules(Composition(new[] { 2, 3 }, new[] { 5 }));
|
||||
var original = board.Balls.Select(b => b.id).ToArray();
|
||||
var groups = board.Groups;
|
||||
var before = Signature(board);
|
||||
Assert.That(board.TryMerge(groups[0].id, groups[0].id).Accepted, Is.False);
|
||||
Assert.That(board.TryMerge(groups[0].id, "missing").Accepted, Is.False);
|
||||
Assert.That(Signature(board), Is.EqualTo(before));
|
||||
Assert.That(board.TryMerge(groups[0].id, groups[1].id).Accepted, Is.True);
|
||||
Assert.That(board.Groups.Single().ballIds, Is.EquivalentTo(original));
|
||||
Assert.That(board.BallCount, Is.EqualTo(5));
|
||||
Assert.That(board.IsSolved, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountingOneRequiresDragBeforeFirstCountAndDuplicateTapIsRejected()
|
||||
{
|
||||
var board = new BoardRules(Counting(1));
|
||||
var ballId = board.Balls[0].id;
|
||||
Assert.That(board.CanCount, Is.False);
|
||||
Assert.That(board.TryCount(ballId).Accepted, Is.False);
|
||||
Assert.That(board.TryMoveGroup(board.Groups[0].id, new Position3(0, 0, 0)).Accepted, Is.False);
|
||||
Assert.That(board.IsSolved, Is.False);
|
||||
Assert.That(board.TryMoveGroup(board.Groups[0].id, new Position3(1, 0, 0)).Accepted, Is.True);
|
||||
Assert.That(board.CanCount, Is.True);
|
||||
Assert.That(board.TryCount(ballId).Count, Is.EqualTo(1));
|
||||
Assert.That(board.IsSolved, Is.True);
|
||||
Assert.That(board.TryCount(ballId).Reason, Is.EqualTo(RejectionReason.AlreadyCounted));
|
||||
Assert.That(board.CountedCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectedAndCountedIdsSurviveSplitMergeMoveAndRestore()
|
||||
{
|
||||
var definition = Counting(3);
|
||||
var board = new BoardRules(definition);
|
||||
var initial = board.Groups;
|
||||
board.TryMerge(initial[0].id, initial[1].id);
|
||||
Assert.That(board.CollectedBallIds.Length, Is.EqualTo(2));
|
||||
Assert.That(board.TryCount(board.Balls[0].id).Accepted, Is.False);
|
||||
board.TryMerge(initial[0].id, initial[2].id);
|
||||
var firstId = board.Balls[0].id;
|
||||
Assert.That(board.TryCount(firstId).Count, Is.EqualTo(1));
|
||||
board.TrySplit(board.Groups[0].id, new[] { firstId }, "selected");
|
||||
Assert.That(board.CanCount, Is.False);
|
||||
Assert.That(board.TryCount(board.Balls[1].id).Accepted, Is.False);
|
||||
board.TryMoveGroup("selected", new Position3(2, 1, -3));
|
||||
var snapshot = board.ToPuzzleState(new CameraState());
|
||||
var restored = new BoardRules(definition, snapshot);
|
||||
restored.TryMerge(restored.Groups[0].id, "selected");
|
||||
Assert.That(restored.CollectedBallIds.Length, Is.EqualTo(3));
|
||||
Assert.That(restored.CountedBallIds, Is.EquivalentTo(new[] { firstId }));
|
||||
Assert.That(restored.TryCount(firstId).Accepted, Is.False);
|
||||
Assert.That(restored.TryCount(restored.Balls[1].id).Count, Is.EqualTo(2));
|
||||
Assert.That(restored.IsSolved, Is.False);
|
||||
Assert.That(restored.TryCount(restored.Balls[2].id).Count, Is.EqualTo(3));
|
||||
Assert.That(restored.IsSolved, Is.True);
|
||||
}
|
||||
|
||||
[TestCase(1)] [TestCase(2)] [TestCase(3)] [TestCase(4)] [TestCase(5)]
|
||||
[TestCase(6)] [TestCase(7)] [TestCase(8)] [TestCase(9)] [TestCase(10)]
|
||||
public void CountingEveryValueRequiresEveryCollectedBallToBeCountedOnce(int total)
|
||||
{
|
||||
var board = new BoardRules(Counting(total));
|
||||
var groups = board.Groups;
|
||||
board.TryMoveGroup(groups[0].id, new Position3(1, 0, 0));
|
||||
for (var i = 1; i < groups.Length; i++) board.TryMerge(groups[0].id, groups[i].id);
|
||||
var balls = board.Balls;
|
||||
Assert.That(board.CanCount, Is.True);
|
||||
Assert.That(board.IsSolved, Is.False);
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
Assert.That(board.TryCount(balls[i].id).Count, Is.EqualTo(i + 1));
|
||||
Assert.That(board.TryCount(balls[i].id).Accepted, Is.False);
|
||||
Assert.That(board.IsSolved, Is.EqualTo(i + 1 == total));
|
||||
}
|
||||
Assert.That(board.TryCount("missing").Accepted, Is.False);
|
||||
Assert.That(board.CountedCount, Is.EqualTo(total));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SupplyIsLockedInBothPuzzleKindsAndFreePlayAllowsZeroThroughTen()
|
||||
{
|
||||
foreach (var board in new[] { new BoardRules(Counting(1)), new BoardRules(Composition(new[] { 1 }, new[] { 1 })) })
|
||||
{
|
||||
Assert.That(board.TrySpawn("new", "group", new Position3()).Reason, Is.EqualTo(RejectionReason.PuzzleSupplyLocked));
|
||||
Assert.That(board.TryReturn(board.Groups[0].id).Reason, Is.EqualTo(RejectionReason.PuzzleSupplyLocked));
|
||||
}
|
||||
var free = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = 1 });
|
||||
Assert.That(free.BallCount, Is.Zero);
|
||||
for (var i = 0; i < 10; i++) Assert.That(free.TrySpawn("ball" + i, "group" + i, new Position3()).Accepted, Is.True);
|
||||
var before = Signature(free);
|
||||
Assert.That(free.TrySpawn("extra", "extra", new Position3()).Reason, Is.EqualTo(RejectionReason.CapacityReached));
|
||||
Assert.That(Signature(free), Is.EqualTo(before));
|
||||
free.TryMerge("group0", "group1");
|
||||
Assert.That(free.TryReturn("group0").Accepted, Is.True);
|
||||
Assert.That(free.BallCount, Is.EqualTo(8));
|
||||
Assert.That(free.TrySpawn("new", "new", new Position3()).Accepted, Is.True);
|
||||
foreach (var group in free.Groups) free.TryReturn(group.id);
|
||||
Assert.That(free.BallCount, Is.Zero);
|
||||
Assert.That(free.Groups, Is.Empty);
|
||||
Assert.That(free.IsSolved, Is.False);
|
||||
Assert.That(free.TrySpawn("again", "again", new Position3()).Accepted, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DuplicateSpawnAndInvalidCoordinatesAreRejectedAtomically()
|
||||
{
|
||||
var free = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = 1 });
|
||||
free.TrySpawn("ball", "group", new Position3(float.MaxValue, 0, 0));
|
||||
var before = Signature(free);
|
||||
Assert.That(free.TrySpawn("ball", "other", new Position3()).Accepted, Is.False);
|
||||
Assert.That(free.TrySpawn("other", "group", new Position3()).Accepted, Is.False);
|
||||
Assert.That(free.TrySpawn("invalid", "invalid", new Position3(float.NaN, 0, 0)).Accepted, Is.False);
|
||||
Assert.That(free.TryMoveGroup("group", new Position3(float.MaxValue, 0, 0)).Accepted, Is.False);
|
||||
Assert.That(free.TryMoveGroup("group", new Position3(0, float.PositiveInfinity, 0)).Accepted, Is.False);
|
||||
Assert.That(Signature(free), Is.EqualTo(before));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AGroupMoveThatOverflowsALaterBallCannotChangeEarlierBallsOrCollectThem()
|
||||
{
|
||||
var definition = new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 },
|
||||
new[] { new Position3(), new Position3(float.MaxValue, 0, 0) });
|
||||
var state = new BoardRules(definition).ToPuzzleState(new CameraState());
|
||||
// A restored counting group can have no committed collection yet, so failed movement must not collect it.
|
||||
state.groups = new[] { new GroupState { id = "group", ballIds = state.balls.Select(b => b.id).ToArray() } };
|
||||
var board = new BoardRules(definition, state);
|
||||
var before = Signature(board);
|
||||
Assert.That(board.TryMoveGroup("group", new Position3(float.MaxValue, 0, 0)).Accepted, Is.False);
|
||||
Assert.That(Signature(board), Is.EqualTo(before));
|
||||
Assert.That(board.CanCount, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefinitionsInputsAndReturnedSnapshotsCannotMutateOwnedBoard()
|
||||
{
|
||||
var initial = new[] { 2, 1 };
|
||||
var target = new[] { 3 };
|
||||
var positions = new[] { new Position3(1, 2, 3), new Position3(), new Position3() };
|
||||
var definition = new StageDefinition(6, StageKind.Composition, initial, target, positions);
|
||||
initial[0] = 10; target[0] = 1; positions[0] = new Position3(100, 0, 0);
|
||||
var board = new BoardRules(definition);
|
||||
var before = Signature(board);
|
||||
var balls = board.Balls; balls[0].id = "changed"; balls[0].position = new Position3(8, 8, 8);
|
||||
var groups = board.Groups; groups[0].ballIds[0] = "changed"; groups[0].id = "changed";
|
||||
var camera = new CameraState();
|
||||
var state = board.ToPuzzleState(camera);
|
||||
var restored = new BoardRules(definition, state);
|
||||
state.balls[0].position = new Position3(9, 9, 9); state.groups[0].ballIds[0] = "bad"; camera.distance = -1;
|
||||
Assert.That(Signature(board), Is.EqualTo(before));
|
||||
Assert.That(Signature(restored), Is.EqualTo(before));
|
||||
Assert.That(board.Balls[0].position.x, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidDefinitionsAreRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new StageDefinition(0, StageKind.Composition, new[] { 1 }, new[] { 1 }));
|
||||
Assert.Throws<ArgumentException>(() => Composition(new[] { 0, 2 }, new[] { 2 }));
|
||||
Assert.Throws<ArgumentException>(() => Composition(new[] { 11 }, new[] { 11 }));
|
||||
Assert.Throws<ArgumentException>(() => Composition(new[] { 2 }, new[] { 3 }));
|
||||
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 2 }, new[] { 2 }));
|
||||
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 1, 1 }, new[] { 1, 1 }));
|
||||
Assert.Throws<ArgumentException>(() => new StageDefinition(1, (StageKind)10, new[] { 1 }, new[] { 1 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a9e3b7c7f3f39e42838d06b916e58e3
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using MagnetismHeroes.Core;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests.EditMode.Core
|
||||
{
|
||||
public sealed class SaveValidatorTests
|
||||
{
|
||||
private static readonly StageDefinition Count = new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 });
|
||||
private static readonly StageDefinition Compose = new StageDefinition(6, StageKind.Composition, new[] { 2 }, new[] { 1, 1 });
|
||||
private static readonly StageDefinition[] Definitions = { Count, Compose };
|
||||
private static ProgressSave Valid() => new ProgressSave { puzzleStates = new[] { new BoardRules(Count).ToPuzzleState(new CameraState()) } };
|
||||
|
||||
[Test]
|
||||
public void ValidPuzzleAndEmptyFreePlayAreAccepted()
|
||||
{
|
||||
var save = Valid();
|
||||
save.completedStageIds = new[] { 2 };
|
||||
save.seenTutorialIds = new[] { "merge", "split" };
|
||||
save.latestFreePlay = new FreePlayState { sourceStageId = 2 };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out var error), Is.True, error);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SchemaMissingCollectionsInvalidStagesAndDuplicateRecordsAreRejected()
|
||||
{
|
||||
Assert.That(SaveValidator.TryValidate(null, Definitions, out _), Is.False);
|
||||
var save = Valid(); save.schemaVersion = 2;
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.completedStageIds = new[] { 2, 2 };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.completedStageIds = new[] { 31 };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.seenTutorialIds = new[] { "unknown" };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.seenTutorialIds = new[] { "merge", "merge" };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.puzzleStates = new[] { save.puzzleStates[0], save.puzzleStates[0] };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.puzzleStates = null;
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save = Valid(); save.puzzleStates[0].stageId = 3;
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidMembershipCoordinatesCameraAndCountingRecordsRejectWholeSave()
|
||||
{
|
||||
Action<PuzzleState>[] corruptions = {
|
||||
s => s.balls[1].id = s.balls[0].id,
|
||||
s => s.groups[1].id = s.groups[0].id,
|
||||
s => s.groups[0].ballIds = Array.Empty<string>(),
|
||||
s => s.groups[0].ballIds = new[] { "missing" },
|
||||
s => s.groups[1].ballIds = s.groups[0].ballIds,
|
||||
s => s.groups = new[] { s.groups[0] },
|
||||
s => s.balls[0].position.x = float.NaN,
|
||||
s => s.balls = new[] { s.balls[0] },
|
||||
s => s.collectedBallIds = new[] { "missing" },
|
||||
s => s.collectedBallIds = new[] { s.balls[0].id, s.balls[0].id },
|
||||
s => s.countedBallIds = new[] { s.balls[0].id },
|
||||
s => { s.collectedBallIds = new[] { s.balls[0].id }; s.countedBallIds = new[] { s.balls[0].id, s.balls[0].id }; },
|
||||
s => s.camera = null,
|
||||
s => s.camera.distance = 0,
|
||||
s => s.camera.distance = float.PositiveInfinity,
|
||||
s => s.camera.target.z = float.NegativeInfinity,
|
||||
s => s.camera.rotation = new Rotation4(0, 0, 0, 0),
|
||||
s => s.collectedBallIds = null
|
||||
};
|
||||
foreach (var corrupt in corruptions)
|
||||
{
|
||||
var save = Valid(); corrupt(save.puzzleStates[0]);
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out var error), Is.False);
|
||||
Assert.That(error, Is.Not.Null.And.Not.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompositionCannotHaveCollectionOrCountingRecords()
|
||||
{
|
||||
var state = new BoardRules(Compose).ToPuzzleState(new CameraState());
|
||||
state.collectedBallIds = new[] { state.balls[0].id };
|
||||
Assert.That(SaveValidator.TryValidatePuzzle(state, Compose, out _), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FreePlaySourceMustBeCompletedAndEmptyBoardCannotContainGroups()
|
||||
{
|
||||
var save = Valid(); save.latestFreePlay = new FreePlayState { sourceStageId = 2 };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
save.completedStageIds = new[] { 2 };
|
||||
save.latestFreePlay.groups = new[] { new GroupState { id = "empty" } };
|
||||
Assert.That(SaveValidator.TryValidate(save, Definitions, out _), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FreePlayCannotRestoreElevenBallsAndRestoreRejectsInvalidInput()
|
||||
{
|
||||
var balls = new BallState[11];
|
||||
var groups = new GroupState[11];
|
||||
for (var i = 0; i < 11; i++)
|
||||
{
|
||||
balls[i] = new BallState { id = "ball" + i };
|
||||
groups[i] = new GroupState { id = "group" + i, ballIds = new[] { balls[i].id } };
|
||||
}
|
||||
var state = new FreePlayState { sourceStageId = 2, balls = balls, groups = groups };
|
||||
Assert.That(SaveValidator.TryValidateFreePlay(state, out _), Is.False);
|
||||
Assert.Throws<ArgumentException>(() => BoardRules.CreateFreePlay(state));
|
||||
var puzzle = Valid().puzzleStates[0]; puzzle.groups[0].ballIds[0] = "missing";
|
||||
Assert.Throws<ArgumentException>(() => new BoardRules(Count, puzzle));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CompletedStageIdsCannotDecreaseOnUpdateButReplayPuzzleIsAllowed()
|
||||
{
|
||||
var previous = Valid(); previous.completedStageIds = new[] { 2, 6 };
|
||||
var next = Valid(); next.completedStageIds = new[] { 6 };
|
||||
Assert.That(SaveValidator.TryValidateUpdate(previous, next, Definitions, out _), Is.False);
|
||||
next.completedStageIds = new[] { 6, 2 };
|
||||
Assert.That(SaveValidator.TryValidateUpdate(previous, next, Definitions, out var error), Is.True, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8413b204741df8f408785b39e1bbd68e
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests.EditMode.Core
|
||||
{
|
||||
public sealed class StageLayoutTests
|
||||
{
|
||||
private static readonly Rotation4 Identity = new Rotation4(0, 0, 0, 1);
|
||||
private static readonly Position3 UnitScale = new Position3(1, 1, 1);
|
||||
|
||||
[Test]
|
||||
public void SameBallPositionsWithDifferentDecorationsRemainDistinctAndDoNotChangeTheMath()
|
||||
{
|
||||
var positions = new[] { new Position3(1, 2, 3), new Position3(4, 5, 6) };
|
||||
var garden = new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, positions,
|
||||
new[] { new DecorationPlacement("garden/arch", new Position3(3, 0, 1), Identity, UnitScale) });
|
||||
var cloud = new StageDefinition(7, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }, positions,
|
||||
new[] { new DecorationPlacement("cloud/platform", new Position3(3, 4, 1), Identity, new Position3(2, 1, 2)) });
|
||||
Assert.That(garden.Layout.Decorations.Count, Is.EqualTo(1));
|
||||
Assert.That(cloud.Layout.Decorations.Count, Is.EqualTo(1));
|
||||
Assert.That(garden.Layout.Decorations[0].AssetKey, Is.EqualTo("garden/arch"));
|
||||
Assert.That(cloud.Layout.Decorations[0].AssetKey, Is.EqualTo("cloud/platform"));
|
||||
Assert.That(garden.Layout.BallPositions, Is.EqualTo(cloud.Layout.BallPositions));
|
||||
foreach (var definition in new[] { garden, cloud })
|
||||
{
|
||||
var board = new BoardRules(definition);
|
||||
Assert.That(board.BallCount, Is.EqualTo(2));
|
||||
Assert.That(board.IsSolved, Is.False);
|
||||
Assert.That(board.TryMerge(board.Groups[0].id, board.Groups[1].id).Accepted, Is.True);
|
||||
Assert.That(board.IsSolved, Is.True);
|
||||
Assert.That(board.BallCount, Is.EqualTo(2));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InputArraysAndReturnedTransformCopiesCannotChangeFixedLayout()
|
||||
{
|
||||
var positions = new[] { new Position3(1, 2, 3) };
|
||||
var decorations = new[] { new DecorationPlacement("workshop/bench", new Position3(4, 5, 6), Identity, UnitScale) };
|
||||
var definition = new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }, positions, decorations);
|
||||
positions[0] = new Position3(100, 0, 0);
|
||||
decorations[0] = new DecorationPlacement("changed", new Position3(), Identity, UnitScale);
|
||||
Assert.That(definition.Layout.Decorations.Count, Is.EqualTo(1));
|
||||
Assert.That(definition.BallPositions[0].x, Is.EqualTo(1));
|
||||
Assert.That(definition.Layout.Decorations[0].AssetKey, Is.EqualTo("workshop/bench"));
|
||||
var positionCopy = definition.Layout.Decorations[0].Position;
|
||||
var rotationCopy = definition.Layout.Decorations[0].Rotation;
|
||||
var scaleCopy = definition.Layout.Decorations[0].Scale;
|
||||
positionCopy.x = 100; rotationCopy.w = 0; scaleCopy.x = 100;
|
||||
Assert.That(definition.Layout.Decorations[0].Position.x, Is.EqualTo(4));
|
||||
Assert.That(definition.Layout.Decorations[0].Rotation.w, Is.EqualTo(1));
|
||||
Assert.That(definition.Layout.Decorations[0].Scale.x, Is.EqualTo(1));
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<DecorationPlacement>)definition.Layout.Decorations)[0] = decorations[0]);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<Position3>)definition.Layout.BallPositions)[0] = positions[0]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PuzzleAndFreePlaySourceIdsSelectOriginalFixedDecorationLayoutOnRestore()
|
||||
{
|
||||
var definition = new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 },
|
||||
new[] { new Position3(), new Position3(1, 0, 0) },
|
||||
new[] { new DecorationPlacement("garden/slide", new Position3(0, 0, 8), Identity, UnitScale) });
|
||||
var catalog = new[] { definition };
|
||||
var board = new BoardRules(definition);
|
||||
board.TryMoveGroup(board.Groups[0].id, new Position3(5, 0, 0));
|
||||
var puzzle = board.ToPuzzleState(new CameraState());
|
||||
var restoredDefinition = catalog.Single(stage => stage.Id == puzzle.stageId);
|
||||
var restored = new BoardRules(restoredDefinition, puzzle);
|
||||
Assert.That(restoredDefinition.Layout.Decorations.Count, Is.EqualTo(1));
|
||||
Assert.That(restoredDefinition.Layout.Decorations[0].AssetKey, Is.EqualTo("garden/slide"));
|
||||
Assert.That(restored.Balls[0].position.x, Is.EqualTo(5));
|
||||
Assert.That(restoredDefinition.BallPositions[0].x, Is.Zero);
|
||||
var free = restored.ToFreePlayState(new CameraState());
|
||||
var freeDefinition = catalog.Single(stage => stage.Id == free.sourceStageId);
|
||||
Assert.That(freeDefinition.Layout.Decorations[0].Position.z, Is.EqualTo(8));
|
||||
Assert.That(BoardRules.CreateFreePlay(free).BallCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvalidDecorationTransformsAndMissingEntriesAreRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new DecorationPlacement("", new Position3(), Identity, UnitScale));
|
||||
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(float.NaN, 0, 0), Identity, UnitScale));
|
||||
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(), new Rotation4(), UnitScale));
|
||||
Assert.Throws<ArgumentException>(() => new DecorationPlacement("arch", new Position3(), Identity, new Position3(0, 1, 1)));
|
||||
Assert.Throws<ArgumentException>(() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 },
|
||||
decorations: new DecorationPlacement[] { null }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d13c7f6ddacc35d40a7a177a7749b18c
|
||||
@@ -0,0 +1,41 @@
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class FirstSliceAssetTests
|
||||
{
|
||||
[Test] public void FirstPuzzleHasAnExecutableAppPrefab()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||
Assert.That(prefab, Is.Not.Null, "The first puzzle must have a real Inspector-editable app prefab.");
|
||||
Assert.That(prefab.GetComponent<GameApp>(), Is.Not.Null);
|
||||
var serialized = new SerializedObject(prefab.GetComponent<GameApp>());
|
||||
foreach (var field in new[] { "_homePanel", "_selectionPanel", "_playPanel", "_world", "_instruction", "_counter", "_stageTitle", "_saveNotice",
|
||||
"_playButton", "_continueButton", "_stageOneButton", "_homeButton", "_selectionHomeButton", "_presenter", "_orbit", "_input", "_voice" })
|
||||
Assert.That(serialized.FindProperty(field).objectReferenceValue, Is.Not.Null, field);
|
||||
}
|
||||
[Test] public void StaticKoreanFontContainsEveryVisibleFirstSliceCharacter()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||
var texts = prefab.GetComponentsInChildren<TMP_Text>(true);
|
||||
foreach (var text in texts)
|
||||
{
|
||||
Assert.That(text.font.atlasPopulationMode, Is.EqualTo(AtlasPopulationMode.Static));
|
||||
Assert.That(text.enableAutoSizing, Is.False);
|
||||
foreach (var character in text.text) Assert.That(text.font.HasCharacter(character), Is.True, text.name + ": " + character);
|
||||
}
|
||||
}
|
||||
[Test] public void StageAssetConvertsToAnIndependentDefinition()
|
||||
{
|
||||
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>("Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage01.asset");
|
||||
var before = EditorJsonUtility.ToJson(stage); var definition = stage.ToDefinition();
|
||||
Assert.That(definition.Id, Is.EqualTo(1)); Assert.That(definition.Total, Is.EqualTo(1));
|
||||
Assert.That(EditorJsonUtility.ToJson(stage), Is.EqualTo(before));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 599347c36cd01ad4fa166c4c6b6f3b77
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class GameSessionTests
|
||||
{
|
||||
private sealed class MemoryStore : IProgressStore
|
||||
{
|
||||
public ProgressSave Save = new ProgressSave();
|
||||
public string LoadWarning;
|
||||
public bool TryLoad(out ProgressSave save, out string error) { save = Save; error = LoadWarning; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error) { Save = save; error = null; return true; }
|
||||
}
|
||||
private static StageDefinition One() => new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 });
|
||||
|
||||
[Test] public void LoadRecoveryWarningIsRetainedForDiagnostics()
|
||||
{
|
||||
var session = new GameSession(new[] { One() }, new MemoryStore { LoadWarning = "Recovered valid backup" });
|
||||
Assert.That(session.LastSaveError, Is.EqualTo("Recovered valid backup"));
|
||||
}
|
||||
[Test] public void FirstStageCannotCompleteWithoutACommittedCollection()
|
||||
{
|
||||
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store);
|
||||
Assert.That(session.StartStage(1), Is.True);
|
||||
var ball = session.Board.Balls.Single(); var group = session.Board.Groups.Single();
|
||||
Assert.That(session.Count(ball.id).Reason, Is.EqualTo(RejectionReason.NotCollected));
|
||||
Assert.That(session.MoveGroup(group.id, new Position3()).Accepted, Is.False);
|
||||
Assert.That(store.Save.completedStageIds, Is.Empty);
|
||||
Assert.That(session.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
||||
}
|
||||
[Test] public void MoveAndCountCreateOneAtomicCompletionAndKeepTheSameBoard()
|
||||
{
|
||||
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store); session.StartStage(1);
|
||||
var ballId = session.Board.Balls.Single().id; var groupId = session.Board.Groups.Single().id;
|
||||
session.MoveGroup(groupId, new Position3(1, 2, -1));
|
||||
var position = session.Board.Balls.Single().position;
|
||||
Assert.That(session.Count(ballId).Count, Is.EqualTo(1));
|
||||
Assert.That(session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(store.Save.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(store.Save.puzzleStates, Is.Empty);
|
||||
Assert.That(store.Save.latestFreePlay.balls.Single().position, Is.EqualTo(position));
|
||||
Assert.That(session.Count(ballId).Accepted, Is.False);
|
||||
Assert.That(store.Save.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||
}
|
||||
[Test] public void IncompleteBoardRestoresCommittedPositionCameraAndCollection()
|
||||
{
|
||||
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store); session.StartStage(1);
|
||||
session.MoveGroup(session.Board.Groups.Single().id, new Position3(1, 2, 3));
|
||||
var camera = GameSession.DefaultCamera(); camera.distance = 11;
|
||||
session.CommitCamera(camera); session.ShowHome();
|
||||
var resumed = new GameSession(new[] { One() }, store); resumed.StartStage(1);
|
||||
Assert.That(resumed.Board.Balls.Single().position, Is.EqualTo(new Position3(1, 2, 3)));
|
||||
Assert.That(resumed.Board.CollectedBallIds.Length, Is.EqualTo(1));
|
||||
Assert.That(resumed.Board.CountedCount, Is.Zero);
|
||||
Assert.That(resumed.Camera.distance, Is.EqualTo(11));
|
||||
Assert.That(resumed.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
||||
}
|
||||
[Test] public void CompletedStageReplayKeepsCompletionAndItsPreviousFreePlaySeparately()
|
||||
{
|
||||
var store = new MemoryStore(); var session = new GameSession(new[] { One() }, store); session.StartStage(1);
|
||||
session.MoveGroup(session.Board.Groups.Single().id, new Position3(2, 0, 0)); session.Count(session.Board.Balls.Single().id);
|
||||
var freePosition = store.Save.latestFreePlay.balls.Single().position;
|
||||
Assert.That(session.IsUnlocked(2), Is.True); Assert.That(session.IsUnlocked(3), Is.False);
|
||||
session.StartStage(1); session.MoveGroup(session.Board.Groups.Single().id, new Position3(1, 0, 0));
|
||||
Assert.That(store.Save.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(store.Save.latestFreePlay.balls.Single().position, Is.EqualTo(freePosition));
|
||||
Assert.That(store.Save.puzzleStates.Single().stageId, Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39eee6770356bc246bc854f34306e1fb
|
||||
@@ -0,0 +1,179 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Persistence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class LocalProgressStoreTests
|
||||
{
|
||||
private string _directory, _path;
|
||||
private StageDefinition _stage;
|
||||
[SetUp] public void SetUp()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory);
|
||||
_path = Path.Combine(_directory, "progress.json");
|
||||
_stage = new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 });
|
||||
}
|
||||
[TearDown] public void TearDown() => Directory.Delete(_directory, true);
|
||||
private LocalProgressStore Store() => new LocalProgressStore(_path, new[] { _stage });
|
||||
private ProgressSave Puzzle()
|
||||
{ var board = new BoardRules(_stage); return new ProgressSave { puzzleStates = new[] { board.ToPuzzleState(GameSession.DefaultCamera()) } }; }
|
||||
|
||||
[Test] public void CommittedPositionAndCameraRoundTripWithoutPartialLoad()
|
||||
{
|
||||
var progress = Puzzle(); progress.puzzleStates[0].balls[0].position = new Position3(1, -2, 3);
|
||||
progress.puzzleStates[0].camera.distance = 10;
|
||||
Assert.That(Store().TrySave(progress, out var error), Is.True, error);
|
||||
Assert.That(Store().TryLoad(out var loaded, out error), Is.True, error);
|
||||
Assert.That(loaded.puzzleStates[0].balls[0].position, Is.EqualTo(new Position3(1, -2, 3)));
|
||||
Assert.That(loaded.puzzleStates[0].camera.distance, Is.EqualTo(10));
|
||||
}
|
||||
[TestCase("position")]
|
||||
[TestCase("x")]
|
||||
[TestCase("camera")]
|
||||
[TestCase("countedBallIds")]
|
||||
[TestCase("latestFreePlay")]
|
||||
public void MissingRequiredFieldsAreRejectedInsteadOfDefaulted(string field)
|
||||
{
|
||||
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
|
||||
var json = File.ReadAllText(_path).Replace("\"" + field + "\":", "\"removed_" + field + "\":");
|
||||
File.WriteAllText(_path, json);
|
||||
Assert.That(Store().TryLoad(out var loaded, out var error), Is.False);
|
||||
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain(field));
|
||||
}
|
||||
[Test] public void DuplicateJsonKeysAndTrailingContentAreRejected()
|
||||
{
|
||||
File.WriteAllText(_path, "{\"schemaVersion\":1,\"schemaVersion\":1}");
|
||||
Assert.That(Store().TryLoad(out _, out _), Is.False);
|
||||
File.Delete(_path); Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
|
||||
File.WriteAllText(_path, File.ReadAllText(_path) + " {}");
|
||||
Assert.That(Store().TryLoad(out _, out _), Is.False);
|
||||
}
|
||||
[Test] public void ValidBackupRecoversWholeBoardAndDamagedOriginalSurvivesNextSave()
|
||||
{
|
||||
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
|
||||
progress.puzzleStates[0].camera.distance = 12; Assert.That(store.TrySave(progress, out _), Is.True);
|
||||
File.WriteAllText(_path, "broken-primary");
|
||||
Assert.That(store.TryLoad(out var loaded, out var error), Is.True, error);
|
||||
Assert.That(loaded.puzzleStates[0].camera.distance, Is.EqualTo(8));
|
||||
Assert.That(error, Does.Contain("Recovered"));
|
||||
Assert.That(store.TrySave(loaded, out error), Is.True, error);
|
||||
var damaged = Directory.GetFiles(_directory, "*.damaged-*").Single();
|
||||
Assert.That(File.ReadAllText(damaged), Is.EqualTo("broken-primary"));
|
||||
Assert.That(File.ReadAllText(_path + ".bak"), Does.Not.Contain("broken-primary"));
|
||||
Assert.That(File.Exists(_path + ".tmp"), Is.False);
|
||||
}
|
||||
[Test] public void WithoutValidBackupLoaderDoesNotOverwriteDamagedEvidence()
|
||||
{
|
||||
File.WriteAllText(_path, "broken-primary"); File.WriteAllText(_path + ".bak", "broken-backup");
|
||||
var store = Store(); Assert.That(store.TryLoad(out _, out _), Is.False);
|
||||
Assert.That(File.ReadAllText(_path), Is.EqualTo("broken-primary"));
|
||||
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
|
||||
Assert.That(Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText), Does.Contain("broken-primary"));
|
||||
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("broken-backup"));
|
||||
}
|
||||
[Test] public void BothDamagedOriginalsSurviveTwoSubsequentSuccessfulWrites()
|
||||
{
|
||||
File.WriteAllText(_path, "distinct-broken-primary"); File.WriteAllText(_path + ".bak", "distinct-broken-backup");
|
||||
var store = Store(); Assert.That(store.TryLoad(out _, out _), Is.False);
|
||||
Assert.That(store.TrySave(new ProgressSave(), out var error), Is.True, error);
|
||||
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
|
||||
var forensic = Directory.GetFiles(_directory, "*.damaged-*").Select(File.ReadAllText).ToArray();
|
||||
Assert.That(forensic, Does.Contain("distinct-broken-primary"));
|
||||
Assert.That(forensic, Does.Contain("distinct-broken-backup"));
|
||||
Assert.That(store.TryLoad(out var restored, out error), Is.True, error);
|
||||
Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1));
|
||||
}
|
||||
[Test] public void MissingPrimaryRestoresEntireValidBackup()
|
||||
{
|
||||
var definitions = new[] { _stage,
|
||||
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
|
||||
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
|
||||
var session = new GameSession(definitions, new LocalProgressStore(_path, definitions));
|
||||
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(1, 2, 3));
|
||||
session.Count(session.Board.Balls[0].id); session.MarkTutorialSeen("merge");
|
||||
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(2, 1, -3), session.Board.Groups[1].id);
|
||||
session.Count(session.Board.Balls[0].id);
|
||||
session.CommitCamera(new CameraState { target = new Position3(1, 2, 3), rotation = new Rotation4(0, .6f, 0, .8f), distance = 11 });
|
||||
var store = new LocalProgressStore(_path, definitions);
|
||||
Assert.That(store.TryLoad(out var expected, out var error), Is.True, error);
|
||||
var six = new BoardRules(definitions[2]); six.TryMoveGroup(six.Groups[0].id, new Position3(-1, 2, 4));
|
||||
expected.puzzleStates = expected.puzzleStates.Concat(new[] { six.ToPuzzleState(GameSession.DefaultCamera()) }).ToArray();
|
||||
Assert.That(store.TrySave(expected, out error), Is.True, error);
|
||||
var expectedJson = File.ReadAllText(_path);
|
||||
Assert.That(store.TrySave(expected, out error), Is.True, error); File.Delete(_path);
|
||||
Assert.That(store.TryLoad(out var loaded, out error), Is.True, error);
|
||||
Assert.That(error, Does.Contain("Recovered valid backup"));
|
||||
Assert.That(loaded.puzzleStates.Length, Is.EqualTo(2));
|
||||
Assert.That(loaded.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
|
||||
Assert.That(store.TrySave(loaded, out error), Is.True, error);
|
||||
Assert.That(File.ReadAllText(_path), Is.EqualTo(expectedJson), "Every contract field, including free play and tutorial progress, must survive.");
|
||||
}
|
||||
[Test] public void MissingPrimaryWithInvalidBackupPreservesEvidenceThroughTwoSaves()
|
||||
{
|
||||
File.WriteAllText(_path + ".bak", "distinct-backup-only-damage");
|
||||
var store = Store(); Assert.That(store.TryLoad(out var loaded, out var error), Is.False);
|
||||
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain("Invalid primary and backup"));
|
||||
Assert.That(File.Exists(_path), Is.False);
|
||||
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("distinct-backup-only-damage"));
|
||||
Assert.That(store.TrySave(new ProgressSave(), out error), Is.True, error);
|
||||
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
|
||||
Assert.That(Directory.GetFiles(_directory, "*.bak.damaged-*").Select(File.ReadAllText), Does.Contain("distinct-backup-only-damage"));
|
||||
Assert.That(store.TryLoad(out loaded, out error), Is.True, error);
|
||||
Assert.That(loaded.puzzleStates.Single().stageId, Is.EqualTo(1));
|
||||
}
|
||||
[Test] public void MissingPrimaryAndBackupStartsFreshWithoutRecoveryError()
|
||||
{
|
||||
Assert.That(Store().TryLoad(out var loaded, out var error), Is.True, error);
|
||||
Assert.That(error, Is.Null); Assert.That(loaded.puzzleStates, Is.Empty);
|
||||
Assert.That(loaded.completedStageIds, Is.Empty); Assert.That(loaded.seenTutorialIds, Is.Empty);
|
||||
Assert.That(loaded.latestFreePlay, Is.Null); Assert.That(Directory.GetFiles(_directory), Is.Empty);
|
||||
}
|
||||
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
|
||||
{
|
||||
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
|
||||
var before = File.ReadAllBytes(_path); progress.puzzleStates[0].groups[0].ballIds = new[] { "missing" };
|
||||
Assert.That(store.TrySave(progress, out _), Is.False); Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
|
||||
}
|
||||
[TestCase("schemaVersion", "null")]
|
||||
[TestCase("schemaVersion", "\"1\"")]
|
||||
[TestCase("schemaVersion", "1.5")]
|
||||
[TestCase("distance", "null")]
|
||||
[TestCase("distance", "\"8\"")]
|
||||
[TestCase("x", "null")]
|
||||
[TestCase("x", "\"0\"")]
|
||||
public void WrongScalarTypesAreRejected(string field, string value)
|
||||
{
|
||||
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
|
||||
var json = File.ReadAllText(_path);
|
||||
json = System.Text.RegularExpressions.Regex.Replace(json, "\"" + field + "\":\\s*-?[0-9]+(?:\\.[0-9]+)?", "\"" + field + "\":" + value);
|
||||
File.WriteAllText(_path, json); Assert.That(Store().TryLoad(out var loaded, out _), Is.False); Assert.That(loaded, Is.Null);
|
||||
}
|
||||
[Test] public void SavedJsonContainsOnlyContractFields()
|
||||
{
|
||||
Assert.That(Store().TrySave(Puzzle(), out _), Is.True);
|
||||
Assert.That(File.ReadAllText(_path), Does.Not.Contain("IsValid"));
|
||||
}
|
||||
[Test] public void LockedTemporaryFileReportsFailureAndKeepsTheLastValidSave()
|
||||
{
|
||||
var store = Store(); Assert.That(store.TrySave(Puzzle(), out _), Is.True);
|
||||
Assert.That(store.TrySave(Puzzle(), out _), Is.True);
|
||||
var before = File.ReadAllBytes(_path);
|
||||
var backupBefore = File.ReadAllBytes(_path + ".bak");
|
||||
using (var locked = new FileStream(_path + ".tmp", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
string error = null; bool saved = true;
|
||||
Assert.DoesNotThrow(() => saved = store.TrySave(Puzzle(), out error));
|
||||
Assert.That(saved, Is.False); Assert.That(error, Is.Not.Empty);
|
||||
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
|
||||
Assert.That(File.ReadAllBytes(_path + ".bak"), Is.EqualTo(backupBefore));
|
||||
}
|
||||
Assert.That(store.TryLoad(out var restored, out _), Is.True);
|
||||
Assert.That(restored.puzzleStates.Length, Is.EqualTo(1));
|
||||
Assert.That(store.TrySave(Puzzle(), out var recoveredError), Is.True, recoveredError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d3b301c187eab84086f1a6504b44942
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "MagnetismHeroes.EditModeTests",
|
||||
"rootNamespace": "MagnetismHeroes.Tests",
|
||||
"references": ["MagnetismHeroes.Core", "MagnetismHeroes.UnityRuntime", "MagnetismHeroes.Editor", "Unity.InputSystem", "Unity.ugui", "Unity.TextMeshPro"],
|
||||
"includePlatforms": ["Editor"],
|
||||
"optionalUnityReferences": ["TestAssemblies"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99d6844afee35a5498b912f170d1a46f
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Persistence;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class PersistenceSessionTests
|
||||
{
|
||||
private string _directory, _path;
|
||||
private static readonly StageDefinition[] Definitions = {
|
||||
new StageDefinition(1, StageKind.Counting, new[] { 1 }, new[] { 1 }),
|
||||
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
|
||||
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
|
||||
private sealed class RecordedStore : IProgressStore
|
||||
{
|
||||
private readonly LocalProgressStore _store;
|
||||
public int Writes;
|
||||
public RecordedStore(string path) { _store = new LocalProgressStore(path, Definitions); }
|
||||
public bool TryLoad(out ProgressSave save, out string error) => _store.TryLoad(out save, out error);
|
||||
public bool TrySave(ProgressSave save, out string error) { Writes++; return _store.TrySave(save, out error); }
|
||||
}
|
||||
[SetUp] public void SetUp()
|
||||
{ _directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-persistence-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory); _path = Path.Combine(_directory, "progress.json"); }
|
||||
[TearDown] public void TearDown() => Directory.Delete(_directory, true);
|
||||
private ProgressSave Load()
|
||||
{ Assert.That(new LocalProgressStore(_path, Definitions).TryLoad(out var save, out var error), Is.True, error); return save; }
|
||||
private static CameraState Camera(float distance) => new CameraState {
|
||||
target = new Position3(1, .8f, -2), rotation = new Rotation4(0, .6f, 0, .8f), distance = distance };
|
||||
private static void CompleteOne(GameSession session)
|
||||
{ session.StartStage(1); session.MoveGroup(session.Board.Groups.Single().id, new Position3(.5f, .8f, 0)); session.Count(session.Board.Balls.Single().id); }
|
||||
|
||||
[Test] public void ActualFileAlternatesTwoUnfinishedBoardsAndLatestFreeWithoutLosingAnyField()
|
||||
{
|
||||
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store); CompleteOne(session);
|
||||
session.Spawn(new Position3(-2, .8f, 0)); session.CommitCamera(Camera(9)); session.ShowHome();
|
||||
session.StartStage(2); var groups = session.Board.Groups;
|
||||
session.MoveAndMerge(groups[0].id, new Position3(.6f, 1.1f, -2), groups[1].id);
|
||||
session.Count(session.Board.Balls[0].id); session.CommitCamera(Camera(11)); session.ShowHome();
|
||||
var fixture = Load(); fixture.completedStageIds = new[] { 1, 2, 3, 4, 5 };
|
||||
Assert.That(new LocalProgressStore(_path, Definitions).TrySave(fixture, out var error), Is.True, error);
|
||||
session = new GameSession(Definitions, new RecordedStore(_path)); session.StartStage(6);
|
||||
session.MoveGroup(session.Board.Groups[0].id, new Position3(-1, .8f, 2)); session.CommitCamera(Camera(7)); session.ShowHome();
|
||||
var before = File.ReadAllBytes(_path); var saved = Load();
|
||||
Assert.That(saved.puzzleStates.Length, Is.EqualTo(2));
|
||||
Assert.That(saved.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
|
||||
foreach (int id in new[] { 2, 6, 2, 6 })
|
||||
{
|
||||
session = new GameSession(Definitions, new RecordedStore(_path)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.Home));
|
||||
Assert.That(session.StartStage(id), Is.True); var expected = saved.puzzleStates.Single(state => state.stageId == id);
|
||||
AssertBoard(session.Board, expected.balls, expected.groups); AssertCamera(session.Camera, expected.camera);
|
||||
Assert.That(session.Board.CollectedBallIds, Is.EqualTo(expected.collectedBallIds));
|
||||
Assert.That(session.Board.CountedBallIds, Is.EqualTo(expected.countedBallIds)); session.ShowHome();
|
||||
Assert.That(session.ContinueFreePlay(), Is.True); AssertBoard(session.Board, saved.latestFreePlay.balls, saved.latestFreePlay.groups);
|
||||
AssertCamera(session.Camera, saved.latestFreePlay.camera); session.ShowHome();
|
||||
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before), "Exact deterministic JSON bytes retain both unfinished plus global Free.");
|
||||
}
|
||||
}
|
||||
[Test] public void CompletionIsOneWriteReplacesOnlyLatestFreeAndReplayKeepsProgress()
|
||||
{
|
||||
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store); CompleteOne(session);
|
||||
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(.5f, 0, 0), session.Board.Groups[1].id);
|
||||
session.Count(session.Board.Balls[0].id); int before = store.Writes;
|
||||
session.Count(session.Board.Balls[1].id); Assert.That(store.Writes, Is.EqualTo(before + 1));
|
||||
var completed = Load(); Assert.That(completed.completedStageIds, Is.EqualTo(new[] { 1, 2 }));
|
||||
Assert.That(completed.puzzleStates, Is.Empty); Assert.That(completed.latestFreePlay.sourceStageId, Is.EqualTo(2));
|
||||
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(.2f, 0, 0));
|
||||
var replay = Load(); Assert.That(replay.completedStageIds, Is.EqualTo(new[] { 1, 2 }));
|
||||
Assert.That(replay.puzzleStates.Single().stageId, Is.EqualTo(1)); Assert.That(replay.latestFreePlay.sourceStageId, Is.EqualTo(2));
|
||||
session.Count(session.Board.Balls.Single().id); Assert.That(Load().latestFreePlay.sourceStageId, Is.EqualTo(1));
|
||||
Assert.That(Load().completedStageIds, Is.EqualTo(new[] { 1, 2 }));
|
||||
}
|
||||
[Test] public void TutorialRecordsAreUniqueValidatedAndPersistAcrossFreeAndPuzzleTransitions()
|
||||
{
|
||||
var store = new RecordedStore(_path); var session = new GameSession(Definitions, store);
|
||||
Assert.That(session.MarkTutorialSeen("merge"), Is.True); int before = store.Writes;
|
||||
Assert.That(session.MarkTutorialSeen("merge"), Is.False); Assert.That(session.MarkTutorialSeen("unknown"), Is.False);
|
||||
Assert.That(store.Writes, Is.EqualTo(before)); CompleteOne(session); Assert.That(session.MarkTutorialSeen("split"), Is.True);
|
||||
session.StartStage(1); session.ShowHome(); session = new GameSession(Definitions, new RecordedStore(_path));
|
||||
Assert.That(session.HasSeenTutorial("merge"), Is.True); Assert.That(session.HasSeenTutorial("split"), Is.True);
|
||||
Assert.That(Load().seenTutorialIds, Is.EqualTo(new[] { "merge", "split" }));
|
||||
}
|
||||
private static void AssertBoard(BoardRules board, BallState[] balls, GroupState[] groups)
|
||||
{
|
||||
Assert.That(board.Balls.Select(ball => ball.id), Is.EqualTo(balls.Select(ball => ball.id)));
|
||||
Assert.That(board.Balls.Select(ball => ball.position), Is.EqualTo(balls.Select(ball => ball.position)));
|
||||
Assert.That(board.Groups.Select(group => group.id), Is.EqualTo(groups.Select(group => group.id)));
|
||||
for (int index = 0; index < groups.Length; index++) Assert.That(board.Groups[index].ballIds, Is.EqualTo(groups[index].ballIds));
|
||||
}
|
||||
private static void AssertCamera(CameraState actual, CameraState expected)
|
||||
{ Assert.That(actual.target, Is.EqualTo(expected.target)); Assert.That(actual.rotation, Is.EqualTo(expected.rotation)); Assert.That(actual.distance, Is.EqualTo(expected.distance)); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8666ab89be2e7ab4a96f90ee79122b2f
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class PresentationAssetTests
|
||||
{
|
||||
private const string Root = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
|
||||
[Test] public void FaceAtlasHasSixDistinctOfflineExpressionsAndDecorationsCannotReceiveInput()
|
||||
{
|
||||
var badge = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/GroupBadge.prefab");
|
||||
var data = new SerializedObject(badge.GetComponent<GroupBadge>()); var frames = data.FindProperty("_expressions");
|
||||
Assert.That(frames.arraySize, Is.EqualTo(6));
|
||||
var sprites = Enumerable.Range(0, 6).Select(i => (Sprite)frames.GetArrayElementAtIndex(i).objectReferenceValue).ToArray();
|
||||
Assert.That(sprites.All(sprite => sprite != null && sprite.texture != null), Is.True);
|
||||
Assert.That(sprites.Select(sprite => sprite.rect).Distinct().Count(), Is.EqualTo(6));
|
||||
Assert.That(sprites.Select(sprite => sprite.texture).Distinct().Count(), Is.EqualTo(1));
|
||||
Assert.That(badge.GetComponentsInChildren<Collider>(true), Is.Empty);
|
||||
Assert.That(badge.GetComponentsInChildren<SpriteRenderer>(true).Length, Is.EqualTo(1));
|
||||
Assert.That(badge.GetComponentsInChildren<TMPro.TextMeshPro>(true).Length, Is.EqualTo(1));
|
||||
Assert.That(badge.transform.Find("RibbonCape"), Is.Not.Null);
|
||||
var ball = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/MagnetFriend.prefab");
|
||||
Assert.That(ball.GetComponentsInChildren<Collider>(true).Length, Is.EqualTo(1));
|
||||
Assert.That(ball.GetComponent<SphereCollider>().radius, Is.EqualTo(.5f));
|
||||
Assert.That(ball.transform.localScale, Is.EqualTo(Vector3.one));
|
||||
}
|
||||
[Test] public void AudioRoutesToExistingMixerAndRequiredNoticesAreStagedForBuild()
|
||||
{
|
||||
var app = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/GameApp.prefab");
|
||||
Assert.That(AssetDatabase.AssetPathToGUID(Root + "Prefabs/GameApp.prefab"), Is.EqualTo("73c3215bc432df740b907a1edefa9ea1"));
|
||||
Assert.That(app.GetComponent<GameSound>(), Is.Not.Null);
|
||||
foreach (string group in new[] { "SFX", "Music" })
|
||||
{
|
||||
var source = app.transform.Find(group).GetComponent<AudioSource>();
|
||||
Assert.That(source.outputAudioMixerGroup.name, Is.EqualTo(group));
|
||||
Assert.That(source.playOnAwake, Is.False); Assert.That(source.spatialBlend, Is.Zero);
|
||||
Assert.That(source.clip, Is.Null, "User has deferred supplying music/SFX; no substitute is selected.");
|
||||
}
|
||||
foreach (string path in new[] { "NotoSansCJK-OFL.txt", "TTS-Apache-2.0.txt", "TTS-model-card.txt" })
|
||||
Assert.That(File.ReadAllText("Assets/StreamingAssets/Notices/" + path).Length, Is.GreaterThan(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 415c49a50d8d790469cd272156da0448
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29ca2c74a650df24593f3fa70fa815b0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,228 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Collections;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class CompositionPlayTests
|
||||
{
|
||||
private sealed class SpyStore : IProgressStore
|
||||
{
|
||||
public ProgressSave Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 13).ToArray() };
|
||||
public int Writes;
|
||||
public bool TryLoad(out ProgressSave save, out string error) { save = Save; error = null; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error) { Save = save; Writes++; error = null; return true; }
|
||||
}
|
||||
private GameApp _app; private SpyStore _store; private Touchscreen _touchscreen;
|
||||
private InputSettings _original, _settings; private HideFlags _flags;
|
||||
[UnitySetUp] public IEnumerator Setup()
|
||||
{
|
||||
_original = InputSystem.settings; _flags = _original.hideFlags; _original.hideFlags = HideFlags.DontSave;
|
||||
_settings = Object.Instantiate(_original); _settings.hideFlags = HideFlags.DontSave;
|
||||
_settings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
_settings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; InputSystem.settings = _settings;
|
||||
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
|
||||
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
|
||||
_app = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
|
||||
_store = new SpyStore(); _app.Initialize(_store); _touchscreen = InputSystem.AddDevice<Touchscreen>(); yield return null;
|
||||
}
|
||||
[UnityTearDown] public IEnumerator TearDown()
|
||||
{
|
||||
InputSystem.RemoveDevice(_touchscreen); Object.Destroy(_app.gameObject);
|
||||
InputSystem.settings = _original; _original.hideFlags = _flags; Object.Destroy(_settings); yield return null;
|
||||
}
|
||||
private Vector2 Point(string id) => _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position);
|
||||
private void Tap(string id) { var point = Point(id); _app.Input.Begin(point, false); _app.Input.End(point); }
|
||||
private void Touch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 point) => InputSystem.QueueStateEvent(_touchscreen,
|
||||
new TouchState { touchId = id, phase = phase, position = point });
|
||||
private string PrepareSplit()
|
||||
{
|
||||
_app.StartStage(10); Physics.SyncTransforms(); var id = _app.Session.Board.Balls[0].id;
|
||||
Tap(id); _app.Input.BeginSplit(); Tap(id); Assert.That(_app.Input.SelectedCount, Is.EqualTo(1)); return id;
|
||||
}
|
||||
[UnityTest] public IEnumerator FacePreviewCancelAndNavigationFollowOnlyCommittedGroups()
|
||||
{
|
||||
_app.StartStage(6); _app.Voice.Clear(); yield return null;
|
||||
var board = JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera));
|
||||
var badges = _app.GetComponentsInChildren<GroupBadge>();
|
||||
var face = badges[0].GetComponentInChildren<SpriteRenderer>();
|
||||
Assert.That(face, Is.Not.Null, "The representative face must support expressions.");
|
||||
var neutral = face.sprite;
|
||||
var states = _app.Session.Board.Balls;
|
||||
var delta = BoardPresenter.ToVector(states[1].position) - BoardPresenter.ToVector(states[0].position) - Vector3.right;
|
||||
_app.Presenter.PreviewGroup(_app.Session.Board.Groups[0].id, delta); yield return null;
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(2));
|
||||
Assert.That(face.sprite, Is.Not.EqualTo(neutral));
|
||||
Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera)), Is.EqualTo(board));
|
||||
_app.Presenter.CancelPreview(); yield return null;
|
||||
Assert.That(face.sprite, Is.EqualTo(neutral));
|
||||
_app.Voice.Guide("guide_merge"); yield return null;
|
||||
var speaking = face.sprite; Assert.That(speaking, Is.Not.EqualTo(neutral));
|
||||
_app.Presenter.CancelPreview(); yield return null;
|
||||
Assert.That(face.sprite, Is.EqualTo(speaking), "Canceling visual drag does not cancel an active guide.");
|
||||
_app.ShowHome(); yield return null;
|
||||
Assert.That(_app.Voice.IsSpeaking, Is.False); Assert.That(_app.Voice.CurrentClipId, Is.Null);
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.Zero);
|
||||
_app.StartStage(6); yield return null;
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(2));
|
||||
Assert.That(_app.Voice.IsSpeaking, Is.False, "Restoring an unfinished board is silent.");
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().All(badge => badge.GetComponentInChildren<SpriteRenderer>().sprite == neutral), Is.True);
|
||||
_app.Voice.Guide("guide_merge"); yield return null;
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().All(badge => badge.GetComponentInChildren<SpriteRenderer>().sprite == speaking), Is.True);
|
||||
yield return new WaitForSecondsRealtime(_app.GetComponent<AudioSource>().clip.length + .1f);
|
||||
Assert.That(_app.Voice.IsSpeaking, Is.False);
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().All(badge => badge.GetComponentInChildren<SpriteRenderer>().sprite == neutral), Is.True);
|
||||
}
|
||||
[UnityTest] public IEnumerator OneThroughTenKeepExactSphereCountSizeAndCommittedLayoutWithOneDecorationSetPerGroup()
|
||||
{
|
||||
_app.StartStage(6); _app.Voice.Clear();
|
||||
for (int count = 1; count <= 10; count++)
|
||||
{
|
||||
var states = Enumerable.Range(0, count).Select(i => new BallState { id = "art-" + i, position = new Position3(i % 5 - 2, .8f + i / 5, 0) }).ToArray();
|
||||
var board = BoardRules.CreateFreePlay(new FreePlayState { sourceStageId = 6, balls = states,
|
||||
groups = new[] { new GroupState { id = "art", ballIds = states.Select(ball => ball.id).ToArray() } } });
|
||||
_app.Presenter.Render(board); yield return null;
|
||||
Assert.That(_app.Presenter.Balls.Count, Is.EqualTo(count));
|
||||
Assert.That(_app.Presenter.Balls.Values.All(ball => ball.transform.localScale == Vector3.one), Is.True);
|
||||
foreach (var state in states) Assert.That(_app.Presenter.Balls[state.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(state.position)));
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(1));
|
||||
Assert.That(_app.GetComponentsInChildren<SpriteRenderer>().Length, Is.EqualTo(1));
|
||||
Assert.That(_app.GetComponentsInChildren<TMPro.TextMeshPro>().Single().text, Is.EqualTo(count.ToString()));
|
||||
_app.Presenter.ShowSelection(new[] { states[0].id }); _app.Orbit.Orbit(new Vector2(45, 20)); yield return null;
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(1));
|
||||
foreach (var state in states) Assert.That(_app.Presenter.Balls[state.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(state.position)));
|
||||
_app.Presenter.CancelPreview(); _app.Presenter.ShowSelection(System.Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
[UnityTest] public IEnumerator SpokenNumberAndDuckingFollowSpeechQueueAndClearImmediatelyOnNavigation()
|
||||
{
|
||||
_app.StartStage(6); _app.Voice.Clear();
|
||||
var cue = _app.transform.Find("Interface/SafeArea/Play/SpokenNumber").GetComponent<TMPro.TMP_Text>();
|
||||
_app.Voice.EnqueueCount(1); _app.Voice.EnqueueCount(2); yield return null;
|
||||
Assert.That(cue.text, Is.EqualTo("숫자 1"));
|
||||
Assert.That(_app.transform.Find("SFX").GetComponent<AudioSource>().volume, Is.EqualTo(.12f).Within(.001));
|
||||
Assert.That(_app.transform.Find("Music").GetComponent<AudioSource>().volume, Is.EqualTo(.06f).Within(.001));
|
||||
float deadline = Time.realtimeSinceStartup + 5;
|
||||
while (_app.Voice.CurrentClipId != "count_02" && Time.realtimeSinceStartup < deadline) yield return null;
|
||||
Assert.That(cue.text, Is.EqualTo("숫자 2"));
|
||||
_app.ShowHome(); Assert.That(cue.text, Is.Empty); Assert.That(_app.Voice.IsSpeaking, Is.False);
|
||||
yield return null;
|
||||
Assert.That(_app.transform.Find("SFX").GetComponent<AudioSource>().volume, Is.EqualTo(.5f));
|
||||
Assert.That(_app.transform.Find("Music").GetComponent<AudioSource>().volume, Is.EqualTo(.25f));
|
||||
}
|
||||
[UnityTest] public IEnumerator MusicResumesCurrentThemeAfterPauseWithoutReplayingSfx()
|
||||
{
|
||||
var clip = AudioClip.Create("test-only-loop", 44100, 1, 44100, false);
|
||||
var settings = new SerializedObject(_app.Sound);
|
||||
settings.FindProperty("_garden").objectReferenceValue = clip; settings.ApplyModifiedPropertiesWithoutUndo();
|
||||
_app.Sound.Theme("Garden"); yield return null;
|
||||
var music = _app.transform.Find("Music").GetComponent<AudioSource>();
|
||||
var sfx = _app.transform.Find("SFX").GetComponent<AudioSource>();
|
||||
sfx.PlayOneShot(clip); Assert.That(music.isPlaying, Is.True);
|
||||
_app.Sound.SendMessage("OnApplicationPause", true); yield return null;
|
||||
Assert.That(music.isPlaying, Is.False); Assert.That(sfx.isPlaying, Is.False);
|
||||
_app.Sound.SendMessage("OnApplicationPause", false); yield return null;
|
||||
Assert.That(music.clip, Is.SameAs(clip)); Assert.That(music.isPlaying, Is.True);
|
||||
Assert.That(sfx.isPlaying, Is.False);
|
||||
_app.Sound.Clear(); Object.Destroy(clip);
|
||||
}
|
||||
[UnityTest] public IEnumerator NativeCanceledSplitRestoresCommittedBoardAndClearsUnconfirmedSelection()
|
||||
{
|
||||
var id = PrepareSplit(); yield return null; var point = Point(id); var saved = JsonUtility.ToJson(_store.Save); var writes = _store.Writes;
|
||||
Touch(301, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(301, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(-80, 20));
|
||||
Touch(301, UnityEngine.InputSystem.TouchPhase.Canceled, point + new Vector2(-80, 20)); yield return null; yield return null;
|
||||
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes));
|
||||
Assert.That(_app.Input.SelectedCount, Is.Zero); Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.Selected && !ball.ContactCandidate), Is.True);
|
||||
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)));
|
||||
}
|
||||
[UnityTest] public IEnumerator NativePinchInSplitDragSameFrameCannotCommitSplitOrLeaveSelectionEffects()
|
||||
{
|
||||
var id = PrepareSplit(); yield return null; var point = Point(id); var writes = _store.Writes;
|
||||
Touch(311, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(311, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(-80, 15));
|
||||
Touch(312, UnityEngine.InputSystem.TouchPhase.Began, point + new Vector2(80, 0));
|
||||
Touch(311, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(-80, 15)); Touch(312, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(80, 0));
|
||||
yield return null; yield return null;
|
||||
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(1)); Assert.That(_store.Writes, Is.EqualTo(writes + 1), "Only the camera end is committed.");
|
||||
Assert.That(_app.Input.SelectedCount, Is.Zero); Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.Selected && !ball.ContactCandidate), Is.True);
|
||||
}
|
||||
[UnityTest] public IEnumerator NativeShortSplitDragCompletesTenOnceAndDoesNotImmediatelyRemerge()
|
||||
{
|
||||
var id = PrepareSplit(); yield return null; var point = Point(id); var writes = _store.Writes; var ids = _app.Session.Board.Balls.Select(ball => ball.id).ToArray();
|
||||
Touch(321, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(321, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(-75, 0));
|
||||
Touch(321, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(-75, 0)); yield return null; yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
|
||||
Assert.That(_app.Session.Board.Groups.Select(group => group.ballIds.Length), Is.EqualTo(new[] { 1, 1 }));
|
||||
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(ids)); Assert.That(_app.Input.IsSplitting, Is.False);
|
||||
yield return null; Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2));
|
||||
}
|
||||
[UnityTest] public IEnumerator SixContactPreviewAndConfirmedMergePreserveShapeAndCompleteOnce()
|
||||
{
|
||||
_app.StartStage(6); yield return null; Physics.SyncTransforms(); var states = _app.Session.Board.Balls;
|
||||
var point = Point(states[0].id); var destination = Point(states[1].id) - new Vector2(35, 0); var writes = _store.Writes;
|
||||
_app.Input.Begin(point, false); _app.Input.Move(destination);
|
||||
Assert.That(_app.Presenter.MergeCandidate, Is.Not.Null); Assert.That(_app.Presenter.Balls.Values.Count(ball => ball.ContactCandidate), Is.EqualTo(2));
|
||||
Assert.That(_store.Writes, Is.EqualTo(writes));
|
||||
_app.Input.End(destination); yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
|
||||
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(states.Select(ball => ball.id)));
|
||||
Assert.That(Vector3.Distance(_app.Presenter.Balls[states[0].id].transform.position, _app.Presenter.Balls[states[1].id].transform.position), Is.EqualTo(1).Within(.001f));
|
||||
Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.ContactCandidate), Is.True);
|
||||
Assert.That(_app.Presenter.Balls.Values.All(ball => ball.transform.Find("ContactLight").GetComponent<LineRenderer>().enabled), Is.True);
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(1));
|
||||
Tap(states[0].id); _app.Input.BeginSplit(); Tap(states[0].id); _app.Input.CancelSplit();
|
||||
Assert.That(_app.Instruction, Is.EqualTo("해냈어! 친구와 자유롭게 놀아 보자."));
|
||||
yield return new WaitForSecondsRealtime(.4f);
|
||||
Assert.That(_app.Presenter.Balls.Values.All(ball => !ball.transform.Find("ContactLight").GetComponent<LineRenderer>().enabled), Is.True);
|
||||
}
|
||||
[UnityTest] public IEnumerator FourteenDisconnectedSelectionThenMergeIsAcceptedByFinalNumbers()
|
||||
{
|
||||
_app.StartStage(14); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups[0];
|
||||
Tap(group.ballIds[0]); _app.Input.BeginSplit(); Tap(group.ballIds[0]); Tap(group.ballIds[2]);
|
||||
Assert.That(_app.Input.SelectedCount, Is.EqualTo(2)); var point = Point(group.ballIds[0]);
|
||||
var endpoint = point + new Vector2(0, 65); _app.Input.Begin(point, false); _app.Input.Move(endpoint); _app.Input.End(endpoint); yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
||||
Assert.That(_app.Session.Board.Groups.Select(value => value.ballIds.Length).OrderBy(value => value), Is.EqualTo(new[] { 1, 1, 2 }));
|
||||
var singles = _app.Session.Board.Groups.Where(value => value.ballIds.Length == 1).ToArray(); var writes = _store.Writes;
|
||||
point = Point(singles[0].ballIds[0]); endpoint = Point(singles[1].ballIds[0]) - new Vector2(35, 0);
|
||||
_app.Input.Begin(point, false); _app.Input.Move(endpoint); _app.Input.End(endpoint); yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
|
||||
Assert.That(_app.Session.Board.Groups.Select(value => value.ballIds.Length), Is.EqualTo(new[] { 2, 2 }));
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(2));
|
||||
Assert.That(_app.GetComponentsInChildren<TMPro.TextMeshPro>().Select(text => text.text), Is.EqualTo(new[] { "2", "2" }));
|
||||
}
|
||||
[UnityTest] public IEnumerator ZeroAllAndOutsideSplitPreserveStateAndOrbitKeepsNumbersAboveAllSpheres()
|
||||
{
|
||||
_app.StartStage(10); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups.Single();
|
||||
Tap(group.ballIds[0]); _app.Input.BeginSplit(); var saved = JsonUtility.ToJson(_store.Save); var writes = _store.Writes;
|
||||
var point = Point(group.ballIds[0]); var end = point + new Vector2(-60, 0);
|
||||
_app.Input.Begin(point, false); _app.Input.Move(end); _app.Input.End(end);
|
||||
Assert.That(_app.Input.SelectedCount, Is.Zero);
|
||||
Tap(group.ballIds[0]); Tap(group.ballIds[1]); point = Point(group.ballIds[0]);
|
||||
_app.Input.Begin(point, false); _app.Input.Move(end); _app.Input.End(end);
|
||||
Assert.That(_app.Input.SelectedCount, Is.EqualTo(2)); Assert.That(_store.Writes, Is.EqualTo(writes));
|
||||
Tap(group.ballIds[1]); point = Point(group.ballIds[0]);
|
||||
Touch(331, UnityEngine.InputSystem.TouchPhase.Began, point); Touch(331, UnityEngine.InputSystem.TouchPhase.Moved, end);
|
||||
Touch(331, UnityEngine.InputSystem.TouchPhase.Ended, new Vector2(-1, point.y)); yield return null; yield return null;
|
||||
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_app.Input.SelectedCount, Is.Zero);
|
||||
foreach (var delta in new[] { new Vector2(200, 250), new Vector2(-150, 400), new Vector2(300, -500) })
|
||||
{
|
||||
_app.Orbit.Orbit(delta); yield return null;
|
||||
var camera = _app.Presenter.Camera; var label = _app.GetComponentsInChildren<TMPro.TextMeshPro>().Single();
|
||||
float numberY = camera.WorldToScreenPoint(label.transform.position).y;
|
||||
Assert.That(numberY, Is.GreaterThan(_app.Presenter.Balls.Values.Max(ball => camera.WorldToScreenPoint(ball.transform.position + camera.transform.up * .5f).y)));
|
||||
Assert.That(_app.GetComponentsInChildren<GroupBadge>().Length, Is.EqualTo(1));
|
||||
Assert.That(_app.Session.Board.Balls.Select(ball => ball.position), Is.EqualTo(_store.Save.puzzleStates.Single().balls.Select(ball => ball.position)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a072a0043d7af814aa3681d938d9a5bd
|
||||
@@ -0,0 +1,305 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class ContentPlayTests
|
||||
{
|
||||
private sealed class Store : IProgressStore
|
||||
{
|
||||
public ProgressSave Saved = new ProgressSave { completedStageIds=Enumerable.Range(1,30).ToArray() };
|
||||
public bool TryLoad(out ProgressSave save,out string error) { save=Saved; error=null; return true; }
|
||||
public bool TrySave(ProgressSave save,out string error) { Saved=save; error=null; return true; }
|
||||
}
|
||||
private GameApp _app;
|
||||
private Store _store;
|
||||
private string _evidence;
|
||||
[System.Serializable] private sealed class CaptureRecord
|
||||
{
|
||||
public int stageId;
|
||||
public string theme;
|
||||
public CameraState camera;
|
||||
public int width=1280, height=720;
|
||||
public string method="Camera.Render to 1280x720 RenderTexture; temporary ScreenSpaceCamera Canvas; RGB24 ReadPixels PNG; restores render mode/target";
|
||||
}
|
||||
private void Capture(string filename)
|
||||
{
|
||||
var camera=_app.Presenter.Camera; var canvas=_app.GetComponentInChildren<Canvas>();
|
||||
var texture=RenderTexture.GetTemporary(1280,720,24); var prior=RenderTexture.active;
|
||||
var renderMode=canvas.renderMode; var oldCamera=canvas.worldCamera; var oldTarget=camera.targetTexture;
|
||||
canvas.renderMode=RenderMode.ScreenSpaceCamera; canvas.worldCamera=camera; canvas.planeDistance=1;
|
||||
camera.targetTexture=texture; Canvas.ForceUpdateCanvases(); camera.Render(); RenderTexture.active=texture;
|
||||
var image=new Texture2D(1280,720,TextureFormat.RGB24,false); image.ReadPixels(new Rect(0,0,1280,720),0,0); image.Apply();
|
||||
File.WriteAllBytes(Path.Combine(_evidence,filename),image.EncodeToPNG()); Object.Destroy(image);
|
||||
File.WriteAllText(Path.Combine(_evidence,filename+".camera.json"),JsonUtility.ToJson(new CaptureRecord {stageId=_app.Session.Board.StageId,theme=_app.Environment.Theme,camera=_app.Orbit.Snapshot()},true));
|
||||
camera.targetTexture=oldTarget; canvas.renderMode=renderMode; canvas.worldCamera=oldCamera; RenderTexture.active=prior; RenderTexture.ReleaseTemporary(texture);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
}
|
||||
[UnitySetUp] public IEnumerator SetUp()
|
||||
{
|
||||
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled=false;
|
||||
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled=false;
|
||||
_app=Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab")).GetComponent<GameApp>();
|
||||
_store=new Store(); _app.Initialize(_store);
|
||||
_evidence=Path.GetFullPath("artifacts/verification/Content/editor-play"); Directory.CreateDirectory(_evidence);
|
||||
yield return null;
|
||||
}
|
||||
[UnityTearDown] public IEnumerator TearDown() { Object.Destroy(_app.gameObject); yield return null; }
|
||||
private float RendererVisibleFraction(Renderer renderer, string evidenceName = null)
|
||||
{
|
||||
var camera=_app.Presenter.Camera; var priorTarget=camera.targetTexture; var priorActive=RenderTexture.active;
|
||||
var target=RenderTexture.GetTemporary(1280,720,24); var image=new Texture2D(1280,720,TextureFormat.RGB24,false);
|
||||
bool enabled=renderer.enabled;
|
||||
try
|
||||
{
|
||||
camera.targetTexture=target;
|
||||
Color32[] Read(bool visible, string suffix)
|
||||
{
|
||||
renderer.enabled=visible; camera.Render(); RenderTexture.active=target;
|
||||
image.ReadPixels(new Rect(0,0,1280,720),0,0); image.Apply();
|
||||
if (evidenceName!=null) File.WriteAllBytes(Path.Combine(_evidence,evidenceName+suffix+".png"),image.EncodeToPNG());
|
||||
return image.GetPixels32();
|
||||
}
|
||||
var on=Read(true,"-on");
|
||||
var bounds=renderer.bounds; var min=Vector2.one; var max=Vector2.zero;
|
||||
for (int x=0;x<2;x++) for (int y=0;y<2;y++) for (int z=0;z<2;z++)
|
||||
{
|
||||
var point=camera.WorldToViewportPoint(new Vector3(x==0 ? bounds.min.x : bounds.max.x,y==0 ? bounds.min.y : bounds.max.y,z==0 ? bounds.min.z : bounds.max.z));
|
||||
min=Vector2.Min(min,point); max=Vector2.Max(max,point);
|
||||
}
|
||||
int left=Mathf.Clamp(Mathf.FloorToInt(min.x*1280)-1,0,1279), right=Mathf.Clamp(Mathf.CeilToInt(max.x*1280)+1,0,1280);
|
||||
int bottom=Mathf.Clamp(Mathf.FloorToInt(min.y*720)-1,0,719), top=Mathf.Clamp(Mathf.CeilToInt(max.y*720)+1,0,720);
|
||||
var off=Read(false,"-off");
|
||||
var others=Object.FindObjectsByType<Renderer>(FindObjectsSortMode.None).Where(other=>other!=renderer && other.enabled).ToArray();
|
||||
Color32[] referenceOn,referenceOff;
|
||||
try { foreach (var other in others) other.enabled=false; referenceOn=Read(true,"-reference-on"); referenceOff=Read(false,"-reference-off"); }
|
||||
finally { foreach (var other in others) other.enabled=true; }
|
||||
int referencePixels=0,visiblePixels=0;
|
||||
bool Different(Color32 first,Color32 second) => System.Math.Abs(first.r-second.r)>5 || System.Math.Abs(first.g-second.g)>5 || System.Math.Abs(first.b-second.b)>5;
|
||||
for (int y=bottom;y<top;y++) for (int x=left;x<right;x++)
|
||||
{
|
||||
int i=y*1280+x;
|
||||
// Compare the same unobscured dark glyph/body pixels; a surviving edge cannot establish legibility.
|
||||
if (Different(referenceOn[i],referenceOff[i]) && referenceOn[i].r+referenceOn[i].g+referenceOn[i].b<480)
|
||||
{ referencePixels++; if (Different(on[i],off[i])) visiblePixels++; }
|
||||
}
|
||||
Assert.That(referencePixels, Is.GreaterThan(0),"The identical unobscured renderer must contribute a reference mask.");
|
||||
if (evidenceName!=null) File.WriteAllText(Path.Combine(_evidence,evidenceName+"-pixel-roi.txt"),$"renderer={renderer.name};rect={left},{bottom},{right-left},{top-bottom};reference={referencePixels};visible={visiblePixels}");
|
||||
return (float)visiblePixels/referencePixels;
|
||||
}
|
||||
finally { renderer.enabled=enabled; camera.targetTexture=priorTarget; RenderTexture.active=priorActive; RenderTexture.ReleaseTemporary(target); Object.Destroy(image); }
|
||||
}
|
||||
[UnityTest] public IEnumerator SplitFiveNumbersRemainRenderedBehindTheActualGardenFrame()
|
||||
{
|
||||
var camera=new CameraState {target=new Position3(0,.6f,0),distance=11,rotation=new Rotation4(-.022557568f,.96225017f,.08418597f,.2578342f)};
|
||||
_store.Saved.latestFreePlay=new FreePlayState {sourceStageId=9,camera=camera,
|
||||
balls=new[] {
|
||||
new BallState {id="s09-b01",position=new Position3(-1.0351909f,.8f,.60371524f)},
|
||||
new BallState {id="s09-b02",position=new Position3(-.03519098f,.8f,.60371524f)},
|
||||
new BallState {id="s09-b03",position=new Position3(-3.9800465f,.8f,1.649168f)},
|
||||
new BallState {id="s09-b04",position=new Position3(.964809f,.8f,.60371524f)},
|
||||
new BallState {id="s09-b05",position=new Position3(-2.9800465f,.8f,1.649168f)}},
|
||||
groups=new[] {new GroupState {id="s09-g01",ballIds=new[] {"s09-b01","s09-b02","s09-b04"}},new GroupState {id="s09-g01-split",ballIds=new[] {"s09-b03","s09-b05"}}}};
|
||||
_app.Initialize(_store); _app.ContinueFreePlay(); yield return null; _app.Voice.Clear();
|
||||
var board=JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot()));
|
||||
Capture("r11-split-five-frame");
|
||||
foreach (var label in _app.GetComponentsInChildren<TMPro.TextMeshPro>())
|
||||
{
|
||||
float fraction=RendererVisibleFraction(label.GetComponent<Renderer>(),"r11-split-number-"+label.text);
|
||||
Assert.That(fraction, Is.GreaterThanOrEqualTo(.95f),$"Number {label.text} must contribute visible glyph pixels through the unchanged Garden frame.");
|
||||
}
|
||||
Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Orbit.Snapshot())), Is.EqualTo(board));
|
||||
}
|
||||
[UnityTest] public IEnumerator OneThroughTenLabelsKeepTheirMembershipAndPixelsThroughOpaqueDecorationAfterOrbit()
|
||||
{
|
||||
_app.StartStage(20); yield return null;
|
||||
var blocker=GameObject.CreatePrimitive(PrimitiveType.Quad);
|
||||
var material=new Material(Shader.Find("Universal Render Pipeline/Unlit")); material.color=Color.white;
|
||||
blocker.GetComponent<Renderer>().sharedMaterial=material;
|
||||
try
|
||||
{
|
||||
for (int scenario=1;scenario<=11;scenario++)
|
||||
{
|
||||
int count=System.Math.Min(scenario,10);
|
||||
var balls=Enumerable.Range(0,count).Select(i=>new BallState {id="arbitrary-"+(count-i),position=new Position3((i%4-1.5f)*1.1f,.8f+i/4*.7f,i%3*.4f)}).ToArray();
|
||||
var groups=scenario==11 ? new[] {new GroupState {id="arbitrary-a",ballIds=balls.Take(5).Select(ball=>ball.id).ToArray()},new GroupState {id="arbitrary-b",ballIds=balls.Skip(5).Select(ball=>ball.id).ToArray()}}
|
||||
: new[] {new GroupState {id="arbitrary",ballIds=balls.Select(ball=>ball.id).ToArray()}};
|
||||
var board=BoardRules.CreateFreePlay(new FreePlayState {sourceStageId=20,balls=balls,groups=groups});
|
||||
_app.Presenter.Render(board); var savedCamera=_app.Orbit.Snapshot(); var saved=JsonUtility.ToJson(board.ToFreePlayState(savedCamera));
|
||||
foreach (int yaw in new[] {0,95,210})
|
||||
{
|
||||
var rotation=Quaternion.Euler(-15,yaw,0);
|
||||
_app.Orbit.Apply(new CameraState {target=new Position3(0,.6f,0),distance=11,rotation=new Rotation4(rotation.x,rotation.y,rotation.z,rotation.w)});
|
||||
var camera=_app.Presenter.Camera; blocker.transform.SetPositionAndRotation(camera.transform.position+camera.transform.forward*2,camera.transform.rotation); blocker.transform.localScale=Vector3.one*20;
|
||||
_app.Presenter.SendMessage("LateUpdate");
|
||||
var labels=_app.Presenter.GetComponentsInChildren<TMPro.TextMeshPro>();
|
||||
Assert.That(labels.Select(label=>label.text), Is.EqualTo(board.Groups.Select(group=>group.ballIds.Length.ToString())));
|
||||
foreach (var label in labels) Assert.That(RendererVisibleFraction(label.GetComponent<Renderer>()), Is.GreaterThanOrEqualTo(.95f),$"count{count}/yaw{yaw}/label{label.text}");
|
||||
Assert.That(RendererVisibleFraction(_app.Presenter.GetComponentInChildren<SpriteRenderer>()), Is.Zero,"Faces retain physical depth occlusion.");
|
||||
Assert.That(RendererVisibleFraction(_app.Presenter.Balls.Values.First().GetComponentInChildren<MeshRenderer>()), Is.Zero,"Balls retain physical depth occlusion.");
|
||||
}
|
||||
Assert.That(JsonUtility.ToJson(board.ToFreePlayState(savedCamera)), Is.EqualTo(saved));
|
||||
}
|
||||
}
|
||||
finally { Object.Destroy(blocker); Object.Destroy(material); }
|
||||
}
|
||||
[UnityTest] public IEnumerator CountingRefreshPreservesViewsAndClearsMarkersInSameFreeBoard()
|
||||
{
|
||||
_app.StartStage(20); yield return null;
|
||||
while (_app.Session.Board.Groups.Length > 1)
|
||||
{
|
||||
var groups=_app.Session.Board.Groups;
|
||||
_app.CommitMove(groups[0].id,new Position3(.1f,0,0),groups[1].id);
|
||||
}
|
||||
var views=_app.Presenter.Balls.ToDictionary(pair=>pair.Key,pair=>pair.Value);
|
||||
var badge=_app.Presenter.GetComponentInChildren<GroupBadge>();
|
||||
var positions=views.ToDictionary(pair=>pair.Key,pair=>pair.Value.transform.position);
|
||||
foreach (string id in views.Keys)
|
||||
{
|
||||
_app.CountBall(id);
|
||||
foreach (var pair in views)
|
||||
{
|
||||
Assert.That(_app.Presenter.Balls[pair.Key], Is.SameAs(pair.Value), "Counting must retain each ball instance.");
|
||||
Assert.That(pair.Value.transform.position, Is.EqualTo(positions[pair.Key]));
|
||||
var marker=(GameObject)typeof(BallView).GetField("_countMarker",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).GetValue(pair.Value);
|
||||
Assert.That(marker.activeSelf, Is.EqualTo(_app.Session.Board.CountedBallIds.Contains(pair.Key)));
|
||||
}
|
||||
Assert.That(_app.Presenter.GetComponentInChildren<GroupBadge>(), Is.SameAs(badge));
|
||||
}
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("count_01"));
|
||||
Assert.That(_app.Voice.PendingCount, Is.EqualTo(10));
|
||||
yield return null;
|
||||
}
|
||||
[UnityTest] public IEnumerator EveryCountingBallCanBeFoundPickedAndDirectlyDraggedAfterOrbit()
|
||||
{
|
||||
var proof=new List<string>();
|
||||
foreach (int stage in new[] {1,2,3,4,5,16,17,18,19,20})
|
||||
{
|
||||
_app.StartStage(stage); yield return null; Physics.SyncTransforms(); Capture($"counting-{stage:D2}-default.png");
|
||||
foreach (string id in _app.Session.Board.Balls.Select(ball=>ball.id).ToArray())
|
||||
{
|
||||
bool moved=false;
|
||||
for (int pitch=-45; pitch<=45 && !moved; pitch+=15)
|
||||
for (int yaw=0; yaw<360 && !moved; yaw+=30)
|
||||
{
|
||||
var rotation=Quaternion.Euler(pitch,yaw,0);
|
||||
_app.Orbit.Apply(new CameraState { target=new Position3(0,.6f,0),distance=11,rotation=new Rotation4(rotation.x,rotation.y,rotation.z,rotation.w) });
|
||||
var camera=_app.Presenter.Camera; var view=_app.Presenter.Balls[id]; var point=(Vector2)camera.WorldToScreenPoint(view.transform.position);
|
||||
var viewport=camera.WorldToViewportPoint(view.transform.position);
|
||||
if (viewport.z<=0 || viewport.x<.1f || viewport.x>.9f || viewport.y<.25f || viewport.y>.82f) continue;
|
||||
if (!Physics.Raycast(camera.ScreenPointToRay(point),out var hit,100) || hit.collider.GetComponent<BallView>()!=view) continue;
|
||||
var before=_app.Session.Board.Balls.First(ball=>ball.id==id).position;
|
||||
foreach (var offset in new[] {new Vector2(0,35),new Vector2(35,0),new Vector2(-35,0),new Vector2(0,-35)})
|
||||
{
|
||||
_app.Input.Begin(point,false); _app.Input.Move(point+offset); _app.Input.End(point+offset);
|
||||
var after=_app.Session.Board.Balls.First(ball=>ball.id==id).position;
|
||||
if (after.x==before.x && after.y==before.y && after.z==before.z) continue;
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Does.Contain(id));
|
||||
proof.Add($"stage={stage} id={id} yaw={yaw} pitch={pitch} pick={hit.collider.name} direct-drag={offset}");
|
||||
Capture($"found-{id}.png"); moved=true; break;
|
||||
}
|
||||
}
|
||||
Assert.That(moved, Is.True,$"Stage {stage} ball {id} has a discoverable and draggable view.");
|
||||
}
|
||||
File.WriteAllLines(Path.Combine(_evidence,"counting-discovery.txt"),proof);
|
||||
Capture($"counting-{stage:D2}.png"); yield return null;
|
||||
}
|
||||
Assert.That(proof.Count, Is.EqualTo(55));
|
||||
}
|
||||
[UnityTest] public IEnumerator FirstSixAndTenGhostsPreserveRealBoardAndNeverRepeatOnReentry()
|
||||
{
|
||||
foreach (int stage in new[] {6,10})
|
||||
{
|
||||
_app.StartStage(stage); yield return null;
|
||||
Assert.That(_app.Demonstration.IsShowing, Is.True);
|
||||
var board=JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera));
|
||||
var completed=_app.Session.CompletedStageIds;
|
||||
yield return new WaitForSecondsRealtime(1.3f);
|
||||
Capture($"ghost-{stage:D2}-selected.png"); yield return null;
|
||||
yield return new WaitForSecondsRealtime(1.7f);
|
||||
Capture($"ghost-{stage:D2}-moved.png"); yield return null;
|
||||
Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera)), Is.EqualTo(board));
|
||||
Assert.That(_app.Session.CompletedStageIds, Is.EqualTo(completed));
|
||||
_app.ShowHome(); _app.StartStage(stage); Assert.That(_app.Demonstration.IsShowing, Is.False);
|
||||
Assert.That(_app.Session.HasSeenTutorial(stage==6 ? "merge" : "split"), Is.True);
|
||||
}
|
||||
}
|
||||
[UnityTest] public IEnumerator EveryThemeSuppliesZeroThroughTenAndRestoresSameSceneryAfterSuccess()
|
||||
{
|
||||
foreach (int stage in new[] {1,16,21})
|
||||
{
|
||||
_app.StartStage(stage); yield return null;
|
||||
string theme=_app.Environment.Theme;
|
||||
for (int step=0; _app.Session.Screen==SessionScreen.Puzzle && step<40; step++)
|
||||
{
|
||||
var hint=_app.Session.Hint;
|
||||
if (hint.Kind==HintKind.Collect) _app.CommitMove(hint.FirstGroupId,new Position3(.1f,0,0));
|
||||
else if (hint.Kind==HintKind.Merge) _app.CommitMove(hint.FirstGroupId,new Position3(.1f,0,0),hint.SecondGroupId);
|
||||
else if (hint.Kind==HintKind.Count) _app.CountBall(hint.BallIds[0]);
|
||||
else _app.CommitSplit(hint.FirstGroupId,hint.BallIds,new Position3(1,0,0));
|
||||
}
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Environment.Theme, Is.EqualTo(theme));
|
||||
foreach (var group in _app.Session.Board.Groups) Assert.That(_app.Session.ReturnGroup(group.id).Accepted, Is.True);
|
||||
_app.ContinueFreePlay(); yield return null;
|
||||
for (int total=0; total<10; total++)
|
||||
{
|
||||
bool found=false;
|
||||
for (int yaw=0; yaw<360 && !found; yaw+=30)
|
||||
{
|
||||
var rotation=Quaternion.Euler(-10,yaw,0); _app.Orbit.Apply(new CameraState {target=new Position3(0,.6f,0),distance=11,rotation=new Rotation4(rotation.x,rotation.y,rotation.z,rotation.w)});
|
||||
if (_app.Presenter.TryFindSpawnPosition(out _)) { _app.SpawnFriend(); found=true; }
|
||||
}
|
||||
Assert.That(found, Is.True,$"Theme {theme}, supply {total+1}");
|
||||
Assert.That(_app.Session.Board.BallCount, Is.EqualTo(total+1)); yield return null;
|
||||
}
|
||||
_app.SpawnFriend(); Assert.That(_app.Session.Board.BallCount, Is.EqualTo(10));
|
||||
_app.Session.CommitCamera(_app.Orbit.Snapshot()); _app.ShowHome(); _app.ContinueFreePlay(); yield return null;
|
||||
Assert.That(_app.Environment.Theme, Is.EqualTo(theme)); Assert.That(_app.Session.Board.StageId, Is.EqualTo(stage));
|
||||
Capture($"theme-{theme}-free10.png"); yield return null;
|
||||
}
|
||||
}
|
||||
[UnityTest] public IEnumerator MeshArchReallyHasAnOpenGapAndHelpHighlightsOnlyCurrentIdsWithoutExecuting()
|
||||
{
|
||||
_app.StartStage(14); yield return null; Physics.SyncTransforms();
|
||||
var arch=_app.Environment.GetComponentsInChildren<MeshCollider>().First(collider=>collider.sharedMesh.name.Contains("BlockArch"));
|
||||
var local=new Ray(new Vector3(0,1,-3),Vector3.forward);
|
||||
Assert.That(arch.Raycast(new Ray(arch.transform.parent.TransformPoint(local.origin),Vector3.forward),out _,6), Is.False,"No box collider can close the Blender arch gap.");
|
||||
var before=JsonUtility.ToJson(_store.Saved); var hint=_app.Session.Hint; _app.ShowHelp();
|
||||
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(before));
|
||||
Assert.That(_app.Presenter.Balls.Values.Where(ball=>ball.Hinted).Select(ball=>ball.BallId), Is.EquivalentTo(hint.BallIds));
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.EqualTo(hint.VoiceId));
|
||||
}
|
||||
[UnityTest] public IEnumerator CollectingCurrentHelpTargetCancelsOldFindGuideWithoutLosingNumberQueue()
|
||||
{
|
||||
_app.StartStage(3); yield return null; _app.ShowHelp(); var hint=_app.Session.Hint;
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("guide_find"));
|
||||
_app.CommitMove(hint.FirstGroupId,new Position3(.1f,0,0));
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.Null,"Committed collection invalidates that old target guide.");
|
||||
_app.StartStage(2); var groups=_app.Session.Board.Groups;
|
||||
_app.CommitMove(groups[0].id,new Position3(.1f,0,0),groups[1].id); _app.CountBall(_app.Session.Board.Balls[0].id);
|
||||
_app.ShowHelp(); int pending=_app.Voice.PendingCount;
|
||||
_app.CommitMove(_app.Session.Board.Groups[0].id,new Position3(.1f,0,0));
|
||||
Assert.That(_app.Voice.PendingCount, Is.EqualTo(pending)); Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("count_01"));
|
||||
}
|
||||
[UnityTest] public IEnumerator SuccessfulCurrentHelpMergeCancelsCompositionGuideInSameFreeBoard()
|
||||
{
|
||||
_app.StartStage(7); yield return null; _app.ShowHelp(); var hint=_app.Session.Hint;
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("guide_merge"));
|
||||
_app.CommitMove(hint.FirstGroupId,new Position3(.1f,0,0),hint.SecondGroupId);
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.Null,"Successful merge cannot retain the obsolete composition guide.");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 781a56d13cd91d54c8e742b2da8ce4f6
|
||||
@@ -0,0 +1,458 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Collections;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using TMPro;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace MagnetismHeroes.Tests
|
||||
{
|
||||
public sealed class FirstPuzzlePlayTests
|
||||
{
|
||||
private sealed class MemoryStore : IProgressStore
|
||||
{
|
||||
public ProgressSave Saved = new ProgressSave();
|
||||
public bool FailWrites;
|
||||
private static ProgressSave Copy(ProgressSave save)
|
||||
{ var copy = JsonUtility.FromJson<ProgressSave>(JsonUtility.ToJson(save)); if (save.latestFreePlay == null) copy.latestFreePlay = null; return copy; }
|
||||
public bool TryLoad(out ProgressSave save, out string error) { save = Copy(Saved); error = null; return true; }
|
||||
public bool TrySave(ProgressSave save, out string error)
|
||||
{
|
||||
error = FailWrites ? "Disk full during committed save" : null;
|
||||
if (FailWrites) return false;
|
||||
Saved = Copy(save); return true;
|
||||
}
|
||||
}
|
||||
private GameApp _app; private MemoryStore _store; private Touchscreen _touchscreen;
|
||||
private InputSettings _originalInputSettings, _testInputSettings;
|
||||
private HideFlags _originalSettingsFlags;
|
||||
[UnitySetUp] public IEnumerator SetUp()
|
||||
{
|
||||
// Batch tests have no focused Game View; route synthetic pointers into the actual player loop.
|
||||
_originalInputSettings = InputSystem.settings; _originalSettingsFlags = _originalInputSettings.hideFlags;
|
||||
// InputManager destroys its HideAndDontSave default on replacement; keep it for exact restoration.
|
||||
_originalInputSettings.hideFlags = HideFlags.DontSave;
|
||||
_testInputSettings = Object.Instantiate(_originalInputSettings); _testInputSettings.hideFlags = HideFlags.DontSave;
|
||||
_testInputSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView;
|
||||
_testInputSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus;
|
||||
InputSystem.settings = _testInputSettings;
|
||||
foreach (var listener in Object.FindObjectsByType<AudioListener>(FindObjectsSortMode.None)) listener.enabled = false;
|
||||
foreach (var camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None)) camera.enabled = false;
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||
Assert.That(prefab, Is.Not.Null); _app = Object.Instantiate(prefab).GetComponent<GameApp>();
|
||||
_store = new MemoryStore(); _app.Initialize(_store); yield return null;
|
||||
}
|
||||
[UnityTearDown] public IEnumerator TearDown()
|
||||
{
|
||||
if (_touchscreen != null) InputSystem.RemoveDevice(_touchscreen); Object.Destroy(_app.gameObject);
|
||||
InputSystem.settings = _originalInputSettings; _originalInputSettings.hideFlags = _originalSettingsFlags;
|
||||
Object.Destroy(_testInputSettings); yield return null;
|
||||
}
|
||||
private Vector2 BallPoint()
|
||||
{
|
||||
var ball = _app.Presenter.Balls.Values.Single();
|
||||
Vector2 point = _app.Presenter.Camera.WorldToScreenPoint(ball.transform.position);
|
||||
var ray = _app.Presenter.Camera.ScreenPointToRay(point);
|
||||
Assert.That(Physics.Raycast(ray, out var hit, 100), Is.True,
|
||||
$"Ball ray miss: screen={Screen.width}x{Screen.height}, point={point}, ball={ball.transform.position}, camera={_app.Presenter.Camera.transform.position}, active={ball.gameObject.activeInHierarchy}");
|
||||
Assert.That(hit.collider.gameObject, Is.EqualTo(ball.gameObject), $"First visible ball ray must hit sphere: hit={hit.collider.name} point={hit.point}, screen={Screen.width}x{Screen.height} ballPoint={point}, ray={ray}, ball={ball.transform.position}, camera={_app.Presenter.Camera.transform.position}, collider={ball.GetComponent<Collider>().bounds}, active={ball.gameObject.activeInHierarchy}");
|
||||
return point;
|
||||
}
|
||||
private void Drag(Vector2 start, Vector2 end) { _app.Input.Begin(start, false); _app.Input.Move(end); _app.Input.End(end); }
|
||||
private void Tap(Vector2 point) { _app.Input.Begin(point, false); _app.Input.End(point); }
|
||||
private void QueueTouch(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position) =>
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = id, phase = phase, position = position });
|
||||
|
||||
[UnityTest] public IEnumerator FailedMoveCameraAndPauseSavesShowAVisibleNoticeAndRecoveryClearsIt()
|
||||
{
|
||||
_app.StartStage(1); yield return null;
|
||||
var before = JsonUtility.ToJson(_store.Saved); _store.FailWrites = true;
|
||||
_app.CommitMove(_app.Session.Board.Groups.Single().id, new Position3(.2f, 0, 0));
|
||||
var notice = _app.GetComponentsInChildren<TMP_Text>(true).Single(text => text.name == "SaveNotice");
|
||||
Assert.That(notice.text, Does.Contain("저장")); Assert.That(notice.gameObject.activeInHierarchy, Is.True);
|
||||
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(before));
|
||||
_store.FailWrites = false; _app.Session.SaveCurrent(); Assert.That(notice.text, Is.Empty);
|
||||
_store.FailWrites = true; _app.Input.Begin(new Vector2(Screen.width - 30, Screen.height / 2), false);
|
||||
_app.Input.Move(new Vector2(Screen.width - 80, Screen.height / 2)); _app.Input.End(new Vector2(Screen.width - 80, Screen.height / 2));
|
||||
Assert.That(notice.text, Does.Contain("저장"));
|
||||
_store.FailWrites = false; _app.Session.SaveCurrent(); Assert.That(notice.text, Is.Empty);
|
||||
_store.FailWrites = true; _app.gameObject.SendMessage("OnApplicationPause", true);
|
||||
Assert.That(notice.text, Does.Contain("저장"));
|
||||
_store.FailWrites = false; _app.gameObject.SendMessage("OnApplicationPause", true);
|
||||
Assert.That(notice.text, Is.Empty);
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator FreeSupplyButtonsCanReachTenRejectMoreReturnAWholeFriendAndRespawnEmptyBoard()
|
||||
{
|
||||
_app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Drag(point, point + new Vector2(60, 0)); yield return null; Physics.SyncTransforms(); Tap(BallPoint()); yield return null;
|
||||
var spawnObject = _app.transform.Find("Interface/SafeArea/Play/SpawnButton");
|
||||
Assert.That(spawnObject, Is.Not.Null, "Free play needs its real supply button.");
|
||||
var spawn = spawnObject.GetComponent<UnityEngine.UI.Button>();
|
||||
var giveBack = _app.transform.Find("Interface/SafeArea/Play/ReturnButton").GetComponent<UnityEngine.UI.Button>();
|
||||
for (int index = 0; index < 9; index++) { spawn.onClick.Invoke(); yield return null; }
|
||||
Assert.That(_app.Session.Board.BallCount, Is.EqualTo(10)); var full = JsonUtility.ToJson(_store.Saved);
|
||||
Assert.That(spawn.interactable, Is.False); spawn.onClick.Invoke(); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(full));
|
||||
while (_app.Session.Board.BallCount > 0)
|
||||
{
|
||||
var ball = _app.Presenter.Balls.Values.First(); Vector2 location = _app.Presenter.Camera.WorldToScreenPoint(ball.transform.position);
|
||||
int before = _app.Session.Board.BallCount; Tap(location); giveBack.onClick.Invoke(); yield return null; Physics.SyncTransforms();
|
||||
Assert.That(_app.Session.Board.BallCount, Is.LessThan(before));
|
||||
}
|
||||
Assert.That(_app.Session.Board.Groups, Is.Empty); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||
spawn.onClick.Invoke(); yield return null; Assert.That(_app.Session.Board.BallCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SuccessfulTwoCountKeepsItsFinalNumbersDuringFreeSplitCancelAndCommit()
|
||||
{
|
||||
_store.Saved.completedStageIds = new[] { 1 }; _app.Initialize(_store);
|
||||
foreach (bool cancel in new[] { true, false })
|
||||
{
|
||||
_app.StartStage(2); yield return null; Physics.SyncTransforms();
|
||||
var groups = _app.Session.Board.Groups;
|
||||
_app.CommitMove(groups[0].id, new Position3(.6f, 0, 0), groups[1].id);
|
||||
var ids = _app.Session.Board.Balls.Select(ball => ball.id).ToArray();
|
||||
foreach (string id in ids)
|
||||
{
|
||||
Vector2 point = _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position);
|
||||
Tap(point);
|
||||
}
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_app.Voice.PendingCount, Is.EqualTo(2));
|
||||
Assert.That(_app.GetComponent<AudioSource>().clip.name, Is.EqualTo("count_01"));
|
||||
Vector2 firstPoint = _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[ids[0]].transform.position); Tap(firstPoint);
|
||||
_app.transform.Find("Interface/SafeArea/Play/SplitButton").GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
|
||||
if (cancel) _app.transform.Find("Interface/SafeArea/Play/CancelSplitButton").GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
|
||||
else { Tap(firstPoint); Drag(firstPoint, firstPoint - new Vector2(120, 0)); }
|
||||
Assert.That(_app.Voice.PendingCount, Is.EqualTo(2), cancel ? "Cancel split must keep count FIFO." : "Committed split must keep count FIFO.");
|
||||
float deadline = Time.realtimeSinceStartup + 5;
|
||||
while (_app.Voice.CurrentClipId == "count_01" && Time.realtimeSinceStartup < deadline) yield return null;
|
||||
Assert.That(_app.Voice.CurrentClipId, Is.EqualTo("count_02")); Assert.That(_app.GetComponent<AudioSource>().isPlaying, Is.True);
|
||||
while (_app.Voice.PendingCount > 0 && Time.realtimeSinceStartup < deadline) yield return null;
|
||||
Assert.That(_app.Voice.PendingCount, Is.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SameUpdateShortTapRespondsBeforeCollectionAndCountsOnceAfterCollection()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); QueueTouch(101, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(101, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||
yield return null; yield return null;
|
||||
Assert.That(_app.Instruction, Does.Contain("먼저")); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
Drag(point, point + new Vector2(70, 20)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||
QueueTouch(102, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(102, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||
yield return null; yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SameUpdateFastDragMovesWithoutCountingAnAlreadyCollectedFriend()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||
var original = _app.Session.Board.Balls.Single().position;
|
||||
QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(40, 15));
|
||||
QueueTouch(111, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(40, 15)); yield return null; yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.Not.EqualTo(original));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SameUpdateCanceledAndOutsideDragsPreserveCommittedState()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms(); _app.Session.SaveCurrent();
|
||||
var point = BallPoint(); var original = _app.Session.Board.Balls.Single().position; var saved = JsonUtility.ToJson(_store.Saved);
|
||||
foreach (var phase in new[] { UnityEngine.InputSystem.TouchPhase.Canceled, UnityEngine.InputSystem.TouchPhase.Ended })
|
||||
{
|
||||
QueueTouch(121, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(121, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(50, 10));
|
||||
QueueTouch(121, phase, phase == UnityEngine.InputSystem.TouchPhase.Ended ? new Vector2(-1, point.y) : point + new Vector2(50, 10));
|
||||
yield return null; yield return null;
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(original)); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
||||
}
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SameUpdateTouchBeginningOnUiNeverCountsTheFriendAtRelease()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||
var button = _app.transform.Find("Interface/SafeArea/Play/HomeButton").GetComponent<RectTransform>();
|
||||
Vector2 uiPoint = button.TransformPoint(button.rect.center);
|
||||
QueueTouch(131, UnityEngine.InputSystem.TouchPhase.Began, uiPoint); QueueTouch(131, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||
yield return null; yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SameUpdateSequentialShortTapsCompleteOnlyOnce()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||
QueueTouch(141, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(141, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||
QueueTouch(142, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(142, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||
yield return null; yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay)); Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator SameUpdateConcurrentShortPinchNeverCountsTheFriend()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Drag(point, point + new Vector2(50, 15)); yield return null; Physics.SyncTransforms(); point = BallPoint();
|
||||
QueueTouch(151, UnityEngine.InputSystem.TouchPhase.Began, point); QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Began, point + new Vector2(80, 0));
|
||||
QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Moved, point + new Vector2(160, 0)); QueueTouch(151, UnityEngine.InputSystem.TouchPhase.Ended, point);
|
||||
QueueTouch(152, UnityEngine.InputSystem.TouchPhase.Ended, point + new Vector2(160, 0)); yield return null; yield return null;
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||
Assert.That(_app.Session.Camera.distance, Is.LessThan(8));
|
||||
}
|
||||
private void DragTo(Vector3 destination)
|
||||
{
|
||||
Physics.SyncTransforms(); var start = BallPoint();
|
||||
var camera = _app.Presenter.Camera; Physics.Raycast(camera.ScreenPointToRay(start), out var hit, 100);
|
||||
var offset = hit.point - _app.Presenter.Balls.Values.Single().transform.position;
|
||||
Drag(start, camera.WorldToScreenPoint(destination + offset));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator GroupNumberFrontViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.identity);
|
||||
[UnityTest] public IEnumerator GroupNumberBelowViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.Euler(-90, 0, 0));
|
||||
[UnityTest] public IEnumerator GroupNumberRolledViewStaysAboveAndVisible() => CheckGroupNumber(Quaternion.Euler(0, 0, 90));
|
||||
[UnityTest] public IEnumerator GroupNumberPreviewFollowsTheFriendWithoutChangingCommittedState() => CheckGroupNumber(Quaternion.Euler(-90, 0, 0), true);
|
||||
|
||||
private IEnumerator CheckGroupNumber(Quaternion rotation, bool preview = false)
|
||||
{
|
||||
_app.StartStage(1); yield return null;
|
||||
var state = _app.Session.Board.Balls.Single();
|
||||
_app.CommitMove(_app.Session.Board.Groups.Single().id, new Position3(4 - state.position.x, 0, 0));
|
||||
_app.CountBall(state.id); Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
var saved = JsonUtility.ToJson(_store.Saved); var position = _app.Session.Board.Balls.Single().position;
|
||||
var committedCamera = JsonUtility.ToJson(_app.Session.Camera);
|
||||
_app.Orbit.Apply(new CameraState { target = new Position3(4, 0.6f, 0), distance = 8,
|
||||
rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) });
|
||||
if (preview) _app.Presenter.PreviewGroup(_app.Session.Board.Groups.Single().id, new Vector3(0.8f, 0, 0.3f));
|
||||
yield return null; Physics.SyncTransforms();
|
||||
var camera = _app.Presenter.Camera; var badge = _app.Presenter.GetComponentInChildren<GroupBadge>();
|
||||
var number = badge.GetComponentInChildren<TextMeshPro>(); number.ForceMeshUpdate();
|
||||
Assert.That(number.text, Is.EqualTo("1")); Assert.That(number.textInfo.characterInfo[0].isVisible, Is.True);
|
||||
Assert.That(Vector3.Dot(number.transform.forward, camera.transform.forward), Is.EqualTo(1).Within(0.001));
|
||||
Assert.That(Vector3.Dot(badge.transform.Find("RepresentativeFace").forward, camera.transform.forward), Is.EqualTo(1).Within(0.001));
|
||||
var glyph = number.textInfo.characterInfo[0];
|
||||
var glyphWorld = new[] { glyph.bottomLeft, glyph.topLeft, glyph.topRight, glyph.bottomRight }.Select(number.transform.TransformPoint).ToArray();
|
||||
var glyphScreen = glyphWorld.Select(camera.WorldToScreenPoint).ToArray();
|
||||
Assert.That(glyphScreen.All(point => point.z > camera.nearClipPlane && point.x > 0 && point.x < Screen.width && point.y > 0 && point.y < Screen.height), Is.True);
|
||||
var ballBounds = _app.Presenter.Balls.Values.Single().GetComponent<SphereCollider>().bounds;
|
||||
float ballTop = float.NegativeInfinity;
|
||||
foreach (int x in new[] { -1, 1 }) foreach (int y in new[] { -1, 1 }) foreach (int z in new[] { -1, 1 })
|
||||
ballTop = Mathf.Max(ballTop, camera.WorldToScreenPoint(ballBounds.center + Vector3.Scale(ballBounds.extents, new Vector3(x, y, z))).y);
|
||||
Assert.That(glyphScreen.Min(point => point.y), Is.GreaterThan(ballTop), "The visible glyph must sit above the sphere in the current camera view.");
|
||||
var glyphCenter = glyphWorld.Aggregate(Vector3.zero, (sum, vertex) => sum + vertex) / glyphWorld.Length;
|
||||
var ray = camera.ScreenPointToRay(camera.WorldToScreenPoint(glyphCenter));
|
||||
Assert.That(Physics.Raycast(ray, Vector3.Distance(ray.origin, glyphCenter)), Is.False, "Scene colliders must not hide the number before its glyph plane.");
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(position));
|
||||
Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(committedCamera)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
||||
_app.Presenter.CancelPreview();
|
||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(new Vector3(position.x, position.y, position.z)));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator PinchReleaseOutsideRestoresCameraAndSuppressesRemainingTouch()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); var second = point + new Vector2(90, 0); var initial = _app.Session.Camera.distance;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 71, phase = UnityEngine.InputSystem.TouchPhase.Began, position = point });
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 72, phase = UnityEngine.InputSystem.TouchPhase.Began, position = second }); yield return null; yield return null;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 72, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = second + new Vector2(80, 0) }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().distance, Is.LessThan(initial)); var saved = JsonUtility.ToJson(_store.Saved);
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 72, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(-1, second.y) }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().distance, Is.EqualTo(initial)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 71, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = point + new Vector2(70, 20) }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(_app.Session.Camera.rotation));
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator CanceledDragCannotBeInheritedByANewFingerInTheSameFrame()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); var initial = _app.Session.Board.Balls.Single().position;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 81, phase = UnityEngine.InputSystem.TouchPhase.Began, position = point }); yield return null; yield return null;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 81, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = point + new Vector2(70, 20) }); yield return null; yield return null;
|
||||
var saved = JsonUtility.ToJson(_store.Saved);
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 81, phase = UnityEngine.InputSystem.TouchPhase.Canceled, position = point + new Vector2(70, 20) });
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 82, phase = UnityEngine.InputSystem.TouchPhase.Began, position = point + new Vector2(140, 30) }); yield return null; yield return null;
|
||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(new Vector3(initial.x, initial.y, initial.z)));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 82, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = point + new Vector2(140, 30) }); yield return null; yield return null;
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(initial)); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator BackgroundInteriorDropIsRejectedAndSurfaceContactRemainsPlayable()
|
||||
{
|
||||
_app.StartStage(1); yield return null; DragTo(new Vector3(1.8f, 0.8f, 0)); yield return null;
|
||||
Assert.That(_app.Session.Board.CollectedBallIds.Length, Is.EqualTo(1));
|
||||
_app.Orbit.Orbit(new Vector2(360, 0)); _app.Session.CommitCamera(_app.Orbit.Snapshot());
|
||||
DragTo(new Vector3(1.8f, 0.65f, 1.1f)); yield return null; Physics.SyncTransforms();
|
||||
var initial = _app.Session.Board.Balls.Single().position;
|
||||
Assert.That(initial.y, Is.EqualTo(0.65f).Within(0.001)); Assert.That(initial.z, Is.EqualTo(1.1f).Within(0.001)); BallPoint();
|
||||
var saved = JsonUtility.ToJson(_store.Saved); var camera = _app.Orbit.Snapshot();
|
||||
DragTo(new Vector3(1.8f, -0.4f, 1.1f)); yield return null;
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(initial), "The scene interior must not become a committed drop.");
|
||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(new Vector3(initial.x, initial.y, initial.z)));
|
||||
Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(camera.rotation)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
|
||||
Physics.SyncTransforms(); BallPoint();
|
||||
}
|
||||
|
||||
[UnityTest] public IEnumerator InspectorButtonsNavigateAndHomeKeepsACameraAndListener()
|
||||
{
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Home));
|
||||
Assert.That(_app.Presenter.Camera.isActiveAndEnabled, Is.True);
|
||||
Assert.That(_app.GetComponentInChildren<AudioListener>().isActiveAndEnabled, Is.True);
|
||||
_app.transform.Find("Interface/SafeArea/Home/PlayButton").GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.StageSelection));
|
||||
_app.transform.Find("Interface/SafeArea/StageSelection/Stage01Button").GetComponent<UnityEngine.UI.Button>().onClick.Invoke();
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.Puzzle));
|
||||
Assert.That(_app.Presenter.Balls.Count, Is.EqualTo(1)); yield return null;
|
||||
}
|
||||
[UnityTest] public IEnumerator TapBeforeCollectionIsRejectedThenDragAndTapCompleteOnTheSameBoard()
|
||||
{
|
||||
_app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Tap(point);
|
||||
Assert.That(_app.Session.Board.CountedCount, Is.Zero); Assert.That(_app.Instruction, Does.Contain("먼저"));
|
||||
Drag(point, point + new Vector2(90, 25)); yield return null; Physics.SyncTransforms();
|
||||
Assert.That(_app.Session.Board.CollectedBallIds.Length, Is.EqualTo(1));
|
||||
var ball = _app.Session.Board.Balls.Single(); Tap(BallPoint());
|
||||
Assert.That(_app.Session.Screen, Is.EqualTo(SessionScreen.FreePlay));
|
||||
Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 })); Assert.That(_store.Saved.puzzleStates, Is.Empty);
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(ball.position));
|
||||
Assert.That(_app.Voice.PendingCount, Is.EqualTo(1), "The success boundary must keep the last count voice.");
|
||||
Tap(BallPoint()); Assert.That(_app.Voice.PendingCount, Is.EqualTo(1));
|
||||
yield return null;
|
||||
}
|
||||
[UnityTest] public IEnumerator CanceledAndOffscreenDragsRestoreOnlyCommittedPositions()
|
||||
{
|
||||
_app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var original = _app.Session.Board.Balls.Single().position; var point = BallPoint();
|
||||
_app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(100, 30));
|
||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.Not.EqualTo(new Vector3(original.x, original.y, original.z)));
|
||||
_app.Session.SaveCurrent(); Assert.That(_store.Saved.puzzleStates.Single().balls.Single().position, Is.EqualTo(original));
|
||||
_app.Input.End(point + new Vector2(100, 30), true);
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(new Vector3(original.x, original.y, original.z)));
|
||||
_app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(100, 30)); _app.Input.End(new Vector2(-1, point.y));
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(original)); yield return null;
|
||||
}
|
||||
[UnityTest] public IEnumerator ButtonBlockedGestureAndPinchNeverCollectOrCountAFriend()
|
||||
{
|
||||
_app.StartStage(1); yield return null; Physics.SyncTransforms(); var point = BallPoint();
|
||||
_app.Input.Begin(point, true); _app.Input.Move(point + new Vector2(100, 20)); _app.Input.End(point + new Vector2(100, 20));
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
_app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(100, 20)); _app.Input.BeginPinch(100, false);
|
||||
_app.Input.Pinch(200); _app.Input.End(point);
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||
Assert.That(_app.Session.Camera.distance, Is.EqualTo(4).Within(0.01)); yield return null;
|
||||
}
|
||||
[UnityTest] public IEnumerator OrbitIsCommittedAtEndAndCanInspectEveryDirection()
|
||||
{
|
||||
_app.StartStage(1); yield return null;
|
||||
var original = _app.Session.Camera.rotation; var empty = new Vector2(Screen.width * 0.5f, Screen.height * 0.6f);
|
||||
_app.Input.Begin(empty, false); _app.Input.Move(empty + new Vector2(150, 80));
|
||||
Assert.That(_app.Session.Camera.rotation, Is.EqualTo(original)); _app.Input.Cancel();
|
||||
Assert.That(_app.Session.Camera.rotation, Is.EqualTo(original));
|
||||
_app.Orbit.Orbit(new Vector2(720, 360)); _app.Session.CommitCamera(_app.Orbit.Snapshot());
|
||||
Assert.That(_store.Saved.puzzleStates.Single().camera.rotation.IsValid, Is.True);
|
||||
_app.Orbit.Zoom(1000); Assert.That(_app.Orbit.Snapshot().distance, Is.EqualTo(3));
|
||||
_app.Orbit.Zoom(0.001f); Assert.That(_app.Orbit.Snapshot().distance, Is.EqualTo(15)); yield return null;
|
||||
}
|
||||
[UnityTest] public IEnumerator RestartRestoresOnlyCommittedPuzzleStateAndDoesNotReplayCounts()
|
||||
{
|
||||
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>("Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage01.asset"); var assetBefore = EditorJsonUtility.ToJson(stage);
|
||||
_app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); Drag(point, point + new Vector2(90, -15));
|
||||
_app.Orbit.Zoom(0.8f); _app.Session.CommitCamera(_app.Orbit.Snapshot()); _app.ShowHome();
|
||||
var position = _store.Saved.puzzleStates.Single().balls.Single().position;
|
||||
Object.Destroy(_app.gameObject); yield return null;
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab");
|
||||
_app = Object.Instantiate(prefab).GetComponent<GameApp>(); _app.Initialize(_store); _app.StartStage(1); yield return null;
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(position));
|
||||
Assert.That(_app.Session.Board.CollectedBallIds.Length, Is.EqualTo(1)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||
Assert.That(_app.Session.Camera.distance, Is.EqualTo(10).Within(0.01)); Assert.That(_app.Voice.PendingCount, Is.Zero);
|
||||
Assert.That(EditorJsonUtility.ToJson(stage), Is.EqualTo(assetBefore));
|
||||
}
|
||||
[UnityTest] public IEnumerator ActualTouchPinchDoesNotStartANewDragUntilBothFingersAreLifted()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); var second = point + new Vector2(100, 0);
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Began, position = point });
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 2, phase = UnityEngine.InputSystem.TouchPhase.Began, position = second }); yield return null; yield return null;
|
||||
Assert.That(_touchscreen.touches.Count(touch => touch.press.isPressed), Is.EqualTo(2));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 2, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = second + new Vector2(70, 0) }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().distance, Is.LessThan(8));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 2, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = second }); yield return null; yield return null;
|
||||
var committedCamera = _app.Session.Camera.rotation;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = point + new Vector2(90, 0) }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(committedCamera));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = point + new Vector2(90, 0) }); yield return null;
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
|
||||
}
|
||||
[UnityTest] public IEnumerator ActualBundledVoicePlaysThroughVoiceMixerAndClearsOnNavigation()
|
||||
{
|
||||
var source = _app.GetComponent<AudioSource>(); Assert.That(source.outputAudioMixerGroup.name, Is.EqualTo("Voice"));
|
||||
Assert.That(source.spatialBlend, Is.Zero);
|
||||
_app.StartStage(1); yield return null; Assert.That(source.clip.name, Is.EqualTo("guide_collect")); Assert.That(source.isPlaying, Is.True);
|
||||
Physics.SyncTransforms(); var point = BallPoint(); Drag(point, point + new Vector2(80, 0)); yield return null;
|
||||
Physics.SyncTransforms(); Tap(BallPoint()); yield return null;
|
||||
Assert.That(source.clip.name, Is.EqualTo("count_01")); Assert.That(source.isPlaying, Is.True);
|
||||
_app.ShowHome(); Assert.That(source.isPlaying, Is.False); Assert.That(_app.Voice.PendingCount, Is.Zero);
|
||||
}
|
||||
[UnityTest] public IEnumerator CanceledPinchRestoresCommittedCameraAndIgnoresTheRemainingFinger()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); var second = point + new Vector2(90, 0); float initial = _app.Session.Camera.distance;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 11, phase = UnityEngine.InputSystem.TouchPhase.Began, position = point });
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 12, phase = UnityEngine.InputSystem.TouchPhase.Began, position = second }); yield return null; yield return null;
|
||||
Assert.That(_touchscreen.touches.Count(touch => touch.press.isPressed), Is.EqualTo(2));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 12, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = second + new Vector2(90, 0) }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().distance, Is.LessThan(initial));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 12, phase = UnityEngine.InputSystem.TouchPhase.Canceled, position = second }); yield return null; yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().distance, Is.EqualTo(initial)); Assert.That(_app.Session.Camera.distance, Is.EqualTo(initial));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 11, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = point + new Vector2(70, 0) }); yield return null;
|
||||
Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(_app.Session.Camera.rotation));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 11, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = point + new Vector2(70, 0) }); yield return null;
|
||||
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
}
|
||||
[UnityTest] public IEnumerator CountVoicesStayOrderedUnderFastInputAndNeverOverlapAGuide()
|
||||
{
|
||||
var source = _app.GetComponent<AudioSource>();
|
||||
_app.Voice.Guide("guide_collect"); _app.Voice.EnqueueCount(1); _app.Voice.EnqueueCount(2); _app.Voice.EnqueueCount(3);
|
||||
Assert.That(source.clip.name, Is.EqualTo("count_01")); Assert.That(_app.Voice.PendingCount, Is.EqualTo(3));
|
||||
float deadline = Time.realtimeSinceStartup + 5;
|
||||
while (source.clip.name == "count_01" && Time.realtimeSinceStartup < deadline) yield return null;
|
||||
Assert.That(source.clip.name, Is.EqualTo("count_02")); Assert.That(source.isPlaying, Is.True);
|
||||
while (source.clip.name == "count_02" && Time.realtimeSinceStartup < deadline) yield return null;
|
||||
Assert.That(source.clip.name, Is.EqualTo("count_03")); Assert.That(source.isPlaying, Is.True);
|
||||
while (_app.Voice.PendingCount > 0 && Time.realtimeSinceStartup < deadline) yield return null;
|
||||
Assert.That(_app.Voice.PendingCount, Is.Zero); Assert.That(source.isPlaying, Is.False);
|
||||
}
|
||||
[UnityTest] public IEnumerator TouchReleaseOutsideTheScreenCancelsAnOtherwiseValidDrag()
|
||||
{
|
||||
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var point = BallPoint(); var initial = _app.Session.Board.Balls.Single().position;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 41, phase = UnityEngine.InputSystem.TouchPhase.Began, position = point }); yield return null; yield return null;
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 41, phase = UnityEngine.InputSystem.TouchPhase.Moved, position = point + new Vector2(50, 10) }); yield return null; yield return null;
|
||||
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.Not.EqualTo(new Vector3(initial.x, initial.y, initial.z)));
|
||||
InputSystem.QueueStateEvent(_touchscreen, new TouchState { touchId = 41, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(-1, point.y) }); yield return null; yield return null;
|
||||
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(initial)); Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty);
|
||||
}
|
||||
[UnityTest] public IEnumerator FlatFloorColliderMatchesTheVisibleMeshAndKeepsTheFriendReachable()
|
||||
{
|
||||
_app.StartStage(1); yield return null; Physics.SyncTransforms();
|
||||
var floor = _app.transform.Find("Playground/PlaygroundIsland");
|
||||
Assert.That(floor.GetComponent<Collider>(), Is.TypeOf<MeshCollider>());
|
||||
Assert.That(floor.GetComponent<Collider>().bounds.max.y, Is.EqualTo(floor.GetComponent<Renderer>().bounds.max.y).Within(0.01));
|
||||
Assert.That(floor.GetComponent<Collider>().bounds.max.y, Is.LessThan(0)); BallPoint();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f105e98652f6b64881959d4aed6a120
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "MagnetismHeroes.PlayModeTests",
|
||||
"rootNamespace": "MagnetismHeroes.Tests",
|
||||
"references": ["MagnetismHeroes.Core", "MagnetismHeroes.UnityRuntime", "Unity.InputSystem", "Unity.ugui", "Unity.TextMeshPro"],
|
||||
"optionalUnityReferences": ["TestAssemblies"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15c23150f0935ef45b227488a99c7863
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6811273b7de8d14fae5b5acebddbadc
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
# 입력·표현·파일 연결
|
||||
|
||||
이 어셈블리는 Input System 입력, URP 표현, uGUI·TMP 화면, 스테이지 변환과 파일 저장을 소유한다. 수학 정답의 새 규칙이나 Editor 제작 API는 소유하지 않는다. Core를 호출하고 `UnityEditor`를 참조하지 않는다.
|
||||
|
||||
GameApp이 저장 구현을 GameSession에 전달하고 BoardInput의 확정 의도를 처리한다. 끌기·분리 선택은 미리보기이며 취소·핀치·영역 이탈에서 확정 상태로 복구한다. UI hit를 세계 Raycast보다 먼저 처리하고 소품의 앞쪽 hit를 무시해 뒤 구를 선택하지 않는다.
|
||||
|
||||
Presenter와 GroupBadge는 확정 소속에서 구·숫자·대표 얼굴·망토를 만든다. 장식에 별도 입력 콜라이더나 수량을 추가하지 않는다. 자석 크기·합체한 아이 배열은 유지하고 분리 재배치만 Core의 확정 결과를 따른다. 화면 전환·재진입에서 이전 미리보기·표정·음성이 남지 않아야 한다.
|
||||
|
||||
VoicePlayer는 승인된 ID의 계수 큐를 순차 재생한다. 같은 보드의 성공 전이에서 큐를 지우지 않으며 화면 이동·pause에서는 지운다. 현재 무음인 SFX/Music 참조를 누락 음성으로 혼동하지 않는다. LocalProgressStore는 전체 JSON을 검증하고 유효 백업을 손상 primary로 교체하지 않는다.
|
||||
|
||||
입력·화면·저장 변경은 Tests/PlayMode에서 실제 프리팹과 충돌·취소·복원을 확인한다. 터치와 오프라인 음성·Android 파일 수명은 같은 APK의 AVD에서 재확인한다. StageAsset 편집이 규칙 목표를 바꾸지 않는지, 전체 한글 glyph와 스마트폰·태블릿 비율에서 잘림이 없는지도 확인한다.
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de11d2cba2cfbfe468aa53238ea830e0
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1 @@
|
||||
[assembly: System.Reflection.AssemblyTitle("MagnetismHeroes.UnityRuntime")]
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd220a54a822bb844b4e400ea66369f6
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e2e3e564c6c53f489bb3da170641fc1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Interaction;
|
||||
using MagnetismHeroes.UnityRuntime.Persistence;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using MagnetismHeroes.UnityRuntime.StageAssets;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MagnetismHeroes.UnityRuntime.Bootstrap
|
||||
{
|
||||
public sealed class GameApp : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private StageAsset[] _stages;
|
||||
[SerializeField] private GameObject _homePanel;
|
||||
[SerializeField] private GameObject _selectionPanel;
|
||||
[SerializeField] private GameObject _playPanel;
|
||||
[SerializeField] private GameObject _world;
|
||||
[SerializeField] private TMP_Text _instruction;
|
||||
[SerializeField] private TMP_Text _counter;
|
||||
[SerializeField] private TMP_Text _stageTitle;
|
||||
[SerializeField] private TMP_Text _saveNotice;
|
||||
[SerializeField] private UnityEngine.UI.Button _playButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _continueButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _stageOneButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _stageTwoButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _spawnButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _returnButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _homeButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _selectionHomeButton;
|
||||
[SerializeField] private UnityEngine.UI.Button[] _compositionButtons = Array.Empty<UnityEngine.UI.Button>();
|
||||
[SerializeField] private UnityEngine.UI.Button _splitButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _cancelSplitButton;
|
||||
[SerializeField] private BoardPresenter _presenter;
|
||||
[SerializeField] private OrbitCamera _orbit;
|
||||
[SerializeField] private BoardInput _input;
|
||||
[SerializeField] private VoicePlayer _voice;
|
||||
[SerializeField] private GameSound _sound;
|
||||
[SerializeField] private UnityEngine.UI.Button[] _stageButtons = Array.Empty<UnityEngine.UI.Button>();
|
||||
[SerializeField] private UnityEngine.UI.Button _helpButton;
|
||||
[SerializeField] private UnityEngine.UI.Button _nextButton;
|
||||
[SerializeField] private BoardEnvironment _environment;
|
||||
[SerializeField] private GhostDemonstration _demonstration;
|
||||
public GameSession Session { get; private set; }
|
||||
public BoardPresenter Presenter => _presenter;
|
||||
public BoardInput Input => _input;
|
||||
public OrbitCamera Orbit => _orbit;
|
||||
public VoicePlayer Voice => _voice;
|
||||
public GameSound Sound => _sound;
|
||||
public BoardEnvironment Environment => _environment;
|
||||
public GhostDemonstration Demonstration => _demonstration;
|
||||
public string Instruction => _instruction.text;
|
||||
public string Counter => _counter.text;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_playButton.onClick.AddListener(ShowSelection);
|
||||
_continueButton.onClick.AddListener(ContinueFreePlay);
|
||||
if (_stageButtons.Length == 0)
|
||||
{
|
||||
_stageOneButton.onClick.AddListener(() => StartStage(1));
|
||||
if (_stageTwoButton != null) _stageTwoButton.onClick.AddListener(() => StartStage(2));
|
||||
}
|
||||
for (int index = 0; index < _stageButtons.Length; index++)
|
||||
{ int stageId = _stages[index].StageId; _stageButtons[index].onClick.AddListener(() => StartStage(stageId)); }
|
||||
if (_helpButton != null) _helpButton.onClick.AddListener(ShowHelp);
|
||||
if (_nextButton != null) _nextButton.onClick.AddListener(StartNextStage);
|
||||
if (_spawnButton != null) _spawnButton.onClick.AddListener(SpawnFriend);
|
||||
if (_returnButton != null) _returnButton.onClick.AddListener(ReturnSelectedFriend);
|
||||
_homeButton.onClick.AddListener(ShowHome);
|
||||
_selectionHomeButton.onClick.AddListener(ShowHome);
|
||||
for (int index = 0; _stageButtons.Length == 0 && index < _compositionButtons.Length; index++)
|
||||
{ int stageId = _stages.Where(stage => stage.ToDefinition().Kind == StageKind.Composition).ElementAt(index).StageId; _compositionButtons[index].onClick.AddListener(() => StartStage(stageId)); }
|
||||
if (_splitButton != null) _splitButton.onClick.AddListener(() => { _input.BeginSplit(); if (_input.IsSplitting) _voice.Guide("guide_split"); });
|
||||
if (_cancelSplitButton != null) _cancelSplitButton.onClick.AddListener(() => { _input.CancelSplit(); _voice.CancelGuide(); });
|
||||
if (_sound != null) foreach (var button in GetComponentsInChildren<UnityEngine.UI.Button>(true)) button.onClick.AddListener(_sound.UI);
|
||||
}
|
||||
private void Start()
|
||||
{
|
||||
if (Session == null) Initialize(new LocalProgressStore(Path.Combine(Application.persistentDataPath, "progress.json"),
|
||||
_stages.Select(stage => stage.ToDefinition()).ToArray()));
|
||||
}
|
||||
public void Initialize(IProgressStore store)
|
||||
{
|
||||
Session = new GameSession(_stages.Select(stage => stage.ToDefinition()), store);
|
||||
Session.SaveFinished += ShowSaveResult;
|
||||
_saveNotice.text = Session.LastSaveError == null ? "" : Session.LastSaveError.StartsWith("Recovered", StringComparison.Ordinal)
|
||||
? "이전 기록으로 이어서 시작할게요." : "기록을 읽지 못해 처음부터 시작할게요.";
|
||||
if (Session.LastSaveError != null) Debug.LogWarning("[ProgressLoad] " + Session.LastSaveError);
|
||||
Refresh(true);
|
||||
}
|
||||
private void ShowSaveResult(string error)
|
||||
{
|
||||
_saveNotice.text = error == null ? "" : "지금은 저장하지 못했어요. 다시 해볼게요.";
|
||||
if (error != null) Debug.LogWarning("[ProgressSave] " + error);
|
||||
}
|
||||
public void SpawnFriend()
|
||||
{
|
||||
if (Session.Screen != SessionScreen.FreePlay || Session.Board.BallCount >= 10) return;
|
||||
_input.CancelSplit();
|
||||
if (!_presenter.TryFindSpawnPosition(out var position)) { _instruction.text = "빈 곳이 보이도록 화면을 움직여 볼까?"; return; }
|
||||
if (Session.Spawn(position).Accepted) { Refresh(false); _sound?.Supply(); }
|
||||
}
|
||||
public void ReturnSelectedFriend()
|
||||
{
|
||||
if (Session.Screen != SessionScreen.FreePlay || _input.SelectedGroup == null) return;
|
||||
string groupId = _input.SelectedGroup; _input.CancelSplit();
|
||||
if (Session.ReturnGroup(groupId).Accepted) { Refresh(false); _sound?.Supply(); }
|
||||
}
|
||||
public void ShowSelection()
|
||||
{ _input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); Session.ShowSelection(); Refresh(true); }
|
||||
public void ShowHome()
|
||||
{ _input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); Session.ShowHome(); Refresh(true); }
|
||||
public void StartStage(int id)
|
||||
{
|
||||
_input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); bool restored = Session.HasUnfinishedStage(id);
|
||||
if (!Session.StartStage(id)) return;
|
||||
Refresh(true);
|
||||
string tutorial = id == 6 ? "merge" : id == 10 ? "split" : null;
|
||||
if (tutorial != null && !Session.HasSeenTutorial(tutorial) && _demonstration != null)
|
||||
{ Session.MarkTutorialSeen(tutorial); _demonstration.Show(id == 10); _voice.Guide(id == 6 ? "demo_merge6" : "demo_split10"); return; }
|
||||
// Restoring never replays historical count/celebration clips.
|
||||
if (Session.Board.Mode == BoardMode.Counting && Session.Board.CollectedBallIds.Length == 0) _voice.Guide("guide_collect");
|
||||
else if (!restored) _voice.Guide(id == 10 ? "guide_split" : "guide_merge");
|
||||
}
|
||||
public void ContinueFreePlay()
|
||||
{ _input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); if (Session.ContinueFreePlay()) Refresh(true); }
|
||||
public void StartNextStage()
|
||||
{ if (Session.HasNextStage) StartStage(Session.Board.StageId + 1); }
|
||||
public void ShowHelp()
|
||||
{
|
||||
var hint = Session.Hint; if (hint == null) return;
|
||||
_voice.CancelGuide();
|
||||
_instruction.text = hint.Kind == HintKind.Collect ? "이 친구를 찾아 끌어와 볼까?"
|
||||
: hint.Kind == HintKind.Count ? "아직 세지 않은 이 구를 눌러 볼까?"
|
||||
: hint.Kind == HintKind.Merge ? "빛나는 두 친구를 가까이 끌어와 붙여 볼까?"
|
||||
: $"빛나는 {hint.GroupSize} 친구에서 나누기를 누르고 {hint.SelectionCount}개를 골라 끌어내 보자.";
|
||||
_presenter.ShowHint(hint.BallIds); _voice.Guide(hint.VoiceId); _sound?.Hint();
|
||||
}
|
||||
public void CommitMove(string groupId, Position3 delta, string mergeTarget = null)
|
||||
{
|
||||
bool wasPuzzle = Session.Screen == SessionScreen.Puzzle;
|
||||
var result = mergeTarget == null ? Session.MoveGroup(groupId, delta) : Session.MoveAndMerge(groupId, delta, mergeTarget);
|
||||
if (result.Accepted) _voice.CancelGuide();
|
||||
Refresh(false);
|
||||
if (result.Accepted && mergeTarget != null) _sound?.Merge();
|
||||
if (result.Accepted && wasPuzzle && Session.Screen == SessionScreen.FreePlay) _sound?.Success();
|
||||
if (result.Accepted && Session.Screen == SessionScreen.Puzzle && Session.Board.CanCount) _voice.Guide("guide_count");
|
||||
}
|
||||
public bool CommitSplit(string groupId, string[] selected, Position3 delta)
|
||||
{
|
||||
bool wasPuzzle = Session.Screen == SessionScreen.Puzzle;
|
||||
string newId = groupId + "-split";
|
||||
while (Session.Board.Groups.Any(group => group.id == newId)) newId += "-split";
|
||||
var result = Session.SplitAndMove(groupId, selected, newId, delta);
|
||||
if (result.Accepted) { _voice.CancelGuide(); Refresh(false); _sound?.Split(); }
|
||||
if (result.Accepted && wasPuzzle && Session.Screen == SessionScreen.FreePlay) _sound?.Success();
|
||||
return result.Accepted;
|
||||
}
|
||||
public void CountBall(string ballId)
|
||||
{
|
||||
if (Session.Board.Mode != BoardMode.Counting) return;
|
||||
bool wasPuzzle = Session.Screen == SessionScreen.Puzzle;
|
||||
var result = Session.Count(ballId);
|
||||
if (result.Accepted) _voice.EnqueueCount(result.Count);
|
||||
if (result.Accepted) { Refresh(false); _sound?.Count(); }
|
||||
if (result.Accepted && wasPuzzle && Session.Screen == SessionScreen.FreePlay) _sound?.Success();
|
||||
if (!result.Accepted && result.Reason == RejectionReason.NotCollected)
|
||||
{
|
||||
_instruction.text = "친구를 먼저 끌어와 볼까?";
|
||||
if (_presenter.Balls.TryGetValue(ballId, out var friend)) friend.Encourage();
|
||||
_sound?.Encourage();
|
||||
}
|
||||
}
|
||||
private void Refresh(bool applyCamera)
|
||||
{
|
||||
var screen = Session.Screen;
|
||||
_homePanel.SetActive(screen == SessionScreen.Home);
|
||||
_selectionPanel.SetActive(screen == SessionScreen.StageSelection);
|
||||
bool playing = screen == SessionScreen.Puzzle || screen == SessionScreen.FreePlay;
|
||||
_playPanel.SetActive(playing); _world.SetActive(playing);
|
||||
_continueButton.gameObject.SetActive(Session.HasFreePlay);
|
||||
if (_stageTwoButton != null) _stageTwoButton.interactable = Session.IsUnlocked(2);
|
||||
for (int index = 0; index < _stageButtons.Length; index++)
|
||||
{
|
||||
int id = _stages[index].StageId; bool unlocked = Session.IsUnlocked(id);
|
||||
_stageButtons[index].interactable = unlocked;
|
||||
string state = !unlocked ? "잠금" : Session.HasUnfinishedStage(id) ? "이어하기" : Session.CompletedStageIds.Contains(id) ? "완료" : "시작";
|
||||
_stageButtons[index].GetComponentInChildren<TMP_Text>().text = $"{id} · {state}";
|
||||
}
|
||||
for (int index = 0; _stageButtons.Length == 0 && index < _compositionButtons.Length; index++) _compositionButtons[index].interactable = Session.IsUnlocked(_stages.Where(stage => stage.ToDefinition().Kind == StageKind.Composition).ElementAt(index).StageId);
|
||||
if (_spawnButton != null) { _spawnButton.gameObject.SetActive(screen == SessionScreen.FreePlay); _spawnButton.interactable = Session.Board != null && Session.Board.BallCount < 10; }
|
||||
if (_returnButton != null) _returnButton.gameObject.SetActive(screen == SessionScreen.FreePlay);
|
||||
if (_helpButton != null) _helpButton.gameObject.SetActive(screen == SessionScreen.Puzzle);
|
||||
if (_nextButton != null) _nextButton.gameObject.SetActive(Session.HasNextStage);
|
||||
if (applyCamera) _orbit.Apply(Session.Camera);
|
||||
_presenter.Render(Session.Board);
|
||||
if (!playing) { _sound?.Clear(); _sound?.Theme("Menu"); return; }
|
||||
_environment?.Apply(_stages.First(stage => stage.StageId == Session.Board.StageId));
|
||||
_sound?.Theme(_stages.First(stage => stage.StageId == Session.Board.StageId).Theme);
|
||||
_stageTitle.text = screen == SessionScreen.FreePlay ? "자유 놀이터" : $"숫자 {Session.Board.BallCount} 친구 찾기";
|
||||
_counter.text = screen == SessionScreen.FreePlay ? $"친구 {Session.Board.BallCount}" : $"센 친구 {Session.Board.CountedCount} / {Session.Board.BallCount}";
|
||||
_instruction.text = screen == SessionScreen.FreePlay ? "해냈어! 친구와 자유롭게 놀아 보자."
|
||||
: Session.Board.CanCount ? "모두 모았네! 하나씩 눌러 세어 보자." : "친구를 찾아 끌어와 볼까?";
|
||||
if (Session.Board.Mode == BoardMode.Composition)
|
||||
{
|
||||
var definition = Session.Definitions.First(stage => stage.Id == Session.Board.StageId);
|
||||
_stageTitle.text = $"{Session.Board.StageId} 친구 만들기";
|
||||
_counter.text = "목표 " + string.Join(" · ", definition.TargetGroups);
|
||||
_instruction.text = "붙이거나 나누어 목표 친구를 만들어 보자.";
|
||||
}
|
||||
RefreshSelection();
|
||||
}
|
||||
public void RefreshSelection()
|
||||
{
|
||||
if (Session == null || _splitButton == null) return;
|
||||
var group = Session.Board?.Groups.FirstOrDefault(value => value.id == _input.SelectedGroup);
|
||||
if (_returnButton != null) _returnButton.interactable = Session.Screen == SessionScreen.FreePlay && group != null && !_input.IsSplitting;
|
||||
_splitButton.gameObject.SetActive(!_input.IsSplitting);
|
||||
_splitButton.interactable = group != null && group.ballIds.Length > 1;
|
||||
_cancelSplitButton.gameObject.SetActive(_input.IsSplitting);
|
||||
if (_input.IsSplitting) _instruction.text = $"선택 {_input.SelectedCount} · 구를 골라 끌어내 보자.";
|
||||
else if (Session.Board?.Mode == BoardMode.Composition) _instruction.text = "붙이거나 나누어 목표 친구를 만들어 보자.";
|
||||
else if (Session.Board?.Mode == BoardMode.FreePlay) _instruction.text = "해냈어! 친구와 자유롭게 놀아 보자.";
|
||||
_presenter.ShowSelection(_input.SelectedIds);
|
||||
}
|
||||
private void OnApplicationPause(bool paused)
|
||||
{ if (paused && Session != null) { _input.CancelSplit(); _voice.Clear(); _demonstration?.Hide(); Session.SaveCurrent(); } }
|
||||
private void OnApplicationQuit() { if (Session != null) { _input.Cancel(); Session.SaveCurrent(); } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d19ee043fa8f8da4e96eec5e9b810d96
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 01c5717ff10611640ad13de2c525b86a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,275 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MagnetismHeroes.Core;
|
||||
using MagnetismHeroes.UnityRuntime.Bootstrap;
|
||||
using MagnetismHeroes.UnityRuntime.Presentation;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.EnhancedTouch;
|
||||
|
||||
namespace MagnetismHeroes.UnityRuntime.Interaction
|
||||
{
|
||||
public sealed class BoardInput : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameApp _app;
|
||||
[SerializeField] private BoardPresenter _presenter;
|
||||
[SerializeField] private OrbitCamera _orbit;
|
||||
[SerializeField] private float _dragThreshold = 14;
|
||||
private enum Gesture { None, Blocked, Friend, Orbit, Pinch }
|
||||
private Gesture _gesture;
|
||||
private Vector2 _start, _last;
|
||||
private string _ballId, _groupId;
|
||||
private bool _dragged;
|
||||
private string _selectedGroup;
|
||||
private readonly HashSet<string> _selected = new HashSet<string>();
|
||||
private bool _splitting, _splitDrag;
|
||||
public bool IsSplitting => _splitting;
|
||||
public int SelectedCount => _selected.Count;
|
||||
public string SelectedGroup => _selectedGroup;
|
||||
public IReadOnlyCollection<string> SelectedIds => _selected;
|
||||
private Plane _dragPlane;
|
||||
private Vector3 _grabPoint, _previewDelta;
|
||||
private float _pinchDistance;
|
||||
private int _fingerId = -1;
|
||||
private bool _waitForAllTouchesReleased;
|
||||
private int _pinchFirstId = -1, _pinchSecondId = -1;
|
||||
private readonly struct TouchContact
|
||||
{
|
||||
public readonly int Id;
|
||||
public readonly UnityEngine.InputSystem.TouchPhase Phase;
|
||||
public readonly Vector2 Position;
|
||||
public TouchContact(int id, UnityEngine.InputSystem.TouchPhase phase, Vector2 position)
|
||||
{ Id = id; Phase = phase; Position = position; }
|
||||
public bool InProgress => Phase == UnityEngine.InputSystem.TouchPhase.Began || Phase == UnityEngine.InputSystem.TouchPhase.Moved || Phase == UnityEngine.InputSystem.TouchPhase.Stationary;
|
||||
}
|
||||
private readonly Queue<TouchContact> _touchEvents = new Queue<TouchContact>();
|
||||
private readonly Dictionary<int, TouchContact> _touches = new Dictionary<int, TouchContact>();
|
||||
public bool AcceptInput => _app.Session != null && _app.Session.Board != null;
|
||||
|
||||
public void BeginSplit()
|
||||
{
|
||||
Cancel();
|
||||
var group = _app.Session?.Board?.Groups.FirstOrDefault(value => value.id == _selectedGroup);
|
||||
if (group == null || group.ballIds.Length < 2) return;
|
||||
_splitting = true; _selected.Clear(); _app.RefreshSelection();
|
||||
}
|
||||
public void CancelSplit()
|
||||
{ Cancel(); if (_splitting) _app.Voice.CancelGuide(); _splitting = false; _selected.Clear(); _selectedGroup = null; _presenter.ShowSelection(_selected); _app.RefreshSelection(); }
|
||||
|
||||
public void Begin(Vector2 point, bool uiBlocked)
|
||||
{
|
||||
Cancel();
|
||||
if (!AcceptInput) return;
|
||||
_presenter.CancelPreview();
|
||||
_start = _last = point;
|
||||
if (uiBlocked) { _gesture = Gesture.Blocked; return; }
|
||||
Physics.SyncTransforms();
|
||||
var ray = _orbit.Camera.ScreenPointToRay(point);
|
||||
if (Physics.Raycast(ray, out var hit, 100) && hit.collider.TryGetComponent<BallView>(out var ball))
|
||||
{
|
||||
_gesture = Gesture.Friend; _ballId = ball.BallId; _groupId = ball.GroupId;
|
||||
_app.Sound?.Pickup();
|
||||
if (_splitting && _selectedGroup != _groupId) { _selected.Clear(); _selectedGroup = _groupId; _app.RefreshSelection(); }
|
||||
_splitDrag = _splitting && _selected.Contains(_ballId);
|
||||
_dragPlane = new Plane(_orbit.Camera.transform.forward, hit.point);
|
||||
_grabPoint = hit.point;
|
||||
}
|
||||
else _gesture = Gesture.Orbit;
|
||||
}
|
||||
|
||||
public void Move(Vector2 point)
|
||||
{
|
||||
if (_gesture == Gesture.None || _gesture == Gesture.Blocked || _gesture == Gesture.Pinch) return;
|
||||
_dragged |= (point - _start).sqrMagnitude >= _dragThreshold * _dragThreshold;
|
||||
if (_dragged && _gesture == Gesture.Friend)
|
||||
{
|
||||
var ray = _orbit.Camera.ScreenPointToRay(point);
|
||||
if (_dragPlane.Raycast(ray, out var distance))
|
||||
{
|
||||
_previewDelta = ray.GetPoint(distance) - _grabPoint;
|
||||
if (_splitting) { if (_splitDrag) _presenter.PreviewSplit(_groupId, _selected.ToArray(), _previewDelta); }
|
||||
else _presenter.PreviewGroup(_groupId, _previewDelta);
|
||||
}
|
||||
}
|
||||
else if (_dragged && _gesture == Gesture.Orbit) _orbit.Orbit(point - _last);
|
||||
_last = point;
|
||||
}
|
||||
|
||||
public void End(Vector2 point, bool canceled = false)
|
||||
{
|
||||
if (canceled || !IsInside(point)) { if (_splitting) CancelSplit(); else Cancel(); return; }
|
||||
if (_gesture == Gesture.Friend)
|
||||
{
|
||||
if (_dragged)
|
||||
{
|
||||
if (!_presenter.CanDropGroup(_groupId)) { if (_splitting) CancelSplit(); else Cancel(); return; }
|
||||
if (_splitting)
|
||||
{
|
||||
if (_splitDrag && _app.CommitSplit(_groupId, _selected.ToArray(), BoardPresenter.ToPosition(_previewDelta)))
|
||||
{ _splitting = false; _selected.Clear(); _selectedGroup = null; }
|
||||
else _presenter.CancelPreview();
|
||||
_app.RefreshSelection();
|
||||
}
|
||||
else _app.CommitMove(_groupId, BoardPresenter.ToPosition(_presenter.DropDelta), _presenter.MergeCandidate);
|
||||
}
|
||||
else if (_splitting)
|
||||
{
|
||||
if (!_selected.Add(_ballId)) _selected.Remove(_ballId);
|
||||
_app.Sound?.Selection();
|
||||
_app.RefreshSelection();
|
||||
}
|
||||
else { _selectedGroup = _groupId; _app.CountBall(_ballId); _app.RefreshSelection(); }
|
||||
}
|
||||
else if (_gesture == Gesture.Orbit && _dragged) _app.Session.CommitCamera(_orbit.Snapshot());
|
||||
else if (_gesture == Gesture.Pinch) _app.Session.CommitCamera(_orbit.Snapshot());
|
||||
_gesture = Gesture.None; _dragged = false; _previewDelta = Vector3.zero; _fingerId = -1;
|
||||
}
|
||||
|
||||
public void BeginPinch(float distance, bool uiBlocked)
|
||||
{
|
||||
if (_splitting) CancelSplit(); else Cancel();
|
||||
_gesture = uiBlocked || !AcceptInput ? Gesture.Blocked : Gesture.Pinch;
|
||||
_pinchDistance = distance;
|
||||
}
|
||||
public void Pinch(float distance)
|
||||
{
|
||||
if (_gesture != Gesture.Pinch || distance <= 0 || _pinchDistance <= 0) return;
|
||||
_orbit.Zoom(distance / _pinchDistance); _pinchDistance = distance;
|
||||
}
|
||||
public void Cancel()
|
||||
{
|
||||
if (_gesture == Gesture.Friend) _presenter.CancelPreview();
|
||||
if ((_gesture == Gesture.Orbit || _gesture == Gesture.Pinch) && _app.Session != null) _orbit.Apply(_app.Session.Camera);
|
||||
_gesture = Gesture.None; _dragged = false; _previewDelta = Vector3.zero; _fingerId = -1;
|
||||
_waitForAllTouchesReleased = false; _pinchFirstId = _pinchSecondId = -1;
|
||||
}
|
||||
|
||||
private bool IsUI(Vector2 point)
|
||||
{
|
||||
if (EventSystem.current == null) return false;
|
||||
var hits = new List<RaycastResult>(); EventSystem.current.RaycastAll(new PointerEventData(EventSystem.current) { position = point }, hits);
|
||||
return hits.Count > 0;
|
||||
}
|
||||
|
||||
private static bool IsInside(Vector2 point) => point.x >= 0 && point.x < Screen.width && point.y >= 0 && point.y < Screen.height;
|
||||
|
||||
private TouchContact? FindTouch(int id) => _touches.TryGetValue(id, out var touch) ? touch : (TouchContact?)null;
|
||||
|
||||
private void FinishSingleTouch()
|
||||
{
|
||||
var touch = FindTouch(_fingerId);
|
||||
if (!touch.HasValue || touch.Value.Phase != UnityEngine.InputSystem.TouchPhase.Ended) { if (_splitting) CancelSplit(); else Cancel(); return; }
|
||||
var point = touch.Value.Position; Move(point); End(point);
|
||||
}
|
||||
|
||||
private void RecordTouch(Finger finger)
|
||||
{
|
||||
if (!AcceptInput || finger.screen != Touchscreen.current) return;
|
||||
// Copy each official history callback before later events overwrite the native touch slot.
|
||||
var touch = finger.lastTouch;
|
||||
_touchEvents.Enqueue(new TouchContact(touch.touchId, touch.phase, touch.screenPosition));
|
||||
}
|
||||
|
||||
private void ProcessTouches()
|
||||
{
|
||||
var active = new List<TouchContact>();
|
||||
foreach (var touch in _touches.Values) if (touch.InProgress) active.Add(touch);
|
||||
if (_fingerId != -1 && !active.Exists(touch => touch.Id == _fingerId))
|
||||
{
|
||||
// Finish the original pointer before any new pointer can inherit its preview.
|
||||
FinishSingleTouch(); _waitForAllTouchesReleased = active.Count > 0; return;
|
||||
}
|
||||
if (_gesture == Gesture.Pinch)
|
||||
{
|
||||
Vector2 release = _last; bool ended = false, canceled = false;
|
||||
foreach (int id in new[] { _pinchFirstId, _pinchSecondId })
|
||||
{
|
||||
var touch = FindTouch(id);
|
||||
if (!touch.HasValue) { canceled = true; continue; }
|
||||
var phase = touch.Value.Phase;
|
||||
if (phase == UnityEngine.InputSystem.TouchPhase.Canceled) canceled = true;
|
||||
if (phase == UnityEngine.InputSystem.TouchPhase.Ended)
|
||||
{ release = touch.Value.Position; ended = true; canceled |= !IsInside(release); }
|
||||
}
|
||||
if (canceled || ended)
|
||||
{ End(release, canceled); _waitForAllTouchesReleased = active.Count > 0; return; }
|
||||
}
|
||||
// A canceled/finished pinch cannot restart when a third finger joins a remaining finger.
|
||||
if (_waitForAllTouchesReleased && _gesture == Gesture.None && active.Count > 0) return;
|
||||
if (active.Count >= 2)
|
||||
{
|
||||
var a = active[0].Position; var b = active[1].Position;
|
||||
if (_gesture != Gesture.Pinch && _gesture != Gesture.Blocked)
|
||||
{
|
||||
BeginPinch(Vector2.Distance(a, b), IsUI(a) || IsUI(b));
|
||||
_pinchFirstId = active[0].Id; _pinchSecondId = active[1].Id;
|
||||
}
|
||||
else Pinch(Vector2.Distance(a, b));
|
||||
_waitForAllTouchesReleased = true;
|
||||
return;
|
||||
}
|
||||
if (_waitForAllTouchesReleased)
|
||||
{
|
||||
if (active.Count == 0) { _waitForAllTouchesReleased = false; _gesture = Gesture.None; _fingerId = -1; }
|
||||
return;
|
||||
}
|
||||
if (_gesture == Gesture.Pinch || (active.Count == 0 && _gesture == Gesture.Blocked))
|
||||
{ End(_last); _fingerId = -1; return; }
|
||||
if (active.Count == 1)
|
||||
{
|
||||
var touch = active[0]; var point = touch.Position;
|
||||
if (_fingerId == -1)
|
||||
{
|
||||
if (touch.Phase != UnityEngine.InputSystem.TouchPhase.Began) { _waitForAllTouchesReleased = true; return; }
|
||||
Begin(point, IsUI(point)); _fingerId = touch.Id;
|
||||
}
|
||||
else Move(point);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!AcceptInput) { _touchEvents.Clear(); _touches.Clear(); return; }
|
||||
bool hadTouches = _touchEvents.Count > 0, suppressRestOfFrame = false;
|
||||
while (_touchEvents.Count > 0)
|
||||
{
|
||||
var touch = _touchEvents.Dequeue(); _touches[touch.Id] = touch;
|
||||
if (!suppressRestOfFrame)
|
||||
{
|
||||
bool ownsGesture = touch.Id == _fingerId || (_gesture == Gesture.Pinch && (touch.Id == _pinchFirstId || touch.Id == _pinchSecondId));
|
||||
bool canceled = touch.Phase == UnityEngine.InputSystem.TouchPhase.Canceled ||
|
||||
(touch.Phase == UnityEngine.InputSystem.TouchPhase.Ended && !IsInside(touch.Position));
|
||||
ProcessTouches(); suppressRestOfFrame = ownsGesture && canceled;
|
||||
}
|
||||
if (!touch.InProgress) _touches.Remove(touch.Id);
|
||||
}
|
||||
if (suppressRestOfFrame) _waitForAllTouchesReleased = _touches.Count > 0;
|
||||
if (hadTouches || _touches.Count > 0) return;
|
||||
var mouse = Mouse.current;
|
||||
if (mouse == null) return;
|
||||
var position = mouse.position.ReadValue();
|
||||
if (mouse.leftButton.wasPressedThisFrame) Begin(position, IsUI(position));
|
||||
if (mouse.leftButton.isPressed) Move(position);
|
||||
if (mouse.leftButton.wasReleasedThisFrame) End(position);
|
||||
if (mouse.scroll.ReadValue().y != 0 && !IsUI(position))
|
||||
{ _orbit.Zoom(Mathf.Exp(mouse.scroll.ReadValue().y * 0.001f)); _app.Session.CommitCamera(_orbit.Snapshot()); }
|
||||
}
|
||||
private void OnEnable()
|
||||
{
|
||||
EnhancedTouchSupport.Enable();
|
||||
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown += RecordTouch;
|
||||
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove += RecordTouch;
|
||||
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp += RecordTouch;
|
||||
}
|
||||
private void OnApplicationPause(bool paused) { if (paused) { Cancel(); _touchEvents.Clear(); _touches.Clear(); } }
|
||||
private void OnDisable()
|
||||
{
|
||||
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown -= RecordTouch;
|
||||
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove -= RecordTouch;
|
||||
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp -= RecordTouch;
|
||||
EnhancedTouchSupport.Disable(); Cancel(); _touchEvents.Clear(); _touches.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38cf3d2ad1224c244a0e1ad7b52602c1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user