Compare commits

...

173 Commits

Author SHA1 Message Date
NINI 23ddce925d Merge playground collisions, scenery themes, drag camera and resume recovery 2026-10-12 22:07:33 +09:00
NINI 2ea65df66f docs: clarify external QA restoration and final review limits 2026-10-11 23:25:21 +09:00
NINI 13822905d3 docs: record playground behavior and Editor validation scope 2026-10-11 23:16:27 +09:00
NINI b438ee266b Reduce repeated small scenery while preserving playground layout 2026-10-11 22:58:43 +09:00
NINI 70b7a5f553 Reduce repeated small scenery while retaining theme and boundary placements 2026-10-11 02:44:01 +09:00
NINI d5874240a5 Skip collection zoom when manual camera is already at maximum distance 2026-10-11 01:22:33 +09:00
NINI 71bf8fab57 Keep native merge depth windows by reframing unsafe endpoints 2026-10-11 01:11:17 +09:00
NINI bb32fdae9d Match native rounded-pixel grab rays and retry missing startup PID 2026-10-10 23:55:54 +09:00
NINI d4128d713d Search counting targets at safe alternate camera elevations 2026-10-10 15:30:07 +09:00
NINI 3bfcac8a58 Retry a real upward route after an unchanged native merge drop 2026-10-10 14:16:35 +09:00
NINI 8bd6c426a8 Distinguish obstructed native picks from grabbed detours 2026-10-10 12:05:59 +09:00
NINI 2a59c4e8eb Fix native progression routes around scenery and narrow depth views 2026-10-10 11:21:44 +09:00
NINI 8728660288 fix: drive native progression from each current camera view 2026-10-10 10:08:47 +09:00
NINI e7ae849f3d Keep initial scenery trees behind and beside the playable board 2026-10-10 09:29:41 +09:00
NINI 24f0347404 Use double scalars for bounded native depth steering 2026-10-10 08:44:52 +09:00
NINI db340b41ed Exclude crop-border scenery from native friend steering 2026-10-10 08:35:08 +09:00
NINI fa3da70923 Keep depth fixtures in save order and bind positions by ID 2026-10-10 08:25:34 +09:00
NINI 2494459945 Order depth fixture camera like the serialized Unity DTO 2026-10-10 08:11:20 +09:00
NINI e1a901ac15 Remove unused native boundary fixture local 2026-10-10 07:51:18 +09:00
NINI ff5d7e1b96 Derive native drag fixtures from selectable integer pointer input 2026-10-10 07:49:48 +09:00
NINI 72638ab091 Preserve the first touch when the active input device changes 2026-10-10 07:30:25 +09:00
NINI 4184023769 Fix native reentry proof for stage-keyed progress slots 2026-10-10 06:56:53 +09:00
NINI 4941dcab52 test: verify every original magnet can be dragged and cancelled safely 2026-10-10 06:39:12 +09:00
NINI 971a792bfd Prove actual cancellable drags for every original stage sphere 2026-10-10 06:34:33 +09:00
NINI f9ec2a8a51 perf: preserve resume positions while reducing closed-mesh winding work 2026-10-10 06:31:42 +09:00
NINI c9d81100fa Skip exterior closed-component winding during resume 2026-10-10 06:29:52 +09:00
NINI 53d5e8a2d5 Wait for actual native board UI before fixture assertions 2026-10-10 06:10:49 +09:00
NINI 891c7d0072 test: compare preserved playground DTO floats by exact IEEE bits 2026-10-10 05:55:00 +09:00
NINI 9850d2c539 Preserve restore geometry results while reusing vertex magnitudes 2026-10-10 05:33:53 +09:00
NINI bc1ce03beb Reuse vertex magnitudes during resume geometry certification 2026-10-10 05:32:59 +09:00
NINI 401fa6a1a6 test: verify playground native boundaries and preserve final evidence gates 2026-10-10 05:23:52 +09:00
NINI 14f89d1248 Certify split-board restoration and preserve corrected camera poses 2026-10-10 04:46:09 +09:00
NINI 2382b4612e Connect scenery safety, drag camera and resume geometry in the game prefab 2026-10-10 04:27:59 +09:00
NINI 47dfb9ebed Integrate preserved resume repair with drag camera improvements 2026-10-10 04:23:44 +09:00
NINI 8601234e3c Preserve resume state through validated scenery placement repair 2026-10-10 04:23:05 +09:00
NINI 9ba70e2cd4 Keep live safety probes active when isolating scenery fixtures 2026-10-10 04:05:20 +09:00
NINI ef547b050a Align fixed-stage visual expectations with six approved landscapes 2026-10-10 04:01:22 +09:00
NINI 4f39d621cd Frame actual friend drags with safe reversible camera assistance 2026-10-10 03:54:08 +09:00
NINI 6e353a1281 Advance blocked split previews to the last safe Core delta 2026-10-10 03:21:54 +09:00
NINI f31ebc6fd9 Remove trailing blank line from playground safety tests 2026-10-10 03:15:01 +09:00
NINI 477bbcdda6 Keep playground drags and split previews on safe sphere paths 2026-10-10 03:12:44 +09:00
NINI a7439bd49b 놀이터 밖 배경 충돌을 끄고 지면·접근 소품 역할 유지 2026-10-10 02:35:20 +09:00
NINI 9a316cfd89 경사 밖으로 소품을 옮기고 실제 나무 뿌리 접촉면 접지 2026-10-10 02:24:42 +09:00
NINI 969bc92d46 놀이터 지면·큰 소품 충돌과 여섯 자연 풍경 연결 2026-10-10 02:04:34 +09:00
NINI 32690a510e Keep local improvement plans and execution history outside product source 2026-10-10 01:45:07 +09:00
NINI 9544f4bce5 add documents 2026-10-10 01:27:54 +09:00
NINI a54ae6fb23 Merge approved hero and meadow visual upgrade into dev
Apply the user-approved magnetic knight and meadow, flower hill, and rock hill assets to all 30 stages and free play.
Preserve gameplay and progress behavior, with final terrain correction and verified asset provenance.
2026-10-09 23:27:19 +09:00
NINI 0d992d9dcc Record verified terrain correction and final approved asset provenance 2026-10-09 02:34:57 +09:00
NINI 620c73cbd2 원경 보정 후 최종 검증과 모델 채택 기록 갱신 2026-10-09 02:33:02 +09:00
NINI 3346eb559b Integrate terrain continuity correction with approved models preserved 2026-10-09 01:56:29 +09:00
NINI ef03611628 Overlap distant horizon terrain with meadow apron to close floating foot gaps 2026-10-09 01:48:02 +09:00
NINI da169df7c7 Record approved model adoption and final Android verification 2026-10-09 01:26:44 +09:00
NINI a906be69a0 docs: 승인 모델 채택과 최종 검증 기록 2026-10-09 01:25:00 +09:00
NINI 83182e7f3e Clarify the scope of captured mesh resource measurements 2026-10-09 00:23:55 +09:00
NINI b880cc196c Validate natural scenery and add soft daylight shadows 2026-10-09 00:23:11 +09:00
NINI b4d88546a2 Capture home readiness without updating the inactive board presenter 2026-10-09 00:08:34 +09:00
NINI 7e4bf1f5ea Capture ten individual and grouped friends in every natural world 2026-10-09 00:03:05 +09:00
NINI 471a2b20c8 Verify natural worlds and group presentation with mobile soft shadows 2026-10-09 00:01:36 +09:00
NINI 2d4b4a88e3 Apply approved knight and natural landscape models in Unity 2026-10-08 23:49:59 +09:00
NINI 43b22d6788 Apply approved knight models and natural scenery to all thirty stages 2026-10-08 23:36:55 +09:00
NINI 7c786c5435 Record user approval of hero and three landscape model candidates 2026-10-08 23:05:56 +09:00
NINI 5185d94631 Add Blender meadow model candidates for review 2026-10-08 02:07:12 +09:00
NINI 37010382fd Add Blender knight model candidates for review 2026-10-08 02:01:09 +09:00
NINI 80be80566a Add native meadow scenery candidates and reproducible Blender review exports 2026-10-08 01:58:53 +09:00
NINI 7d7efbe6b6 Document knight model review and regeneration in Korean 2026-10-08 01:45:39 +09:00
NINI 5ad1a237c8 Create review-only magnetic knight body and equipment models 2026-10-08 01:44:06 +09:00
NINI 58410281e0 add ref image 2026-10-08 00:19:43 +09:00
NINI 2d98e1a6d5 Record user approval of the verified Android first version 2026-10-07 23:56:28 +09:00
NINI 73ffcbef09 Record independent final acceptance review and remaining user approval 2026-10-07 03:22:48 +09:00
NINI 6a4701f052 Clarify voice candidate output and intended runtime path 2026-10-07 03:20:33 +09:00
NINI 075f2a0cab Point historical verification links to preserved task evidence 2026-10-07 03:17:36 +09:00
NINI 2d45e11120 Document verified Android game architecture, rules and development procedures 2026-10-07 03:15:08 +09:00
NINI ea2ad4506c Vary native counting view elevation when scenery occludes balls 2026-10-07 02:43:24 +09:00
NINI d9f63acbe2 Record final 30-stage Android verification and current handoff 2026-10-07 02:23:58 +09:00
NINI 5ebb86e2a8 Observe native save completion with a bounded rapid-count poll 2026-10-07 01:55:54 +09:00
NINI 4d8f63e9b2 Observe actual same-board Free before the last numeric word completes 2026-10-07 01:39:17 +09:00
NINI dbdce3b661 Stop native split retries after a legitimate completed partition 2026-10-07 01:35:48 +09:00
NINI 160b595e2f Verify recorder identity once and retain original native process handles 2026-10-07 01:22:46 +09:00
NINI 1bb7ca68da Solve final paired stages by splitting one and merging only the intended group 2026-10-07 01:08:48 +09:00
NINI 7c2f11ef06 Confirm native split UI before selecting and drag a visible selected ball 2026-10-07 00:56:46 +09:00
NINI cd8d574ff8 Use ball first-hit rays and retry native prop-occluded split selections through normal merge 2026-10-07 00:35:13 +09:00
NINI 196f0ebb07 Reveal selected ball centres before native split and verify exact selected IDs 2026-10-07 00:31:12 +09:00
NINI cc88785e7c Separate Where-Object comparison parameters and reject malformed native selectors 2026-10-07 00:22:54 +09:00
NINI 913f19c360 Reject native verification parse errors before launch and fix split dispatch branch 2026-10-07 00:20:48 +09:00
NINI d3b2ede09d Construct rapid QA board from actual collected puzzle DTO and preserve source hashes 2026-10-07 00:16:18 +09:00
NINI 8167b3e70b Verify native rapid replay transitions to Free20 without altering actual thirty records 2026-10-07 00:14:05 +09:00
NINI f6bd33bbcf Separate fresh thirty-stage progression from labelled native rapid-count boundary 2026-10-07 00:11:40 +09:00
NINI 4375ac04ed Inspect rapid-count board through native yaw and pitch views 2026-10-07 00:08:38 +09:00
NINI d14a49d629 Use depth-scaled sphere silhouettes for native rapid-count view checks 2026-10-06 23:56:16 +09:00
NINI 35e063b17b Guard native world taps and zoom larger counting boards through real touch 2026-10-06 23:54:11 +09:00
NINI 931b5f42e7 Verify all approved voice PCM directly in actual APK with negative controls 2026-10-06 23:33:24 +09:00
NINI 8b2f2d6489 Reject nonexistent native projection IDs and verify real split selection assignment 2026-10-06 23:22:25 +09:00
NINI 740399ee6f Retain and restore original native boundary recording volume routes 2026-10-06 23:21:18 +09:00
NINI 28433dc544 Preserve one selected native split ball as an array 2026-10-06 23:20:17 +09:00
NINI 545fdce70d Use fresh replay three to capture actual cancelable collection guide 2026-10-06 23:09:38 +09:00
NINI 20184ad35a Verify native navigation and pause cancel stale Korean speech 2026-10-06 23:08:55 +09:00
NINI b11a0de52e Complete Final native boundaries and full-speech evidence gates 2026-10-06 23:07:16 +09:00
NINI 869b7183ab Normalize only Git-proven unchanged test-generated player settings 2026-10-06 23:01:47 +09:00
NINI f4ee23c10d Add Final Android gate with fresh native thirty-stage progression 2026-10-06 22:59:57 +09:00
NINI 7db006544c docs: close T7 scope without sound effects or music 2026-10-06 22:47:52 +09:00
NINI c040cafc93 docs: record local presentation integration and next asset step 2026-10-05 23:45:16 +09:00
NINI ed73a845e8 docs(handoff): sync dev handoff into verified T7 2026-10-05 23:42:06 +09:00
NINI be8f3d9300 docs: record verified T7 presentation and remaining audio acceptance 2026-10-05 23:40:46 +09:00
NINI dffd90b1b8 fix(android-qa): wait for actual recording volume route 2026-10-05 23:19:59 +09:00
NINI 879cea84c4 fix(presentation): keep outlined group numbers readable through scenery 2026-10-05 23:10:01 +09:00
NINI 2970c2dcfe fix(android-qa): observe first home and production pause save 2026-10-05 22:37:17 +09:00
NINI 9f571a5c02 fix(android-qa): await network transitions and recorder termination 2026-10-05 22:30:19 +09:00
NINI 4081a1c1ed fix(presentation): reuse counting views and gate native QA readiness 2026-10-05 22:24:13 +09:00
NINI 573c9a356f Record resumed T7 verification work 2026-10-05 22:18:55 +09:00
NINI 818e0493b2 Document paused T7 development and verification handoff 2026-10-05 16:59:48 +09:00
NINI 9aa9e3c709 Name bundled TTS notices independently of excluded model directories 2026-10-05 15:42:45 +09:00
NINI 977c68aee8 Distinguish synthetic visual fixtures from actual session captures 2026-10-05 15:32:50 +09:00
NINI 44cfaca931 Record Editor measurement scope without claiming device render metrics 2026-10-05 15:32:13 +09:00
NINI 64689afdc9 Keep nearby group numbers associated and strengthen light ribbon visibility 2026-10-05 15:30:01 +09:00
NINI bcc5c9a7b9 Complete cartoon presentation, Korean voice adoption and responsive art framing 2026-10-05 15:24:23 +09:00
NINI dd471eb5b5 Record approval of remaining forty voice clips 2026-10-05 15:03:10 +09:00
NINI a383bc9d13 Record resumed development evidence and approved voice correction 2026-10-05 14:25:51 +09:00
NINI 9d8e939704 Complete thirty-stage content with verified Android audio and independent review 2026-10-05 14:25:01 +09:00
NINI ccc8b77cf5 test(content): verify native Android PCM and complete speech coverage 2026-10-05 14:00:08 +09:00
NINI 1f26fbc421 fix(content): cancel obsolete guides after committed movement 2026-10-05 13:10:31 +09:00
NINI 64f4f19ae9 feat(content): author thirty stages, live hints, progression and Blender themes 2026-10-05 13:05:27 +09:00
NINI 60f8a55bb9 Complete independent puzzle persistence and verified backup recovery 2026-10-05 12:32:38 +09:00
NINI babaee3608 Fix backup recovery when primary progress save is missing 2026-10-05 12:17:34 +09:00
NINI 503c56a19e Derive Android collection touch from the committed stage layout 2026-10-05 11:48:42 +09:00
NINI 7641365e37 Preserve damaged backup evidence across later saves 2026-10-05 11:44:28 +09:00
NINI 7c2f6d9fd1 Normalize unchanged ShaderGraph setting after Android builds 2026-10-05 11:37:35 +09:00
NINI e84eb1a35e Complete persistence boundaries and free-play supply with device verification 2026-10-05 11:30:12 +09:00
NINI a651e5ca7f feat: add magnetic friend merge and split puzzles 2026-10-05 11:08:09 +09:00
NINI 08ce71c0a2 Stabilize composition Android input verification and unchanged asset normalization 2026-10-05 05:51:12 +09:00
NINI e924e697a2 Restore free-play cancellation prompt and map emulator touch coordinates 2026-10-05 05:28:49 +09:00
NINI e5f19d6fef Instrument Android composition input for native diagnosis 2026-10-05 05:21:19 +09:00
NINI 4c411e446d Add atomic composition controls and representative stages 2026-10-05 05:03:40 +09:00
NINI 1e7c258631 docs: record verified first Android slice and measurement limits 2026-10-05 04:28:19 +09:00
NINI 277f1a6c80 feat: verify first Android puzzle and preserve short touches 2026-10-05 04:21:45 +09:00
NINI e20f64ff1f Record exact Git blob equality before restoring generated line endings 2026-10-05 04:02:03 +09:00
NINI b172c776a8 Keep Android build provenance clean after unchanged Unity settings rewrite 2026-10-05 04:01:11 +09:00
NINI f5e8f152ad Merge commit 'e37b6af9844f74e2eeb46350b48e31177aae48bc' into dryforge/T3 2026-10-05 03:56:18 +09:00
NINI e37b6af984 Preserve short touch events through official EnhancedTouch history 2026-10-05 03:54:53 +09:00
NINI f3f1f14fb1 Add negative Android provenance checks and short tap exercise 2026-10-05 03:51:27 +09:00
NINI 92cbb52b73 Require final source provenance for captured Android PCM 2026-10-05 03:49:06 +09:00
NINI 4936180e45 Bind Android smoke to build provenance and actual PCM assertions 2026-10-05 03:46:17 +09:00
NINI d656efe0dc Exclude Android release metadata and record emulator audio 2026-10-05 03:22:04 +09:00
NINI 0b43d45865 Add Android APK inspection and device verification gates 2026-10-05 03:12:45 +09:00
NINI 304a77619e docs: record verified first puzzle Editor behavior 2026-10-05 03:06:46 +09:00
NINI 50ae5b5c6f fix: keep friend numerals readable through camera rotation 2026-10-05 03:00:14 +09:00
NINI b8decaadc5 Keep friend numbers above spheres in the current camera view 2026-10-05 02:58:02 +09:00
NINI 9affdd86a4 feat: connect Main scene to first puzzle app prefab 2026-10-05 02:43:48 +09:00
NINI 75ed8a7f13 feat: integrate first counting puzzle with Korean voice and local progress 2026-10-05 02:43:06 +09:00
NINI b8f5cee0b8 Verify immutable source manifest hashes for reused voices 2026-10-05 02:41:09 +09:00
NINI 72232731d1 Reject duplicate source generation references 2026-10-05 02:40:09 +09:00
NINI ceb063dfdc Verify recorded source manifest SHA and immutable legacy history 2026-10-05 02:37:55 +09:00
NINI 4f10fcc91a chore: add idempotent Main prefab connection entry 2026-10-05 02:36:59 +09:00
NINI 7b3eb62925 Integrate immutable voice input snapshots and history validation 2026-10-05 02:24:47 +09:00
NINI 8afff106da Preserve immutable per-run voice inputs and validate source history 2026-10-05 02:24:06 +09:00
NINI b2eded808e Reject background penetration and preserve canceled touch ownership 2026-10-05 02:19:34 +09:00
NINI e65f970e9a Integrate approved voice records and complete reuse environment history 2026-10-05 02:01:30 +09:00
NINI ab63ef532d Implement the playable first counting puzzle with committed input, persistence and Korean voice 2026-10-05 01:57:21 +09:00
NINI fb71c444f6 Record three user-approved voices and trace reused generation environments 2026-10-05 01:56:50 +09:00
NINI 8d4ffb6550 Take root verification evidence improvement 2026-10-05 01:38:47 +09:00
NINI 263fecc19a chore: retain case counts for failed Unity verification 2026-10-05 01:37:28 +09:00
NINI 5898162b7d Integrate Qwen validation evidence and retained anomalous take 2026-10-05 01:25:30 +09:00
NINI 906b467510 Retain isolated number retry evidence and Korean voice register 2026-10-05 01:24:19 +09:00
NINI 1572ddda19 Verify voice export quality guard and record final file checks 2026-10-05 01:20:29 +09:00
NINI 74803a3355 Integrate isolated Qwen voice drafts into the first puzzle 2026-10-05 01:19:01 +09:00
NINI cd32491bbb Add pinned local Qwen GPU voice generation and first puzzle drafts 2026-10-05 01:17:47 +09:00
NINI 729afb3da7 chore: pin JSON serializer dependency for local saves 2026-10-05 01:01:46 +09:00
NINI dcd1ec9ed5 feat: add validated magnet number rules 2026-10-05 00:49:46 +09:00
NINI 8593a35a59 Preserve immutable decoration layouts in stage definitions 2026-10-05 00:45:54 +09:00
NINI 92243fccb6 Implement pure C# magnet puzzle rules and save validation 2026-10-05 00:37:43 +09:00
NINI b0a2a7014b chore: keep editor runtime tools out of Android builds 2026-10-05 00:31:47 +09:00
NINI 6a0a3e159e chore: enable local Unity editor controls 2026-10-05 00:29:44 +09:00
NINI fdd219660b chore: scaffold Unity Android project and verification commands 2026-10-05 00:22:20 +09:00
NINI e2dc8f90fb chore: ignore Unity caches and development plans 2026-10-05 00:12:32 +09:00
961 changed files with 196250 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
Library
Temp
Obj
obj
Logs
UserSettings
Build
Builds
artifacts/
.utmp
.vs/
*.csproj
*.sln
*.slnx
*.user
__pycache__
.dryforge/
+6
View File
@@ -0,0 +1,6 @@
{
"version": "1.0",
"components": [
"Microsoft.VisualStudio.Workload.ManagedGame"
]
}
+59
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 944782f304ac00540aa14b9451c7458b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 431363d771451094da26ab23050c6b00
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+11
View File
@@ -0,0 +1,11 @@
# 순수 규칙과 진행 제어
이 어셈블리는 구·묶음·목표·계수, 진행 화면, 저장 DTO 검증과 `IProgressStore`를 소유한다. Unity 입력·Transform·물리·UI·파일 읽기와 제작 도구는 소유하지 않는다. `noEngineReferences: true`와 빈 참조를 유지한다.
변경은 BoardRules의 확정 조작과 GameSession의 진행 전이를 통해 전달한다. 거부된 조작은 상태를 바꾸지 않으며 호출자가 명시적 거부 사유를 받는다. Presenter에 상태를 복제하거나 애니메이션 프레임을 정답 근거로 전달하지 않는다.
합체·분리의 구 ID 집합 보존, 정확히 한 묶음 소속, 모든 비어 있지 않은 진부분집합 분리, 순서 없는 목표 다중집합을 검사한다. 계수는 수집·단일 묶음 조건과 중복 ID를 검사한다. 진행 수정에는 재플레이 완료 기록 유지, 두 미완료 슬롯과 한 자유 놀이, 성공의 단일 저장 갱신을 포함한다.
DTO lowerCamelCase는 파일 계약이다. 자유 놀이에 계수 필드를 추가하지 않는다. 저장 검증 수정은 누락·중복·비유한 위치·무효 quaternion·구 소속·잘못된 source와 unsupported 버전을 실제 사례로 확인한다. Tests/EditMode의 규칙·세션·저장 검사를 실행하며 결과를 화면 연결 검증으로 확대하지 않는다.
이어하기 준비는 Unity 판정 결과를 엔진 참조 없는 BoardRestore로 받는다. 기존 진행의 분리된 사본을 준비·검증하고 한 번 저장한 뒤에만 Board·Camera·Screen·진행을 교체한다. 위치 보정은 일반 이동의 수집 부작용을 호출하지 않으며 ID·묶음 순서·소속·수집·계수와 묶음 내 동일 이동을 검증한다. 유효 배치가 없는 경우의 초기화는 문제된 슬롯의 초기 정의만 허용한다. 자유 놀이 모드·source, 완료·해금·시범·다른 슬롯과 쓰기 실패의 기존 상태를 보존하는 정상·부정 대조를 포함한다.
@@ -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
+68
View File
@@ -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
+262
View File
@@ -0,0 +1,262 @@
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);
// Restore preparation changes positions without treating the correction as collection or play.
public bool TryTranslateForRestore(IReadOnlyDictionary<string, Position3> translations, out BoardRules prepared)
{
prepared = null;
if (translations == null || translations.Any(pair => !_groups.ContainsKey(pair.Key) || !pair.Value.IsValid)) return false;
var balls = Balls;
foreach (var group in _groups)
{
if (!translations.TryGetValue(group.Key, out var delta)) continue;
foreach (var ball in balls.Where(ball => group.Value.Contains(ball.id)))
{
var point = ball.position;
ball.position = new Position3(point.x + delta.x, point.y + delta.y, point.z + delta.z);
if (!ball.position.IsValid) return false;
}
}
if (Mode == BoardMode.FreePlay)
{
var state = ToFreePlayState(GameSession.DefaultCamera()); state.balls = balls;
prepared = CreateFreePlay(state);
}
else
{
var state = ToPuzzleState(GameSession.DefaultCamera()); state.balls = balls;
prepared = new BoardRules(_definition, state);
}
return true;
}
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
+163
View File
@@ -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
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace MagnetismHeroes.Core
{
public enum SessionScreen { Home, StageSelection, Puzzle, FreePlay }
public sealed class BoardRestore
{
public BoardRules Board { get; }
public CameraState Camera { get; }
public bool ResetToInitial { get; }
public BoardRestore(BoardRules board, CameraState camera, bool resetToInitial = false)
{ Board = board; Camera = camera; ResetToInitial = resetToInitial; }
}
// 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, Func<BoardRules, CameraState, BoardRestore> prepare = null)
{
var definition = _definitions.FirstOrDefault(value => value.Id == stageId);
if (definition == null || !IsUnlocked(stageId)) return false;
var next = CopyProgress(); SnapshotCurrent(next);
var restored = next.puzzleStates.FirstOrDefault(state => state.stageId == stageId);
return Enter(next, new BoardRules(definition, restored), restored == null ? DefaultCamera() : CopyCamera(restored.camera),
SessionScreen.Puzzle, prepare);
}
public bool ContinueFreePlay(Func<BoardRules, CameraState, BoardRestore> prepare = null)
{
if (_progress.latestFreePlay == null) return false;
var next = CopyProgress(); SnapshotCurrent(next);
return Enter(next, BoardRules.CreateFreePlay(next.latestFreePlay), CopyCamera(next.latestFreePlay.camera), SessionScreen.FreePlay, prepare);
}
private bool Enter(ProgressSave next, BoardRules restored, CameraState camera, SessionScreen screen,
Func<BoardRules, CameraState, BoardRestore> prepare)
{
var prepared = prepare == null ? new BoardRestore(restored, camera) : prepare(restored, camera);
if (!ValidPreparation(restored, prepared)) return false;
if (screen == SessionScreen.Puzzle)
{
var existing = next.puzzleStates.FirstOrDefault(state => state.stageId == restored.StageId);
if (existing != null && !prepared.ResetToInitial)
{ ApplyPreparedPositions(existing.balls, prepared.Board); existing.camera = CopyCamera(prepared.Camera); }
else if (existing != null)
next.puzzleStates = next.puzzleStates.Select(state => state.stageId == restored.StageId ? prepared.Board.ToPuzzleState(prepared.Camera) : state).ToArray();
else next.puzzleStates = next.puzzleStates.Concat(new[] { prepared.Board.ToPuzzleState(prepared.Camera) }).OrderBy(state => state.stageId).ToArray();
}
else if (!prepared.ResetToInitial)
{ ApplyPreparedPositions(next.latestFreePlay.balls, prepared.Board); next.latestFreePlay.camera = CopyCamera(prepared.Camera); }
else next.latestFreePlay = prepared.Board.ToFreePlayState(prepared.Camera);
if (!SaveValidator.TryValidateUpdate(_progress, next, _definitions, out _)) return false;
var saved = _store.TrySave(next, out var error);
LastSaveError = error; SaveFinished?.Invoke(error);
if (!saved) return false;
_progress = next; Board = prepared.Board; Camera = CopyCamera(prepared.Camera); Screen = screen;
return true;
}
private static void ApplyPreparedPositions(BallState[] balls, BoardRules prepared)
{
var positions = prepared.Balls.ToDictionary(ball => ball.id, ball => ball.position);
foreach (var ball in balls) ball.position = positions[ball.id];
}
private bool ValidPreparation(BoardRules restored, BoardRestore prepared)
{
if (prepared?.Board == null || !SaveValidator.ValidCamera(prepared.Camera)
|| prepared.Board.StageId != restored.StageId || prepared.Board.Mode != restored.Mode) return false;
var expected = restored;
if (prepared.ResetToInitial)
{
expected = new BoardRules(_definitions.First(stage => stage.Id == restored.StageId));
if (restored.Mode == BoardMode.FreePlay) expected = BoardRules.CreateFreePlay(expected.ToFreePlayState(DefaultCamera()));
}
if (!expected.CollectedBallIds.SequenceEqual(prepared.Board.CollectedBallIds)
|| !expected.CountedBallIds.SequenceEqual(prepared.Board.CountedBallIds)) return false;
var sourceGroups = expected.Groups; var groups = prepared.Board.Groups;
if (sourceGroups.Length != groups.Length) return false;
for (int index = 0; index < groups.Length; index++)
if (sourceGroups[index].id != groups[index].id || !sourceGroups[index].ballIds.SequenceEqual(groups[index].ballIds)) return false;
var source = expected.Balls.ToDictionary(ball => ball.id, ball => ball.position);
var target = prepared.Board.Balls.ToDictionary(ball => ball.id, ball => ball.position);
if (source.Count != target.Count || source.Keys.Except(target.Keys).Any()) return false;
foreach (var group in groups)
{
var first = group.ballIds[0]; var before = source[first]; var after = target[first];
var delta = new Position3(after.x - before.x, after.y - before.y, after.z - before.z);
if (prepared.ResetToInitial && (delta.x != 0 || delta.y != 0 || delta.z != 0)) return false;
foreach (var id in group.ballIds)
if (Math.Abs(target[id].x - source[id].x - delta.x) > .0001f
|| Math.Abs(target[id].y - source[id].y - delta.y) > .0001f
|| Math.Abs(target[id].z - source[id].z - delta.z) > .0001f) return false;
}
return true;
}
private ProgressSave CopyProgress() => new ProgressSave {
schemaVersion = _progress.schemaVersion, completedStageIds = (int[])_progress.completedStageIds.Clone(),
seenTutorialIds = (string[])_progress.seenTutorialIds.Clone(),
puzzleStates = _progress.puzzleStates.Select(state => new PuzzleState { stageId = state.stageId,
balls = CopyBalls(state.balls), groups = CopyGroups(state.groups), camera = CopyCamera(state.camera),
collectedBallIds = (string[])state.collectedBallIds.Clone(), countedBallIds = (string[])state.countedBallIds.Clone() }).ToArray(),
latestFreePlay = _progress.latestFreePlay == null ? null : new FreePlayState { sourceStageId = _progress.latestFreePlay.sourceStageId,
balls = CopyBalls(_progress.latestFreePlay.balls), groups = CopyGroups(_progress.latestFreePlay.groups), camera = CopyCamera(_progress.latestFreePlay.camera) } };
private static BallState[] CopyBalls(BallState[] balls) => balls.Select(ball => new BallState { id = ball.id, position = ball.position }).ToArray();
private static GroupState[] CopyGroups(GroupState[] groups) => groups.Select(group => new GroupState { id = group.id, ballIds = (string[])group.ballIds.Clone() }).ToArray();
private void SnapshotCurrent(ProgressSave progress)
{
if (Board == null) return;
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);
}
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()
{
SnapshotCurrent(_progress);
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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 75997e84f9c140949a3de4010efa7746
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+11
View File
@@ -0,0 +1,11 @@
# 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와 실제 화면으로 검증한다. 파일 생성 성공을 설치·재생 성공으로 기록하지 않는다.
풍경 변경은 PlaygroundSceneryAssets의 대상 한정 경로로 지면·충돌 역할·30개 고정 배치만 갱신한다. 실제 import root를 보존하고 메시 바닥·접근 가능한 표면으로 접지한다. PlaygroundRestoreGeometryAssets는 승인 메시의 읽기 전용 정점·삼각형과 원본·meta 해시를 추출하며 기존 캐시의 자동 재생성을 거부한다. 초기 구·목표·기존 GUID와 승인 파일 보존, 모든 초기 구의 실제 첫 hit 선택·끌기, 두 가로 비율의 화면을 검증한다.
@@ -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,68 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using MagnetismHeroes.UnityRuntime.Bootstrap;
using Unity.Collections;
using UnityEditor;
using UnityEngine;
namespace MagnetismHeroes.Editor
{
public static class PlaygroundRestoreGeometryAssets
{
public const string CachePath = "Assets/MagnetismHeroes/UnityRuntime/Bootstrap/PlaygroundRestoreGeometry.asset";
public static void BindGameApp()
{
var cache = AssetDatabase.LoadAssetAtPath<PlaygroundRestoreGeometry>(CachePath);
if (cache == null) throw new InvalidOperationException("Restore geometry must exist before binding the game prefab.");
const string path = "Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs/GameApp.prefab";
var prefab = PrefabUtility.LoadPrefabContents(path);
try
{
var app = prefab.GetComponent<GameApp>();
var data = new SerializedObject(app);
var reference = data.FindProperty("_restoreGeometry");
if (reference.objectReferenceValue == cache) return;
Undo.RecordObject(app, "Bind playground restore geometry");
reference.objectReferenceValue = cache; data.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(app); PrefabUtility.SaveAsPrefabAsset(prefab, path);
Debug.Log("Bound the existing read-only restore geometry to the game prefab.");
}
finally { PrefabUtility.UnloadPrefabContents(prefab); }
}
public static void CreateCache()
{
if (AssetDatabase.LoadMainAssetAtPath(CachePath) != null) throw new InvalidOperationException("Restore geometry already exists; verify before replacing it.");
var meshes = AssetDatabase.FindAssets("t:GameObject", new[] { "Assets/MagnetismHeroes/UnityRuntime/Presentation/Prefabs" })
.Select(AssetDatabase.GUIDToAssetPath).Select(AssetDatabase.LoadAssetAtPath<GameObject>)
.SelectMany(prefab => prefab.GetComponentsInChildren<MeshCollider>(true)).Select(collider => collider.sharedMesh)
.Where(mesh => mesh != null).Distinct().OrderBy(mesh => AssetDatabase.GetAssetPath(mesh)).ThenBy(mesh => mesh.name).ToArray();
var entries = meshes.Select(mesh => {
Vector3[] vertices; int[] triangles;
using (var readOnly = MeshUtility.AcquireReadOnlyMeshData(mesh))
{
var data = readOnly[0]; using var buffer = new NativeArray<Vector3>(data.vertexCount, Allocator.Temp);
data.GetVertices(buffer); vertices = buffer.ToArray();
var indices = data.indexFormat == UnityEngine.Rendering.IndexFormat.UInt16
? data.GetIndexData<ushort>().ToArray().Select(index => (int)index).ToArray()
: data.GetIndexData<uint>().ToArray().Select(index => (int)index).ToArray();
triangles = Enumerable.Range(0, data.subMeshCount).SelectMany(submesh => {
var descriptor = data.GetSubMesh(submesh);
return Enumerable.Range(descriptor.indexStart, descriptor.indexCount).Select(index => indices[index] + descriptor.baseVertex);
}).ToArray();
}
string path = AssetDatabase.GetAssetPath(mesh);
return new PlaygroundRestoreGeometry.Entry { mesh = mesh, vertices = vertices, triangles = triangles, bounds = mesh.bounds,
geometrySha256 = PlaygroundRestoreGeometry.Hash(vertices, triangles), sourceSha256 = FileHash(path),
sourceMetaSha256 = FileHash(path + ".meta"), sourceGuid = AssetDatabase.AssetPathToGUID(path) };
}).ToArray();
if (entries.Length == 0) throw new InvalidOperationException("No authored collider meshes found.");
var cache = ScriptableObject.CreateInstance<PlaygroundRestoreGeometry>(); cache.SetEntries(entries);
AssetDatabase.CreateAsset(cache, CachePath); EditorUtility.SetDirty(cache); AssetDatabase.SaveAssets();
Debug.Log("Created read-only restore geometry for " + entries.Length + " unchanged collider meshes.");
}
private static string FileHash(string path)
{ using var sha = SHA256.Create(); return BitConverter.ToString(sha.ComputeHash(File.ReadAllBytes(path))).Replace("-", "").ToLowerInvariant(); }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64260de0ba3968c43ba2b2f17991ebae
@@ -0,0 +1,215 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MagnetismHeroes.UnityRuntime.StageAssets;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
namespace MagnetismHeroes.Editor
{
// Targeted authoring only. Reuses approved model/material bytes and all existing GUIDs.
public static class PlaygroundSceneryAssets
{
private const string Root = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
private static readonly HashSet<string> Terrain = new HashSet<string> { "MeadowClearing", "GentleHill", "DistantRidge", "FarHorizon" };
private static readonly HashSet<string> Solid = new HashSet<string> { "MeadowClearing", "GentleHill", "RoundCanopyTree", "SageCanopyTree", "TallWindTree", "RoundLimestone", "LowLimestone", "SlateBoulder", "OpenStoneArch", "MossStoneSteps" };
public static void Apply()
{
var paths = AssetDatabase.FindAssets("t:Prefab", new[] { Root + "Prefabs/Meadow" }).Select(AssetDatabase.GUIDToAssetPath).ToArray();
foreach (string path in paths)
{
var prefab = PrefabUtility.LoadPrefabContents(path);
try
{
if (Solid.Contains(prefab.name))
foreach (var filter in prefab.GetComponentsInChildren<MeshFilter>(true))
{
var collider = filter.GetComponent<MeshCollider>();
if (collider == null) collider = filter.gameObject.AddComponent<MeshCollider>();
collider.sharedMesh = filter.sharedMesh; collider.convex = false;
}
if (Solid.Contains(prefab.name)) PrefabUtility.SaveAsPrefabAsset(prefab, path);
}
finally { PrefabUtility.UnloadPrefabContents(prefab); }
}
var props = paths.ToDictionary(path => Path.GetFileNameWithoutExtension(path), AssetDatabase.LoadAssetAtPath<GameObject>);
var signatures = new List<string>();
for (int id = 1; id <= 30; id++)
{
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
var data = new SerializedObject(stage);
string before = GameplaySignature(data);
var placements = Compose(stage, props);
Ground(placements);
var decorations = data.FindProperty("_decorations"); decorations.arraySize = placements.Count;
for (int i = 0; i < placements.Count; i++)
{
var item = decorations.GetArrayElementAtIndex(i); var placement = placements[i];
item.FindPropertyRelative("prefab").objectReferenceValue = placement.prefab;
item.FindPropertyRelative("position").vector3Value = placement.position;
item.FindPropertyRelative("eulerAngles").vector3Value = placement.eulerAngles;
item.FindPropertyRelative("scale").vector3Value = placement.scale;
}
// Compact aerial play volume; whole-sphere padding is applied by the movement layer.
// The lowest visible mesh surface sits at approximately -.457 here.
data.FindProperty("_playgroundBounds").boundsValue = new Bounds(new Vector3(0, 3.025f, 0), new Vector3(17, 6.95f, 13));
data.ApplyModifiedPropertiesWithoutUndo();
if (GameplaySignature(new SerializedObject(stage)) != before) throw new InvalidOperationException("Gameplay fields changed: " + id);
signatures.Add(id + ":" + before); EditorUtility.SetDirty(stage);
}
var app = PrefabUtility.LoadPrefabContents(Root + "Prefabs/GameApp.prefab");
try
{
var floor = app.transform.Find("Playground/PlaygroundIsland");
foreach (var collider in floor.GetComponents<Collider>()) Object.DestroyImmediate(collider);
PrefabUtility.SaveAsPrefabAsset(app, Root + "Prefabs/GameApp.prefab");
}
finally { PrefabUtility.UnloadPrefabContents(app); }
AssetDatabase.SaveAssets();
string evidence = Environment.GetEnvironmentVariable("MAGNETISM_PLAYGROUND_EVIDENCE");
if (!string.IsNullOrEmpty(evidence)) { Directory.CreateDirectory(evidence); File.WriteAllLines(Path.Combine(evidence, "unchanged-gameplay-signatures.txt"), signatures); }
Debug.Log("[PlaygroundScenery] 30 fixed layouts grounded on imported meshes. Approved artwork and original gameplay fields preserved.");
}
private static string GameplaySignature(SerializedObject data)
{
string Array(string field)
{
var array = data.FindProperty(field);
return string.Join("|", Enumerable.Range(0, array.arraySize).Select(i => {
var item = array.GetArrayElementAtIndex(i);
return item.propertyType == SerializedPropertyType.Vector3 ? JsonUtility.ToJson(item.vector3Value) : item.intValue.ToString(); }));
}
return data.FindProperty("_stageId").intValue + ";" + data.FindProperty("_kind").intValue + ";" + data.FindProperty("_theme").stringValue + ";" + Array("_initialGroups") + ";" + Array("_targetGroups") + ";" + Array("_ballPositions");
}
private static List<StageDecoration> Compose(StageAsset stage, Dictionary<string, GameObject> props)
{
var result = new List<StageDecoration>();
int group = (stage.StageId - 1) / 5, variant = (stage.StageId - 1) % 5;
void Place(string name, float x, float z, float scale = 1, float yaw = 0) => result.Add(new StageDecoration { prefab = props[name], position = new Vector3(x, 0, z), scale = Vector3.one * scale, eulerAngles = new Vector3(0, yaw, 0) });
result.Add(new StageDecoration { prefab = props["MeadowClearing"], position = new Vector3(0, -.485f, 0), scale = new Vector3(5, .2f, 5) });
// Preserve the original discoveries, with their openings genuinely selectable.
Place("OpenStoneArch", -4, 1.6f); Place("MossStoneSteps", 4, 1.7f);
for (int side = 0; side < 4; side++)
{
float yaw = side * 90; var direction = Quaternion.Euler(0, yaw, 0) * Vector3.forward;
Place("FarHorizon", direction.x * 27, direction.z * 27, 1, yaw);
result[result.Count - 1].position += Vector3.down * .46f;
Place("DistantRidge", direction.x * 22, direction.z * 22, 1, yaw);
result[result.Count - 1].position += Vector3.down * .46f;
}
int hills = group == 2 ? 5 : group == 5 ? 4 : 2;
for (int i = 0; i < hills; i++)
{
float angle = (35 + i * 360f / hills + variant * 6) * Mathf.Deg2Rad;
// Keep sloping hills beyond the full root/stone/plant footprints.
// The approved props have level bases; a pivot-only height cannot seat them on a steep slope.
float radius = group == 2 ? 17.5f : 19;
Place("GentleHill", Mathf.Sin(angle) * radius, Mathf.Cos(angle) * radius, group == 2 ? .8f + i % 2 * .15f : 1.3f, i * 63 + variant * 9);
}
// Dense woodland, broad open flower meadows, and low stone silhouettes differ in composition.
int trees = group == 1 ? 9 : group == 3 ? 11 : group == 0 ? 2 : group == 2 ? 3 : 1;
for (int i = 0; i < trees; i++)
{
// Frame the board from behind and the sides; a full ring puts large crowns beside the default camera.
float angle = (trees == 1 ? 35 + variant * 8 : -75 + i * 150f / (trees - 1) + (variant - 2) * 4) * Mathf.Deg2Rad;
float radius = group == 1 || group == 3 ? 8.6f + i % 3 * .7f : 10.5f;
string tree = group == 1 ? new[] { "RoundCanopyTree", "SageCanopyTree", "RoundCanopyTree" }[(i + variant) % 3] : group == 3 ? (i % 2 == 0 ? "TallWindTree" : "SageCanopyTree") : "RoundCanopyTree";
Place(tree, Mathf.Sin(angle) * radius, Mathf.Cos(angle) * radius * .72f, .85f + (i + variant) % 3 * .13f, i * 47 + variant * 21);
}
int stones = group == 4 ? 10 : group == 5 ? 7 : 2;
for (int i = 0; i < stones; i++)
{
float angle = (50 + i * 360f / stones + variant * 11) * Mathf.Deg2Rad;
string stone = new[] { "RoundLimestone", "SlateBoulder", "LowLimestone" }[(i + variant) % 3];
float radius = 7.7f + i % 2 * 1.2f;
float z = Mathf.Cos(angle) * radius * .73f;
float stoneScale = (.75f + (i + variant) % 3 * .17f) * (z < -2 ? .6f : 1);
Place(stone, Mathf.Sin(angle) * radius, z, stoneScale, i * 39 + variant * 13);
Place("PebbleScatter", Mathf.Sin(angle) * (radius - .7f), Mathf.Cos(angle) * radius * .73f, .8f, i * 63);
}
if (group == 5)
{
Place("OpenStoneArch", 7.2f, -3.5f + variant * .35f, .85f, 60 + variant * 8);
Place("MossStoneSteps", -7, -2.8f - variant * .2f, .8f, -35 + variant * 12);
}
int vegetation = group == 3 ? 22 : group == 0 || group == 2 ? 18 : group == 1 ? 12 : 9;
for (int i = 0; i < vegetation; i++)
{
float angle = (i * 137.5f + variant * 19) * Mathf.Deg2Rad;
float radius = 5.4f + i % 4 * .8f;
string name = group == 0 ? new[] { "DaisyColony", "GrassTuftMint", "MintShrub" }[(i + variant) % 3]
: group == 1 ? new[] { "MintShrub", "SageShrub", "GrassTuftMint" }[(i + variant) % 3]
: group == 2 ? new[] { "PinkCosmosColony", "LavenderLupineColony", "GrassTuftGold" }[(i + variant) % 3]
: group == 3 ? new[] { "FernCluster", "FernCluster", "GrassTuftMint", "SageShrub" }[(i + variant) % 4]
: new[] { "GrassTuftGold", "FernCluster", "GrassTuftMint" }[(i + variant) % 3];
Place(name, Mathf.Sin(angle) * radius, Mathf.Cos(angle) * radius * .77f, .6f + (i + variant) % 3 * .14f, i * 71 + variant * 29);
}
// A low continuous natural outline marks the compact playable rectangle.
// The broad clearing outside it remains a connected background apron.
int edgeIndex = 0;
void Edge(float x, float z)
{
string name = group >= 4 ? "PebbleScatter" : group == 0 && edgeIndex % 10 == 0 ? "DaisyColony"
: group == 2 && edgeIndex % 10 == 0 ? "LavenderLupineColony"
: (group == 1 || group == 3) && edgeIndex % 4 == 0 ? "GrassTuftGold" : "PebbleScatter";
Place(name, x, z, .75f + edgeIndex % 2 * .1f, edgeIndex * 47 + variant * 9); edgeIndex++;
}
for (int i = 0; i <= 13; i++) { float x = -8.5f + 17f * i / 13; Edge(x, -6.5f); Edge(x, 6.5f); }
for (int i = 1; i < 10; i++) { float z = -6.5f + 13f * i / 10; Edge(-8.5f, z); Edge(8.5f, z); }
return result;
}
private static GameObject Instance(StageDecoration placement)
{
var item = (GameObject)PrefabUtility.InstantiatePrefab(placement.prefab);
item.hideFlags = HideFlags.HideAndDontSave;
item.transform.SetPositionAndRotation(placement.position, Quaternion.Euler(placement.eulerAngles));
item.transform.localScale = placement.scale; return item;
}
private static float Surface(IEnumerable<Collider> ground, float x, float z)
{
float height = float.NegativeInfinity; var ray = new Ray(new Vector3(x, 30, z), Vector3.down);
foreach (var collider in ground)
if (collider.Raycast(ray, out var hit, 60)) height = Mathf.Max(height, hit.point.y);
if (float.IsNegativeInfinity(height)) throw new InvalidOperationException($"No real terrain at {x},{z}");
return height;
}
private static float Bottom(GameObject item) => item.GetComponentsInChildren<MeshRenderer>().Min(renderer => renderer.bounds.min.y);
private static void Ground(List<StageDecoration> placements)
{
var instances = new List<GameObject>(); var ground = new List<Collider>();
try
{
var clearing = Instance(placements[0]); instances.Add(clearing); ground.AddRange(clearing.GetComponentsInChildren<Collider>());
Physics.SyncTransforms();
// Peripheral terrain joins the clearing at its own actual surface height, with a small overlap.
foreach (var placement in placements.Where(item => item.prefab.name == "GentleHill"))
{
var hill = Instance(placement); instances.Add(hill);
placement.position.y += Surface(ground, placement.position.x, placement.position.z) - Bottom(hill) - .015f;
hill.transform.position = placement.position; ground.AddRange(hill.GetComponentsInChildren<Collider>()); Physics.SyncTransforms();
}
foreach (var placement in placements.Where(item => !Terrain.Contains(item.prefab.name)))
{
var prop = Instance(placement); instances.Add(prop);
placement.position.y += Surface(ground, placement.position.x, placement.position.z) - Bottom(prop);
// Rounded approved rocks otherwise touch on a tiny pole and read as floating.
// Seat the visible lower cap slightly in the actual terrain, without changing the mesh.
if (placement.prefab.name == "RoundLimestone" || placement.prefab.name == "SlateBoulder" || placement.prefab.name == "LowLimestone")
placement.position.y -= .10f * placement.scale.y;
// These approved bent-trunk models have a lower trunk pole than their
// five root flare end caps. Seat those actual root undersides, too.
if (placement.prefab.name == "RoundCanopyTree" || placement.prefab.name == "SageCanopyTree")
placement.position.y -= .095f * placement.scale.y;
prop.transform.position = placement.position;
}
}
finally { foreach (var item in instances) Object.DestroyImmediate(item); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e1bb7dc6a9bb57b46ba65160734d277e
@@ -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,293 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using MagnetismHeroes.UnityRuntime.Presentation;
using MagnetismHeroes.UnityRuntime.StageAssets;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
namespace MagnetismHeroes.Editor
{
// Deliberately scoped: never invokes the initial project/content builders.
public static class VisualUpgradeAssets
{
private const string Root = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
private const float FarHorizonRadius = 27;
[Serializable] private sealed class ApprovedFile { public string repositoryRelativePath, sha256; }
[Serializable] private sealed class Approval { public string status; public ApprovedFile[] approvedFiles; }
[Serializable] private sealed class PortableMaterial
{
public string name;
public float[] baseColorSrgb, authoredSRGB;
public float roughness, metallic;
}
[Serializable] private sealed class Provenance { public PortableMaterial[] portableMaterials, materials; }
[Serializable] private sealed class ImportRecord
{
public string asset, sha256;
public Vector3 rootEuler;
public int meshes, triangles;
}
[Serializable] private sealed class ApplyRecord
{
public string utc, status;
public int approvedFiles, stages;
public ImportRecord[] imports;
public string[] unchangedNonvisualStageSignatures;
}
public static void ApplyDaylightShadows()
{
var app = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
try
{
var sun = app.transform.Find("Sun").GetComponent<Light>();
sun.shadows = LightShadows.Soft; sun.shadowStrength = .55f;
sun.color = new Color(1, .97f, .91f); sun.intensity = 1.3f;
PrefabUtility.SaveAsPrefabAsset(app, FirstSliceAssets.AppPrefabPath);
}
finally { PrefabUtility.UnloadPrefabContents(app); }
var pipeline = new SerializedObject(AssetDatabase.LoadMainAssetAtPath("Assets/Settings/Mobile_RPAsset.asset"));
pipeline.FindProperty("m_SoftShadowsSupported").boolValue = true;
pipeline.FindProperty("m_SoftShadowQuality").intValue = 1;
pipeline.FindProperty("m_ShadowDistance").floatValue = 30;
pipeline.ApplyModifiedPropertiesWithoutUndo(); AssetDatabase.SaveAssets();
Debug.Log("[VisualUpgrade] Existing daylight light and mobile URP: soft .55-strength shadows, low soft-shadow quality, one 1024 cascade, 30m distance; model/material/stage bytes untouched.");
}
public static void ApplyHorizonContinuity()
{
foreach (int id in Enumerable.Range(1, 30))
{
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
string before = StageSignature(stage);
if (stage.Decorations.Count(item => item.prefab.name == "FarHorizon") != 4)
throw new InvalidOperationException("Expected four existing horizon placements in stage " + id);
Undo.RecordObject(stage, "Connect distant horizon to meadow apron");
var data = new SerializedObject(stage); var placements = data.FindProperty("_decorations");
for (int index = 0; index < placements.arraySize; index++)
{
var placement = placements.GetArrayElementAtIndex(index);
if (placement.FindPropertyRelative("prefab").objectReferenceValue.name != "FarHorizon") continue;
var position = placement.FindPropertyRelative("position"); var point = position.vector3Value;
var direction = new Vector3(point.x, 0, point.z).normalized;
position.vector3Value = new Vector3(direction.x * FarHorizonRadius, point.y, direction.z * FarHorizonRadius);
}
data.ApplyModifiedPropertiesWithoutUndo();
if (StageSignature(stage) != before) throw new InvalidOperationException("Nonvisual stage state changed: " + id);
}
AssetDatabase.SaveAssets();
Debug.Log("[VisualUpgrade] Only 120 FarHorizon positions moved inward to overlap the existing apron/ridge; stage nonvisual fields unchanged.");
}
public static void Apply()
{
string approvalPath = Environment.GetEnvironmentVariable("MAGNETISM_VISUAL_APPROVAL");
if (string.IsNullOrEmpty(approvalPath)) throw new InvalidOperationException("Exact reviewed approval record is required.");
var approval = JsonUtility.FromJson<Approval>(File.ReadAllText(approvalPath));
if (approval.status != "APPROVED" || approval.approvedFiles.Length != 25)
throw new InvalidOperationException("All 25 reviewed files must be approved before adoption.");
foreach (var file in approval.approvedFiles)
if (Hash(file.repositoryRelativePath) != file.sha256)
throw new InvalidOperationException("Approved bytes changed: " + file.repositoryRelativePath);
var stages = Enumerable.Range(1, 30).Select(id => AssetDatabase.LoadAssetAtPath<StageAsset>(
$"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset")).ToArray();
var signatures = stages.Select(StageSignature).ToArray();
Directory.CreateDirectory(Root + "Materials/VisualUpgrade");
Directory.CreateDirectory(Root + "Prefabs/Meadow");
AssetDatabase.Refresh();
var materials = new Dictionary<string, Material>();
foreach (string path in new[] { "SourceAssets/Models/Heroes/provenance.json", "SourceAssets/Models/Meadow/provenance.json" })
{
var provenance = JsonUtility.FromJson<Provenance>(File.ReadAllText(path));
foreach (var spec in provenance.portableMaterials ?? provenance.materials)
{
string materialPath = Root + "Materials/VisualUpgrade/" + spec.name + ".mat";
var material = AssetDatabase.LoadAssetAtPath<Material>(materialPath);
if (material == null)
{
material = new Material(Shader.Find("Universal Render Pipeline/Lit"));
AssetDatabase.CreateAsset(material, materialPath);
}
// Unity's material Color is sRGB; Lit converts it for the linear render pipeline.
// The Blender linearBaseColor is deliberately not assigned as another sRGB color.
var color = spec.baseColorSrgb ?? spec.authoredSRGB;
material.SetColor("_BaseColor", new Color(color[0], color[1], color[2], 1));
material.SetFloat("_Smoothness", 1 - spec.roughness);
material.SetFloat("_Metallic", spec.metallic);
material.enableInstancing = true;
EditorUtility.SetDirty(material); materials.Add(spec.name, material);
}
}
var imports = new List<ImportRecord>();
foreach (var file in approval.approvedFiles.Where(item => item.repositoryRelativePath.EndsWith(".fbx", StringComparison.Ordinal)))
{
var importer = (ModelImporter)AssetImporter.GetAtPath(file.repositoryRelativePath);
importer.globalScale = 1; importer.useFileScale = true; importer.bakeAxisConversion = true;
importer.importAnimation = false; importer.importCameras = false; importer.importLights = false;
importer.SaveAndReimport();
foreach (var source in AssetDatabase.LoadAllAssetsAtPath(file.repositoryRelativePath).OfType<Material>())
{
if (!materials.TryGetValue(source.name, out var material))
throw new InvalidOperationException("Material has no portable provenance: " + source.name);
importer.AddRemap(new AssetImporter.SourceAssetIdentifier(typeof(Material), source.name), material);
}
importer.SaveAndReimport();
var model = AssetDatabase.LoadAssetAtPath<GameObject>(file.repositoryRelativePath);
var meshes = model.GetComponentsInChildren<MeshFilter>(true);
imports.Add(new ImportRecord { asset = file.repositoryRelativePath, sha256 = file.sha256,
rootEuler = model.transform.localEulerAngles, meshes = meshes.Length,
triangles = meshes.Sum(filter => filter.sharedMesh.triangles.Length / 3) });
}
ApplyBody(); ApplyEquipment();
var props = approval.approvedFiles.Where(item => item.repositoryRelativePath.Contains("/Models/Meadow/") && item.repositoryRelativePath.EndsWith(".fbx", StringComparison.Ordinal))
.ToDictionary(item => Path.GetFileNameWithoutExtension(item.repositoryRelativePath), item => Scenery(item.repositoryRelativePath));
foreach (var stage in stages) ApplyStage(stage, props);
for (int index = 0; index < stages.Length; index++)
if (StageSignature(stages[index]) != signatures[index]) throw new InvalidOperationException("Nonvisual stage state changed: " + (index + 1));
var app = PrefabUtility.LoadPrefabContents(FirstSliceAssets.AppPrefabPath);
try
{
// Preserve the old floor's exact input/drag collision boundary.
app.transform.Find("Playground/PlaygroundIsland").GetComponent<MeshRenderer>().enabled = false;
app.transform.Find("GameCamera").GetComponent<Camera>().backgroundColor = new Color(.58f, .83f, .88f);
PrefabUtility.SaveAsPrefabAsset(app, FirstSliceAssets.AppPrefabPath);
}
finally { PrefabUtility.UnloadPrefabContents(app); }
AssetDatabase.SaveAssets();
string evidence = Environment.GetEnvironmentVariable("MAGNETISM_VISUAL_EVIDENCE");
Directory.CreateDirectory(evidence);
File.WriteAllText(Path.Combine(evidence, "source-import.json"), JsonUtility.ToJson(new ApplyRecord {
utc = DateTime.UtcNow.ToString("o"), status = "APPLIED_EXACT_APPROVED_BYTES_NONVISUAL_STAGE_FIELDS_UNCHANGED",
approvedFiles = approval.approvedFiles.Length, stages = stages.Length, imports = imports.ToArray(),
unchangedNonvisualStageSignatures = signatures }, true));
Debug.Log("[VisualUpgrade] Exact approved models imported, original gameplay stage fields preserved, one equipment assembly per badge connected.");
}
private static void ApplyBody()
{
string path = Root + "Prefabs/MagnetFriend.prefab";
var ball = PrefabUtility.LoadPrefabContents(path);
try
{
var old = ball.transform.Find("ApprovedHeroBody");
if (old != null) Object.DestroyImmediate(old.gameObject);
var body = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Models/Heroes/HeroBody.fbx"));
body.name = "ApprovedHeroBody"; body.transform.SetParent(ball.transform, false);
var renderer = body.GetComponentInChildren<MeshRenderer>();
var data = new SerializedObject(ball.GetComponent<BallView>());
data.FindProperty("_body").objectReferenceValue = renderer; data.ApplyModifiedPropertiesWithoutUndo();
Object.DestroyImmediate(ball.GetComponent<MeshRenderer>());
Object.DestroyImmediate(ball.GetComponent<MeshFilter>());
PrefabUtility.SaveAsPrefabAsset(ball, path);
}
finally { PrefabUtility.UnloadPrefabContents(ball); }
}
private static void ApplyEquipment()
{
string path = Root + "Prefabs/GroupBadge.prefab";
var badge = PrefabUtility.LoadPrefabContents(path);
try
{
var old = badge.transform.Find("KnightEquipment");
if (old != null) Object.DestroyImmediate(old.gameObject);
var equipment = new GameObject("KnightEquipment"); equipment.transform.SetParent(badge.transform, false);
var alignment = new GameObject("ViewAlignment"); alignment.transform.SetParent(equipment.transform, false);
var model = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Models/Heroes/KnightEquipment.fbx"));
model.transform.SetParent(alignment.transform, false);
// Keep the imported FBX root untouched. Orient its front to the sprite's -Z front.
var shield = model.GetComponentsInChildren<MeshRenderer>().Single(item => item.name == "Shield_TealFace");
float front = equipment.transform.InverseTransformPoint(shield.bounds.center).z;
if (Mathf.Abs(front) < .01f) throw new InvalidOperationException("Cannot identify imported equipment front.");
if (front > 0) alignment.transform.localRotation = Quaternion.Euler(0, 180, 0);
var data = new SerializedObject(badge.GetComponent<GroupBadge>());
data.FindProperty("_equipment").objectReferenceValue = equipment.transform; data.ApplyModifiedPropertiesWithoutUndo();
Debug.Log($"[VisualUpgrade] equipment imported front={front}, view alignment={alignment.transform.localEulerAngles}, imported root={model.transform.localEulerAngles}");
PrefabUtility.SaveAsPrefabAsset(badge, path);
}
finally { PrefabUtility.UnloadPrefabContents(badge); }
}
private static GameObject Scenery(string source)
{
string name = Path.GetFileNameWithoutExtension(source);
var prop = new GameObject(name);
try
{
var model = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath<GameObject>(source));
model.transform.SetParent(prop.transform, false);
if (name == "OpenStoneArch" || name == "MossStoneSteps")
foreach (var filter in model.GetComponentsInChildren<MeshFilter>())
filter.gameObject.AddComponent<MeshCollider>().sharedMesh = filter.sharedMesh;
return PrefabUtility.SaveAsPrefabAsset(prop, Root + "Prefabs/Meadow/" + name + ".prefab");
}
finally { Object.DestroyImmediate(prop); }
}
private static void ApplyStage(StageAsset stage, Dictionary<string, GameObject> props)
{
var placements = new List<StageDecoration>();
void Place(string name, float x, float y, float z, float scale = 1, float yaw = 0) => placements.Add(new StageDecoration {
prefab = props[name], position = new Vector3(x, y, z), scale = Vector3.one * scale, eulerAngles = new Vector3(0, yaw, 0) });
// A broad, nearly level visual meadow meets the original floor at y=-.45.
// Keep its approved mesh/material bytes and the existing playable collider.
placements.Add(new StageDecoration { prefab = props["MeadowClearing"],
position = new Vector3(0, -.485f, 0), scale = new Vector3(5, .2f, 5) });
Place("OpenStoneArch", -4, 0, 1.6f); Place("MossStoneSteps", 4, 0, 1.7f);
Place("PebbleScatter", 3.8f, 0, -2.6f);
for (int side = 0; side < 4; side++)
{
float yaw = side * 90; var direction = Quaternion.Euler(0, yaw, 0) * Vector3.forward;
Place("FarHorizon", direction.x * FarHorizonRadius, -.485f, direction.z * FarHorizonRadius, 1, yaw);
Place("DistantRidge", direction.x * 22, -.485f, direction.z * 22, 1, yaw);
var corner = Quaternion.Euler(0, yaw + 45, 0) * Vector3.forward * 19;
Place("GentleHill", corner.x, -.485f, corner.z, 1.8f, yaw);
}
string tree = stage.StageId <= 10 ? "RoundCanopyTree" : stage.StageId <= 20 ? "SageCanopyTree" : "TallWindTree";
Place(tree, -6.6f, -.45f, 4.8f); Place(tree, 6.6f, -.45f, 5.8f);
Place(tree, -7.5f, -.45f, -6); Place(tree, 7.5f, -.45f, -6);
Place(stage.StageId <= 10 ? "MintShrub" : "SageShrub", -6, -.45f, -2.4f, .75f);
if (stage.StageId <= 10)
{
Place("DaisyColony", -4.1f, 0, -1.8f, .7f); Place("FernCluster", 4.7f, 0, 3.3f);
Place("GrassTuftMint", -3.4f, 0, 1.4f); Place("GrassTuftGold", 3.7f, 0, 3);
}
else if (stage.StageId <= 20)
{
Place("PinkCosmosColony", -4.1f, 0, -1.8f, .7f); Place("LavenderLupineColony", 4.7f, 0, 3.3f, .8f);
Place("DaisyColony", -5.6f, 0, 3.8f); Place("GrassTuftGold", 3.7f, 0, 3);
}
else
{
Place("SlateBoulder", -6.2f, -.45f, 5.4f); Place("RoundLimestone", 5.6f, -.45f, 4.2f);
Place("LowLimestone", -4.8f, 0, -2.7f); Place("FernCluster", 3.7f, 0, 3);
}
var data = new SerializedObject(stage); var decorations = data.FindProperty("_decorations");
decorations.arraySize = placements.Count;
for (int index = 0; index < placements.Count; index++)
{
var target = decorations.GetArrayElementAtIndex(index); var placement = placements[index];
target.FindPropertyRelative("prefab").objectReferenceValue = placement.prefab;
target.FindPropertyRelative("position").vector3Value = placement.position;
target.FindPropertyRelative("eulerAngles").vector3Value = placement.eulerAngles;
target.FindPropertyRelative("scale").vector3Value = placement.scale;
}
data.ApplyModifiedPropertiesWithoutUndo();
}
private static string StageSignature(StageAsset stage)
{
var data = new SerializedObject(stage);
string Array(string field) { var value = data.FindProperty(field); return string.Join("|", Enumerable.Range(0, value.arraySize).Select(index => {
var item = value.GetArrayElementAtIndex(index);
return item.propertyType == SerializedPropertyType.Vector3 ? JsonUtility.ToJson(item.vector3Value) : item.intValue.ToString(); })); }
return stage.StageId + ";" + stage.Theme + ";" + data.FindProperty("_kind").intValue + ";" + Array("_initialGroups") + ";" + Array("_targetGroups") + ";" + Array("_ballPositions");
}
private static string Hash(string path)
{
using (var algorithm = SHA256.Create()) using (var file = File.OpenRead(path))
return BitConverter.ToString(algorithm.ComputeHash(file)).Replace("-", "").ToLowerInvariant();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 40789cbe0397f8745bff78d14a544113
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 98fbd46e6e73fc24c8f35e6b9ed686f4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+183
View File
@@ -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:
+8
View File
@@ -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,47 @@
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"));
foreach (var placement in asset.Decorations) Assert.That(placement.prefab, Is.Not.Null);
foreach (string name in new[] { "MeadowClearing", "OpenStoneArch", "MossStoneSteps", "PebbleScatter", "GentleHill" })
Assert.That(asset.Decorations.Any(placement => placement.prefab.name == name), Is.True, $"Stage {id} shared discovery and terrain: {name}");
var primaryDecorations = new[] { "DaisyColony", "RoundCanopyTree", "PinkCosmosColony", "FernCluster", "RoundLimestone", "OpenStoneArch" };
Assert.That(asset.Decorations.Any(placement => placement.prefab.name == primaryDecorations[(id - 1) / 5]), Is.True, $"Stage {id} five-stage natural composition");
foreach (string name in new[] { "FarHorizon", "DistantRidge" })
Assert.That(asset.Decorations.Where(placement => placement.prefab.name == name).Select(placement => placement.eulerAngles.y),
Is.EquivalentTo(new[] { 0f, 90f, 180f, 270f }), $"Stage {id} {name} surrounds all four directions");
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,151 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MagnetismHeroes.Core;
using MagnetismHeroes.UnityRuntime.Persistence;
using NUnit.Framework;
namespace MagnetismHeroes.Tests
{
public sealed class PlaygroundResumeSessionTests
{
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 Store : IProgressStore
{
public ProgressSave Save;
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 static PuzzleState Legacy()
{
return new PuzzleState { stageId = 2,
balls = new[] { new BallState { id = "legacy-a", position = new Position3(30, 2, 0) }, new BallState { id = "legacy-b", position = new Position3(31, 2, 0) } },
groups = new[] { new GroupState { id = "legacy-group", ballIds = new[] { "legacy-a", "legacy-b" } } },
collectedBallIds = new[] { "legacy-a" }, countedBallIds = new[] { "legacy-a" }, camera = GameSession.DefaultCamera() };
}
private static ProgressSave Fixture() => new ProgressSave {
completedStageIds = new[] { 1, 2, 3, 4, 5, 6 }, seenTutorialIds = new[] { "merge", "split" },
puzzleStates = new[] { new BoardRules(Definitions[0]).ToPuzzleState(GameSession.DefaultCamera()), Legacy(), new BoardRules(Definitions[2]).ToPuzzleState(GameSession.DefaultCamera()) },
latestFreePlay = new BoardRules(Definitions[2]).ToFreePlayState(GameSession.DefaultCamera()) };
private static BoardRestore Repair(BoardRules board, CameraState camera)
{
Assert.That(board.TryTranslateForRestore(new Dictionary<string, Position3> { ["legacy-group"] = new Position3(-23, 0, 0) }, out var prepared), Is.True);
return new BoardRestore(prepared, camera);
}
[Test] public void RestoreTranslationRetainsIdentityMembershipPartialCollectionAndCountWithoutSuccess()
{
var original = new BoardRules(Definitions[1], Legacy());
var prepared = Repair(original, GameSession.DefaultCamera()).Board;
Assert.That(original.Balls[0].position.x, Is.EqualTo(30));
Assert.That(prepared.Balls.Select(ball => ball.id), Is.EqualTo(original.Balls.Select(ball => ball.id)));
Assert.That(prepared.Groups.Single().ballIds, Is.EqualTo(original.Groups.Single().ballIds));
Assert.That(prepared.CollectedBallIds, Is.EqualTo(original.CollectedBallIds));
Assert.That(prepared.CountedBallIds, Is.EqualTo(original.CountedBallIds));
Assert.That(prepared.IsSolved, Is.False);
Assert.That(prepared.Balls[1].position.x - prepared.Balls[0].position.x, Is.EqualTo(1));
var ordinary = new BoardRules(Definitions[1], Legacy()); ordinary.TryMoveGroup("legacy-group", new Position3(-23, 0, 0));
Assert.That(ordinary.CollectedBallIds.Length, Is.EqualTo(2), "Negative control: ordinary play movement would corrupt partial collection.");
}
[Test] public void InvalidTranslationRejectsWholeReplacementAndLeavesSourceUntouched()
{
var board = new BoardRules(Definitions[1], Legacy());
Assert.That(board.TryTranslateForRestore(new Dictionary<string, Position3> { ["legacy-group"] = new Position3(float.NaN, 0, 0) }, out var prepared), Is.False);
Assert.That(prepared, Is.Null); Assert.That(board.Balls[0].position.x, Is.EqualTo(30));
Assert.That(board.CollectedBallIds, Is.EqualTo(new[] { "legacy-a" }));
}
[Test] public void OneValidatedWriteReplacesOnlyTargetWhileKeepingOtherPuzzlesFreeProgressAndTutorials()
{
var fixture = Fixture(); var otherOne = fixture.puzzleStates[0]; var otherSix = fixture.puzzleStates[2]; var free = fixture.latestFreePlay;
var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
Assert.That(session.StartStage(2, Repair), Is.True);
Assert.That(store.Writes, Is.EqualTo(1)); Assert.That(session.Screen, Is.EqualTo(SessionScreen.Puzzle));
Assert.That(store.Save.completedStageIds, Is.EqualTo(fixture.completedStageIds)); Assert.That(store.Save.seenTutorialIds, Is.EqualTo(fixture.seenTutorialIds));
AssertPuzzle(store.Save.puzzleStates[0], otherOne); AssertPuzzle(store.Save.puzzleStates[2], otherSix);
Assert.That(store.Save.latestFreePlay.balls.Select(ball => ball.position), Is.EqualTo(free.balls.Select(ball => ball.position)));
Assert.That(store.Save.latestFreePlay.sourceStageId, Is.EqualTo(free.sourceStageId));
Assert.That(store.Save.puzzleStates[1].balls[0].position.x, Is.EqualTo(7));
Assert.That(fixture.puzzleStates[1].balls[0].position.x, Is.EqualTo(30), "Preparing and saving must not mutate a loaded store snapshot.");
}
[Test] public void InconclusivePreparationHasNoWriteAndKeepsCurrentBoardCameraAndEverySlot()
{
var fixture = Fixture(); var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
session.StartStage(6); var current = session.Board; var camera = session.Camera; int before = store.Writes;
Assert.That(session.StartStage(2, (board, view) => null), Is.False);
Assert.That(store.Writes, Is.EqualTo(before)); Assert.That(session.Board, Is.SameAs(current)); Assert.That(session.Camera, Is.SameAs(camera));
Assert.That(store.Save.puzzleStates.Single(state => state.stageId == 2).balls[0].position.x, Is.EqualTo(30));
}
[TestCase(false)] [TestCase(true)] public void ValidUnsortedDtosKeepTheirRawIdentityMembershipAndMarkerOrder(bool repair)
{
var fixture = Fixture(); fixture.puzzleStates = fixture.puzzleStates.Reverse().ToArray();
var original = fixture.puzzleStates.Single(state => state.stageId == 2);
original.balls = original.balls.Reverse().ToArray(); original.groups[0].ballIds = original.groups[0].ballIds.Reverse().ToArray();
original.collectedBallIds = new[] { "legacy-b", "legacy-a" };
var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
Assert.That(session.StartStage(2, repair ? Repair : null), Is.True);
Assert.That(store.Writes, Is.EqualTo(1)); Assert.That(store.Save.puzzleStates.Select(state => state.stageId), Is.EqualTo(new[] { 6, 2, 1 }));
var actual = store.Save.puzzleStates.Single(state => state.stageId == 2);
Assert.That(actual.balls.Select(ball => ball.id), Is.EqualTo(original.balls.Select(ball => ball.id)));
Assert.That(actual.groups[0].ballIds, Is.EqualTo(original.groups[0].ballIds));
Assert.That(actual.collectedBallIds, Is.EqualTo(original.collectedBallIds)); Assert.That(actual.countedBallIds, Is.EqualTo(original.countedBallIds));
Assert.That(actual.balls[0].position.x, Is.EqualTo(repair ? 8 : 31)); Assert.That(original.balls[0].position.x, Is.EqualTo(31));
AssertPuzzle(store.Save.puzzleStates[0], fixture.puzzleStates[0]); AssertPuzzle(store.Save.puzzleStates[2], fixture.puzzleStates[2]);
}
[Test] public void ReplacementRejectsChangedIdsMembershipAndNonRigidShapeWithoutAnyWrite()
{
foreach (string change in new[] { "ids", "shape", "collection" })
{
var store = new Store { Save = Fixture() }; var session = new GameSession(Definitions, store);
Assert.That(session.StartStage(2, (board, camera) => {
var state = board.ToPuzzleState(camera);
if (change == "ids") { state.balls[1].id = "new-id"; state.groups[0].ballIds[1] = "new-id"; }
if (change == "shape") state.balls[1].position.x += 1;
if (change == "collection") state.collectedBallIds = new[] { "legacy-a", "legacy-b" };
return new BoardRestore(new BoardRules(Definitions[1], state), camera);
}), Is.False);
Assert.That(store.Writes, Is.Zero); Assert.That(session.Board, Is.Null);
}
}
[Test] public void ExplicitInitialResetOnlyChangesTheAffectedPuzzleAndNeverRemovesPastCompletion()
{
var fixture = Fixture(); var store = new Store { Save = fixture }; var session = new GameSession(Definitions, store);
Assert.That(session.StartStage(2, (board, camera) => new BoardRestore(new BoardRules(Definitions[1]), GameSession.DefaultCamera(), true)), Is.True);
Assert.That(store.Writes, Is.EqualTo(1)); Assert.That(session.Board.CollectedBallIds, Is.Empty); Assert.That(session.Board.CountedBallIds, Is.Empty);
Assert.That(session.Board.Balls.Select(ball => ball.id), Is.EqualTo(new[] { "s02-b01", "s02-b02" }));
Assert.That(session.CompletedStageIds, Is.EqualTo(fixture.completedStageIds));
AssertPuzzle(store.Save.puzzleStates[0], fixture.puzzleStates[0]); AssertPuzzle(store.Save.puzzleStates[2], fixture.puzzleStates[2]);
Assert.That(store.Save.latestFreePlay.sourceStageId, Is.EqualTo(6));
}
[Test] public void ActualStoreWriteFailureKeepsPrimaryValidBackupCurrentBoardAndNoIntermediateTargetWrite()
{
string directory = Path.Combine(Path.GetTempPath(), "MagnetismHeroes-restore-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "progress.json");
try
{
var store = new LocalProgressStore(path, Definitions); Assert.That(store.TrySave(Fixture(), out _), Is.True); Assert.That(store.TrySave(Fixture(), out _), Is.True);
var session = new GameSession(Definitions, store); session.StartStage(6);
byte[] primary = File.ReadAllBytes(path), backup = File.ReadAllBytes(path + ".bak"); var board = session.Board; var camera = session.Camera;
using (new FileStream(path + ".tmp", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
Assert.That(session.StartStage(2, Repair), Is.False);
Assert.That(session.LastSaveError, Is.Not.Null); Assert.That(session.Board, Is.SameAs(board)); Assert.That(session.Camera, Is.SameAs(camera));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(primary)); Assert.That(File.ReadAllBytes(path + ".bak"), Is.EqualTo(backup));
Assert.That(store.TryLoad(out var loaded, out var error), Is.True, error);
Assert.That(loaded.puzzleStates.Single(state => state.stageId == 2).balls[0].position.x, Is.EqualTo(30));
}
finally { Directory.Delete(directory, true); }
}
private static void AssertPuzzle(PuzzleState actual, PuzzleState expected)
{
Assert.That(actual.stageId, Is.EqualTo(expected.stageId)); Assert.That(actual.balls.Select(ball => ball.id), Is.EqualTo(expected.balls.Select(ball => ball.id)));
Assert.That(actual.balls.Select(ball => ball.position), Is.EqualTo(expected.balls.Select(ball => ball.position)));
Assert.That(actual.groups.Select(group => group.id), Is.EqualTo(expected.groups.Select(group => group.id)));
for (int index = 0; index < actual.groups.Length; index++) Assert.That(actual.groups[index].ballIds, Is.EqualTo(expected.groups[index].ballIds));
Assert.That(actual.collectedBallIds, Is.EqualTo(expected.collectedBallIds)); Assert.That(actual.countedBallIds, Is.EqualTo(expected.countedBallIds));
Assert.That(actual.camera.target, Is.EqualTo(expected.camera.target)); Assert.That(actual.camera.rotation, Is.EqualTo(expected.camera.rotation)); Assert.That(actual.camera.distance, Is.EqualTo(expected.camera.distance));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e1b9640c8d119ad488ff61dadfd6d2ce
@@ -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,294 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Object = UnityEngine.Object;
using MagnetismHeroes.UnityRuntime.Presentation;
using MagnetismHeroes.UnityRuntime.StageAssets;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MagnetismHeroes.Tests
{
public sealed class VisualUpgradeAssetTests
{
private const string Root = "Assets/MagnetismHeroes/UnityRuntime/Presentation/";
[Test] public void MobileDaylightSupportsSoftGroundShadowsWithoutReplacingThePlayableFloor()
{
var app = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/GameApp.prefab");
var sun = app.transform.Find("Sun").GetComponent<Light>();
Assert.That(sun.shadows, Is.EqualTo(LightShadows.Soft));
Assert.That(sun.shadowStrength, Is.InRange(.3f, .7f));
Assert.That(sun.intensity, Is.InRange(1.1f, 1.5f));
var pipeline = new SerializedObject(AssetDatabase.LoadMainAssetAtPath("Assets/Settings/Mobile_RPAsset.asset"));
Assert.That(pipeline.FindProperty("m_MainLightShadowsSupported").boolValue, Is.True);
Assert.That(pipeline.FindProperty("m_SoftShadowsSupported").boolValue, Is.True);
Assert.That(pipeline.FindProperty("m_MainLightShadowmapResolution").intValue, Is.EqualTo(1024));
Assert.That(pipeline.FindProperty("m_ShadowCascadeCount").intValue, Is.EqualTo(1));
Assert.That(pipeline.FindProperty("m_ShadowDistance").floatValue, Is.InRange(20f, 35f));
var floor = app.transform.Find("Playground/PlaygroundIsland");
Assert.That(floor.GetComponents<Collider>(), Is.Empty, "The hidden legacy floor must not block above/below the visible meadow.");
Assert.That(floor.GetComponent<MeshRenderer>().enabled, Is.False);
}
[Test] public void ApprovedBodyAndOneEquipmentAssemblyAreConnectedWithoutChangingSelectionSphere()
{
var ball = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/MagnetFriend.prefab");
var body = ball.transform.Find("ApprovedHeroBody");
Assert.That(body, Is.Not.Null, "Approved FBX body must replace the primitive rendering.");
Assert.That(ball.transform.localScale, Is.EqualTo(Vector3.one));
Assert.That(ball.GetComponentsInChildren<Collider>(true).Length, Is.EqualTo(1));
Assert.That(ball.GetComponent<SphereCollider>().radius, Is.EqualTo(.5f));
var renderer = body.GetComponentInChildren<MeshRenderer>();
Assert.That(renderer.sharedMaterial.name, Is.EqualTo("Hero_CyanSatin"));
Assert.That(renderer.sharedMaterial.color.r, Is.EqualTo(.17f).Within(.0001f));
Assert.That(renderer.sharedMaterial.color.g, Is.EqualTo(.72f).Within(.0001f));
Assert.That(renderer.sharedMaterial.color.b, Is.EqualTo(.83f).Within(.0001f));
var mesh = body.GetComponentInChildren<MeshFilter>().sharedMesh;
Assert.That(mesh.bounds.size.x, Is.EqualTo(1).Within(.002f));
var badge = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/GroupBadge.prefab");
Assert.That(badge.transform.Cast<Transform>().Count(child => child.name == "KnightEquipment"), Is.EqualTo(1));
Assert.That(badge.GetComponentsInChildren<Collider>(true), Is.Empty);
Assert.That(badge.GetComponentsInChildren<Animator>(true), Is.Empty);
Assert.That(badge.GetComponentsInChildren<SkinnedMeshRenderer>(true), Is.Empty);
Assert.That(badge.GetComponentsInChildren<SpriteRenderer>(true).Length, Is.EqualTo(1));
foreach (var material in badge.transform.Find("KnightEquipment").GetComponentsInChildren<MeshRenderer>(true).SelectMany(item => item.sharedMaterials))
Assert.That(AssetDatabase.GetAssetPath(material), Does.StartWith(Root + "Materials/VisualUpgrade/"));
foreach (string name in new[] { "HeroBody", "KnightEquipment" })
{
var importer = (ModelImporter)AssetImporter.GetAtPath(Root + "Models/Heroes/" + name + ".fbx");
Assert.That(importer.globalScale, Is.EqualTo(1));
Assert.That(importer.bakeAxisConversion, Is.True); Assert.That(importer.importAnimation, Is.False);
}
}
[Test] public void AllThirtyStagesConnectNaturalThemesWithOpenNearMeshCollidersAndColliderFreeDistantDecor()
{
foreach (int id in Enumerable.Range(1, 30))
{
var stage = AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
Assert.That(stage.Theme, Is.EqualTo(id <= 10 ? "Garden" : id <= 20 ? "Cloud" : "Workshop"));
Assert.That(stage.Decorations.Any(item => item.prefab.name == "MeadowClearing"), Is.True);
var arch = stage.Decorations.First(item => item.prefab.name == "OpenStoneArch").prefab;
Assert.That(arch.GetComponentsInChildren<BoxCollider>(true), Is.Empty);
Assert.That(arch.GetComponentsInChildren<MeshCollider>(true).All(item => !item.convex && item.sharedMesh != null), Is.True);
Assert.That(arch.GetComponentsInChildren<MeshCollider>(true), Is.Not.Empty);
foreach (var decoration in stage.Decorations)
{
string name = decoration.prefab.name;
bool solid = name == "MeadowClearing" || name == "GentleHill" || name.Contains("Tree") || name.Contains("Limestone") || name == "SlateBoulder" || name == "OpenStoneArch" || name == "MossStoneSteps";
var colliders = decoration.prefab.GetComponentsInChildren<Collider>(true);
if (solid) Assert.That(colliders, Is.Not.Empty, name + " needs a shape-matched collider; runtime disables inaccessible instances, while authoring uses terrain for surface probes.");
else Assert.That(colliders, Is.Empty, name + " is small vegetation or unreachable scenery.");
foreach (var collider in colliders)
{
Assert.That(collider, Is.TypeOf<MeshCollider>());
var meshCollider = (MeshCollider)collider;
Assert.That(meshCollider.sharedMesh, Is.SameAs(collider.GetComponent<MeshFilter>().sharedMesh));
}
}
}
}
private static StageAsset Stage(int id) => AssetDatabase.LoadAssetAtPath<StageAsset>($"Assets/MagnetismHeroes/UnityRuntime/StageAssets/Stage{id:D2}.asset");
private static GameObject[] Place(StageAsset stage) => stage.Decorations.Select(placement => {
var item = Object.Instantiate(placement.prefab);
item.transform.SetPositionAndRotation(placement.position, Quaternion.Euler(placement.eulerAngles));
item.transform.localScale = placement.scale; return item;
}).ToArray();
private static bool Terrain(string name) => name == "MeadowClearing" || name == "GentleHill" || name == "DistantRidge" || name == "FarHorizon";
private static float Surface(IEnumerable<Collider> colliders, float x, float z)
{
float top = float.NegativeInfinity;
foreach (var collider in colliders)
if (collider.Raycast(new Ray(new Vector3(x, 30, z), Vector3.down), out var hit, 60)) top = Mathf.Max(top, hit.point.y);
return top;
}
[Test] public void AllPlacedRootsTouchTheActualImportedSurfaceIncludingHillSlopes()
{
for (int id = 1; id <= 30; id++)
{
var stage = Stage(id); var items = Place(stage);
try
{
Physics.SyncTransforms();
var terrain = items.Where((item, i) => stage.Decorations[i].prefab.name == "MeadowClearing" || stage.Decorations[i].prefab.name == "GentleHill")
.SelectMany(item => item.GetComponentsInChildren<Collider>()).ToArray();
for (int i = 0; i < items.Length; i++)
{
var placement = stage.Decorations[i]; if (Terrain(placement.prefab.name)) continue;
float surface = Surface(terrain, placement.position.x, placement.position.z);
float bottom = items[i].GetComponentsInChildren<MeshRenderer>().Min(renderer => renderer.bounds.min.y);
Assert.That(float.IsNegativeInfinity(surface), Is.False, $"Stage {id}: {placement.prefab.name} has no supporting surface.");
bool rock = placement.prefab.name == "RoundLimestone" || placement.prefab.name == "SlateBoulder" || placement.prefab.name == "LowLimestone";
bool rootTree = placement.prefab.name == "RoundCanopyTree" || placement.prefab.name == "SageCanopyTree";
Assert.That(bottom - surface, Is.InRange(rock || rootTree ? -.15f : -.03f, .03f), $"Stage {id}: {placement.prefab.name} floating or deeply buried.");
if (rock)
{
var stone = items[i].GetComponentInChildren<MeshCollider>();
// Check the stone itself, not nearby pebbles or its overall Bounds.
Assert.That(stone.Raycast(new Ray(new Vector3(placement.position.x, surface + .02f, placement.position.z - 3), Vector3.forward), out _, 6), Is.True, "The main stone lower cap must meet the terrain.");
float top = items[i].GetComponentsInChildren<MeshRenderer>().Max(renderer => renderer.bounds.max.y);
Assert.That(top - surface, Is.GreaterThan((top - bottom) * .7f), "Only a shallow lower cap may be seated.");
}
}
// Every reachable horizontal point has a real surface; no holes at the boundary corners.
for (float x = stage.PlaygroundBounds.min.x; x <= stage.PlaygroundBounds.max.x; x += .5f)
for (float z = stage.PlaygroundBounds.min.z; z <= stage.PlaygroundBounds.max.z; z += .5f)
Assert.That(float.IsNegativeInfinity(Surface(terrain, x, z)), Is.False, $"Stage {id}: terrain gap {x},{z}.");
}
finally { foreach (var item in items) Object.DestroyImmediate(item); }
}
}
private static IEnumerable<int> StageIds => Enumerable.Range(1, 30);
[TestCaseSource(nameof(StageIds))] public void EveryAuthoredRootFootprintMeetsTerrainWithoutSlopeGapsOrDeepBurial(int id)
{
var stage = Stage(id); var items = Place(stage);
try
{
Physics.SyncTransforms();
var terrain = items.Where((item, i) => stage.Decorations[i].prefab.name == "MeadowClearing" || stage.Decorations[i].prefab.name == "GentleHill")
.SelectMany(item => item.GetComponentsInChildren<Collider>()).ToArray();
for (int i = 0; i < items.Length; i++)
{
var placement = stage.Decorations[i]; if (Terrain(placement.prefab.name)) continue;
var vertices = items[i].GetComponentsInChildren<MeshFilter>().SelectMany(filter => filter.sharedMesh.vertices.Select(filter.transform.TransformPoint)).ToArray();
float bottom = vertices.Min(point => point.y);
bool rock = placement.prefab.name == "RoundLimestone" || placement.prefab.name == "SlateBoulder" || placement.prefab.name == "LowLimestone";
// Use the actual imported lower roots/stems/cap vertices, not the center
// or a Renderer AABB. A root naturally has some height above its authored
// base; require the supporting ground under its full footprint to agree
// with that base instead of tolerating slope-induced air or burial.
foreach (var point in vertices.Where(point => point.y - bottom <= .12f * placement.scale.y))
{
float surface = Surface(terrain, point.x, point.z);
float baseGap = (point.y - surface) - (point.y - bottom);
bool rootTree = placement.prefab.name == "RoundCanopyTree" || placement.prefab.name == "SageCanopyTree";
Assert.That(baseGap, Is.InRange(rock || rootTree ? -.16f : -.035f, .035f),
$"Stage {id}: {placement.prefab.name} root footprint {point} floats or penetrates a slope (baseGap={baseGap}).");
}
}
}
finally { foreach (var item in items) Object.DestroyImmediate(item); }
}
[TestCaseSource(nameof(StageIds))] public void EachOfTheFiveTreeRootFlaresActuallyTouchesItsSupportingGround(int id)
{
var stage = Stage(id); var items = Place(stage);
try
{
Physics.SyncTransforms();
var terrain = items.Where((item, i) => stage.Decorations[i].prefab.name == "MeadowClearing" || stage.Decorations[i].prefab.name == "GentleHill")
.SelectMany(item => item.GetComponentsInChildren<Collider>()).ToArray();
for (int i = 0; i < items.Length; i++)
{
var placement = stage.Decorations[i];
if (placement.prefab.name != "RoundCanopyTree" && placement.prefab.name != "SageCanopyTree") continue;
var vertices = items[i].GetComponentsInChildren<MeshFilter>().SelectMany(filter => filter.sharedMesh.vertices.Select(filter.transform.TransformPoint)).ToArray();
float bottom = vertices.Min(point => point.y);
// The authored five flares extend beyond the central trunk. Inspect their
// actual lower end caps in all five directions, independently of the pivot.
var feet = vertices.Where(point => point.y - bottom < .18f * placement.scale.y
&& new Vector2(point.x - placement.position.x, point.z - placement.position.z).magnitude > .45f * placement.scale.x).ToArray();
Assert.That(feet, Is.Not.Empty);
var firstRoot = items[i].transform.InverseTransformPoint(feet[0]);
float phase = Mathf.Atan2(firstRoot.z, firstRoot.x) * Mathf.Rad2Deg;
for (int direction = 0; direction < 5; direction++)
{
var cap = feet.Where(point => {
var local = items[i].transform.InverseTransformPoint(point);
float angle = Mathf.Repeat(Mathf.Atan2(local.z, local.x) * Mathf.Rad2Deg - phase, 360);
return Mathf.RoundToInt(angle / 72) % 5 == direction;
}).ToArray();
Assert.That(cap, Is.Not.Empty, "The approved root flare cap must be identifiable.");
float gap = cap.Min(point => point.y - Surface(terrain, point.x, point.z));
Assert.That(gap, Is.InRange(-.025f, .025f), $"Stage {id}: {placement.prefab.name} root {direction} end cap is floating or deeply buried (gap={gap}).");
}
}
}
finally { foreach (var item in items) Object.DestroyImmediate(item); }
}
[Test] public void EveryOriginalSphereFitsAndCanBeSelectedFromAnAboveGroundView()
{
var spherePrefab = AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/MagnetFriend.prefab");
for (int id = 1; id <= 30; id++)
{
var stage = Stage(id); var items = Place(stage); var balls = new List<GameObject>();
try
{
var positions = new SerializedObject(stage).FindProperty("_ballPositions");
for (int i = 0; i < positions.arraySize; i++)
{
var sphere = Object.Instantiate(spherePrefab); sphere.transform.position = positions.GetArrayElementAtIndex(i).vector3Value; balls.Add(sphere);
}
Physics.SyncTransforms();
var obstacles = items.SelectMany(item => item.GetComponentsInChildren<Collider>()).ToArray();
var allColliders = obstacles.Concat(balls.Select(ball => ball.GetComponent<SphereCollider>())).ToArray();
foreach (var ball in balls)
{
var collider = ball.GetComponent<SphereCollider>(); var point = ball.transform.position;
var bounds = stage.PlaygroundBounds;
Assert.That(bounds.Contains(point - Vector3.one * collider.radius) && bounds.Contains(point + Vector3.one * collider.radius), Is.True, $"Stage {id}: original sphere outside playground.");
foreach (var obstacle in obstacles)
Assert.That(Physics.ComputePenetration(collider, point, Quaternion.identity, obstacle, obstacle.transform.position, obstacle.transform.rotation, out _, out float depth) && depth > .005f, Is.False, $"Stage {id}: sphere at {point} buried in {obstacle.name}.");
bool selectable = false;
foreach (float pitch in new[] { 20f, 45f, 70f })
for (int yaw = 0; yaw < 360 && !selectable; yaw += 30)
{
var rotation = Quaternion.Euler(pitch, yaw, 0);
var origin = new Vector3(0, 1.25f, 0) + rotation * new Vector3(0, 0, -12);
var local = Quaternion.Inverse(rotation) * (point - origin);
if (local.z <= 0 || Mathf.Abs(local.x / local.z) > Mathf.Tan(31 * Mathf.Deg2Rad) * 16f / 9 || Mathf.Abs(local.y / local.z) > Mathf.Tan(31 * Mathf.Deg2Rad)) continue;
var ray = new Ray(origin, (point - origin).normalized);
Collider first = null; float nearest = float.PositiveInfinity;
foreach (var candidate in allColliders)
if (candidate.Raycast(ray, out var hit, 30) && hit.distance < nearest) { nearest = hit.distance; first = candidate; }
selectable |= first == collider;
}
Assert.That(selectable, Is.True, $"Stage {id}: original sphere {point} has no selectable view.");
}
}
finally { foreach (var ball in balls) Object.DestroyImmediate(ball); foreach (var item in items) Object.DestroyImmediate(item); }
}
}
[Test] public void ArchHoleIsOpenWhileItsStonePillarStillBlocksARay()
{
var arch = Object.Instantiate(AssetDatabase.LoadAssetAtPath<GameObject>(Root + "Prefabs/Meadow/OpenStoneArch.prefab"));
try
{
Physics.SyncTransforms(); var colliders = arch.GetComponentsInChildren<Collider>();
bool Hit(float x) => colliders.Any(collider => collider.Raycast(new Ray(new Vector3(x, .8f, -3), Vector3.forward), out _, 6));
Assert.That(Hit(0), Is.False, "The visible arch opening must stay open.");
Assert.That(Hit(1.18f), Is.True, "The visible stone pillar must remain solid.");
}
finally { Object.DestroyImmediate(arch); }
}
[Test] public void AVisiblePassableNaturalOutlineMarksEveryPlaygroundSide()
{
for (int id = 1; id <= 30; id++)
{
var stage = Stage(id); var bounds = stage.PlaygroundBounds;
var passable = stage.Decorations.Where(item => item.prefab.GetComponentsInChildren<Collider>().Length == 0 && !Terrain(item.prefab.name)).ToArray();
foreach (float x in new[] { bounds.min.x, bounds.max.x })
Assert.That(passable.Count(item => Mathf.Abs(item.position.x - x) < .05f && item.position.z >= bounds.min.z && item.position.z <= bounds.max.z), Is.GreaterThan(5), "The side edge must be visually marked without obstructing small vegetation.");
foreach (float z in new[] { bounds.min.z, bounds.max.z })
Assert.That(passable.Count(item => Mathf.Abs(item.position.z - z) < .05f && item.position.x >= bounds.min.x && item.position.x <= bounds.max.x), Is.GreaterThan(5), "The front/back edge must be visually marked.");
}
}
[Test] public void SixStageGroupsHaveDifferentNaturalCompositionsAndFiveFixedVariations()
{
var primary = new[] { "DaisyColony", "RoundCanopyTree", "PinkCosmosColony", "FernCluster", "RoundLimestone", "OpenStoneArch" };
var groupCompositions = new HashSet<string>();
for (int group = 0; group < 6; group++)
{
var layouts = new HashSet<string>();
for (int variant = 0; variant < 5; variant++)
{
var stage = Stage(group * 5 + variant + 1);
Assert.That(stage.Decorations.Any(placement => placement.prefab.name == primary[group]), Is.True);
layouts.Add(string.Join(";", stage.Decorations.Select(placement => placement.prefab.name + JsonUtility.ToJson(placement.position) + JsonUtility.ToJson(placement.scale) + JsonUtility.ToJson(placement.eulerAngles))));
if (variant == 0) groupCompositions.Add(string.Join(";", stage.Decorations.GroupBy(placement => placement.prefab.name).OrderBy(items => items.Key).Select(items => items.Key + ":" + items.Count())));
}
Assert.That(layouts.Count, Is.EqualTo(5), "Fixed stages must not repeat exactly the same layout.");
}
Assert.That(groupCompositions.Count, Is.EqualTo(6), "Changing only colors or positions must not disguise identical compositions.");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61d4021e6d4aace41964197d9377009b
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 29ca2c74a650df24593f3fa70fa815b0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,255 @@
#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); _app.Presenter.BindEnvironment(_app.Environment); _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);
}
private void OrbitUntilOriginalBallsAreFirstHits(params string[] ids)
{
var start = new Vector2(Screen.width * .1f, Screen.height * .8f);
if (_app.Presenter.Camera.transform.forward.y > -.1f)
{ _app.Input.Begin(start, false); _app.Input.Move(start - new Vector2(0, 200)); _app.Input.End(start - new Vector2(0, 200)); }
for (int pitch = 0; pitch < 2; pitch++)
{
for (int yaw = 0; yaw < 12; yaw++)
{
Physics.SyncTransforms();
if (Mathf.Abs(Vector3.Dot(_app.Presenter.Balls[ids[0]].transform.position - _app.Presenter.Balls[ids[1]].transform.position, _app.Presenter.Camera.transform.forward)) < .02f && ids.All(id =>
{
var point = Point(id);
return point.x > 0 && point.x < Screen.width && point.y > 0 && point.y < Screen.height
&& Physics.Raycast(_app.Presenter.Camera.ScreenPointToRay(point), out var hit, 100)
&& hit.collider == _app.Presenter.Balls[id].GetComponent<SphereCollider>();
})) return;
_app.Input.Begin(start, false); _app.Input.Move(start + new Vector2(120, 0)); _app.Input.End(start + new Vector2(120, 0));
}
if (pitch == 0)
{ _app.Input.Begin(start, false); _app.Input.Move(start - new Vector2(0, 40)); _app.Input.End(start - new Vector2(0, 40)); }
}
Assert.Fail("The original requested IDs must be reachable by an above-ground empty-space orbit.");
}
[UnityTest] public IEnumerator FourteenDisconnectedSelectionThenMergeIsAcceptedByFinalNumbers()
{
_app.StartStage(14); yield return null; Physics.SyncTransforms(); var group = _app.Session.Board.Groups[0];
OrbitUntilOriginalBallsAreFirstHits(group.ballIds[0], group.ballIds[2]);
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 }));
Assert.That(_app.Presenter.IsSafeBoard(_app.Session.Board), Is.True);
var singles = _app.Session.Board.Groups.Where(value => value.ballIds.Length == 1).ToArray();
OrbitUntilOriginalBallsAreFirstHits(singles.Select(value => value.ballIds[0]).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,309 @@
#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<Transform>().Single(item=>item.name=="OpenStoneArch(Clone)");
var colliders=arch.GetComponentsInChildren<MeshCollider>();
Assert.That(colliders, Is.Not.Empty);
Assert.That(arch.GetComponentsInChildren<BoxCollider>(), Is.Empty);
Ray ArchRay(float x) => new Ray(arch.TransformPoint(new Vector3(x,1,-3)),arch.TransformDirection(Vector3.forward));
Assert.That(colliders.Any(collider=>collider.Raycast(ArchRay(0),out _,6)), Is.False,"Actual approved arch gap remains open.");
Assert.That(colliders.Any(collider=>collider.Raycast(ArchRay(1.1f),out _,6)), Is.True,"Actual approved arch side blocks input rays.");
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,468 @@
#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.TestTools;
namespace MagnetismHeroes.Tests
{
public sealed class DragCameraPlayTests
{
private sealed class MemoryStore : IProgressStore
{
public ProgressSave Save = new ProgressSave { completedStageIds = Enumerable.Range(1, 30).ToArray(), seenTutorialIds = new[] { "merge", "split" } };
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 MemoryStore _store;
private readonly System.Collections.Generic.List<Collider> _disabledScenery = new System.Collections.Generic.List<Collider>();
[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 MemoryStore(); _app.Initialize(_store); _app.StartStage(6); _app.Voice.Clear();
_disabledScenery.Clear();
foreach (var collider in _app.Environment.GetComponentsInChildren<Collider>())
if (!collider.isTrigger && !collider.name.Contains("Clearing") && collider.enabled) { _disabledScenery.Add(collider); collider.enabled = false; }
yield return null;
}
[UnityTearDown] public IEnumerator Teardown() { Object.Destroy(_app.gameObject); yield return null; }
private void Inject(params Vector3[] positions)
{
var rotation = Quaternion.Euler(12, 0, 0);
var camera = new CameraState { target = new Position3(0, 2, 0), distance = 8, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) };
var balls = positions.Select((point, index) => new BallState { id = "camera-" + index, position = BoardPresenter.ToPosition(point) }).ToArray();
_store.Save.latestFreePlay = new FreePlayState { sourceStageId = 6, balls = balls,
groups = balls.Select((ball, index) => new GroupState { id = "group-" + index, ballIds = new[] { ball.id } }).ToArray(), camera = camera };
_app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms();
}
private Vector2 Point(string id) => _app.Presenter.Camera.WorldToScreenPoint(_app.Presenter.Balls[id].transform.position);
[UnityTest] public IEnumerator ActualDragMovesCameraWhileStationaryFingerPreservesPreviewAndCommittedBoard()
{
Inject(new Vector3(-1, 2, 0), new Vector3(.5f, 2, 2));
var camera = _app.Presenter.Camera; var initialRotation = camera.transform.rotation;
var saved = JsonUtility.ToJson(_store.Save); int writes = _store.Writes;
var board = JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera));
var manual = JsonUtility.ToJson(_app.Orbit.Snapshot());
var start = Point("camera-0"); var pointer = start + new Vector2(22, 0);
_app.Input.Begin(start, false); _app.Input.Move(pointer);
var preview = _app.Presenter.Balls["camera-0"].transform.position;
float until = Time.realtimeSinceStartup + .7f; float sampleAt = 0;
var frames = new System.Collections.Generic.List<string> { "elapsed,cameraX,cameraY,cameraZ,rotationX,rotationY,rotationZ,rotationW,previewX,previewY,previewZ,saveWrites" };
while (Time.realtimeSinceStartup < until)
{
yield return null; _app.Input.Move(pointer);
if (Time.realtimeSinceStartup < sampleAt) continue;
sampleAt = Time.realtimeSinceStartup + .02f;
var pose = camera.transform; var position = _app.Presenter.Balls["camera-0"].transform.position;
frames.Add($"{Time.realtimeSinceStartup},{pose.position.x},{pose.position.y},{pose.position.z},{pose.rotation.x},{pose.rotation.y},{pose.rotation.z},{pose.rotation.w},{position.x},{position.y},{position.z},{_store.Writes}");
}
WriteRecords("camera-motion-stationary-finger.csv", frames);
Assert.That(Quaternion.Angle(initialRotation, camera.transform.rotation), Is.GreaterThan(3), "A real friend drag must smoothly choose a view that reveals depth.");
Assert.That(_app.Presenter.Balls["camera-0"].transform.position, Is.EqualTo(preview));
Assert.That(JsonUtility.ToJson(_app.Session.Board.ToFreePlayState(_app.Session.Camera)), Is.EqualTo(board));
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual));
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes));
_app.Input.Cancel();
yield return new WaitForSecondsRealtime(1.5f);
Assert.That(Quaternion.Angle(initialRotation, camera.transform.rotation), Is.LessThan(.1f));
Assert.That(_app.Presenter.Balls["camera-0"].transform.position, Is.EqualTo(BoardPresenter.ToVector(_app.Session.Board.Balls[0].position)));
}
[UnityTest] public IEnumerator DeepFriendsCanActuallyMeetAndMergeThroughTheRebasedInputPlane()
{
Inject(new Vector3(-1.5f, 2, -1), new Vector3(.5f, 2, 2));
var initial = _app.Session.Board.Balls.Select(ball => ball.id).ToArray();
var start = Point("camera-0"); var pointer = start + new Vector2(20, 0);
_app.Input.Begin(start, false); _app.Input.Move(pointer);
yield return new WaitForSecondsRealtime(.6f);
var camera = _app.Presenter.Camera;
var records = new System.Collections.Generic.List<string> { "frame,worldX,worldY,worldZ,pointerX,pointerY,cameraX,cameraY,cameraZ,distance,candidate" };
float until = Time.realtimeSinceStartup + 4; int frame = 0;
while (_app.Presenter.MergeCandidate == null && Time.realtimeSinceStartup < until)
{
var source = _app.Presenter.Balls["camera-0"].transform.position;
var target = _app.Presenter.Balls["camera-1"].transform.position;
var screenDelta = (Vector2)(camera.WorldToScreenPoint(target) - camera.WorldToScreenPoint(source));
pointer += Vector2.ClampMagnitude(screenDelta, 3);
_app.Input.Move(pointer);
float distance = Vector3.Distance(_app.Presenter.Balls["camera-0"].transform.position, target);
if (frame++ % 20 == 0) records.Add($"{frame},{source.x},{source.y},{source.z},{pointer.x},{pointer.y},{camera.transform.position.x},{camera.transform.position.y},{camera.transform.position.z},{distance},{_app.Presenter.MergeCandidate}");
yield return null;
}
WriteRecords("depth-drag.csv", records);
Assert.That(_app.Presenter.MergeCandidate, Is.EqualTo("group-1"), "Screen overlap is insufficient: the actual safe 3D spheres must reach contact.");
var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position);
Assert.That(Vector3.Distance(preview["camera-0"], preview["camera-1"]), Is.EqualTo(1).Within(.001));
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2));
_app.Input.End(pointer); yield return null;
Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EquivalentTo(initial));
foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id]));
}
private static void WriteRecords(string name, System.Collections.Generic.IEnumerable<string> records)
{
string directory = System.Environment.GetEnvironmentVariable("MAGNETISM_DRAG_CAMERA_EVIDENCE");
if (string.IsNullOrEmpty(directory)) return;
System.IO.Directory.CreateDirectory(directory); System.IO.File.WriteAllLines(System.IO.Path.Combine(directory, name), records);
}
[UnityTest] public IEnumerator CandidateHysteresisAvoidsFrameByFrameSwitchingAndFarFriendsAreNotTracked()
{
Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1), new Vector3(1.05f, 2, -1));
var pointer = Point("camera-0"); _app.Input.Begin(pointer, false); pointer += new Vector2(22, 0); _app.Input.Move(pointer);
yield return new WaitForSecondsRealtime(.1f);
string candidate = _app.Orbit.TrackedFriend; Assert.That(candidate, Is.Not.Null);
for (int index = 0; index < 20; index++)
{
pointer += new Vector2(index % 2 == 0 ? 1 : -1, 0); _app.Input.Move(pointer); yield return null;
Assert.That(_app.Orbit.TrackedFriend, Is.EqualTo(candidate));
}
_app.Input.Cancel();
Inject(new Vector3(-4, 2, 0), new Vector3(4, 2, 0));
pointer = Point("camera-0"); _app.Input.Begin(pointer, false); _app.Input.Move(pointer + new Vector2(22, 0));
yield return new WaitForSecondsRealtime(.2f);
Assert.That(_app.Orbit.TrackedFriend, Is.Null);
}
[UnityTest] public IEnumerator TapThresholdUiAndSplitSelectionDoNotStartCameraAssistance()
{
Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 0));
var camera = _app.Presenter.Camera; var position = camera.transform.position; var rotation = camera.transform.rotation;
var point = Point("camera-0");
_app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(10, 0)); yield return new WaitForSecondsRealtime(.1f); _app.Input.End(point);
Assert.That(_app.Orbit.IsAssisting, Is.False);
_app.Input.Begin(point, true); _app.Input.Move(point + new Vector2(100, 0)); yield return new WaitForSecondsRealtime(.1f); _app.Input.End(point);
Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation));
_store.Save.latestFreePlay.groups = new[] { new GroupState { id = "group-0", ballIds = new[] { "camera-0", "camera-1" } } };
_app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms();
point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.End(point); _app.Input.BeginSplit();
_app.Input.Begin(point, false); _app.Input.End(point); yield return new WaitForSecondsRealtime(.1f);
Assert.That(_app.Input.SelectedIds, Is.EquivalentTo(new[] { "camera-0" })); Assert.That(_app.Orbit.IsAssisting, Is.False);
Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation));
}
[UnityTest] public IEnumerator SplitDragAssistsAndReturnsWithPreviewExactlyMatchingCommittedBothHalves()
{
Inject(new Vector3(-1, 2, 0), new Vector3(0, 2, 0), new Vector3(1, 2, 0));
_store.Save.latestFreePlay.groups = new[] { new GroupState { id = "group-0", ballIds = new[] { "camera-0", "camera-1", "camera-2" } } };
_app.Initialize(_store); _app.ContinueFreePlay(); _app.Voice.Clear(); Physics.SyncTransforms();
var camera = _app.Presenter.Camera; var original = camera.transform.rotation; var manual = JsonUtility.ToJson(_app.Orbit.Snapshot());
var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.End(point); _app.Input.BeginSplit();
_app.Input.Begin(point, false); _app.Input.End(point); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(-55, 25));
yield return new WaitForSecondsRealtime(.4f);
Assert.That(_app.Orbit.IsAssisting, Is.True); Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.GreaterThan(2));
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(1));
var preview = _app.Presenter.Balls.ToDictionary(pair => pair.Key, pair => pair.Value.transform.position);
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual));
_app.Input.End(point + new Vector2(-55, 25)); yield return null;
Assert.That(_app.Session.Board.Groups.Length, Is.EqualTo(2));
Assert.That(_app.Session.Board.Balls.Select(ball => ball.id), Is.EquivalentTo(preview.Keys));
foreach (var ball in _app.Session.Board.Balls) Assert.That(BoardPresenter.ToVector(ball.position), Is.EqualTo(preview[ball.id]));
Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay.camera), Is.EqualTo(manual));
yield return new WaitForSecondsRealtime(1.5f); Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.LessThan(.1));
}
[UnityTest] public IEnumerator CancelPinchOutsidePauseAndNavigationRestorePreviewCameraAndSavedState()
{
foreach (var mode in new[] { "cancel", "pinch", "outside", "pause", "navigation" })
{
Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1));
var camera = _app.Presenter.Camera; var original = camera.transform.rotation;
var saved = JsonUtility.ToJson(_store.Save); var manual = JsonUtility.ToJson(_app.Orbit.Snapshot());
var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(24, 0));
yield return new WaitForSecondsRealtime(.25f);
Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.GreaterThan(2), mode);
switch (mode)
{
case "cancel": _app.Input.Cancel(); break;
case "pinch": _app.Input.BeginPinch(100, false); _app.Input.Pinch(125); _app.Input.End(point, true); break;
case "outside": _app.Input.Move(new Vector2(-1, point.y)); break;
case "pause": _app.Input.SendMessage("OnApplicationPause", true); break;
case "navigation": _app.ShowHome(); _app.ContinueFreePlay(); break;
}
yield return new WaitForSecondsRealtime(1.5f);
Assert.That(_app.Orbit.IsAssisting, Is.False, mode); Assert.That(Quaternion.Angle(original, camera.transform.rotation), Is.LessThan(.1), mode);
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual), mode);
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved), mode);
foreach (var ball in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[ball.id].transform.position, Is.EqualTo(BoardPresenter.ToVector(ball.position)), mode);
}
}
[UnityTest] public IEnumerator RegrabDuringReturnDoesNotSnapCameraOrChangeFirstHitSelection()
{
Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1));
var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(25, 0));
yield return new WaitForSecondsRealtime(.4f); _app.Input.Cancel(); yield return null;
var camera = _app.Presenter.Camera; var position = camera.transform.position; var rotation = camera.transform.rotation;
point = Point("camera-0"); Physics.SyncTransforms();
Assert.That(Physics.Raycast(camera.ScreenPointToRay(point), out var hit, 100), Is.True);
Assert.That(hit.collider.GetComponent<BallView>().BallId, Is.EqualTo("camera-0"));
_app.Input.Begin(point, false);
Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation));
_app.Input.End(point); Assert.That(_app.Input.SelectedGroup, Is.EqualTo("group-0"));
}
[UnityTest] public IEnumerator ValidManualRolledPoseRoundTripsAndInsidePropPoseIsCorrected()
{
Inject(new Vector3(0, 2, 0));
var rotation = Quaternion.Euler(25, 135, 45);
var state = new CameraState { target = new Position3(0, 2, 0), distance = 7, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) };
_app.Orbit.Apply(state);
Assert.That(Quaternion.Angle(rotation, _app.Presenter.Camera.transform.rotation), Is.LessThan(.001f));
_app.Session.CommitCamera(_app.Orbit.Snapshot());
var snapshot = JsonUtility.ToJson(_app.Orbit.Snapshot()); _app.ShowHome(); _app.ContinueFreePlay();
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(snapshot));
var obstacle = new GameObject("camera-test-solid"); obstacle.transform.SetParent(_app.Environment.transform); obstacle.transform.position = _app.Presenter.Camera.transform.position;
obstacle.AddComponent<BoxCollider>().size = Vector3.one * 2; Physics.SyncTransforms();
var balls = _app.Session.Board.Balls.Select(ball => (ball.id, ball.position)).ToArray();
_app.Orbit.Apply(state); yield return null;
Assert.That(obstacle.GetComponent<Collider>().bounds.Contains(_app.Presenter.Camera.transform.position), Is.False);
Assert.That(_app.Session.Board.Balls.Select(ball => (ball.id, ball.position)), Is.EqualTo(balls));
Object.Destroy(obstacle);
}
[UnityTest] public IEnumerator PinchTakesVisiblePoseWithoutSnapAndSavesOnlyAfterActualManualZoom()
{
Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 1));
var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(25, 0));
yield return new WaitForSecondsRealtime(.4f);
var camera = _app.Presenter.Camera; var position = camera.transform.position; var rotation = camera.transform.rotation;
var saved = JsonUtility.ToJson(_store.Save); int writes = _store.Writes;
_app.Input.BeginPinch(100, false);
Assert.That(camera.transform.position, Is.EqualTo(position)); Assert.That(camera.transform.rotation, Is.EqualTo(rotation));
Assert.That(_app.Orbit.IsAssisting, Is.False); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved));
_app.Input.Pinch(110);
Assert.That(Quaternion.Angle(camera.transform.rotation, rotation), Is.LessThan(.001f));
Assert.That(Vector3.Distance(camera.transform.position, position), Is.LessThan(1));
var manual = JsonUtility.ToJson(_app.Orbit.Snapshot()); Assert.That(_store.Writes, Is.EqualTo(writes));
_app.Input.End(point); Assert.That(_store.Writes, Is.EqualTo(writes + 1));
Assert.That(JsonUtility.ToJson(_store.Save.latestFreePlay.camera), Is.EqualTo(manual));
_app.ShowHome(); _app.ContinueFreePlay(); Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manual));
}
[UnityTest] public IEnumerator NearbyFriendBehindAThinPropIsNotAnInteractionCameraCandidate()
{
Inject(new Vector3(-1, 2, 0), new Vector3(1.5f, 2, 1));
var wall = new GameObject("camera-test-blocked-approach"); wall.transform.SetParent(_app.Environment.transform);
wall.transform.position = new Vector3(.3f, 2, .5f); wall.AddComponent<BoxCollider>().size = new Vector3(.02f, 5, 5); Physics.SyncTransforms();
var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(22, 0));
yield return new WaitForSecondsRealtime(.2f);
Assert.That(_app.Orbit.IsAssisting, Is.True); Assert.That(_app.Orbit.TrackedFriend, Is.Null); Assert.That(_app.Presenter.MergeCandidate, Is.Null);
_app.Input.Cancel(); Object.Destroy(wall);
}
[UnityTest] public IEnumerator EntireRequestedPitchOrbitBlockedByLargePropStillFindsAnExteriorCamera()
{
Inject(new Vector3(0, 2, 0));
var state = _app.Orbit.Snapshot(); var box = new GameObject("camera-test-large-terrain"); box.transform.SetParent(_app.Environment.transform);
box.transform.position = new Vector3(0, 10, -7); box.AddComponent<BoxCollider>().size = new Vector3(12, 25, 12); Physics.SyncTransforms();
_app.Orbit.Apply(state); yield return null;
var camera = _app.Presenter.Camera;
Assert.That(box.GetComponent<Collider>().bounds.Contains(camera.transform.position), Is.False);
Assert.That(camera.transform.position.y, Is.GreaterThan(_app.Environment.PlaygroundBounds.min.y));
Assert.That(_app.Orbit.Snapshot().distance, Is.InRange(3, 15));
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("camera-0"));
Assert.That(_app.Session.Board.Balls.Single().position, Is.EqualTo(new Position3(0, 2, 0)));
Object.Destroy(box);
}
[UnityTest] public IEnumerator PhoneAndTabletAssistanceKeepsGrabAndNearbyFriendInFrameWithVisibleNumbers()
{
foreach (var size in new[] { new Vector2Int(2400, 1080), new Vector2Int(1600, 1200) })
{
Inject(new Vector3(-1, 2, 0), new Vector3(1, 2, 2));
var camera = _app.Presenter.Camera; camera.aspect = (float)size.x / size.y;
var point = Point("camera-0"); Assert.That(Physics.Raycast(camera.ScreenPointToRay(point), out var hit, 100), Is.True);
var offset = hit.point - _app.Presenter.Balls["camera-0"].transform.position;
_app.Input.Begin(point, false); var pointer = point + new Vector2(25, 0); _app.Input.Move(pointer);
var preview = _app.Presenter.Balls["camera-0"].transform.position;
float until = Time.realtimeSinceStartup + .6f;
while (Time.realtimeSinceStartup < until)
{
yield return null; _app.Input.Move(pointer);
Assert.That(_app.Presenter.Balls["camera-0"].transform.position, Is.EqualTo(preview));
Assert.That(Vector2.Distance(camera.WorldToScreenPoint(preview + offset), pointer), Is.LessThan(.1f), "The grabbed surface remains under the stationary finger while camera moves.");
}
foreach (var ball in _app.Presenter.Balls.Values)
{
var viewport = camera.WorldToViewportPoint(ball.transform.position);
Assert.That(viewport.z, Is.GreaterThan(camera.nearClipPlane));
Assert.That(viewport.x, Is.InRange(.12f, .88f)); Assert.That(viewport.y, Is.InRange(.12f, .88f));
}
_app.Presenter.SendMessage("LateUpdate");
foreach (var badge in _app.Presenter.GetComponentsInChildren<GroupBadge>())
{
var number = badge.GetComponentInChildren<TMPro.TextMeshPro>(); number.ForceMeshUpdate();
Assert.That(number.text, Is.EqualTo("1")); Assert.That(number.textInfo.characterInfo[0].isVisible, Is.True);
var character = number.textInfo.characterInfo[0];
foreach (var vertex in new[] { character.bottomLeft, character.topLeft, character.topRight, character.bottomRight })
{
var view = camera.WorldToViewportPoint(number.transform.TransformPoint(vertex));
Assert.That(view.z, Is.GreaterThan(camera.nearClipPlane)); Assert.That(view.x, Is.InRange(0, 1)); Assert.That(view.y, Is.InRange(0, 1));
}
}
Capture(size, "drag-assisted-" + size.x + "x" + size.y);
_app.Input.Cancel();
}
}
private void Capture(Vector2Int size, string name)
{
string directory = System.Environment.GetEnvironmentVariable("MAGNETISM_DRAG_CAMERA_EVIDENCE");
if (string.IsNullOrEmpty(directory)) return;
var camera = _app.Presenter.Camera; var canvas = _app.GetComponentInChildren<Canvas>();
var renderMode = canvas.renderMode; var canvasCamera = canvas.worldCamera; float planeDistance = canvas.planeDistance;
var target = new RenderTexture(size.x, size.y, 24); var oldTarget = camera.targetTexture; var active = RenderTexture.active;
var pixels = new Texture2D(size.x, size.y, TextureFormat.RGB24, false);
try
{
canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = camera; canvas.planeDistance = .5f;
camera.targetTexture = target; camera.Render(); RenderTexture.active = target;
pixels.ReadPixels(new Rect(0, 0, size.x, size.y), 0, 0); pixels.Apply();
System.IO.File.WriteAllBytes(System.IO.Path.Combine(directory, name + ".png"), pixels.EncodeToPNG());
WriteRecords(name + ".txt", new[] { "Actual GameApp prefab Camera.Render with simulated viewport; Editor result, not physical phone/tablet input or performance.",
"camera=" + camera.transform.position + ";rotation=" + camera.transform.rotation + ";tracked=" + _app.Orbit.TrackedFriend + ";manual=" + JsonUtility.ToJson(_app.Orbit.Snapshot()) });
}
finally
{
camera.targetTexture = oldTarget; RenderTexture.active = active; target.Release(); Object.Destroy(target); Object.Destroy(pixels);
canvas.renderMode = renderMode; canvas.worldCamera = canvasCamera; canvas.planeDistance = planeDistance;
}
}
[UnityTest] public IEnumerator ThirtyOriginalStageBoardsRemainDiscoverableFromValidAboveGroundManualViews()
{
foreach (var collider in _disabledScenery) if (collider != null) collider.enabled = true;
Physics.SyncTransforms();
var discoveries = new System.Collections.Generic.List<string>();
var dragProof = new System.Collections.Generic.List<string>();
int totalDragged = 0;
for (int stage = 1; stage <= 30; stage++)
{
_app.StartStage(stage); _app.Voice.Clear(); yield return null;
var before = _app.Session.Board.Balls.Select(ball => (ball.id, ball.position)).ToArray();
var visible = new System.Collections.Generic.HashSet<string>(); var original = _app.Orbit.Snapshot();
var dragged = new System.Collections.Generic.HashSet<string>();
var positions = _app.Session.Board.Balls.ToDictionary(ball => ball.id, ball => BoardPresenter.ToVector(ball.position));
var boardSnapshot = JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera));
var saved = JsonUtility.ToJson(_store.Save); int writes = _store.Writes;
WriteRecords($"initial-stage-{stage}-board-before.json", new[] { boardSnapshot });
WriteRecords($"initial-stage-{stage}-save-before.json", new[] { saved });
var grabField = _app.Input.GetType().GetField("_ballId", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
foreach (float pitch in new[] { 15f, 35f, 60f, 80f })
for (float yaw = 0; yaw < 360; yaw += 15)
{
var rotation = Quaternion.Euler(pitch, yaw, 0);
_app.Orbit.Apply(new CameraState { target = original.target, distance = 12, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) });
var camera = _app.Presenter.Camera; Physics.SyncTransforms(); _app.Presenter.SendMessage("LateUpdate");
var badges = _app.Presenter.GetComponentsInChildren<GroupBadge>();
Assert.That(camera.transform.position.y, Is.GreaterThan(_app.Environment.PlaygroundBounds.min.y));
foreach (var ball in _app.Session.Board.Balls)
{
var world = BoardPresenter.ToVector(ball.position); var view = camera.WorldToViewportPoint(world);
if (view.z <= camera.nearClipPlane || view.x < .04f || view.x > .96f || view.y < .25f || view.y > .84f) continue;
if (!Physics.Raycast(camera.ScreenPointToRay(camera.WorldToScreenPoint(world)), out var hit, 100) || hit.collider.GetComponent<BallView>()?.BallId != ball.id) continue;
var group = _app.Session.Board.Groups.First(item => item.ballIds.Contains(ball.id));
int groupIndex = System.Array.FindIndex(_app.Session.Board.Groups, item => item.id == group.id);
var number = badges[groupIndex].GetComponentInChildren<TMPro.TextMeshPro>(); number.ForceMeshUpdate();
Assert.That(number.text, Is.EqualTo(group.ballIds.Length.ToString()), "The actual group number must match membership in every explored original stage.");
bool readable = true;
foreach (var character in number.textInfo.characterInfo.Take(number.textInfo.characterCount))
{
readable &= character.isVisible;
foreach (var vertex in new[] { character.bottomLeft, character.topLeft, character.topRight, character.bottomRight })
{
var glyphPoint = camera.WorldToViewportPoint(number.transform.TransformPoint(vertex));
readable &= glyphPoint.z > camera.nearClipPlane && glyphPoint.x > 0 && glyphPoint.x < 1 && glyphPoint.y > .2f && glyphPoint.y < .9f;
}
}
var glyphCenter = number.transform.position; var glyphRay = new Ray(camera.transform.position, (glyphCenter - camera.transform.position).normalized);
readable &= !Physics.Raycast(glyphRay, Vector3.Distance(camera.transform.position, glyphCenter) - .01f);
if (!readable) continue;
if (visible.Add(ball.id)) discoveries.Add($"stage={stage};id={ball.id};position={world};camera={camera.transform.position};pitch={pitch};yaw={yaw};visibleGlyph={number.text};glyph={number.transform.position}");
if (dragged.Contains(ball.id) || view.x < .221f || view.x > .771f || view.y < .306f || view.y > .741f) continue;
foreach (var direction in new[] { Vector2.up, Vector2.down, Vector2.left, Vector2.right })
{
var manual = _app.Orbit.Snapshot(); var manualJson = JsonUtility.ToJson(manual);
var point = (Vector2)camera.WorldToScreenPoint(world); var end = point + direction * 40;
var endView = new Vector2(end.x / Screen.width, end.y / Screen.height);
if (endView.x < .221f || endView.x > .771f || endView.y < .306f || endView.y > .741f) continue;
_app.Input.Begin(point, false); Assert.That(grabField.GetValue(_app.Input), Is.EqualTo(ball.id), "Begin must grab this exact initial sphere ID.");
_app.Input.Move(end);
var preview = _app.Presenter.Balls[ball.id].transform.position;
float pixels = Vector2.Distance(camera.WorldToScreenPoint(preview), point); var delta = _app.Presenter.DropDelta;
bool moved = pixels >= 25 && Vector3.Distance(preview, world) >= .05f && _app.Orbit.IsAssisting;
Assert.That(_app.Presenter.CanDropGroup(group.id), Is.True, "Every whole-sphere preview remains safe against the actual ground and scenery.");
Assert.That(_app.Presenter.Balls.Keys, Is.EquivalentTo(positions.Keys));
foreach (var member in _app.Session.Board.Balls)
Assert.That(_app.Presenter.Balls[member.id].transform.position, Is.EqualTo(positions[member.id] + (group.ballIds.Contains(member.id) ? delta : Vector3.zero)), "Only the grabbed whole group translates, preserving its shape.");
Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera)), Is.EqualTo(boardSnapshot));
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manualJson));
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes));
var attempt = $"stage={stage};id={ball.id};grabbed={grabField.GetValue(_app.Input)};group={group.id};members={string.Join(",", group.ballIds)};initial={world};preview={preview};delta={delta};pointerStart={point};pointerEnd={end};pointerStartViewport={view};pointerEndViewport={endView};previewWorldDistance={Vector3.Distance(preview, world):R};previewPixels={pixels:R};passed={moved};manual={manualJson};coreUnchanged=True;saveWrites={writes};sceneryEnabled={_app.Environment.GetComponentsInChildren<Collider>().Count(item => item.enabled && item.gameObject.activeInHierarchy && !item.isTrigger)}";
_app.Input.Cancel();
foreach (var member in _app.Session.Board.Balls) Assert.That(_app.Presenter.Balls[member.id].transform.position, Is.EqualTo(positions[member.id]), "Cancel restores every initial sphere exactly.");
Assert.That(_app.Presenter.DropDelta, Is.EqualTo(Vector3.zero)); Assert.That(_app.Presenter.MergeCandidate, Is.Null);
Assert.That(JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera)), Is.EqualTo(boardSnapshot));
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes));
Assert.That(JsonUtility.ToJson(_app.Orbit.Snapshot()), Is.EqualTo(manualJson));
// The camera has not advanced a frame between Move and Cancel; verify the live return settles without a fixed sleep.
float deadline = Time.realtimeSinceStartup + 2;
while (_app.Orbit.IsReturning && Time.realtimeSinceStartup < deadline) yield return null;
Assert.That(_app.Orbit.IsReturning, Is.False);
Assert.That(Vector3.Distance(camera.transform.position, BoardPresenter.ToVector(manual.target) + Vector3.up * .65f
- new Quaternion(manual.rotation.x, manual.rotation.y, manual.rotation.z, manual.rotation.w) * Vector3.forward * manual.distance), Is.LessThan(.0011f));
Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved)); Assert.That(_store.Writes, Is.EqualTo(writes));
dragProof.Add(attempt + ";cancelRestored=True;manualRestored=True;idsShapeMembershipPreserved=True;saveUnchanged=True");
WriteRecords("initial-173-drag-attempts.txt", dragProof);
if (moved) { dragged.Add(ball.id); totalDragged++; break; }
}
}
}
Assert.That(visible, Is.EquivalentTo(_app.Session.Board.Balls.Select(ball => ball.id)), "Actual sphere first-hit discovery stage " + stage);
WriteRecords($"initial-stage-{stage}-board-after.json", new[] { JsonUtility.ToJson(_app.Session.Board.ToPuzzleState(_app.Session.Camera)) });
Assert.That(dragged, Is.EquivalentTo(_app.Session.Board.Balls.Select(ball => ball.id)), "Every original sphere ID must actually move at least 25 screen pixels through Input, then cancel without committing stage " + stage);
Assert.That(_app.Session.Board.Balls.Select(ball => (ball.id, ball.position)), Is.EqualTo(before));
}
Assert.That(totalDragged, Is.EqualTo(173));
WriteRecords("thirty-stage-first-hit-discovery.txt", discoveries);
WriteRecords("initial-173-drag-attempts.txt", dragProof);
}
[UnityTest] public IEnumerator ObstructedReturnRetracesTheActualSafeAssistPathWithoutCameraSnap()
{
Inject(new Vector3(-1.5f, 2, -1), new Vector3(.5f, 2, 2));
var camera = _app.Presenter.Camera; var manualPosition = camera.transform.position; var manualRotation = camera.transform.rotation;
var point = Point("camera-0"); _app.Input.Begin(point, false); _app.Input.Move(point + new Vector2(20, 0));
yield return new WaitForSecondsRealtime(.7f); var assisted = camera.transform.position;
_app.Input.Cancel(); yield return new WaitForSecondsRealtime(1.5f);
var obstacle = new GameObject("camera-test-return-shortcut-obstacle"); obstacle.transform.SetParent(_app.Environment.transform);
obstacle.transform.position = Vector3.Lerp(manualPosition, assisted, .5f); obstacle.AddComponent<BoxCollider>().size = Vector3.one * .15f; Physics.SyncTransforms();
point = Point("camera-0"); _app.Input.Begin(point, false); var pointer = point + new Vector2(20, 0); _app.Input.Move(pointer);
yield return new WaitForSecondsRealtime(.8f);
Assert.That(Vector3.Distance(camera.transform.position, assisted), Is.LessThan(.1f), "The original curved assistance path must remain traversable with a blocked straight return chord.");
Assert.That(Physics.SphereCastAll(camera.transform.position, .35f, (manualPosition - camera.transform.position).normalized, Vector3.Distance(camera.transform.position, manualPosition)).Any(hit => hit.collider == obstacle.GetComponent<Collider>()), Is.True);
var saved = JsonUtility.ToJson(_store.Save); _app.Input.Cancel(); float until = Time.realtimeSinceStartup + 4;
var records = new System.Collections.Generic.List<string>();
while (_app.Orbit.IsReturning && Time.realtimeSinceStartup < until)
{
var previous = camera.transform.position; yield return null;
records.Add("previous=" + previous + ";position=" + camera.transform.position + ";rotation=" + camera.transform.rotation);
bool crossed = Physics.SphereCastAll(previous, .35f, (camera.transform.position - previous).normalized, Vector3.Distance(previous, camera.transform.position)).Any(hit => hit.collider == obstacle.GetComponent<Collider>());
WriteRecords("obstructed-return-frames.txt", records);
Assert.That(crossed, Is.False);
Assert.That(Vector3.Distance(previous, manualPosition) > .1f && Vector3.Distance(camera.transform.position, manualPosition) < .001f, Is.False, "A blocked return cannot instantly snap to the baseline camera.");
}
WriteRecords("safe-obstructed-return.txt", records);
Assert.That(_app.Orbit.IsReturning, Is.False); Assert.That(Vector3.Distance(camera.transform.position, manualPosition), Is.LessThan(.002f));
Assert.That(Quaternion.Angle(camera.transform.rotation, manualRotation), Is.LessThan(.02)); Assert.That(JsonUtility.ToJson(_store.Save), Is.EqualTo(saved));
Object.Destroy(obstacle);
}
[UnityTest] public IEnumerator UndergroundSavedViewIsCorrectedWithoutChangingBallIdsOrPositions()
{
Inject(new Vector3(0, 2, 0));
var board = JsonUtility.ToJson(_app.Session.Board.Balls);
var rotation = Quaternion.Euler(-90, 0, 0);
_app.Orbit.Apply(new CameraState { target = new Position3(0, 2, 0), distance = 8, rotation = new Rotation4(rotation.x, rotation.y, rotation.z, rotation.w) });
yield return null;
Assert.That(_app.Presenter.Camera.transform.position.y, Is.GreaterThan(0));
Assert.That(JsonUtility.ToJson(_app.Session.Board.Balls), Is.EqualTo(board));
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1fed67558eff50b4a85be0f7cf14aa35
@@ -0,0 +1,575 @@
#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, _otherTouchscreen;
private InputSettings _originalInputSettings, _testInputSettings;
private HideFlags _originalSettingsFlags;
private readonly System.Collections.Generic.List<string> _touchTrace = new System.Collections.Generic.List<string>();
private void TraceTouch(UnityEngine.InputSystem.EnhancedTouch.Finger finger)
{
var touch = finger.lastTouch;
_touchTrace.Add($"{Time.frameCount},{finger.screen.deviceId},{Touchscreen.current?.deviceId},{touch.touchId},{touch.phase},{touch.screenPosition.x:R},{touch.screenPosition.y:R}");
}
[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;
UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Enable();
_touchTrace.Clear(); _touchTrace.Add("frame,sourceDevice,currentDevice,touchId,phase,pointerX,pointerY");
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown += TraceTouch;
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove += TraceTouch;
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp += TraceTouch;
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); _app.Presenter.BindEnvironment(_app.Environment); _app.Orbit.BindEnvironment(_app.Environment); yield return null;
}
[UnityTearDown] public IEnumerator TearDown()
{
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerDown -= TraceTouch;
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerMove -= TraceTouch;
UnityEngine.InputSystem.EnhancedTouch.Touch.onFingerUp -= TraceTouch;
UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Disable();
var evidence = System.Environment.GetEnvironmentVariable("MAGNETISM_TOUCH_EVIDENCE");
if (!string.IsNullOrEmpty(evidence) && _touchTrace.Count > 1)
{
System.IO.Directory.CreateDirectory(evidence);
System.IO.File.WriteAllLines(System.IO.Path.Combine(evidence, TestContext.CurrentContext.Test.Name + ".csv"), _touchTrace);
}
if (_touchscreen != null) InputSystem.RemoveDevice(_touchscreen);
if (_otherTouchscreen != null) InputSystem.RemoveDevice(_otherTouchscreen); 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 FirstAndSecondDragAfterAnotherTouchscreenBothUseTheActualPointerDelta()
{
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _otherTouchscreen = InputSystem.AddDevice<Touchscreen>();
_app.StartStage(1); yield return null; Physics.SyncTransforms();
InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 201, phase = UnityEngine.InputSystem.TouchPhase.Began, position = new Vector2(5, 5) });
InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 201, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(5, 5) });
yield return null; yield return null;
Assert.That(Touchscreen.current, Is.SameAs(_otherTouchscreen));
var camera = JsonUtility.ToJson(_app.Session.Camera); var id = _app.Session.Board.Balls.Single().id;
for (int attempt = 0; attempt < 2; attempt++)
{
Physics.SyncTransforms(); var start = BallPoint(); var end = start + new Vector2(-40, 0);
var before = BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position);
var ray = _app.Presenter.Camera.ScreenPointToRay(start); Physics.Raycast(ray, out var hit, 100);
var plane = new Plane(_app.Presenter.Camera.transform.forward, hit.point);
Assert.That(plane.Raycast(ray, out var from), Is.True);
var endRay = _app.Presenter.Camera.ScreenPointToRay(end); Assert.That(plane.Raycast(endRay, out var to), Is.True);
var expected = before + endRay.GetPoint(to) - ray.GetPoint(from);
QueueTouch(202 + attempt, UnityEngine.InputSystem.TouchPhase.Began, start);
QueueTouch(202 + attempt, UnityEngine.InputSystem.TouchPhase.Moved, end);
QueueTouch(202 + attempt, UnityEngine.InputSystem.TouchPhase.Ended, end);
yield return null; yield return null;
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo(id));
Assert.That(Vector3.Distance(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position), expected), Is.LessThan(.001f), "Both the first and second real queued drag must retain Began and the pointer direction.");
Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(camera)); Assert.That(_app.Session.Board.CountedCount, Is.Zero);
yield return new WaitForSecondsRealtime(1.5f);
}
}
[UnityTest] public IEnumerator SwitchedTouchscreenBoundaryDragAndHeldReleaseCommitTheSameLastSafePosition()
{
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _otherTouchscreen = InputSystem.AddDevice<Touchscreen>();
_store.Saved.completedStageIds = new[] { 1 };
for (int attempt = 0; attempt < 2; attempt++)
{
_store.Saved.latestFreePlay = new FreePlayState { sourceStageId = 1,
balls = new[] { new BallState { id = "native-drag-0", position = new Position3(-6.8f, 4, 0) } },
groups = new[] { new GroupState { id = "native-drag-group", ballIds = new[] { "native-drag-0" } } },
camera = new CameraState { target = new Position3(-6.8f, 3.35f, 0), rotation = new Rotation4(0, 0, 0, 1), distance = 14 } };
_app.Initialize(_store); _app.ContinueFreePlay(); yield return null; Physics.SyncTransforms();
InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Began, position = new Vector2(5, 5) });
InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 1, phase = UnityEngine.InputSystem.TouchPhase.Ended, position = new Vector2(5, 5) });
yield return null; yield return null;
var saved = JsonUtility.ToJson(_store.Saved); var camera = JsonUtility.ToJson(_app.Session.Camera);
var start = BallPoint(); var end = start - new Vector2(Screen.width * 193f / 2400f, 0);
QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Began, start);
QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Moved, end);
yield return null; yield return null;
var preview = _app.Presenter.Balls.Single().Value.transform.position;
Assert.That(preview.x, Is.EqualTo(-8).Within(.004f)); Assert.That(preview.y, Is.EqualTo(4).Within(.004f)); Assert.That(preview.z, Is.EqualTo(0).Within(.004f));
yield return new WaitForSecondsRealtime(.7f);
Assert.That(_app.Presenter.Balls.Single().Value.transform.position, Is.EqualTo(preview));
Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved), "Camera assistance and a held pointer cannot save a preview.");
QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Ended, end); yield return null; yield return null;
Assert.That(_app.Session.Board.Balls.Single().id, Is.EqualTo("native-drag-0"));
Assert.That(_app.Session.Board.Groups.Single().ballIds, Is.EqualTo(new[] { "native-drag-0" }));
Assert.That(Vector3.Distance(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().position), preview), Is.LessThan(.001f));
Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(camera));
Assert.That(_store.Saved.completedStageIds, Is.EqualTo(new[] { 1 })); Assert.That(_store.Saved.puzzleStates, Is.Empty);
yield return new WaitForSecondsRealtime(1.5f);
}
}
[UnityTest] public IEnumerator AnotherTouchscreenWithTheSameIdCannotMovePinchOrEndTheHeldFriend()
{
_touchscreen = InputSystem.AddDevice<Touchscreen>(); _otherTouchscreen = InputSystem.AddDevice<Touchscreen>();
_app.StartStage(1); yield return null; Physics.SyncTransforms();
var start = BallPoint(); var saved = JsonUtility.ToJson(_store.Saved); var camera = JsonUtility.ToJson(_app.Session.Camera);
QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Began, start); yield return null; yield return null;
QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Moved, start + new Vector2(-40, 0)); yield return null; yield return null;
var preview = _app.Presenter.Balls.Values.Single().transform.position;
foreach (var phase in new[] { UnityEngine.InputSystem.TouchPhase.Began, UnityEngine.InputSystem.TouchPhase.Moved, UnityEngine.InputSystem.TouchPhase.Ended })
{
InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 1, phase = phase, position = start + new Vector2(100, 30) });
InputSystem.QueueStateEvent(_otherTouchscreen, new TouchState { touchId = 2, phase = phase, position = start + new Vector2(200, 30) });
yield return null; yield return null;
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(preview));
Assert.That(_app.Orbit.IsAssisting, Is.True); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
}
QueueTouch(1, UnityEngine.InputSystem.TouchPhase.Canceled, start + new Vector2(-40, 0)); yield return null; yield return null;
Assert.That(_app.Session.Board.CollectedBallIds, Is.Empty); Assert.That(JsonUtility.ToJson(_store.Saved), Is.EqualTo(saved));
Assert.That(JsonUtility.ToJson(_app.Session.Camera), Is.EqualTo(camera));
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(BoardPresenter.ToVector(_app.Session.Board.Balls.Single().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 GroupNumberCorrectedUndergroundViewStaysAboveAndVisible() => 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) });
Assert.That(_app.Presenter.Camera.transform.position.y, Is.GreaterThan(-.45f), "Saved underground views must be corrected above the clearing.");
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 BackgroundInteriorDragStopsAndCommitsLastSafeSurfacePosition()
{
_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;
var stopped = _app.Session.Board.Balls.Single().position;
Assert.That(stopped.y, Is.LessThan(initial.y));
Assert.That(stopped.y, Is.GreaterThanOrEqualTo(_app.Environment.PlaygroundBounds.min.y + .499f));
Assert.That(_app.Presenter.IsSafePosition(stopped), Is.True);
Assert.That(_app.Presenter.Balls.Values.Single().transform.position, Is.EqualTo(BoardPresenter.ToVector(stopped)));
Assert.That(_app.Orbit.Snapshot().rotation, Is.EqualTo(camera.rotation)); Assert.That(JsonUtility.ToJson(_store.Saved), Is.Not.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 VisibleClearingColliderMatchesItsMeshAndHiddenLegacyFloorDoesNotBlock()
{
_app.StartStage(1); yield return null; Physics.SyncTransforms();
var floor = _app.transform.Find("Playground/PlaygroundIsland");
Assert.That(floor.GetComponent<Collider>() == null, Is.True, "The hidden legacy floor must have no collider.");
var clearing = _app.Environment.GetComponentsInChildren<MeshCollider>().Single(collider => collider.name == "MeadowClearing");
Assert.That(clearing.enabled && !clearing.isTrigger, Is.True);
Assert.That(clearing.sharedMesh, Is.SameAs(clearing.GetComponent<MeshFilter>().sharedMesh));
var ray = new Ray(new Vector3(0, .1f, 0), Vector3.down);
Assert.That(clearing.Raycast(ray, out var surface, 2), Is.True);
Assert.That(Physics.Raycast(ray, out var actual, 2), Is.True);
Assert.That(actual.collider, Is.SameAs(clearing)); Assert.That(actual.point, Is.EqualTo(surface.point));
Assert.That(surface.point.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"]
}

Some files were not shown because too many files have changed in this diff Show More