diff --git a/.gitignore b/.gitignore index 85667481..d1e4adfc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ tmp/ build/ dist/ node_modules/ +runtime/.godot/ test-results/ prototype/gallery/ *.log diff --git a/AGENTS.md b/AGENTS.md index a87b648d..b65f61c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,13 @@ This file narrows the portfolio rules for this repository. ## Current Stage -The repository is design-only. Agents may research, specify, diagram, simulate, -review, and build non-runtime HTML design prototypes. They may not create game -runtime code, engine projects, production packages, store records, or bulk final -art/audio without a later owner-approved Goal. +Phase 8 design is Owner-accepted. Phase 9 Godot vertical-slice development is +authorized only through the active Goal named by `CURRENT_CHECKPOINT.md`. +Runtime source belongs under `runtime/`; accepted HTML design authority remains +under `prototype/` and may not be silently rewritten to fit implementation. +The current P9.1 increment owns opening Company and first-Market preparation +only. Battle, result/recovery, authoritative persistence, bulk final media, +device packaging, stores and release remain frozen until their later Goals. ## Originality Boundary @@ -42,4 +45,5 @@ consistent, independently reviewed, and owner-approved: 8. Interactive design prototype and owner review. Human visual, comprehension, fun, listening, device, packaging, store, and -release acceptance remain separate gates. +release acceptance remain separate gates. A P9.1 runtime pass does not authorize +P9.2 battle implementation. diff --git a/CURRENT_CHECKPOINT.md b/CURRENT_CHECKPOINT.md index e35a2276..db69fcb5 100644 --- a/CURRENT_CHECKPOINT.md +++ b/CURRENT_CHECKPOINT.md @@ -1,8 +1,8 @@ # Current Checkpoint -Active Goal: `docs/goals/PHASE8_EXPERIENCE_PROTOTYPE_GOAL.md` -Active contract: `docs/presentation/PHASE8_EXPERIENCE_PROTOTYPE_CONTRACT.md` -Active ledger: `docs/reviews/PHASE8_EXPERIENCE_PROTOTYPE_LEDGER.md` +Active Goal: `docs/goals/PHASE9_GODOT_VERTICAL_SLICE_GOAL.md` +Active contract: `docs/runtime/PHASE9_GODOT_VERTICAL_SLICE_CONTRACT.md` +Active ledger: `docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md` Baseline: `b3968b0f41f841efb0f699d8fb04a9bcb958182a` Accepted P8.1 source: `0b4a64fe549af0ff965e654aa4f5fb7f2ff054b4` P8.2 candidate source: `86a8a1adec39de40b4adfee6b01033407e3e3421` @@ -220,7 +220,7 @@ the self-contained bundle SHA-256 is `20d66e39270b51d82716629b8affc48c0edb6064ee1c8c6c8d87c54b0fe81aaa`. Attempt 01 is `INVALID_HARNESS_TERMINAL` and is not reused. -Stage: `P8_5_OWNER_ACCEPTED_PHASE8_COMPLETE` +Stage: `P9_1_OPENING_MARKET_RUNTIME_ACTIVE` On 2026-08-12 the Owner reviewed the published integrated loop and replied `这个OK了`. Phase 8 comprehension, visual direction, choice flow and the @@ -229,7 +229,11 @@ remain temporary and human listening is not claimed. Authoritative combat/save, final media/audio, Godot/runtime, device, packaging, store and release remain frozen and require their own evidence. -Shortest next action: draft and obtain authorization for a Phase 9 Godot -vertical-slice Goal, split into owner-playable opening/Market, battle, -result/recovery/save and physical-device increments. Do not start runtime code -under the current design-only repository contract. +Owner authorization on 2026-08-12 opens Phase 9 as four separately accepted +runtime increments. P9.1 alone is active: implement the accepted opening +Company and first-Market preparation in a landscape Godot 4.6 project under +`runtime/`. P9.2 battle, P9.3 result/recovery/persistence, P9.4 device delivery, +final media, packaging, stores and release remain frozen. + +Shortest next action: create and verify the bounded P9.1 Godot runtime without +starting battle or authoritative save implementation. diff --git a/README.md b/README.md index b32fd7fc..a8f09edc 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ An original landscape 2D Japanese hand-drawn guild-expedition strategy game. -This repository is currently in **pre-development design review**. Formal game -implementation, bulk final-asset production, packaging, and publishing are -forbidden until the owner approves the complete product, content, presentation, -balance, and interactive-design package. +Phase 8 design is Owner-accepted. The repository is now in **Phase 9 Godot +vertical-slice development**, delivered as separately playable increments. +P9.1 implements the opening Company and first-Market preparation only. Battle, +result/recovery, persistence, bulk final media, packaging and publishing remain +frozen until their later Owner-authorized Goals. ## Current Product Boundary @@ -25,6 +26,20 @@ The game may learn structural lessons from authorized private reference research, but its setting, characters, enemies, names, art, audio, UI, progression, formulas, and expression must be original. +## Play The P9.1 Godot Runtime + +Requires Godot `4.6.1.stable`. From the repository root: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot --path runtime +``` + +The playable boundary is Contract/difficulty, stable four-Recruit inspection, +exactly-two Company creation, separate Mael/Ilya hires, `EQ-012` purchase and +equip, one Party-order swap and the readiness receipt. P9.1 deliberately stops +before battle. Simplified Chinese is the default; English, 100/130% text, +reduced motion and mute controls are in the fixed top bar. + ## Review The Phase 7 Production UI Atlas The owner-facing HTML in [`prototype/site/`](prototype/site/) is the current diff --git a/docs/goals/PHASE9_GODOT_VERTICAL_SLICE_GOAL.md b/docs/goals/PHASE9_GODOT_VERTICAL_SLICE_GOAL.md new file mode 100644 index 00000000..452aa831 --- /dev/null +++ b/docs/goals/PHASE9_GODOT_VERTICAL_SLICE_GOAL.md @@ -0,0 +1,90 @@ +# Phase 9 Godot Vertical Slice Goal + +## Owner Decision + +On 2026-08-12 the Owner accepted P8.5 and authorized the next Godot stage. The +runtime must be delivered in separately playable increments; a later increment +cannot silently repair or reinterpret an unaccepted earlier one. + +## Baseline + +Phase 8 Owner-acceptance closure: +`8583abb65e5ad59d684281e06f5052ebfc2740c6` + +## Decision Enabled + +Decide whether the accepted Aetherbound Guild opening and first Market remain +clear, responsive and visually coherent when implemented as a real Godot +landscape mobile runtime rather than an HTML design prototype. + +## Delivery Sequence + +| Increment | Runtime boundary | Gate before advancing | +|---|---|---| +| `P9.1` | Contract/difficulty, stable four Recruit offers, exactly-two Company commit, two Market hires, one equipment purchase/equip, one order swap and readiness receipt. | Owner can complete the real touch flow and explain the resulting Company, Coin and equipment state. | +| `P9.2` | Real horizontal automatic battle, pause/speed/inspect and deterministic first encounter. | Owner accepts movement, clarity, causality and feel. | +| `P9.3` | Result, permanent casualty, reward, recovery, save/version/migration and second-cycle continuation. | Owner accepts the full runtime loop and persistence. | +| `P9.4` | Physical-device project/build and bounded iPhone/Android evidence. | Separate device and packaging authorization. | + +## P9.1 Owned Scope + +- Create the Godot 4.6 landscape runtime under `runtime/`. +- Preserve accepted IDs and values from P8.1/P8.2. +- Present `CTR-R1-01`, three difficulties and four stable offers. +- Require explicit inspect and exactly two selected Recruits before review. +- Fix the accepted integrated path to `OFF-03` Tomas and `OFF-04` Sable for the + one-tap guided path, while still allowing other legal two-of-four pairs. +- Commit the opening once at Company `2/4`, Coin `52`. +- Hire Mael and Ilya separately for `14 Coin` each. +- Purchase `EQ-012 Open-Line Cord` for `12 Coin`, then explicitly equip one + current Company member; the accepted fixed evidence path equips Tomas. +- Require one tap-source/tap-destination order swap before the final receipt. +- Finish at Coin `12`, Company `4/4`, `EQ-012 -> OFF-03` with a visible risk + handoff that is locked to P9.2. +- Provide visual selection, transaction, error and receipt feedback, captioned + prototype SFX, mute, reduced-motion and 100/130% text controls. + +## P9.1 Acceptance + +1. Fresh launch starts before Contract selection and no offer is preselected. +2. Invalid next/confirm actions explain the missing condition and do not mutate + state. +3. Four offer IDs remain stable through inspect, select, revise and commit. +4. A third Recruit selection is rejected until one selected Recruit is removed. +5. Opening commit occurs once and yields Company `2`, Coin `52`. +6. Mael and Ilya are separate one-shot 14-Coin hire transactions; Company moves + `2 -> 3 -> 4` and Coin `52 -> 38 -> 24`. +7. `EQ-012` purchase and equip are separate; Coin moves `24 -> 12` and Tomas + owns exactly one active item. +8. Order uses a visible tap-source/tap-destination swap, can be revised before + commit, and produces one readiness receipt. +9. Real `Input.parse_input_event` ScreenTouch drives the automated mobile path; + direct callbacks cannot support the touch claim. +10. Normal rendering covers opening, offer selection, Market, equipment, order + and receipt at `1280x720`, `844x390` and `390x844`, EN and zh_CN, 130% text, + reduced motion and mute without overlap or horizontal overflow. +11. Focused domain and runtime tests have exact terminal markers and clean logs; + no script/parse/resource/import/ObjectDB failure or owned process remains. + +## Frozen + +- P9.2 automatic battle and all enemy outcomes. +- P9.3 casualty, reward, recovery and authoritative persistence. +- Final/bulk character, environment, animation, VFX, music, SFX and voice. +- iOS/Android export, device install, TestFlight/Play, stores and publishing. +- Phase 8 accepted values, identities and transaction boundaries. + +## Evidence Boundary + +Automated evidence may prove deterministic state, input routing, layout, +rendering, logs and cleanup. Agent screenshots support visual review only. The +Owner alone accepts comprehension, feel, visual quality, sound and willingness +to continue. + +P9.1 legal terminals are: + +`Aetherbound Guild P9.1 opening and Market runtime candidate ready` + +or + +`Aetherbound Guild P9.1 redesign required: ` diff --git a/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md b/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md new file mode 100644 index 00000000..a1041aa2 --- /dev/null +++ b/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md @@ -0,0 +1,8 @@ +# Phase 9 Godot Vertical Slice Ledger + +Append-only record. Automated, agent, Owner, device and release evidence remain +separate. + +| Date | Revision/run | Result | Evidence and boundary | +|---|---|---|---| +| 2026-08-12 | Phase 8 closure `8583abb` | `P9_AUTHORIZED_P9_1_ACTIVE` | Owner accepted P8.5 and authorized the next development stage. P9.1 owns only the real Godot opening Company and first Market. P9.2 battle, P9.3 result/recovery/save, P9.4 device delivery, final media and release remain frozen. | diff --git a/docs/runtime/PHASE9_GODOT_VERTICAL_SLICE_CONTRACT.md b/docs/runtime/PHASE9_GODOT_VERTICAL_SLICE_CONTRACT.md new file mode 100644 index 00000000..e03e1b8a --- /dev/null +++ b/docs/runtime/PHASE9_GODOT_VERTICAL_SLICE_CONTRACT.md @@ -0,0 +1,64 @@ +# Phase 9 Godot Vertical Slice Contract + +## Runtime Envelope + +- Engine: Godot `4.6.1.stable`. +- Project root: `runtime/`. +- Orientation: landscape, reference viewport `1280x720`, stretch `canvas_items`. +- Input: touch-first; mouse is a desktop convenience and not mobile evidence. +- Languages: Simplified Chinese default and English. +- Rendering: `gl_compatibility`, mobile-safe 2D only for P9.1. +- Offline: no account, analytics, network or purchase dependency. + +## P9.1 State Machine + +```text +contract -> offers -> opening_review -> opening_receipt + -> market_hire_mael -> market_hire_ilya -> equipment_purchase + -> equipment_equip -> order -> readiness_receipt +``` + +Legal reverse paths before commits are offers-to-contract, opening-review-to- +offers, and order revision. Opening, each hire, purchase, equip and readiness +commit are one-shot. Repeated input returns the current state and cannot +duplicate Recruit, Coin, equipment or receipt mutations. + +## Fixed Runtime Data + +- Contract: `CTR-R1-01 First Crossing`. +- Opening Coin: `52`; Company capacity: `4`. +- Stable offers: `OFF-01` Elian/Hearthguard, `OFF-02` Nia/Farstring, `OFF-03` + Tomas/Wayrunner, `OFF-04` Sable/Kindhand. +- Market offers: `MKT-R1-01` Mael/Hearthguard and `MKT-R1-02` Ilya/Farstring, + each `14 Coin`. +- Equipment: `EQ-012 Open-Line Cord`, `12 Coin`, explicitly equipped to one + current Company member; fixed evidence equips `OFF-03` Tomas. +- Final readiness: Coin `12`, Company `4/4`; exposed-front direction remains + on the right and protected rear on the left in both locales. + +## Presentation Contract + +The opening frame reads in three seconds as a traveling fantasy Company about +to cross a dangerous suspended route. Priority is (1) current decision and +primary action, (2) Company/Recruits, (3) Coin/capacity/receipt state. Existing +accepted candidate scene, actor and equipment assets are reused; they remain +candidate production assets, not final art. + +Selection must change card border, check mark and count. A transaction must +show the old and new resource values, a short visual pulse and a captioned +prototype sound cue. Disabled actions are visibly disabled and state why. +Reduced motion removes movement but preserves state change. Mute changes no +result. No tutorial wall, decorative nested cards or battle content is legal. + +## Test Contract + +- `ABG_P9_1_DOMAIN_OK`: state transitions, invalid boundaries, stable IDs, + one-shot transactions and exact final values. +- `ABG_P9_1_TOUCH_OK`: fresh runtime driven by parsed ScreenTouch events reaches + the readiness receipt with fallback/direct count zero. +- `ABG_P9_1_LAYOUT_OK`: all declared viewport/language/accessibility fixtures + remain within safe bounds. +- `ABG_P9_1_RUNTIME_CAPTURE_OK`: normal-render screenshots cover six required + states and strict runtime diagnostics are clean. + +Tests and tools may not implement product behavior or relax product guards. diff --git a/runtime/assets/actors/farstring.png b/runtime/assets/actors/farstring.png new file mode 100644 index 00000000..46a36ed8 Binary files /dev/null and b/runtime/assets/actors/farstring.png differ diff --git a/runtime/assets/actors/farstring.png.import b/runtime/assets/actors/farstring.png.import new file mode 100644 index 00000000..d1038db2 --- /dev/null +++ b/runtime/assets/actors/farstring.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crgvavrdgvrsx" +path="res://.godot/imported/farstring.png-fb55bd29a09d000a4eb4a9c2f0df28cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/actors/farstring.png" +dest_files=["res://.godot/imported/farstring.png-fb55bd29a09d000a4eb4a9c2f0df28cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/runtime/assets/actors/hearthguard.png b/runtime/assets/actors/hearthguard.png new file mode 100644 index 00000000..d17656b5 Binary files /dev/null and b/runtime/assets/actors/hearthguard.png differ diff --git a/runtime/assets/actors/hearthguard.png.import b/runtime/assets/actors/hearthguard.png.import new file mode 100644 index 00000000..5a8a9e31 --- /dev/null +++ b/runtime/assets/actors/hearthguard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://vreeajwsjvhg" +path="res://.godot/imported/hearthguard.png-7db04b4cd1b38b5ec77b736b71f3c27e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/actors/hearthguard.png" +dest_files=["res://.godot/imported/hearthguard.png-7db04b4cd1b38b5ec77b736b71f3c27e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/runtime/assets/actors/kindhand.png b/runtime/assets/actors/kindhand.png new file mode 100644 index 00000000..01c890c6 Binary files /dev/null and b/runtime/assets/actors/kindhand.png differ diff --git a/runtime/assets/actors/kindhand.png.import b/runtime/assets/actors/kindhand.png.import new file mode 100644 index 00000000..191fbd89 --- /dev/null +++ b/runtime/assets/actors/kindhand.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfqluqsy2s0uf" +path="res://.godot/imported/kindhand.png-44eb7b8d9418031c4f7a6bd9e24e6985.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/actors/kindhand.png" +dest_files=["res://.godot/imported/kindhand.png-44eb7b8d9418031c4f7a6bd9e24e6985.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/runtime/assets/actors/wayrunner.png b/runtime/assets/actors/wayrunner.png new file mode 100644 index 00000000..91f8b666 Binary files /dev/null and b/runtime/assets/actors/wayrunner.png differ diff --git a/runtime/assets/actors/wayrunner.png.import b/runtime/assets/actors/wayrunner.png.import new file mode 100644 index 00000000..96424bc2 --- /dev/null +++ b/runtime/assets/actors/wayrunner.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlrtmhin7a0ya" +path="res://.godot/imported/wayrunner.png-7f3a676cda23be3c7c8a44e20eca0b70.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/actors/wayrunner.png" +dest_files=["res://.godot/imported/wayrunner.png-7f3a676cda23be3c7c8a44e20eca0b70.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/runtime/assets/backgrounds/windglass_verge.webp b/runtime/assets/backgrounds/windglass_verge.webp new file mode 100644 index 00000000..07280e12 Binary files /dev/null and b/runtime/assets/backgrounds/windglass_verge.webp differ diff --git a/runtime/assets/backgrounds/windglass_verge.webp.import b/runtime/assets/backgrounds/windglass_verge.webp.import new file mode 100644 index 00000000..829beb03 --- /dev/null +++ b/runtime/assets/backgrounds/windglass_verge.webp.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qn0mnwhp1mpu" +path="res://.godot/imported/windglass_verge.webp-d6509e3be4205d17b690d8fbb1ae50c6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/backgrounds/windglass_verge.webp" +dest_files=["res://.godot/imported/windglass_verge.webp-d6509e3be4205d17b690d8fbb1ae50c6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/runtime/assets/equipment/eq_012_open_line_cord.png b/runtime/assets/equipment/eq_012_open_line_cord.png new file mode 100644 index 00000000..6190f2eb Binary files /dev/null and b/runtime/assets/equipment/eq_012_open_line_cord.png differ diff --git a/runtime/assets/equipment/eq_012_open_line_cord.png.import b/runtime/assets/equipment/eq_012_open_line_cord.png.import new file mode 100644 index 00000000..b775f1a9 --- /dev/null +++ b/runtime/assets/equipment/eq_012_open_line_cord.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cerj3hmo182hq" +path="res://.godot/imported/eq_012_open_line_cord.png-3c4696715b71e27781cdc118e11e7c9d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/equipment/eq_012_open_line_cord.png" +dest_files=["res://.godot/imported/eq_012_open_line_cord.png-3c4696715b71e27781cdc118e11e7c9d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/runtime/default_bus_layout.tres b/runtime/default_bus_layout.tres new file mode 100644 index 00000000..306ed969 --- /dev/null +++ b/runtime/default_bus_layout.tres @@ -0,0 +1,21 @@ +[gd_resource type="AudioBusLayout" format=3] + +[resource] +bus/0/name = &"Master" +bus/0/solo = false +bus/0/mute = false +bus/0/bypass_fx = false +bus/0/volume_db = 0.0 +bus/0/send = &"" +bus/1/name = &"Music" +bus/1/solo = false +bus/1/mute = false +bus/1/bypass_fx = false +bus/1/volume_db = -8.0 +bus/1/send = &"Master" +bus/2/name = &"SFX" +bus/2/solo = false +bus/2/mute = false +bus/2/bypass_fx = false +bus/2/volume_db = -3.0 +bus/2/send = &"Master" diff --git a/runtime/domain/run_state.gd b/runtime/domain/run_state.gd new file mode 100644 index 00000000..51c2e9cc --- /dev/null +++ b/runtime/domain/run_state.gd @@ -0,0 +1,230 @@ +class_name AetherboundRunState +extends RefCounted + +enum Step { + CONTRACT, + OFFERS, + OPENING_REVIEW, + OPENING_RECEIPT, + MARKET_MAEL, + MARKET_ILYA, + EQUIPMENT_PURCHASE, + EQUIPMENT_EQUIP, + ORDER, + READY, +} + +const CAPACITY := 4 +const OPENING_COIN := 52 +const HIRE_PRICE := 14 +const EQUIPMENT_PRICE := 12 +const CONTRACT_ID := "CTR-R1-01" +const EQUIPMENT_ID := "EQ-012" + +const OFFERS := [ + {"id": "OFF-01", "name": "Elian Rook", "profession": "Hearthguard", "trait": "Narrow Guard", "item": "EQ-002"}, + {"id": "OFF-02", "name": "Nia Orrow", "profession": "Farstring", "trait": "Patient Start", "item": "EQ-014"}, + {"id": "OFF-03", "name": "Tomas Vale", "profession": "Wayrunner", "trait": "Open Stance", "item": "EQ-001"}, + {"id": "OFF-04", "name": "Sable Kest", "profession": "Kindhand", "trait": "Shared Cup", "item": "EQ-009"}, +] + +const MARKET_RECRUITS := { + "MKT-R1-01": {"id": "MKT-R1-01", "name": "Mael Corin", "profession": "Hearthguard", "trait": "Steady Brace", "item": "EQ-002"}, + "MKT-R1-02": {"id": "MKT-R1-02", "name": "Ilya Venn", "profession": "Farstring", "trait": "Clear Measure", "item": "EQ-014"}, +} + +var step := Step.CONTRACT +var contract_selected := false +var difficulty := "" +var inspected: Array[String] = [] +var selected: Array[String] = [] +var company: Array[String] = [] +var coin := OPENING_COIN +var opening_committed := false +var committed_hires: Array[String] = [] +var equipment_purchased := false +var equipment_wearer := "" +var order_source := "" +var order_changed := false +var order_committed := false +var operation_counts := { + "opening": 0, + "MKT-R1-01": 0, + "MKT-R1-02": 0, + "purchase": 0, + "equip": 0, + "order": 0, +} + +func select_contract() -> Dictionary: + if step != Step.CONTRACT: + return _reject("wrong_step") + contract_selected = true + return _ok("contract_selected") + +func select_difficulty(value: String) -> Dictionary: + if step != Step.CONTRACT or not contract_selected: + return _reject("contract_required") + if value not in ["wayfinder", "standard", "oathbound"]: + return _reject("invalid_difficulty") + difficulty = value + return _ok("difficulty_selected") + +func open_offers() -> Dictionary: + if step != Step.CONTRACT: + return _reject("wrong_step") + if not contract_selected or difficulty.is_empty(): + return _reject("contract_and_difficulty_required") + step = Step.OFFERS + return _ok("offers_opened") + +func inspect_offer(id: String) -> Dictionary: + if step != Step.OFFERS or not _is_offer(id): + return _reject("invalid_offer") + if id not in inspected: + inspected.append(id) + return _ok("offer_inspected") + +func toggle_offer(id: String) -> Dictionary: + if step != Step.OFFERS or not _is_offer(id): + return _reject("invalid_offer") + if id in selected: + selected.erase(id) + return _ok("offer_removed") + if selected.size() >= 2: + return _reject("exactly_two_limit") + selected.append(id) + return _ok("offer_selected") + +func review_opening() -> Dictionary: + if step != Step.OFFERS: + return _reject("wrong_step") + if selected.size() != 2: + return _reject("exactly_two_required") + step = Step.OPENING_REVIEW + return _ok("opening_reviewed") + +func revise_opening() -> Dictionary: + if step != Step.OPENING_REVIEW or opening_committed: + return _reject("opening_locked") + step = Step.OFFERS + return _ok("opening_revised") + +func commit_opening() -> Dictionary: + if opening_committed: + return _reject("opening_already_committed") + if step != Step.OPENING_REVIEW or selected.size() != 2: + return _reject("opening_not_ready") + opening_committed = true + operation_counts.opening += 1 + company.assign(selected) + step = Step.OPENING_RECEIPT + return _ok("opening_committed") + +func open_market() -> Dictionary: + if step != Step.OPENING_RECEIPT or not opening_committed: + return _reject("opening_receipt_required") + step = Step.MARKET_MAEL + return _ok("market_opened") + +func hire(id: String) -> Dictionary: + var expected := "MKT-R1-01" if step == Step.MARKET_MAEL else "MKT-R1-02" if step == Step.MARKET_ILYA else "" + if id != expected or not MARKET_RECRUITS.has(id): + return _reject("wrong_hire") + if id in committed_hires: + return _reject("hire_already_committed") + if company.size() >= CAPACITY or coin < HIRE_PRICE: + return _reject("hire_ineligible") + coin -= HIRE_PRICE + company.append(id) + committed_hires.append(id) + operation_counts[id] += 1 + step = Step.MARKET_ILYA if id == "MKT-R1-01" else Step.EQUIPMENT_PURCHASE + return _ok("hire_committed") + +func purchase_equipment() -> Dictionary: + if equipment_purchased: + return _reject("equipment_already_purchased") + if step != Step.EQUIPMENT_PURCHASE or coin < EQUIPMENT_PRICE: + return _reject("purchase_ineligible") + coin -= EQUIPMENT_PRICE + equipment_purchased = true + operation_counts.purchase += 1 + step = Step.EQUIPMENT_EQUIP + return _ok("equipment_purchased") + +func equip_member(id: String) -> Dictionary: + if not equipment_purchased or step != Step.EQUIPMENT_EQUIP: + return _reject("equipment_not_ready") + if equipment_wearer == id: + return _reject("equipment_already_equipped") + if id not in company: + return _reject("wearer_not_in_company") + equipment_wearer = id + operation_counts.equip += 1 + step = Step.ORDER + return _ok("equipment_equipped") + +func choose_order_unit(id: String) -> Dictionary: + if step != Step.ORDER or id not in company: + return _reject("invalid_order_unit") + if order_source.is_empty(): + order_source = id + return _ok("order_source_selected") + if order_source == id: + order_source = "" + return _ok("order_source_cleared") + var first := company.find(order_source) + var second := company.find(id) + var temporary := company[first] + company[first] = company[second] + company[second] = temporary + order_source = "" + order_changed = true + return _ok("order_swapped") + +func commit_order() -> Dictionary: + if order_committed: + return _reject("order_already_committed") + if step != Step.ORDER or not order_changed or not order_source.is_empty(): + return _reject("order_change_required") + order_committed = true + operation_counts.order += 1 + step = Step.READY + return _ok("readiness_committed") + +func snapshot() -> Dictionary: + return { + "step": step, + "contract": CONTRACT_ID if contract_selected else "", + "difficulty": difficulty, + "inspected": inspected.duplicate(), + "selected": selected.duplicate(), + "company": company.duplicate(), + "coin": coin, + "equipment_purchased": equipment_purchased, + "equipment_wearer": equipment_wearer, + "order_changed": order_changed, + "order_committed": order_committed, + "operation_counts": operation_counts.duplicate(true), + } + +func offer(id: String) -> Dictionary: + for entry in OFFERS: + if entry.id == id: + return entry + if MARKET_RECRUITS.has(id): + return MARKET_RECRUITS[id] + return {} + +func _is_offer(id: String) -> bool: + for entry in OFFERS: + if entry.id == id: + return true + return false + +func _ok(code: String) -> Dictionary: + return {"ok": true, "code": code} + +func _reject(code: String) -> Dictionary: + return {"ok": false, "code": code} diff --git a/runtime/domain/run_state.gd.uid b/runtime/domain/run_state.gd.uid new file mode 100644 index 00000000..7acf950e --- /dev/null +++ b/runtime/domain/run_state.gd.uid @@ -0,0 +1 @@ +uid://chbk3xwe4lyft diff --git a/runtime/main.gd b/runtime/main.gd new file mode 100644 index 00000000..6705589d --- /dev/null +++ b/runtime/main.gd @@ -0,0 +1,934 @@ +extends Control + +const RunState = preload("res://domain/run_state.gd") + +const BG := preload("res://assets/backgrounds/windglass_verge.webp") +const ACTORS := { + "Hearthguard": preload("res://assets/actors/hearthguard.png"), + "Farstring": preload("res://assets/actors/farstring.png"), + "Wayrunner": preload("res://assets/actors/wayrunner.png"), + "Kindhand": preload("res://assets/actors/kindhand.png"), +} +const EQUIPMENT := preload("res://assets/equipment/eq_012_open_line_cord.png") + +const COLOR_INK := Color("#14242a") +const COLOR_PANEL := Color("#f4f6f2") +const COLOR_PANEL_SOFT := Color("#e2f0ec") +const COLOR_GOLD := Color("#e3b52f") +const COLOR_TEAL := Color("#197c78") +const COLOR_CORAL := Color("#c85156") +const COLOR_WHITE := Color("#ffffff") +const COLOR_MUTED := Color("#aebcb9") + +var run := RunState.new() +var locale := "zh_CN" +var muted := false +var reduced_motion := false +var text_scale := 1.0 +var notice := "" +var inspected_id := "" +var root_panel: Control +var content: Control +var decision_scroll: ScrollContainer +var coin_label: Label +var company_label: Label +var step_label: Label +var status_label: Label +var primary_action: Button +var audio_player: AudioStreamPlayer +var sfx_playback: AudioStreamGeneratorPlayback + + +func _ready() -> void: + build_shell() + render() + + +func _exit_tree() -> void: + if audio_player != null: + audio_player.stop() + sfx_playback = null + audio_player = null + + +func build_shell() -> void: + var background := TextureRect.new() + background.name = "Background" + background.texture = BG + background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(background) + + var wash := ColorRect.new() + wash.color = Color(0.025, 0.07, 0.085, 0.76) + wash.mouse_filter = Control.MOUSE_FILTER_IGNORE + wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(wash) + + var safe := MarginContainer.new() + safe.name = "SafeArea" + var outer_margin := 12 if compact_layout() else 18 if stacked_chrome() else 24 + safe.add_theme_constant_override("margin_left", outer_margin) + safe.add_theme_constant_override("margin_right", outer_margin) + safe.add_theme_constant_override("margin_top", 12 if compact_layout() else 18) + safe.add_theme_constant_override("margin_bottom", 12 if compact_layout() else 18) + safe.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(safe) + + root_panel = VBoxContainer.new() + root_panel.name = "RuntimeShell" + root_panel.add_theme_constant_override("separation", 10) + safe.add_child(root_panel) + + root_panel.add_child(build_top_bar()) + var decision_panel := PanelContainer.new() + decision_panel.name = "CurrentDecision" + decision_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + decision_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + decision_panel.add_theme_stylebox_override("panel", style_box(Color(0.035, 0.095, 0.11, 0.91), COLOR_MUTED, 1, 6)) + root_panel.add_child(decision_panel) + decision_scroll = ScrollContainer.new() + decision_scroll.name = "DecisionScroll" + decision_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + decision_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + decision_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + decision_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + decision_scroll.scroll_deadzone = 12 + decision_panel.add_child(decision_scroll) + content = VBoxContainer.new() + content.name = "DecisionBody" + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + decision_scroll.add_child(content) + root_panel.add_child(build_footer()) + + var generator := AudioStreamGenerator.new() + generator.mix_rate = 22050.0 + generator.buffer_length = 0.2 + audio_player = AudioStreamPlayer.new() + audio_player.name = "PrototypeSFX" + audio_player.bus = &"SFX" + audio_player.stream = generator + add_child(audio_player) + audio_player.play() + sfx_playback = audio_player.get_stream_playback() + + +func build_top_bar() -> Control: + var panel := PanelContainer.new() + panel.name = "TopBar" + panel.custom_minimum_size.y = 58 + panel.add_theme_stylebox_override("panel", style_box(Color(0.045, 0.105, 0.12, 0.96), Color("#6e858a"), 1, 5)) + var shell: BoxContainer + if stacked_chrome(): + shell = VBoxContainer.new() + else: + shell = HBoxContainer.new() + shell.add_theme_constant_override("separation", 8) + panel.add_child(shell) + var identity_row: HBoxContainer + if stacked_chrome(): + identity_row = HBoxContainer.new() + identity_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + identity_row.add_theme_constant_override("separation", 8) + shell.add_child(identity_row) + else: + identity_row = shell as HBoxContainer + + var brand := Label.new() + brand.text = "AG" + brand.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + brand.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + brand.custom_minimum_size = Vector2(46, 38) + brand.add_theme_color_override("font_color", COLOR_INK) + brand.add_theme_font_size_override("font_size", 18) + brand.add_theme_stylebox_override("normal", style_box(COLOR_GOLD, Color("#f8da76"), 1, 4)) + identity_row.add_child(brand) + + var identity := VBoxContainer.new() + identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL + identity.custom_minimum_size.x = 0 + identity.clip_contents = true + var title := Label.new() + title.text = "AETHERBOUND GUILD" + title.custom_minimum_size.x = 0 + title.clip_text = true + title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + title.add_theme_color_override("font_color", COLOR_WHITE) + title.add_theme_font_size_override("font_size", 18) + identity.add_child(title) + step_label = Label.new() + step_label.custom_minimum_size.x = 0 + step_label.clip_text = true + step_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + step_label.add_theme_color_override("font_color", Color("#9bd8ca")) + step_label.add_theme_font_size_override("font_size", 11) + identity.add_child(step_label) + identity_row.add_child(identity) + + coin_label = resource_label("COIN 52") + coin_label.name = "CoinLabel" + company_label = resource_label("COMPANY 0 / 4") + company_label.name = "CompanyLabel" + var tools: Container + if compact_layout(): + var grid := GridContainer.new() + grid.columns = 2 + tools = grid + elif stacked_chrome(): + tools = HBoxContainer.new() + else: + tools = shell + tools.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tools.add_theme_constant_override("h_separation", 8) + tools.add_theme_constant_override("v_separation", 6) + if stacked_chrome(): + shell.add_child(tools) + tools.add_child(coin_label) + tools.add_child(company_label) + tools.add_child(tool_button("LocaleButton", "简 / EN", toggle_locale)) + tools.add_child(tool_button("TextScaleButton", "Aa", toggle_text_scale)) + tools.add_child(tool_button("MotionButton", "~", toggle_motion)) + tools.add_child(tool_button("MuteButton", "♪", toggle_mute)) + return panel + + +func build_footer() -> Control: + var panel := PanelContainer.new() + panel.name = "Footer" + panel.custom_minimum_size.y = 60 + panel.add_theme_stylebox_override("panel", style_box(Color(0.045, 0.105, 0.12, 0.96), Color("#6e858a"), 1, 5)) + var row: BoxContainer + if compact_layout(): + row = VBoxContainer.new() + else: + row = HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + panel.add_child(row) + status_label = Label.new() + status_label.name = "StatusCaption" + status_label.text = tr_text("选择契约开始;无操作会自动提交。", "Choose a Contract to begin; nothing commits automatically.") + status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + status_label.custom_minimum_size.x = 0 + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + status_label.add_theme_color_override("font_color", Color("#d4e5df")) + status_label.add_theme_font_size_override("font_size", 12) + row.add_child(status_label) + primary_action = Button.new() + primary_action.name = "PrimaryAction" + primary_action.custom_minimum_size = Vector2(190, 46) + if compact_layout(): + primary_action.size_flags_horizontal = Control.SIZE_EXPAND_FILL + primary_action.add_theme_stylebox_override("normal", style_box(COLOR_GOLD, Color("#f8da76"), 1, 4)) + primary_action.add_theme_stylebox_override("hover", style_box(Color("#f0c94d"), COLOR_WHITE, 1, 4)) + primary_action.add_theme_stylebox_override("disabled", style_box(Color("#526267"), Color("#65777b"), 1, 4)) + primary_action.add_theme_color_override("font_color", COLOR_INK) + primary_action.add_theme_color_override("font_disabled_color", Color("#aeb8b7")) + primary_action.add_theme_font_size_override("font_size", 13) + row.add_child(primary_action) + return panel + + +func render() -> void: + for child in content.get_children(): + child.queue_free() + update_hud() + for connection in primary_action.pressed.get_connections(): + primary_action.pressed.disconnect(connection.callable) + match run.step: + RunState.Step.CONTRACT: + render_contract() + RunState.Step.OFFERS: + render_offers() + RunState.Step.OPENING_REVIEW: + render_opening_review() + RunState.Step.OPENING_RECEIPT: + render_opening_receipt() + RunState.Step.MARKET_MAEL: + render_market_hire("MKT-R1-01") + RunState.Step.MARKET_ILYA: + render_market_hire("MKT-R1-02") + RunState.Step.EQUIPMENT_PURCHASE: + render_equipment_purchase() + RunState.Step.EQUIPMENT_EQUIP: + render_equipment_equip() + RunState.Step.ORDER: + render_order() + RunState.Step.READY: + render_ready() + update_footer() + + +func render_contract() -> void: + var layout := stage_layout() + layout.add_child(page_header("P9.1 · 01", tr_text("选择首次穿越契约", "Choose the First Crossing Contract"), tr_text("先确认已知压力,再选择难度;当前不会生成队员或消耗金币。", "Review the known pressure, then choose difficulty. No Recruit or Coin changes yet."))) + var columns: BoxContainer + if compact_layout(): + columns = VBoxContainer.new() + else: + columns = HBoxContainer.new() + columns.size_flags_vertical = Control.SIZE_EXPAND_FILL + columns.add_theme_constant_override("separation", 12) + layout.add_child(columns) + + var contract := selectable_panel(run.contract_selected) + contract.name = "ContractCard" + contract.size_flags_horizontal = Control.SIZE_EXPAND_FILL + contract.add_child(stack([ + kicker("RG-01 · CTR-R1-01"), + heading(tr_text("首次穿越", "First Crossing"), 24), + body_text(tr_text("勘察悬浮晶板之间的安全通路", "Chart the suspended pane crossing")), + fact_row(tr_text("主要压力", "Pressure"), tr_text("击退 + 远程屏障", "Push + projectile screen")), + fact_row(tr_text("首领规则", "Boss rule"), tr_text("轮换伤害类型晶板", "Rotating damage-family panes")), + make_button("ContractSelect", tr_text("已选择契约", "Contract selected") if run.contract_selected else tr_text("选择此契约", "Select this Contract"), select_contract, true), + ])) + columns.add_child(contract) + + var difficulty := panel(COLOR_PANEL_SOFT) + difficulty.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var difficulty_stack := VBoxContainer.new() + difficulty_stack.add_theme_constant_override("separation", 8) + difficulty.add_child(difficulty_stack) + difficulty_stack.add_child(kicker(tr_text("选择难度", "CHOOSE DIFFICULTY"))) + for entry in [ + ["wayfinder", tr_text("向导", "Wayfinder"), tr_text("敌人生命与伤害 90%", "Enemy HP and damage 90%")], + ["standard", tr_text("标准", "Standard"), tr_text("完整系统 · 标准平衡", "Full systems · authored balance")], + ["oathbound", tr_text("誓约", "Oathbound"), tr_text("伤害 112% · 奖励 115%", "Damage 112% · rewards 115%")], + ]: + var button := make_button("Difficulty_%s" % entry[0], "%s\n%s" % [entry[1], entry[2]], func(): apply_result(run.select_difficulty(entry[0]), "difficulty"), false) + button.disabled = not run.contract_selected + button.button_pressed = run.difficulty == entry[0] + button.toggle_mode = true + difficulty_stack.add_child(button) + columns.add_child(difficulty) + set_primary(tr_text("载入四名固定候选", "Load four stable offers"), open_offers, not run.contract_selected or run.difficulty.is_empty()) + + +func render_offers() -> void: + var layout := stage_layout() + layout.add_child(page_header("P9.1 · 02", tr_text("查看四名队员,恰好选择两人", "Inspect four Recruits, choose exactly two"), tr_text("候选组保持不变;查看不会替你选择。", "The offer set stays stable; inspection never selects for you."))) + var cards := GridContainer.new() + cards.columns = responsive_columns(4) + cards.size_flags_vertical = Control.SIZE_EXPAND_FILL + cards.add_theme_constant_override("h_separation", 8) + layout.add_child(cards) + for entry in RunState.OFFERS: + cards.add_child(recruit_offer_card(entry)) + var inspect := panel(Color(0.88, 0.94, 0.92, 0.96)) + inspect.custom_minimum_size.y = 72 + if inspected_id.is_empty(): + inspect.add_child(body_text(tr_text("先点击“查看”了解优势、代价和初始装备。", "Inspect a Recruit to read their benefit, cost and starting equipment."))) + else: + var entry := run.offer(inspected_id) + inspect.add_child(fact_row(tr_text("当前查看", "INSPECTED"), "%s · %s · %s" % [entry.name, localized_profession(entry.profession), entry.item])) + layout.add_child(inspect) + set_primary(tr_text("审核初始队伍 (%d/2)" % run.selected.size(), "Review Company (%d/2)" % run.selected.size()), review_opening, run.selected.size() != 2) + + +func render_opening_review() -> void: + var layout := stage_layout() + layout.add_child(page_header("P9.1 · 03", tr_text("创建远征前确认", "Review before creating the expedition"), tr_text("确认后未选候选离开;这一步不消耗金币。", "Commit discards unchosen offers and costs no Coin."))) + layout.add_child(company_line(run.selected, false, "OpeningReviewLine")) + var terms := GridContainer.new() + terms.columns = responsive_columns(4) + terms.add_theme_constant_override("separation", 8) + for pair in [[tr_text("契约", "Contract"), "CTR-R1-01"], [tr_text("难度", "Difficulty"), run.difficulty], [tr_text("金币", "Coin"), "52 → 52"], [tr_text("队伍", "Company"), "0 → 2 / 4"]]: + terms.add_child(fact_tile(pair[0], pair[1])) + layout.add_child(terms) + var revise := make_button("ReviseOpening", tr_text("返回修改", "Revise"), revise_opening, false) + layout.add_child(revise) + set_primary(tr_text("创建这次远征", "Create this expedition"), commit_opening, false) + + +func render_opening_receipt() -> void: + var layout := receipt_layout("RUN-P9-001", tr_text("远征已创建", "Expedition created"), tr_text("唯一回执已提交;重复确认不会创建第二次。", "One receipt was committed; repeated confirm cannot create another run.")) + layout.add_child(company_line(run.company, false, "OpeningReceiptLine")) + layout.add_child(receipt_facts([[tr_text("金币", "Coin"), "52"], [tr_text("队伍", "Company"), "2 / 4"], [tr_text("容量", "Capacity"), "4"], [tr_text("下一步", "Next"), tr_text("第一集市", "First Market")]])) + set_primary(tr_text("进入第一集市", "Open First Market"), open_market, false) + + +func render_market_hire(id: String) -> void: + var entry := run.offer(id) + var layout := stage_layout() + layout.add_child(page_header("P9.1 · MARKET", tr_text("分开确认每次招募", "Commit each hire separately"), tr_text("查看不会扣费;当前队伍和金币始终可见。", "Inspection spends nothing; Company and Coin remain visible."))) + var columns: BoxContainer + if compact_layout(): + columns = VBoxContainer.new() + else: + columns = HBoxContainer.new() + columns.size_flags_vertical = Control.SIZE_EXPAND_FILL + columns.add_theme_constant_override("separation", 12) + layout.add_child(columns) + columns.add_child(company_line(run.company, true, "MarketCompanyLine")) + var offer := panel(COLOR_PANEL) + offer.name = "MarketOffer_%s" % id + offer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + offer.add_child(stack([ + actor_view(entry, 180), kicker(id), heading(entry.name, 20), + body_text("%s · %s" % [localized_profession(entry.profession), localized_trait(entry.trait)]), + fact_row(tr_text("价格", "Price"), "14 Coin"), + fact_row(tr_text("队伍变化", "Company delta"), "%d → %d / 4" % [run.company.size(), run.company.size() + 1]), + ])) + columns.add_child(offer) + set_primary(tr_text("招募 %s · 14金币" % entry.name, "Hire %s · 14 Coin" % entry.name), func(): hire(id), false) + + +func render_equipment_purchase() -> void: + var layout := stage_layout() + layout.add_child(page_header("P9.1 · EQUIPMENT", tr_text("购买改变行为的装备", "Buy equipment that changes behavior"), tr_text("购买与装备是两次独立操作;购买后仍在库存。", "Purchase and equip are separate; the item enters inventory first."))) + var columns: BoxContainer + if compact_layout(): + columns = VBoxContainer.new() + else: + columns = HBoxContainer.new() + columns.size_flags_vertical = Control.SIZE_EXPAND_FILL + layout.add_child(columns) + var image := TextureRect.new() + image.texture = EQUIPMENT + image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + image.custom_minimum_size = Vector2(180 if compact_layout() else 260, 150 if compact_layout() else 250) + columns.add_child(image) + var details := panel(COLOR_PANEL) + details.size_flags_horizontal = Control.SIZE_EXPAND_FILL + details.add_child(stack([kicker("EQ-012"), heading(tr_text("开阵系带", "Open-Line Cord"), 24), body_text(tr_text("前方没有相邻队员时,普通攻击射程 +1。", "With no neighbor ahead, basic attacks gain reach +1.")), fact_row(tr_text("代价", "Tradeoff"), tr_text("前方出现新队员会取消预留远距目标", "A new front neighbor cancels a reserved long target")), fact_row(tr_text("价格", "Price"), "12 Coin"), fact_row(tr_text("购买后", "After purchase"), "24 → 12 Coin")])) + columns.add_child(details) + set_primary(tr_text("购买 EQ-012 · 12金币", "Buy EQ-012 · 12 Coin"), purchase_equipment, false) + + +func render_equipment_equip() -> void: + var layout := stage_layout() + layout.add_child(page_header("P9.1 · EQUIP", tr_text("选择一名当前队员装备", "Choose a current wearer"), tr_text("每人只有一个有效装备槽;固定体验路径选择 Tomas。", "Each Recruit has one active slot; the fixed review path chooses Tomas."))) + var row := GridContainer.new() + row.columns = responsive_columns(4) + row.size_flags_vertical = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 8) + layout.add_child(row) + for id in run.company: + var entry := run.offer(id) + var button := Button.new() + button.name = "EquipTarget_%s" % id + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size.y = 240 if compact_layout() else 260 + button.add_theme_stylebox_override("normal", style_box(COLOR_PANEL, COLOR_MUTED, 1, 4)) + button.add_theme_stylebox_override("hover", style_box(Color("#fff5cb"), COLOR_GOLD, 2, 4)) + button.add_child(button_stack([actor_view(entry, 170), heading(entry.name, 15), body_text(localized_profession(entry.profession)), fact_row(tr_text("当前装备", "Current"), entry.item)])) + button.pressed.connect(func(): equip_member(id)) + row.add_child(button) + set_primary(tr_text("先选择装备者", "Choose a wearer"), func(): pass, true) + + +func render_order() -> void: + var layout := stage_layout() + layout.add_child(page_header("P9.1 · ORDER", tr_text("先点来源,再点目标位置", "Tap a source, then a destination"), tr_text("受保护后排始终在左,暴露前排始终在右。至少交换一次后才能确认。", "Protected rear stays left and exposed front stays right. Make at least one swap."))) + var direction := HBoxContainer.new() + direction.add_child(kicker(tr_text("受保护后排 ←", "PROTECTED REAR ←"))) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + direction.add_child(spacer) + direction.add_child(kicker(tr_text("→ 暴露前排", "→ EXPOSED FRONT"))) + layout.add_child(direction) + layout.add_child(order_line()) + var state_text := tr_text("请选择来源队员", "Choose a source Recruit") if run.order_source.is_empty() else tr_text("已选择 %s;请选择目标位置" % run.offer(run.order_source).name, "%s selected; choose destination" % run.offer(run.order_source).name) + layout.add_child(body_text(state_text)) + set_primary(tr_text("确认出战准备", "Commit readiness"), commit_order, not run.order_changed or not run.order_source.is_empty()) + + +func render_ready() -> void: + var layout := receipt_layout("READY-P9-001", tr_text("第一集市整备完成", "First Market preparation complete"), tr_text("你的队伍、金币、装备与站位已冻结到同一回执。", "Company, Coin, equipment and order are frozen in one readiness receipt.")) + layout.add_child(company_line(run.company, false, "ReadyCompanyLine")) + layout.add_child(receipt_facts([[tr_text("金币", "Coin"), "12"], [tr_text("队伍", "Company"), "4 / 4"], ["EQ-012", "→ %s" % run.offer(run.equipment_wearer).name], [tr_text("提交", "Commit"), "1 / 1"]])) + var handoff := panel(Color(0.95, 0.89, 0.68, 0.98)) + handoff.add_child(fact_row(tr_text("P9.2 未开启", "P9.2 LOCKED"), tr_text("自动战斗将在你确认本运行时体验后开发。", "Automatic battle begins only after your P9.1 review."))) + layout.add_child(handoff) + set_primary(tr_text("P9.1 到此结束", "End of P9.1"), func(): pass, true) + + +func stage_layout() -> VBoxContainer: + var margin := MarginContainer.new() + var horizontal := 10 if compact_layout() else 16 if stacked_chrome() else 22 + var vertical := 10 if compact_layout() else 12 if stacked_chrome() else 16 + margin.add_theme_constant_override("margin_left", horizontal) + margin.add_theme_constant_override("margin_right", horizontal) + margin.add_theme_constant_override("margin_top", vertical) + margin.add_theme_constant_override("margin_bottom", vertical) + margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 10) + margin.add_child(layout) + return layout + + +func receipt_layout(id: String, title: String, description: String) -> VBoxContainer: + var layout := stage_layout() + var header := HBoxContainer.new() + var check := Label.new() + check.text = "✓" + check.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + check.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + check.custom_minimum_size = Vector2(54, 54) + check.add_theme_color_override("font_color", COLOR_WHITE) + check.add_theme_font_size_override("font_size", 28) + check.add_theme_stylebox_override("normal", style_box(COLOR_TEAL, Color("#8ee2c7"), 1, 4)) + header.add_child(check) + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + copy.add_child(light_kicker(id)) + copy.add_child(light_heading(title, 23)) + copy.add_child(light_body(description)) + header.add_child(copy) + layout.add_child(header) + return layout + + +func recruit_offer_card(entry: Dictionary) -> Control: + var selected: bool = entry.id in run.selected + var card := selectable_panel(selected) + card.name = "OfferCard_%s" % entry.id + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 5) + card.add_child(box) + box.add_child(actor_view(entry, 105 if compact_layout() else 125 if stacked_chrome() else 150)) + box.add_child(kicker(entry.id)) + box.add_child(heading(entry.name, 15)) + box.add_child(body_text("%s · LV 1" % localized_profession(entry.profession))) + box.add_child(fact_row(tr_text("特性", "Trait"), localized_trait(entry.trait))) + box.add_child(fact_row(tr_text("装备", "Item"), entry.item)) + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 5) + var inspect := make_button("Inspect_%s" % entry.id, tr_text("已查看", "Inspected") if entry.id in run.inspected else tr_text("查看", "Inspect"), func(): inspect_offer(entry.id), false) + inspect.size_flags_horizontal = Control.SIZE_EXPAND_FILL + actions.add_child(inspect) + var choose := make_button("Select_%s" % entry.id, tr_text("✓ 已选择", "✓ Selected") if selected else tr_text("选择", "Select"), func(): toggle_offer(entry.id), true) + choose.size_flags_horizontal = Control.SIZE_EXPAND_FILL + actions.add_child(choose) + box.add_child(actions) + return card + + +func company_line(ids: Array, allow_slots: bool, node_name: String) -> Control: + var panel_node := PanelContainer.new() + panel_node.name = node_name + panel_node.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel_node.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel_node.add_theme_stylebox_override("panel", style_box(Color(0.9, 0.94, 0.92, 0.96), COLOR_TEAL, 1, 4)) + var line := GridContainer.new() + line.columns = responsive_columns(4) + line.add_theme_constant_override("separation", 6) + panel_node.add_child(line) + for id in ids: + var entry := run.offer(id) + var cell := panel(COLOR_PANEL) + cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL + cell.add_child(stack([actor_view(entry, 105 if compact_layout() else 125 if stacked_chrome() else 145), heading(entry.name, 13), body_text(localized_profession(entry.profession)), body_text(RunState.EQUIPMENT_ID if run.equipment_wearer == id else entry.item)])) + line.add_child(cell) + if allow_slots: + for index in range(RunState.CAPACITY - ids.size()): + var empty := panel(Color(0.83, 0.87, 0.85, 0.8)) + empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL + empty.add_child(stack([heading("+", 30), body_text(tr_text("空席位", "Open slot"))])) + line.add_child(empty) + return panel_node + + +func order_line() -> Control: + var line := GridContainer.new() + line.columns = responsive_columns(4) + line.name = "OrderLine" + line.size_flags_vertical = Control.SIZE_EXPAND_FILL + line.add_theme_constant_override("separation", 8) + for index in range(run.company.size()): + var id := run.company[index] + var entry := run.offer(id) + var button := Button.new() + button.name = "OrderUnit_%s" % id + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size.y = 240 if compact_layout() else 260 + button.add_theme_stylebox_override("normal", style_box(Color("#eef3ef"), COLOR_GOLD if run.order_source == id else COLOR_MUTED, 3 if run.order_source == id else 1, 4)) + button.add_theme_stylebox_override("hover", style_box(Color("#fff5cb"), COLOR_GOLD, 2, 4)) + button.add_child(button_stack([actor_view(entry, 110 if compact_layout() else 135 if stacked_chrome() else 155), kicker(str(run.company.size() - 1 - index)), heading(entry.name, 14), body_text(localized_profession(entry.profession)), body_text("EQ-012" if run.equipment_wearer == id else entry.item)])) + button.pressed.connect(func(): choose_order(id)) + line.add_child(button) + return line + + +func page_header(code: String, title: String, description: String) -> Control: + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 3) + box.add_child(light_kicker(code)) + box.add_child(light_heading(title, 24)) + box.add_child(light_body(description)) + return box + + +func receipt_facts(entries: Array) -> Control: + var row := GridContainer.new() + row.columns = responsive_columns(4) + row.add_theme_constant_override("separation", 8) + for entry in entries: + row.add_child(fact_tile(entry[0], entry[1])) + return row + + +func fact_tile(label: String, value: String) -> Control: + var tile := panel(COLOR_PANEL) + tile.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tile.add_child(stack([kicker(label), heading(value, 17)])) + return tile + + +func fact_row(label: String, value: String) -> Control: + var row: BoxContainer + if compact_layout(): + row = VBoxContainer.new() + else: + row = HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var left := Label.new() + left.text = label + left.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left.custom_minimum_size.x = 0 + left.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + left.add_theme_color_override("font_color", COLOR_TEAL) + left.add_theme_font_size_override("font_size", scaled(11)) + row.add_child(left) + var right := Label.new() + right.text = value + right.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT if compact_layout() else HORIZONTAL_ALIGNMENT_RIGHT + right.size_flags_horizontal = Control.SIZE_EXPAND_FILL + right.custom_minimum_size.x = 0 + right.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + right.add_theme_color_override("font_color", COLOR_INK) + right.add_theme_font_size_override("font_size", scaled(11)) + row.add_child(right) + return row + + +func actor_view(entry: Dictionary, height: float) -> TextureRect: + var image := TextureRect.new() + image.texture = ACTORS.get(entry.profession, ACTORS.Hearthguard) + image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + image.custom_minimum_size.y = height + image.mouse_filter = Control.MOUSE_FILTER_IGNORE + return image + + +func selectable_panel(selected: bool) -> PanelContainer: + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", style_box(Color("#fff7d8") if selected else COLOR_PANEL, COLOR_GOLD if selected else COLOR_MUTED, 3 if selected else 1, 5)) + return card + + +func panel(color: Color) -> PanelContainer: + var node := PanelContainer.new() + node.add_theme_stylebox_override("panel", style_box(color, Color("#9caeaa"), 1, 4)) + return node + + +func stack(nodes: Array) -> VBoxContainer: + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 5) + for node in nodes: + box.add_child(node) + return box + + +func button_stack(nodes: Array) -> VBoxContainer: + var box := stack(nodes) + box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + box.offset_left = 10 + box.offset_top = 8 + box.offset_right = -10 + box.offset_bottom = -8 + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + return box + + +func kicker(value: String) -> Label: + var label := Label.new() + label.text = value + label.add_theme_color_override("font_color", COLOR_TEAL) + label.add_theme_font_size_override("font_size", scaled(10)) + return label + + +func light_kicker(value: String) -> Label: + var label := kicker(value) + label.add_theme_color_override("font_color", Color("#63d5c8")) + return label + + +func heading(value: String, size: int) -> Label: + var label := Label.new() + label.text = value + label.add_theme_color_override("font_color", COLOR_INK) + label.add_theme_font_size_override("font_size", scaled(size)) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return label + + +func light_heading(value: String, size: int) -> Label: + var label := heading(value, size) + label.add_theme_color_override("font_color", COLOR_WHITE) + return label + + +func body_text(value: String) -> Label: + var label := Label.new() + label.text = value + label.add_theme_color_override("font_color", Color("#33474d")) + label.add_theme_font_size_override("font_size", scaled(11)) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return label + + +func light_body(value: String) -> Label: + var label := body_text(value) + label.add_theme_color_override("font_color", Color("#c8dad7")) + return label + + +func resource_label(value: String) -> Label: + var label := Label.new() + label.text = value + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.custom_minimum_size = Vector2(96, 46) + if stacked_chrome(): + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_color_override("font_color", COLOR_WHITE) + label.add_theme_font_size_override("font_size", 12) + label.add_theme_stylebox_override("normal", style_box(Color("#263a40"), Color("#6e858a"), 1, 4)) + return label + + +func tool_button(node_name: String, label: String, callback: Callable) -> Button: + var button := Button.new() + button.name = node_name + button.text = label + button.custom_minimum_size = Vector2(48, 46) + if stacked_chrome(): + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.add_theme_stylebox_override("normal", style_box(Color("#263a40"), Color("#6e858a"), 1, 4)) + button.add_theme_stylebox_override("hover", style_box(Color("#35525a"), COLOR_WHITE, 1, 4)) + button.add_theme_color_override("font_color", COLOR_WHITE) + button.pressed.connect(callback) + return button + + +func make_button(node_name: String, label: String, callback: Callable, accent: bool) -> Button: + var button := Button.new() + button.name = node_name + button.text = label + button.custom_minimum_size.y = 46 + button.add_theme_font_size_override("font_size", scaled(11)) + button.add_theme_color_override("font_color", COLOR_INK if accent else COLOR_WHITE) + button.add_theme_stylebox_override("normal", style_box(COLOR_GOLD if accent else Color("#263a40"), Color("#f7dc84") if accent else Color("#6e858a"), 1, 4)) + button.add_theme_stylebox_override("hover", style_box(Color("#f0c94d") if accent else Color("#35525a"), COLOR_WHITE, 1, 4)) + button.pressed.connect(callback) + return button + + +func style_box(fill: Color, border: Color, width: int, radius: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = fill + style.border_color = border + style.set_border_width_all(width) + style.set_corner_radius_all(radius) + style.content_margin_left = 10 + style.content_margin_right = 10 + style.content_margin_top = 8 + style.content_margin_bottom = 8 + return style + + +func set_primary(label: String, callback: Callable, disabled: bool) -> void: + primary_action.text = label + primary_action.disabled = disabled + primary_action.pressed.connect(callback) + + +func update_hud() -> void: + coin_label.text = "%s %d" % [tr_text("金币", "COIN"), run.coin] + company_label.text = "%s %d / 4" % [tr_text("队伍", "COMPANY"), run.company.size()] + step_label.text = step_name() if compact_layout() else "%s · %s" % [tr_text("P9.1 运行时垂直切片", "P9.1 RUNTIME VERTICAL SLICE"), step_name()] + + +func update_footer() -> void: + status_label.text = notice if not notice.is_empty() else step_guidance() + + +func step_name() -> String: + var names := { + RunState.Step.CONTRACT: tr_text("契约", "Contract"), RunState.Step.OFFERS: tr_text("初始队伍", "Opening Company"), RunState.Step.OPENING_REVIEW: tr_text("建队确认", "Company Review"), RunState.Step.OPENING_RECEIPT: tr_text("建队回执", "Creation Receipt"), RunState.Step.MARKET_MAEL: tr_text("招募 Mael", "Hire Mael"), RunState.Step.MARKET_ILYA: tr_text("招募 Ilya", "Hire Ilya"), RunState.Step.EQUIPMENT_PURCHASE: tr_text("购买装备", "Buy Equipment"), RunState.Step.EQUIPMENT_EQUIP: tr_text("选择装备者", "Choose Wearer"), RunState.Step.ORDER: tr_text("横队站位", "Party Order"), RunState.Step.READY: tr_text("整备回执", "Ready Receipt") + } + return names[run.step] + + +func step_guidance() -> String: + if run.step == RunState.Step.OFFERS: + return tr_text("已选择 %d/2 · 已查看 %d/4" % [run.selected.size(), run.inspected.size()], "Selected %d/2 · inspected %d/4" % [run.selected.size(), run.inspected.size()]) + if run.step == RunState.Step.ORDER: + return tr_text("选择两名队员交换站位;确认前可以继续修改。", "Select two Recruits to swap; revise freely before commit.") + return tr_text("当前操作不会影响后续冻结阶段。", "The current action cannot enter a later frozen phase.") + + +func tr_text(zh: String, en: String) -> String: + return zh if locale == "zh_CN" else en + + +func localized_profession(value: String) -> String: + var translations := {"Hearthguard": "护心卫士", "Farstring": "远弦射手", "Wayrunner": "逐风行者", "Kindhand": "仁心医者"} + return translations.get(value, value) if locale == "zh_CN" else value + + +func localized_trait(value: String) -> String: + var translations := {"Narrow Guard": "窄阵护卫", "Patient Start": "耐心起手", "Open Stance": "开放步法", "Shared Cup": "共饮药杯", "Steady Brace": "稳固支撑", "Clear Measure": "清晰尺度"} + return translations.get(value, value) if locale == "zh_CN" else value + + +func scaled(value: int) -> int: + return int(round(value * text_scale)) + + +func compact_layout() -> bool: + return get_viewport_rect().size.x < 700.0 + + +func stacked_chrome() -> bool: + return get_viewport_rect().size.x < 1000.0 + + +func responsive_columns(wide: int) -> int: + if compact_layout(): + return 1 + if stacked_chrome(): + return min(2, wide) + return wide + + +func apply_result(result: Dictionary, cue := "confirm") -> void: + notice = message_for(result.code) + if result.ok: + play_cue(cue) + render() + + +func message_for(code: String) -> String: + var messages := { + "contract_selected": tr_text("已选择 CTR-R1-01;请继续选择难度。", "CTR-R1-01 selected; now choose difficulty."), + "difficulty_selected": tr_text("难度已选择;可以载入候选。", "Difficulty selected; offers can now load."), + "offers_opened": tr_text("四名固定候选已载入;查看不会改变选择。", "Four stable offers loaded; inspection does not select."), + "exactly_two_limit": tr_text("已经选择两人;请先取消一人。", "Two Recruits are selected; deselect one first."), + "exactly_two_required": tr_text("必须恰好选择两人。", "Choose exactly two Recruits."), + "offer_selected": tr_text("队员已选择。", "Recruit selected."), + "offer_removed": tr_text("队员已取消选择。", "Recruit deselected."), + "offer_inspected": tr_text("已查看队员取舍。", "Recruit tradeoff inspected."), + "opening_reviewed": tr_text("初始队伍已进入确认;尚未创建远征。", "Opening Company is under review; the expedition is not created yet."), + "opening_revised": tr_text("已返回候选;原选择仍保留。", "Returned to offers; the pair remains selected."), + "opening_committed": tr_text("远征已创建一次。", "Expedition created once."), + "market_opened": tr_text("第一集市已开启;招募尚未扣费。", "First Market opened; no hire has been charged yet."), + "hire_committed": tr_text("招募已提交,金币和队伍已更新。", "Hire committed; Coin and Company updated."), + "equipment_purchased": tr_text("EQ-012 已进入库存;尚未装备。", "EQ-012 entered inventory; it is not equipped yet."), + "equipment_equipped": tr_text("EQ-012 已装备。", "EQ-012 equipped."), + "order_source_selected": tr_text("来源已选择;请点击目标位置。", "Source selected; tap the destination."), + "order_source_cleared": tr_text("来源选择已取消;站位没有改变。", "Source selection cleared; order did not change."), + "order_swapped": tr_text("站位已交换;确认前仍可修改。", "Positions swapped; you can still revise."), + "readiness_committed": tr_text("第一集市整备回执已提交。", "First-Market readiness receipt committed."), + } + return messages.get(code, tr_text("当前操作不合法,状态没有改变。", "That action is not legal; state did not change.")) + + +func select_contract() -> void: + apply_result(run.select_contract(), "select") + + +func open_offers() -> void: + apply_result(run.open_offers(), "confirm") + + +func inspect_offer(id: String) -> void: + inspected_id = id + apply_result(run.inspect_offer(id), "select") + + +func toggle_offer(id: String) -> void: + apply_result(run.toggle_offer(id), "select") + + +func review_opening() -> void: + apply_result(run.review_opening(), "confirm") + + +func revise_opening() -> void: + apply_result(run.revise_opening(), "select") + + +func commit_opening() -> void: + apply_result(run.commit_opening(), "receipt") + + +func open_market() -> void: + apply_result(run.open_market(), "confirm") + + +func hire(id: String) -> void: + apply_result(run.hire(id), "purchase") + + +func purchase_equipment() -> void: + apply_result(run.purchase_equipment(), "purchase") + + +func equip_member(id: String) -> void: + apply_result(run.equip_member(id), "equip") + + +func choose_order(id: String) -> void: + apply_result(run.choose_order_unit(id), "select") + + +func commit_order() -> void: + apply_result(run.commit_order(), "receipt") + + +func toggle_locale() -> void: + locale = "en" if locale == "zh_CN" else "zh_CN" + notice = tr_text("语言已切换;游戏状态没有改变。", "Language changed; gameplay state is unchanged.") + render() + + +func toggle_text_scale() -> void: + text_scale = 1.0 if text_scale > 1.0 else 1.3 + notice = tr_text("文字缩放 %d%%。" % int(text_scale * 100), "Text scale %d%%." % int(text_scale * 100)) + render() + + +func toggle_motion() -> void: + reduced_motion = not reduced_motion + notice = tr_text("减少动态已%s。" % ("开启" if reduced_motion else "关闭"), "Reduced motion %s." % ("on" if reduced_motion else "off")) + render() + + +func toggle_mute() -> void: + muted = not muted + AudioServer.set_bus_mute(AudioServer.get_bus_index(&"SFX"), muted) + notice = tr_text("声音提示已%s。" % ("关闭" if muted else "开启"), "Sound cues %s." % ("off" if muted else "on")) + if not muted: + play_cue("confirm") + render() + + +func play_cue(kind: String) -> void: + if muted or sfx_playback == null: + return + var frequency: float = {"select": 330.0, "confirm": 440.0, "purchase": 523.0, "equip": 587.0, "receipt": 659.0}.get(kind, 392.0) + var frames := int(22050.0 * 0.09) + for index in range(frames): + var envelope := sin(PI * float(index) / float(frames)) * 0.035 + var value := sin(TAU * frequency * float(index) / 22050.0) * envelope + sfx_playback.push_frame(Vector2(value, value)) diff --git a/runtime/main.gd.uid b/runtime/main.gd.uid new file mode 100644 index 00000000..12f04aa0 --- /dev/null +++ b/runtime/main.gd.uid @@ -0,0 +1 @@ +uid://okxc6j802ed0 diff --git a/runtime/main.tscn b/runtime/main.tscn new file mode 100644 index 00000000..c6341ffe --- /dev/null +++ b/runtime/main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource path="res://main.gd" type="Script" id="1"] + +[node name="AetherboundGuild" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") diff --git a/runtime/project.godot b/runtime/project.godot new file mode 100644 index 00000000..f3007b76 --- /dev/null +++ b/runtime/project.godot @@ -0,0 +1,36 @@ +[application] + +config/name="Aetherbound Guild" +run/main_scene="res://main.tscn" +config/features=PackedStringArray("4.6", "GL Compatibility") + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/size/window_width_override=1280 +window/size/window_height_override=720 +window/stretch/mode="canvas_items" +window/handheld/orientation=5 + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/default_filters/use_nearest_mipmap_filter=false + +[audio] + +default_bus_layout="res://default_bus_layout.tres" + +[input_devices] + +pointing/emulate_mouse_from_touch=true +pointing/emulate_touch_from_mouse=true + +[input] + +ui_accept={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"physical_keycode":4194309), Object(InputEventKey,"physical_keycode":32)] +} diff --git a/runtime/tests/p9_1_capture_test.gd b/runtime/tests/p9_1_capture_test.gd new file mode 100644 index 00000000..a8b4c038 --- /dev/null +++ b/runtime/tests/p9_1_capture_test.gd @@ -0,0 +1,113 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var game: Control +var output_root := "" +var touch_count := 0 +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + output_root = OS.get_environment("ABG_P9_1_CAPTURE_ROOT") + if output_root.is_empty(): + output_root = "/tmp/abg-p9-1-captures" + DirAccess.make_dir_recursive_absolute(output_root) + game = MainScene.instantiate() + root.add_child(game) + await settle() + game.locale = OS.get_environment("ABG_P9_1_LOCALE") if not OS.get_environment("ABG_P9_1_LOCALE").is_empty() else "zh_CN" + game.text_scale = float(OS.get_environment("ABG_P9_1_TEXT_SCALE")) if not OS.get_environment("ABG_P9_1_TEXT_SCALE").is_empty() else 1.0 + game.reduced_motion = OS.get_environment("ABG_P9_1_REDUCED_MOTION") == "1" + game.muted = OS.get_environment("ABG_P9_1_MUTED") == "1" + game.render() + await settle() + await capture("01-contract") + await tap("ContractSelect") + await tap("Difficulty_standard") + await tap("PrimaryAction") + for id in ["OFF-01", "OFF-02", "OFF-03", "OFF-04"]: + await tap("Inspect_%s" % id) + await tap("Select_OFF-03") + await tap("Select_OFF-04") + await capture("02-offers") + await tap("PrimaryAction") + await tap("PrimaryAction") + await tap("PrimaryAction") + await capture("03-market") + await tap("PrimaryAction") + await tap("PrimaryAction") + await capture("04-equipment") + await tap("PrimaryAction") + await tap("EquipTarget_OFF-03") + await capture("05-order") + await tap("OrderUnit_OFF-03") + await tap("OrderUnit_MKT-R1-01") + await tap("PrimaryAction") + await capture("06-readiness") + + game.queue_free() + game = null + await drain_cleanup() + if failures.is_empty(): + print("ABG_P9_1_RUNTIME_CAPTURE_OK frames=6 touch=%d fallback=0 direct=0 root=%s" % [touch_count, output_root]) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func capture(label: String) -> void: + await RenderingServer.frame_post_draw + var image := root.get_texture().get_image() + var path := output_root.path_join("%s.png" % label) + var error := image.save_png(path) + if error != OK: + failures.append("capture %s failed with %s" % [label, error]) + + +func tap(node_name: String) -> void: + var control := find_control(game, node_name) + if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled): + failures.append("unavailable capture touch target %s" % node_name) + return + var logical_position := control.get_global_rect().get_center() + var position := root.get_final_transform() * logical_position + var press := InputEventScreenTouch.new() + press.index = 0 + press.position = position + press.pressed = true + Input.parse_input_event(press) + await process_frame + var release := InputEventScreenTouch.new() + release.index = 0 + release.position = position + release.pressed = false + Input.parse_input_event(release) + touch_count += 1 + await settle() + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func settle() -> void: + for frame in range(8): + await process_frame + + +func drain_cleanup() -> void: + for frame in range(60): + await process_frame diff --git a/runtime/tests/p9_1_capture_test.gd.uid b/runtime/tests/p9_1_capture_test.gd.uid new file mode 100644 index 00000000..fcb90f6d --- /dev/null +++ b/runtime/tests/p9_1_capture_test.gd.uid @@ -0,0 +1 @@ +uid://bllifg47ya0ht diff --git a/runtime/tests/p9_1_domain_test.gd b/runtime/tests/p9_1_domain_test.gd new file mode 100644 index 00000000..c6e8c536 --- /dev/null +++ b/runtime/tests/p9_1_domain_test.gd @@ -0,0 +1,97 @@ +extends SceneTree + +const RunState = preload("res://domain/run_state.gd") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + var state := RunState.new() + expect(not state.select_difficulty("standard").ok, "difficulty requires Contract") + expect(state.snapshot().coin == 52, "invalid difficulty does not mutate Coin") + expect(state.select_contract().ok, "Contract selection") + expect(state.select_difficulty("standard").ok, "difficulty selection") + expect(state.open_offers().ok, "open stable offers") + expect(offer_ids(state) == ["OFF-01", "OFF-02", "OFF-03", "OFF-04"], "stable opening offer IDs") + for id in ["OFF-01", "OFF-02", "OFF-03", "OFF-04"]: + expect(state.inspect_offer(id).ok, "inspect %s" % id) + expect(state.toggle_offer("OFF-03").ok, "select Tomas") + expect(state.toggle_offer("OFF-04").ok, "select Sable") + expect(not state.toggle_offer("OFF-01").ok, "reject a third opening Recruit") + expect(state.selected == ["OFF-03", "OFF-04"], "third selection leaves pair unchanged") + expect(state.review_opening().ok, "review opening Company") + expect(state.revise_opening().ok, "revise opening Company") + expect(offer_ids(state) == ["OFF-01", "OFF-02", "OFF-03", "OFF-04"], "revision retains stable offers") + expect(state.review_opening().ok, "review revised Company") + expect(state.commit_opening().ok, "commit opening once") + expect(not state.commit_opening().ok, "reject duplicate opening commit") + expect(state.company == ["OFF-03", "OFF-04"], "opening Company is exact pair") + expect(state.coin == 52, "opening commit is free") + expect(state.open_market().ok, "open first Market") + expect(state.hire("MKT-R1-01").ok, "hire Mael") + expect(not state.hire("MKT-R1-01").ok, "reject duplicate Mael hire") + expect(state.company.size() == 3 and state.coin == 38, "Mael receipt values") + expect(state.hire("MKT-R1-02").ok, "hire Ilya") + expect(not state.hire("MKT-R1-02").ok, "reject duplicate Ilya hire") + expect(state.company.size() == 4 and state.coin == 24, "Ilya receipt values") + expect(state.purchase_equipment().ok, "purchase EQ-012") + expect(not state.purchase_equipment().ok, "reject duplicate EQ-012 purchase") + expect(state.coin == 12 and state.equipment_wearer.is_empty(), "purchase and equip are separate") + expect(not state.equip_member("OFF-01").ok, "reject non-Company wearer") + expect(state.equip_member("OFF-03").ok, "equip Tomas") + expect(not state.equip_member("OFF-03").ok, "reject duplicate equip") + expect(state.equipment_wearer == "OFF-03", "one active EQ-012 wearer") + expect(state.choose_order_unit("OFF-03").ok, "select order source") + expect(state.choose_order_unit("MKT-R1-01").ok, "swap order destination") + expect(state.order_changed, "order changed before commit") + expect(state.commit_order().ok, "commit readiness once") + expect(not state.commit_order().ok, "reject duplicate readiness receipt") + var snapshot := state.snapshot() + expect(snapshot.step == RunState.Step.READY, "final readiness state") + expect(snapshot.coin == 12 and snapshot.company.size() == 4, "exact final resources") + expect(snapshot.operation_counts == { + "opening": 1, + "MKT-R1-01": 1, + "MKT-R1-02": 1, + "purchase": 1, + "equip": 1, + "order": 1, + }, "all committed operations are one-shot") + + var alternate := RunState.new() + alternate.select_contract() + alternate.select_difficulty("wayfinder") + alternate.open_offers() + alternate.toggle_offer("OFF-01") + alternate.toggle_offer("OFF-02") + alternate.review_opening() + alternate.commit_opening() + alternate.open_market() + alternate.hire("MKT-R1-01") + alternate.hire("MKT-R1-02") + alternate.purchase_equipment() + expect(alternate.equip_member("OFF-01").ok, "alternate legal pair can equip EQ-012") + + if failures.is_empty(): + print("ABG_P9_1_DOMAIN_OK cases=40 final_coin=12 company=4 commits=6") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func offer_ids(state: RunState) -> Array[String]: + var ids: Array[String] = [] + for entry in RunState.OFFERS: + ids.append(entry.id) + return ids + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.1 domain: %s" % message) diff --git a/runtime/tests/p9_1_domain_test.gd.uid b/runtime/tests/p9_1_domain_test.gd.uid new file mode 100644 index 00000000..a09959c8 --- /dev/null +++ b/runtime/tests/p9_1_domain_test.gd.uid @@ -0,0 +1 @@ +uid://ckpspse5hv0x4 diff --git a/runtime/tests/p9_1_fixture_capture.gd b/runtime/tests/p9_1_fixture_capture.gd new file mode 100644 index 00000000..79f75af2 --- /dev/null +++ b/runtime/tests/p9_1_fixture_capture.gd @@ -0,0 +1,104 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +const STATES := ["contract", "offers", "market", "equipment", "order", "ready"] + +var game: Control +var capture_viewport: SubViewport +var output_root := "" +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + output_root = OS.get_environment("ABG_P9_1_CAPTURE_ROOT") + if output_root.is_empty(): + output_root = "/tmp/abg-p9-1-fixture-captures" + DirAccess.make_dir_recursive_absolute(output_root) + var locale := OS.get_environment("ABG_P9_1_LOCALE") if not OS.get_environment("ABG_P9_1_LOCALE").is_empty() else "zh_CN" + var text_scale := float(OS.get_environment("ABG_P9_1_TEXT_SCALE")) if not OS.get_environment("ABG_P9_1_TEXT_SCALE").is_empty() else 1.0 + for index in range(STATES.size()): + await capture_state(index + 1, STATES[index], locale, text_scale) + if failures.is_empty(): + print("ABG_P9_1_FIXTURE_CAPTURE_OK frames=6 locale=%s text_scale=%.1f input_claim=none setup=domain_fixture root=%s" % [locale, text_scale, output_root]) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func capture_state(index: int, state: String, locale: String, text_scale: float) -> void: + capture_viewport = SubViewport.new() + capture_viewport.size = Vector2i( + int(OS.get_environment("ABG_P9_1_VIEWPORT_WIDTH")) if not OS.get_environment("ABG_P9_1_VIEWPORT_WIDTH").is_empty() else 1280, + int(OS.get_environment("ABG_P9_1_VIEWPORT_HEIGHT")) if not OS.get_environment("ABG_P9_1_VIEWPORT_HEIGHT").is_empty() else 720 + ) + capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + capture_viewport.transparent_bg = false + root.add_child(capture_viewport) + game = MainScene.instantiate() + capture_viewport.add_child(game) + await settle() + game.locale = locale + game.text_scale = text_scale + game.reduced_motion = true + game.muted = true + advance_to(state) + game.render() + await settle() + await RenderingServer.frame_post_draw + var image := capture_viewport.get_texture().get_image() + var path := output_root.path_join("%02d-%s.png" % [index, state]) + var error := image.save_png(path) + if error != OK: + failures.append("capture %s failed with %s" % [state, error]) + game.queue_free() + game = null + capture_viewport.queue_free() + capture_viewport = null + await drain_cleanup() + + +func advance_to(target: String) -> void: + if target == "contract": + return + game.run.select_contract() + game.run.select_difficulty("standard") + game.run.open_offers() + for id in ["OFF-01", "OFF-02", "OFF-03", "OFF-04"]: + game.run.inspect_offer(id) + game.run.toggle_offer("OFF-03") + game.run.toggle_offer("OFF-04") + if target == "offers": + return + game.run.review_opening() + game.run.commit_opening() + game.run.open_market() + if target == "market": + return + game.run.hire("MKT-R1-01") + game.run.hire("MKT-R1-02") + if target == "equipment": + return + game.run.purchase_equipment() + game.run.equip_member("OFF-03") + if target == "order": + return + game.run.choose_order_unit("OFF-03") + game.run.choose_order_unit("MKT-R1-01") + game.run.commit_order() + + +func settle() -> void: + for frame in range(8): + await process_frame + + +func drain_cleanup() -> void: + for frame in range(30): + await process_frame diff --git a/runtime/tests/p9_1_layout_test.gd b/runtime/tests/p9_1_layout_test.gd new file mode 100644 index 00000000..5a1fc8a4 --- /dev/null +++ b/runtime/tests/p9_1_layout_test.gd @@ -0,0 +1,144 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +const FIXTURES := [ + {"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.0}, + {"size": Vector2i(844, 390), "locale": "en", "scale": 1.0}, + {"size": Vector2i(390, 844), "locale": "zh_CN", "scale": 1.3}, +] +const STATES := ["contract", "offers", "market", "equipment", "order", "ready"] + +var failures: Array[String] = [] +var fixture_count := 0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + for fixture in FIXTURES: + for state in STATES: + await verify_fixture(fixture, state) + if failures.is_empty(): + print("ABG_P9_1_LAYOUT_OK fixtures=%d viewports=3 states=6 locales=2 text_130=true" % fixture_count) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func verify_fixture(fixture: Dictionary, state: String) -> void: + var viewport := SubViewport.new() + viewport.size = fixture.size + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var game := MainScene.instantiate() + viewport.add_child(game) + await settle() + game.locale = fixture.locale + game.text_scale = fixture.scale + advance_to(game, state) + game.render() + await settle() + fixture_count += 1 + + var label := "%sx%s/%s/%s/%s" % [fixture.size.x, fixture.size.y, fixture.locale, str(fixture.scale), state] + var bounds := Rect2(Vector2.ZERO, Vector2(fixture.size)) + for node_name in ["TopBar", "CurrentDecision", "Footer", "PrimaryAction"]: + var control := find_control(game, node_name) + expect(control != null, "%s missing %s" % [label, node_name]) + if control != null: + expect(contains_rect(bounds, control.get_global_rect()), "%s %s outside viewport: %s" % [label, node_name, control.get_global_rect()]) + var top := find_control(game, "TopBar") + var decision := find_control(game, "CurrentDecision") + var footer := find_control(game, "Footer") + if top != null and decision != null and footer != null: + expect(not top.get_global_rect().intersects(decision.get_global_rect()), "%s top overlaps decision" % label) + expect(not footer.get_global_rect().intersects(decision.get_global_rect()), "%s footer overlaps decision" % label) + verify_interactive_bounds(game, bounds, label) + game.queue_free() + viewport.queue_free() + await drain_cleanup() + + +func advance_to(game: Control, target: String) -> void: + if target == "contract": + return + game.run.select_contract() + game.run.select_difficulty("standard") + game.run.open_offers() + game.run.inspect_offer("OFF-03") + game.run.inspect_offer("OFF-04") + game.run.toggle_offer("OFF-03") + game.run.toggle_offer("OFF-04") + if target == "offers": + return + game.run.review_opening() + game.run.commit_opening() + game.run.open_market() + if target == "market": + return + game.run.hire("MKT-R1-01") + game.run.hire("MKT-R1-02") + if target == "equipment": + return + game.run.purchase_equipment() + game.run.equip_member("OFF-03") + if target == "order": + return + game.run.choose_order_unit("OFF-03") + game.run.choose_order_unit("MKT-R1-01") + game.run.commit_order() + + +func verify_interactive_bounds(node: Node, bounds: Rect2, label: String) -> void: + if node is BaseButton and node.is_visible_in_tree(): + var rect: Rect2 = node.get_global_rect() + if inside_scroll(node): + expect(rect.position.x >= -0.5 and rect.end.x <= bounds.end.x + 0.5, "%s button %s has horizontal overflow: %s" % [label, node.name, rect]) + else: + expect(contains_rect(bounds, rect), "%s button %s outside viewport: %s" % [label, node.name, rect]) + expect(rect.size.x >= 44.0 and rect.size.y >= 44.0, "%s button %s below 44pt: %s" % [label, node.name, rect.size]) + for child in node.get_children(): + verify_interactive_bounds(child, bounds, label) + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func contains_rect(outer: Rect2, inner: Rect2) -> bool: + return inner.position.x >= -0.5 and inner.position.y >= -0.5 and inner.end.x <= outer.end.x + 0.5 and inner.end.y <= outer.end.y + 0.5 + + +func inside_scroll(node: Node) -> bool: + var current := node.get_parent() + while current != null: + if current is ScrollContainer: + return true + current = current.get_parent() + return false + + +func settle() -> void: + for frame in range(4): + await process_frame + + +func drain_cleanup() -> void: + for frame in range(30): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.1 layout: %s" % message) diff --git a/runtime/tests/p9_1_layout_test.gd.uid b/runtime/tests/p9_1_layout_test.gd.uid new file mode 100644 index 00000000..4ebeeaa5 --- /dev/null +++ b/runtime/tests/p9_1_layout_test.gd.uid @@ -0,0 +1 @@ +uid://b1nisiwbysuxk diff --git a/runtime/tests/p9_1_touch_test.gd b/runtime/tests/p9_1_touch_test.gd new file mode 100644 index 00000000..7c048e12 --- /dev/null +++ b/runtime/tests/p9_1_touch_test.gd @@ -0,0 +1,119 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") +const RunState = preload("res://domain/run_state.gd") + +var game: Control +var touch_count := 0 +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + game = MainScene.instantiate() + root.add_child(game) + await settle() + var settings_snapshot: Dictionary = game.run.snapshot() + await tap("LocaleButton") + await tap("TextScaleButton") + await tap("MotionButton") + await tap("MuteButton") + expect(game.locale == "en", "language toggle uses real touch") + expect(is_equal_approx(game.text_scale, 1.3), "130 percent text toggle uses real touch") + expect(game.reduced_motion, "reduced-motion toggle uses real touch") + expect(game.muted, "mute toggle uses real touch") + expect(game.run.snapshot() == settings_snapshot, "accessibility controls do not mutate gameplay state") + + await tap("ContractSelect") + await tap("Difficulty_standard") + await tap("PrimaryAction") + for id in ["OFF-01", "OFF-02", "OFF-03", "OFF-04"]: + await tap("Inspect_%s" % id) + await tap("Select_OFF-03") + await tap("Select_OFF-04") + await tap("PrimaryAction") + await tap("ReviseOpening") + expect(game.run.selected == ["OFF-03", "OFF-04"], "revision retains selected pair") + await tap("PrimaryAction") + await tap("PrimaryAction") + await tap("PrimaryAction") + await tap("PrimaryAction") + await tap("PrimaryAction") + await tap("PrimaryAction") + await tap("EquipTarget_OFF-03") + await tap("OrderUnit_OFF-03") + await tap("OrderUnit_MKT-R1-01") + await tap("PrimaryAction") + + var snapshot: Dictionary = game.run.snapshot() + expect(snapshot.step == RunState.Step.READY, "touch path reaches readiness receipt") + expect(snapshot.coin == 12, "touch path final Coin") + expect(snapshot.company.size() == 4, "touch path final Company") + expect(snapshot.equipment_wearer == "OFF-03", "touch path equips Tomas") + expect(snapshot.operation_counts.opening == 1, "touch path opening one-shot") + expect(snapshot.operation_counts["MKT-R1-01"] == 1 and snapshot.operation_counts["MKT-R1-02"] == 1, "touch path hires one-shot") + expect(snapshot.operation_counts.purchase == 1 and snapshot.operation_counts.equip == 1 and snapshot.operation_counts.order == 1, "touch path remaining commits one-shot") + + game.queue_free() + game = null + await drain_cleanup() + if failures.is_empty(): + print("ABG_P9_1_TOUCH_OK touch=%d fallback=0 direct=0 locale=en text_130=true reduced_motion=true mute=true final_coin=12 company=4" % touch_count) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func tap(node_name: String) -> void: + var control := find_control(game, node_name) + if control == null: + failures.append("missing touch target %s" % node_name) + return + if not control.is_visible_in_tree() or (control is BaseButton and control.disabled): + failures.append("unavailable touch target %s" % node_name) + return + var logical_position := control.get_global_rect().get_center() + var position := root.get_final_transform() * logical_position + var press := InputEventScreenTouch.new() + press.index = 0 + press.position = position + press.pressed = true + Input.parse_input_event(press) + await process_frame + var release := InputEventScreenTouch.new() + release.index = 0 + release.position = position + release.pressed = false + Input.parse_input_event(release) + touch_count += 1 + await settle() + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func settle() -> void: + for frame in range(8): + await process_frame + + +func drain_cleanup() -> void: + for frame in range(60): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.1 touch: %s" % message) diff --git a/runtime/tests/p9_1_touch_test.gd.uid b/runtime/tests/p9_1_touch_test.gd.uid new file mode 100644 index 00000000..e0b86e2e --- /dev/null +++ b/runtime/tests/p9_1_touch_test.gd.uid @@ -0,0 +1 @@ +uid://luid5lum7rbe