diff --git a/CURRENT_CHECKPOINT.md b/CURRENT_CHECKPOINT.md index 2e0683ca..c84d724e 100644 --- a/CURRENT_CHECKPOINT.md +++ b/CURRENT_CHECKPOINT.md @@ -1,9 +1,19 @@ # Current Checkpoint -Active Goal: `docs/goals/PHASE9_0_BOOT_TITLE_GOAL.md` -Active contract: `docs/runtime/PHASE9_0_BOOT_TITLE_CONTRACT.md` +Active Goal: `docs/goals/PHASE9_0R1_GAME_OPENING_REDESIGN_GOAL.md` +Active contract: `docs/runtime/PHASE9_0R1_GAME_OPENING_CONTRACT.md` Active ledger: `docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md` -Baseline: `b3968b0f41f841efb0f699d8fb04a9bcb958182a` +Redesign baseline: `00b019543e571c89c20adaa97b4e805ecbd3e6f4` +Stage: `P9_0R1_GAME_OPENING_ACTIVE` + +Current P9.0R1 replaces the Owner-rejected form-like Web opening with one +scene-based flow: Title -> Guild Yard front/rear choice -> deterministic first +battle -> explicit first-expedition victory. Contract/difficulty, paid Market, +equipment/order configuration, persistence, chapter expansion, authoritative +combat, device packaging, TestFlight, stores and release remain frozen. The +older Phase 8/P9 history below is retained as provenance, not current scope. + +Historical portfolio baseline: `b3968b0f41f841efb0f699d8fb04a9bcb958182a` Accepted P8.1 source: `0b4a64fe549af0ff965e654aa4f5fb7f2ff054b4` P8.2 candidate source: `86a8a1adec39de40b4adfee6b01033407e3e3421` Accepted P8.2 closure: `d308f8bc14b043ed133ce2d99cbce461aecec81f` diff --git a/docs/goals/PHASE9_0R1_GAME_OPENING_REDESIGN_GOAL.md b/docs/goals/PHASE9_0R1_GAME_OPENING_REDESIGN_GOAL.md new file mode 100644 index 00000000..b1d6e71d --- /dev/null +++ b/docs/goals/PHASE9_0R1_GAME_OPENING_REDESIGN_GOAL.md @@ -0,0 +1,125 @@ +# Phase 9.0R1 Game Opening Redesign Goal + +## Owner Decision + +The Owner rejected remote Web source `f0aa84738041cebf4968d91c6bf170668804e203`. +The opening looked like a form rather than a game, exposed development labels, +split recruitment into two unexplained systems, hid revision/reset affordances, +and stopped before any playable encounter. + +This Goal replaces that first-play structure. It does not preserve the rejected +Contract/First Market presentation merely because its domain tests were green. + +## Decision Enabled + +Decide whether a fresh player can enter a recognizable Japanese hand-painted +fantasy game, build one understandable two-person Company, see those characters +act in a short battle, understand the result, and restart without coaching. + +## Player Chain + +```text +title -> guild_yard_team_build -> departure -> tutorial_battle -> first_victory + ^ | | + +-- replace+---------------- home / restart -------+ +``` + +## Owned Scope + +- Keep the verified cold boot and Title technical behavior but remove internal + milestone language from all player-facing screens. +- Replace Contract, difficulty, four-provisional-plus-two-paid recruitment, + equipment purchase/equip, order optimization and receipt screens with one + scene-based opening Company choice. +- Present two explicit positions: + - front: Hearthguard or Wayrunner; + - rear: Farstring or Kindhand. +- Show the selected characters standing in the yard before departure. Choosing + the other candidate replaces the current occupant; it never dead-ends. +- Run one 30-60 second authored tutorial battle with visible advance, front-line + contact, enemy attack, front role response, rear ranged attack or healing, + enemy defeat and victory. +- Always expose Home and a confirmed Restart Experience action after Title. +- End with `First expedition complete`, the chosen Company and one explicit next + goal. No chapter-complete claim is legal. + +## Experience Contract + +- Fantasy: assemble a small guild squad and watch the formation prove itself on + a dangerous route. +- First focal read: characters acting in the world. +- Second focal read: the current choice or threat. +- Third focal read: compact health/role feedback and one primary action. +- First action by 20 seconds: place or replace one character. +- First complete Company by 60 seconds without coaching. +- First encounter begins by 90 seconds and resolves by 150 seconds. +- The player can explain front versus rear contribution after the result. + +## Presentation Contract + +- Japanese 2D hand-painted fantasy environment and full-body character art are + the visual authority already accepted in Phase 6. +- World/actors occupy at least 70% of the landscape play field. HUD and commands + overlay the world; they do not replace it with a dashboard. +- UI materials use dark indigo lacquer, warm parchment, brushed gold edge, + teal magical glass and crimson danger. Rectangular controls need layered + border, highlight, shadow and pressed states; generic flat gray/gold buttons + are prohibited. +- Cards may frame one candidate choice, but a card wall or page-sized panel is + prohibited. Each candidate owns one clear role, consequence and Join/Replace + action. +- No `P9`, `CTR`, `OFF`, `MKT`, `EQ`, receipt, vertical-slice, locked-future or + implementation terminology appears in player copy. + +## Dynamic Slice + +Representative screen: tutorial battle at `844x390` landscape. + +Focal order: + +1. selected front and rear characters versus the Riftblade on one ground line; +2. the current attack/guard/heal/projectile consequence; +3. compact health and one pause/restart surface. + +Motion-state table: + +| Event | Actor | Anticipation | Impact | Recovery/final | Reduced motion | +|---|---|---|---|---|---| +| departure | Company | 250 ms lean | advance across ground | contact stance | snap to contact | +| enemy attack | Riftblade | telegraph | front HP loss | idle | telegraph + final | +| guard/dodge | front | brace/lean | reduced damage | ready | final pose | +| arrow | Farstring | draw | projectile + enemy HP loss | ready | projectile final | +| heal | Kindhand | lantern raise | front HP restoration | ready | value + final | +| victory | Company/enemy | final attack | enemy falls | victory banner | final poses | + +## Acceptance + +1. Fresh launch and in-game Restart both reach the same Title/yard state. +2. Home and Restart remain visible and touchable on yard, battle and victory. +3. Exactly one front and one rear choice are required; every candidate can be + selected, replaced and selected again with no stale state. +4. No pre-battle Contract, difficulty, paid hire, equipment or order step exists. +5. Parsed ScreenTouch drives the complete chain with fallback/direct zero. +6. Normal rendering covers Title, empty yard, one selection, ready Company, + battle pressure and victory at `1280x720`, `844x390` and `390x844` in + zh_CN/English and 130% text without clipping. +7. A 15-30 second normal-render recording proves visible actor motion and state + change; screenshots alone cannot pass the dynamic slice. +8. Strict script/parse/resource/import/ObjectDB/RID diagnostics are clean and + no owned process remains. + +## Frozen + +- Contract selection, difficulty, paid Market recruitment, equipment and manual + formation optimization. +- Persistent save/migration, chapters, expanded battles, progression, economy, + final audio, bulk assets, device packaging, stores and release. +- Changes to complete content counts or later-game authority. + +## Legal Terminal + +`Aetherbound Guild P9.0R1 game opening candidate ready` + +or + +`Aetherbound Guild P9.0R1 redesign required: ` diff --git a/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md b/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md index a52324df..00a5b730 100644 --- a/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md +++ b/docs/reviews/PHASE9_GODOT_VERTICAL_SLICE_LEDGER.md @@ -12,3 +12,4 @@ separate. | 2026-08-12 | Owner Web preview request | `P9_0_WEB_PREVIEW_AUTHORIZED` | Owner requested a dedicated remote H5 page for phone review. Only a non-threaded Godot Web export, browser verification and private Artifact Gallery deployment of fixed P9.0 content are authorized. Product behavior, later pages, native packages, stores and release remain frozen. | | 2026-08-12 | Web source `f0aa847`; Gallery item `PRji-yhJo0nQ`; `p9-0-web-f0aa847-attempt01` | `P9_0_REMOTE_WEB_READY` | Private signed item redirects to the full-screen runtime entry with an artifact-scoped HttpOnly cookie. Remote HTML/JS/WASM/PCK response types and executable CSP pass; unauthorized entry remains gated. Chrome phone-viewport verification reaches the Chinese Title and Contract by real canvas input, portrait shows the rotate overlay, and browser warnings/errors are zero. Final evidence inventory verifies 34/34 with SHA-256 `cb3111f86012d313c85d1eebed6574b8b5936531bf30821421f89f1442aeb945`; the signed token is not recorded in Git. Owner phone comprehension, fun, listening, physical device, packaging, store and release remain open. | | 2026-08-12 | Owner remote H5 playtest of `f0aa847` | `P9_0_OWNER_REJECTED_REDESIGN_REQUIRED` | Earliest product failure is the first post-Title action: the runtime reads as a configuration form rather than a game. There is no visible in-game restart/home path; Contract, First Market, provisional two-of-four selection, two later forced hires, equipment/order revision and the stopping point are not understandable without coaching. Engineering labels and receipt language are exposed as if they were chapter content. The current H5 is retained only as a problem reference. P9.0/P9.1 are not Owner-accepted; P9.2, content expansion, packaging and release remain frozen. A successor must compare structural onboarding options and reach a short encounter before advanced preparation systems. | +| 2026-08-12 | Owner authorization for opening redesign | `P9_0R1_GAME_OPENING_ACTIVE` | Owner authorized a full first-play and UI redesign. The active slice is Title -> scene-based two-position Company choice -> deterministic short battle -> explicit first-expedition victory, with always-visible Home/Restart. Contract, difficulty, paid Market hire, equipment/order configuration, persistence, chapters, later combat, packaging and release remain frozen. Existing accepted Japanese hand-painted environment, character, Riftblade and VFX assets are reused before any new generation. | diff --git a/docs/runtime/PHASE9_0R1_GAME_OPENING_CONTRACT.md b/docs/runtime/PHASE9_0R1_GAME_OPENING_CONTRACT.md new file mode 100644 index 00000000..0a3b190e --- /dev/null +++ b/docs/runtime/PHASE9_0R1_GAME_OPENING_CONTRACT.md @@ -0,0 +1,72 @@ +# Phase 9.0R1 Game Opening Runtime Contract + +## State + +The successor runtime owns only: + +```text +BOOT -> TITLE -> YARD -> BATTLE -> VICTORY +``` + +`home()` returns to Title and clears transient team/battle state. `restart()` +performs the same reset and returns to Title after confirmation. Browser reload +also starts from Boot because this slice has no persistence. + +## Team + +Front choices: + +- Hearthguard: takes the enemy contact and visibly guards. +- Wayrunner: takes the enemy contact and visibly evades/counterattacks. + +Rear choices: + +- Farstring: attacks safely with a visible projectile. +- Kindhand: restores the injured front character. + +One member per position. Selecting another candidate replaces that position. +No currency, offer ID, purchase, dismissal or capacity system is involved. + +## Battle + +The battle is a deterministic tutorial presentation, not the later authoritative +combat system. It must bind visible values and motion to one fixed sequence: + +1. Company and one Riftblade advance to contact. +2. Riftblade damages the selected front. +3. Front guard/dodge response reduces or answers pressure. +4. Farstring damages the Riftblade, or Kindhand heals the front. +5. The Company performs a final combined action. +6. Riftblade reaches zero and falls; victory appears once. + +The two rear choices must visibly change the sequence and final explanation. +Home/Restart cannot grant a victory or retain transient HP. + +## Reused Runtime Art + +No new provider generation is owned by this slice. Runtime copies are +byte-identical to accepted Phase 6 authority: + +- `runtime/assets/backgrounds/first_expedition_field.png` <- + `phase6_battle_scale_04/outputs/01_empty_battlefield.png`; +- Riftblade idle/attack/hit/death <- accepted Phase 6 motion frames; +- `protect_absorb`, `protect_gain` and `damage_heavy` <- accepted Phase 6 VFX + `@2x` files. + +The existing four full-body Recruit anchors remain the Company identity source. +Godot `.import` sidecars are import metadata, not new art sources. + +## Player Copy + +Allowed player labels describe place, role, action, threat and result. Internal +IDs and delivery milestones are forbidden. The victory state says the first +expedition is complete and that the guild can next learn hiring and equipment; +it does not claim a chapter is complete. + +## Evidence Markers + +- `ABG_P9_0R1_DOMAIN_OK` +- `ABG_P9_0R1_TOUCH_OK` +- `ABG_P9_0R1_LAYOUT_OK` +- `ABG_P9_0R1_RUNTIME_CAPTURE_OK` +- `ABG_P9_0R1_DYNAMIC_SLICE_OK` diff --git a/runtime/assets/backgrounds/first_expedition_field.png b/runtime/assets/backgrounds/first_expedition_field.png new file mode 100644 index 00000000..e59c8fca Binary files /dev/null and b/runtime/assets/backgrounds/first_expedition_field.png differ diff --git a/runtime/assets/backgrounds/first_expedition_field.png.import b/runtime/assets/backgrounds/first_expedition_field.png.import new file mode 100644 index 00000000..f9d46505 --- /dev/null +++ b/runtime/assets/backgrounds/first_expedition_field.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d0t6j8kk3l478" +path="res://.godot/imported/first_expedition_field.png-512185aef842432d998b716fbdbda726.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/backgrounds/first_expedition_field.png" +dest_files=["res://.godot/imported/first_expedition_field.png-512185aef842432d998b716fbdbda726.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/enemies/riftblade/attack.png b/runtime/assets/enemies/riftblade/attack.png new file mode 100644 index 00000000..2db0bc81 Binary files /dev/null and b/runtime/assets/enemies/riftblade/attack.png differ diff --git a/runtime/assets/enemies/riftblade/attack.png.import b/runtime/assets/enemies/riftblade/attack.png.import new file mode 100644 index 00000000..588eba20 --- /dev/null +++ b/runtime/assets/enemies/riftblade/attack.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dp0vd7ysqlxhq" +path="res://.godot/imported/attack.png-f368cb5dce3612ada5cc3dc0654ef93d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemies/riftblade/attack.png" +dest_files=["res://.godot/imported/attack.png-f368cb5dce3612ada5cc3dc0654ef93d.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/enemies/riftblade/death.png b/runtime/assets/enemies/riftblade/death.png new file mode 100644 index 00000000..a8641012 Binary files /dev/null and b/runtime/assets/enemies/riftblade/death.png differ diff --git a/runtime/assets/enemies/riftblade/death.png.import b/runtime/assets/enemies/riftblade/death.png.import new file mode 100644 index 00000000..1e1b783e --- /dev/null +++ b/runtime/assets/enemies/riftblade/death.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3koj8sncknch" +path="res://.godot/imported/death.png-e68dfbb00e5ad6224a0d831754a35124.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemies/riftblade/death.png" +dest_files=["res://.godot/imported/death.png-e68dfbb00e5ad6224a0d831754a35124.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/enemies/riftblade/hit.png b/runtime/assets/enemies/riftblade/hit.png new file mode 100644 index 00000000..2d049a82 Binary files /dev/null and b/runtime/assets/enemies/riftblade/hit.png differ diff --git a/runtime/assets/enemies/riftblade/hit.png.import b/runtime/assets/enemies/riftblade/hit.png.import new file mode 100644 index 00000000..1f5b97e5 --- /dev/null +++ b/runtime/assets/enemies/riftblade/hit.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://m5i4rlks6f5b" +path="res://.godot/imported/hit.png-48d85e065b0cdc6acc16f8869b8964d4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemies/riftblade/hit.png" +dest_files=["res://.godot/imported/hit.png-48d85e065b0cdc6acc16f8869b8964d4.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/enemies/riftblade/idle.png b/runtime/assets/enemies/riftblade/idle.png new file mode 100644 index 00000000..c3d0c783 Binary files /dev/null and b/runtime/assets/enemies/riftblade/idle.png differ diff --git a/runtime/assets/enemies/riftblade/idle.png.import b/runtime/assets/enemies/riftblade/idle.png.import new file mode 100644 index 00000000..403b0140 --- /dev/null +++ b/runtime/assets/enemies/riftblade/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmvrgg6a8gtcd" +path="res://.godot/imported/idle.png-b4995d90d56d1cbedcb6819a1c5eddc9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemies/riftblade/idle.png" +dest_files=["res://.godot/imported/idle.png-b4995d90d56d1cbedcb6819a1c5eddc9.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/vfx/damage_heavy.png b/runtime/assets/vfx/damage_heavy.png new file mode 100644 index 00000000..b1a682a7 Binary files /dev/null and b/runtime/assets/vfx/damage_heavy.png differ diff --git a/runtime/assets/vfx/damage_heavy.png.import b/runtime/assets/vfx/damage_heavy.png.import new file mode 100644 index 00000000..3da7bf76 --- /dev/null +++ b/runtime/assets/vfx/damage_heavy.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://h3dvkx4rq4ok" +path="res://.godot/imported/damage_heavy.png-59e6de32464699ffe87188d4361ab7d2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/vfx/damage_heavy.png" +dest_files=["res://.godot/imported/damage_heavy.png-59e6de32464699ffe87188d4361ab7d2.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/vfx/protect_absorb.png b/runtime/assets/vfx/protect_absorb.png new file mode 100644 index 00000000..ea5b835c Binary files /dev/null and b/runtime/assets/vfx/protect_absorb.png differ diff --git a/runtime/assets/vfx/protect_absorb.png.import b/runtime/assets/vfx/protect_absorb.png.import new file mode 100644 index 00000000..a6924dea --- /dev/null +++ b/runtime/assets/vfx/protect_absorb.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://iwlvuwmmlice" +path="res://.godot/imported/protect_absorb.png-33f11958ef887c357bc8746b262471ac.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/vfx/protect_absorb.png" +dest_files=["res://.godot/imported/protect_absorb.png-33f11958ef887c357bc8746b262471ac.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/vfx/protect_gain.png b/runtime/assets/vfx/protect_gain.png new file mode 100644 index 00000000..6e9f33ec Binary files /dev/null and b/runtime/assets/vfx/protect_gain.png differ diff --git a/runtime/assets/vfx/protect_gain.png.import b/runtime/assets/vfx/protect_gain.png.import new file mode 100644 index 00000000..4efd7531 --- /dev/null +++ b/runtime/assets/vfx/protect_gain.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btnelcc7vr06g" +path="res://.godot/imported/protect_gain.png-d3bfbe940288931ab6022856af268d59.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/vfx/protect_gain.png" +dest_files=["res://.godot/imported/protect_gain.png-d3bfbe940288931ab6022856af268d59.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/main.gd b/runtime/main.gd index 96a5424c..f6d3e448 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -1,79 +1,135 @@ extends Control -const RunState = preload("res://domain/run_state.gd") const UI_FONT := preload("res://assets/fonts/NotoSansSC.ttf") - -enum AppPage { - BOOT, - TITLE, - RUN, -} - -const BG := preload("res://assets/backgrounds/windglass_verge.webp") +const YARD_BACKGROUND := preload("res://assets/backgrounds/windglass_verge.webp") +const FIELD_BACKGROUND := preload("res://assets/backgrounds/first_expedition_field.png") const ACTORS := { "Hearthguard": preload("res://assets/actors/hearthguard.png"), - "Farstring": preload("res://assets/actors/farstring.png"), "Wayrunner": preload("res://assets/actors/wayrunner.png"), + "Farstring": preload("res://assets/actors/farstring.png"), "Kindhand": preload("res://assets/actors/kindhand.png"), } -const EQUIPMENT := preload("res://assets/equipment/eq_012_open_line_cord.png") +const RIFTBLADE := { + "idle": preload("res://assets/enemies/riftblade/idle.png"), + "attack": preload("res://assets/enemies/riftblade/attack.png"), + "hit": preload("res://assets/enemies/riftblade/hit.png"), + "death": preload("res://assets/enemies/riftblade/death.png"), +} +const VFX := { + "guard": preload("res://assets/vfx/protect_absorb.png"), + "heal": preload("res://assets/vfx/protect_gain.png"), + "hit": preload("res://assets/vfx/damage_heavy.png"), +} + const BOOT_REQUIRED_PATHS: Array[String] = [ "res://main.tscn", - "res://domain/run_state.gd", + "res://main.gd", "res://assets/backgrounds/windglass_verge.webp", + "res://assets/backgrounds/first_expedition_field.png", "res://assets/actors/hearthguard.png", - "res://assets/actors/farstring.png", "res://assets/actors/wayrunner.png", + "res://assets/actors/farstring.png", "res://assets/actors/kindhand.png", - "res://assets/equipment/eq_012_open_line_cord.png", + "res://assets/enemies/riftblade/idle.png", + "res://assets/enemies/riftblade/attack.png", "res://assets/fonts/NotoSansSC.ttf", - "res://default_bus_layout.tres", ] -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") +const CANDIDATES := { + "Hearthguard": { + "slot": "front", + "name_zh": "艾连", + "name_en": "Elian", + "role_zh": "盾卫 · 稳稳挡住敌人", + "role_en": "Guardian · holds the enemy", + }, + "Wayrunner": { + "slot": "front", + "name_zh": "托马斯", + "name_en": "Tomas", + "role_zh": "游侠 · 闪避后反击", + "role_en": "Runner · evades and counters", + }, + "Farstring": { + "slot": "rear", + "name_zh": "妮娅", + "name_en": "Nia", + "role_zh": "弓手 · 后排持续射击", + "role_en": "Archer · fires from the rear", + }, + "Kindhand": { + "slot": "rear", + "name_zh": "赛布尔", + "name_en": "Sable", + "role_zh": "治疗 · 为前排恢复生命", + "role_en": "Healer · restores the front", + }, +} + +enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY } + +const COLOR_INK := Color("#10182c") +const COLOR_INDIGO := Color("#172745") +const COLOR_INDIGO_HOVER := Color("#24446c") +const COLOR_PARCHMENT := Color("#f1e2b5") +const COLOR_PARCHMENT_DARK := Color("#d6bc78") +const COLOR_GOLD := Color("#dcae43") +const COLOR_GOLD_LIGHT := Color("#ffe49a") +const COLOR_TEAL := Color("#67d9ce") +const COLOR_CRIMSON := Color("#9d3348") +const COLOR_WHITE := Color("#fffaf0") -var run := RunState.new() @export var auto_boot := true + var page := AppPage.BOOT +var locale := "zh_CN" +var text_scale := 1.0 +var reduced_motion := false +var muted := false + var boot_index := 0 -var boot_current_path := "" -var boot_failed_path := "" var boot_verified := false -var boot_success_presented := false +var boot_failed_path := "" var boot_running := false var boot_paths_override: Array[String] = [] -var locale := "zh_CN" -var muted := false -var reduced_motion := false -var text_scale := 1.0 + +var front_choice := "" +var rear_choice := "" var notice := "" -var inspected_id := "" -var background_wash: ColorRect -var entry_layer: Control -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 restart_confirmation := false + +var battle_phase := 0 +var battle_clock := 0.0 +var battle_playing := false +var front_hp := 100 +var enemy_hp := 100 +var guard_events := 0 +var rear_events := 0 +var victory_events := 0 + +var display_root: Control +var front_sprite: TextureRect +var rear_sprite: TextureRect +var enemy_sprite: TextureRect +var projectile: Control +var front_base_position := Vector2.ZERO +var rear_base_position := Vector2.ZERO +var enemy_base_position := Vector2.ZERO var audio_player: AudioStreamPlayer var sfx_playback: AudioStreamGeneratorPlayback +var render_pending := false func _ready() -> void: - var runtime_theme := Theme.new() - runtime_theme.default_font = UI_FONT - theme = runtime_theme - build_shell() + var ui_theme := Theme.new() + ui_theme.default_font = UI_FONT + theme = ui_theme + display_root = Control.new() + display_root.name = "DisplayRoot" + display_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(display_root) + setup_audio() + resized.connect(queue_render) render() if auto_boot: call_deferred("run_boot_verification") @@ -86,74 +142,10 @@ func _exit_tree() -> void: 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) - - background_wash = ColorRect.new() - background_wash.name = "BackgroundWash" - background_wash.color = Color(0.025, 0.07, 0.085, 0.76) - background_wash.mouse_filter = Control.MOUSE_FILTER_IGNORE - background_wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - add_child(background_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) - - var page_stack := Control.new() - page_stack.name = "PageStack" - page_stack.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - page_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL - page_stack.size_flags_vertical = Control.SIZE_EXPAND_FILL - safe.add_child(page_stack) - - entry_layer = Control.new() - entry_layer.name = "EntryLayer" - entry_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - page_stack.add_child(entry_layer) - - root_panel = VBoxContainer.new() - root_panel.name = "RuntimeShell" - root_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - root_panel.add_theme_constant_override("separation", 10) - page_stack.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()) - +func setup_audio() -> void: var generator := AudioStreamGenerator.new() generator.mix_rate = 22050.0 - generator.buffer_length = 0.2 + generator.buffer_length = 0.25 audio_player = AudioStreamPlayer.new() audio_player.name = "PrototypeSFX" audio_player.bus = &"SFX" @@ -164,294 +156,356 @@ func build_shell() -> void: 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 queue_render() -> void: + if render_pending: + return + render_pending = true + call_deferred("render") func render() -> void: - if page != AppPage.RUN: - render_entry_page() - return - entry_layer.visible = false - root_panel.visible = true - background_wash.color = Color(0.025, 0.07, 0.085, 0.76) - 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_entry_page() -> void: - root_panel.visible = false - entry_layer.visible = true - background_wash.color = Color(0.01, 0.035, 0.045, 0.18 if page == AppPage.TITLE else 0.42) - for child in entry_layer.get_children(): + render_pending = false + front_sprite = null + rear_sprite = null + enemy_sprite = null + projectile = null + for child in display_root.get_children(): child.free() - if page == AppPage.BOOT: - render_boot() + var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY] else YARD_BACKGROUND + add_background(background_texture) + if portrait_layout(): + render_portrait_notice() else: - render_title() + match page: + AppPage.BOOT: + render_boot() + AppPage.TITLE: + render_title() + AppPage.YARD: + render_yard() + AppPage.BATTLE: + render_battle() + AppPage.VICTORY: + render_victory() + if restart_confirmation: + render_restart_confirmation() + + +func add_background(texture: Texture2D) -> void: + var background := TextureRect.new() + background.name = "WorldBackground" + background.texture = texture + background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + background.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + background.mouse_filter = Control.MOUSE_FILTER_IGNORE + display_root.add_child(background) + + +func render_portrait_notice() -> void: + add_full_wash(Color(0.015, 0.04, 0.07, 0.72)) + var panel := PanelContainer.new() + panel.name = "RotateNotice" + panel.position = Vector2(24, size.y * 0.32) + panel.size = Vector2(size.x - 48, 226) + panel.add_theme_stylebox_override("panel", parchment_style()) + var box := VBoxContainer.new() + box.alignment = BoxContainer.ALIGNMENT_CENTER + box.add_theme_constant_override("separation", 10) + panel.add_child(box) + box.add_child(centered_label("↻", 34, COLOR_GOLD)) + box.add_child(centered_label(tr_text("请将手机旋转为横屏", "Rotate your phone to landscape"), 21, COLOR_INK)) + var guidance := centered_label(tr_text("角色、选择和战斗会在横屏中完整显示", "Characters, choices and battle will fit in landscape"), 12, Color("#55472f")) + guidance.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(guidance) + display_root.add_child(panel) + if page != AppPage.TITLE and page != AppPage.BOOT: + var home := game_button("PortraitHome", tr_text("回到主页", "Home"), go_home, false) + home.position = Vector2(70, size.y * 0.32 + 244) + home.size = Vector2(size.x - 140, 52) + display_root.add_child(home) func render_boot() -> void: - var shell := CenterContainer.new() - shell.name = "BootScreen" - shell.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - entry_layer.add_child(shell) - var column := VBoxContainer.new() - column.custom_minimum_size = Vector2(320, 0) - column.add_theme_constant_override("separation", 12) - shell.add_child(column) - var mark := Label.new() - mark.text = "AG" - mark.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - mark.add_theme_color_override("font_color", COLOR_GOLD) - mark.add_theme_font_size_override("font_size", scaled(42)) - column.add_child(mark) - column.add_child(entry_heading(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 25)) - var status_text := tr_text("本地内容验证完成", "Local content verified") if boot_verified else tr_text("正在验证本地内容", "Verifying local content") - var status := entry_body(status_text) - status.name = "BootStatus" - status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - column.add_child(status) + add_full_wash(Color(0.015, 0.035, 0.07, 0.58)) + var shell := VBoxContainer.new() + shell.name = "BootPanel" + shell.position = Vector2(size.x * 0.12, size.y * 0.25) + shell.size = Vector2(size.x * 0.76, size.y * 0.5) + shell.alignment = BoxContainer.ALIGNMENT_CENTER + shell.add_theme_constant_override("separation", 16) + display_root.add_child(shell) + shell.add_child(centered_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 38, COLOR_WHITE)) + var status := tr_text("远征装备已经就绪", "Expedition kit ready") if boot_verified else tr_text("正在整理远征装备…", "Preparing the expedition…") + shell.add_child(centered_label(status, 18, COLOR_TEAL)) var progress := ProgressBar.new() progress.name = "BootProgress" - progress.show_percentage = false + progress.custom_minimum_size = Vector2(0, 18) progress.max_value = max(1, active_boot_paths().size()) progress.value = boot_index - progress.custom_minimum_size = Vector2(320, 8) - column.add_child(progress) - var detail_text := tr_text("准备开始本地校验", "Preparing local verification") + progress.show_percentage = false + progress.add_theme_stylebox_override("background", lacquer_style(Color("#121d34"), Color("#6d7f95"), 1)) + progress.add_theme_stylebox_override("fill", lacquer_style(COLOR_GOLD, COLOR_GOLD_LIGHT, 1)) + shell.add_child(progress) if not boot_failed_path.is_empty(): - detail_text = tr_text("无法读取:%s" % boot_failed_path, "Could not load: %s" % boot_failed_path) - elif boot_verified: - detail_text = tr_text("%d / %d 项资源已就绪" % [boot_index, active_boot_paths().size()], "%d / %d resources ready" % [boot_index, active_boot_paths().size()]) - elif not boot_current_path.is_empty(): - detail_text = tr_text("正在检查:%s" % boot_current_path.get_file(), "Checking: %s" % boot_current_path.get_file()) - var detail := entry_body(detail_text) - detail.name = "BootDetail" - detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - column.add_child(detail) - if not boot_failed_path.is_empty(): - var retry := entry_button("BootRetry", tr_text("重试本地校验", "Retry verification"), retry_boot, true) - column.add_child(retry) + shell.add_child(centered_label(tr_text("有一件装备没有准备好", "One required asset could not be prepared"), 14, Color("#ffb4b4"))) + var retry := game_button("BootRetry", tr_text("重新检查", "Check again"), retry_boot, true) + retry.custom_minimum_size = Vector2(260, 56) + shell.add_child(retry) func render_title() -> void: - var margin := MarginContainer.new() - margin.name = "TitleScreen" - margin.add_theme_constant_override("margin_left", 18 if compact_layout() else 36) - margin.add_theme_constant_override("margin_right", 18 if compact_layout() else 36) - margin.add_theme_constant_override("margin_top", 18 if compact_layout() else 30) - margin.add_theme_constant_override("margin_bottom", 18 if compact_layout() else 30) - margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - entry_layer.add_child(margin) - var layout := VBoxContainer.new() - margin.add_child(layout) - + add_full_wash(Color(0.01, 0.025, 0.055, 0.26)) var tools := HBoxContainer.new() tools.name = "TitleTools" - tools.alignment = BoxContainer.ALIGNMENT_END + tools.position = Vector2(size.x - 286, 22) + tools.size = Vector2(264, 50) tools.add_theme_constant_override("separation", 8) - tools.add_child(title_tool_button("TitleLocaleButton", "简 / EN", toggle_locale)) - tools.add_child(title_tool_button("TitleTextScaleButton", "Aa", toggle_text_scale)) - tools.add_child(title_tool_button("TitleMotionButton", "~", toggle_motion)) - tools.add_child(title_tool_button("TitleMuteButton", "♪", toggle_mute)) - layout.add_child(tools) - - var spacer := Control.new() - spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL - layout.add_child(spacer) + display_root.add_child(tools) + tools.add_child(compact_button("LocaleButton", "简 / EN", toggle_locale)) + tools.add_child(compact_button("TextScaleButton", "Aa", toggle_text_scale)) + tools.add_child(compact_button("MotionButton", "~", toggle_motion)) + tools.add_child(compact_button("MuteButton", "♪", toggle_mute)) var copy := VBoxContainer.new() copy.name = "TitleCopy" - copy.custom_minimum_size.x = 330 if compact_layout() else 500 - copy.add_theme_constant_override("separation", 8) - layout.add_child(copy) - copy.add_child(entry_kicker(tr_text("本地内容已验证", "LOCAL CONTENT VERIFIED"))) - copy.add_child(entry_heading(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 40 if compact_layout() else 54)) - copy.add_child(entry_body(tr_text("从公会门槛出发,组建队伍,读懂风险,再让他们自动迎战。", "Build a Company at the guild threshold, read the risk, then watch them fight automatically."))) - var state := Label.new() - state.name = "NoRunStatus" - state.text = tr_text("尚无远征记录", "NO EXPEDITION YET") - state.add_theme_color_override("font_color", Color("#d6e6df")) - state.add_theme_font_size_override("font_size", scaled(14)) - copy.add_child(state) - var action := entry_button("StartNewGame", tr_text("开始新游戏", "Start New Game"), start_new_game, true) - action.custom_minimum_size = Vector2(260, 54) - copy.add_child(action) - action.grab_focus() - var note := entry_body(tr_text("新游戏将先进入契约与难度选择,不会自动创建远征。", "New Game opens Contract and difficulty setup; it does not create an expedition automatically.")) - note.add_theme_font_size_override("font_size", scaled(11)) - copy.add_child(note) + copy.position = Vector2(size.x * 0.065, size.y * 0.47) + copy.size = Vector2(size.x * 0.57, size.y * 0.31) + copy.add_theme_constant_override("separation", 7) + display_root.add_child(copy) + copy.add_child(game_label(tr_text("风路断裂,公会正在召集第一支小队", "The wind road is broken. The guild needs its first squad."), 15, COLOR_TEAL)) + copy.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 46, COLOR_WHITE)) + copy.add_child(game_label(tr_text("选择前排与后排,让他们在第一场战斗中证明配合。", "Choose a front and rear role, then watch them prove the formation."), 17, COLOR_WHITE)) + + var start := game_button("StartAdventure", "◆ " + tr_text("进入公会庭院", "Enter the Guild Yard") + " ◆", start_adventure, true) + start.position = Vector2(size.x * 0.065, size.y * 0.82) + start.size = Vector2(min(380.0, size.x * 0.44), 58) + display_root.add_child(start) + + +func render_hud(context: String) -> void: + var bar := PanelContainer.new() + bar.name = "GameHUD" + bar.position = Vector2(12, 10) + bar.size = Vector2(size.x - 24, 54) + bar.add_theme_stylebox_override("panel", lacquer_style(Color(0.045, 0.075, 0.14, 0.94), COLOR_GOLD, 2)) + display_root.add_child(bar) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + bar.add_child(row) + var home := icon_button("HomeButton", "⌂", tr_text("主页", "Home"), go_home) + row.add_child(home) + var identity := VBoxContainer.new() + identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(identity) + identity.add_child(game_label(tr_text("裂脉远征团", "AETHERBOUND GUILD"), 15, COLOR_WHITE)) + identity.add_child(game_label(context, 10, COLOR_TEAL)) + var restart := icon_button("RestartButton", "↻", tr_text("重新开始", "Restart"), request_restart) + row.add_child(restart) + row.add_child(icon_button("MuteButton", "♪", tr_text("声音", "Sound"), toggle_mute)) + + +func render_yard() -> void: + add_full_wash(Color(0.015, 0.025, 0.055, 0.12)) + render_hud(tr_text("公会庭院 · 组建两人小队", "Guild Yard · Build a two-person squad")) + var direction := PanelContainer.new() + direction.name = "YardDirection" + direction.position = Vector2(22, 76) + direction.size = Vector2(min(390.0, size.x * 0.44), 66) + direction.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.9), COLOR_GOLD, 2)) + var direction_box := VBoxContainer.new() + direction_box.add_theme_constant_override("separation", 1) + direction.add_child(direction_box) + direction_box.add_child(game_label(tr_text("组建第一支两人小队", "Build your first two-person squad"), 18, COLOR_WHITE)) + direction_box.add_child(game_label(tr_text("先选蓝色前排,再选绿色后排;可随时替换。", "Choose a blue front, then a green rear. You can replace either."), 11, COLOR_PARCHMENT)) + display_root.add_child(direction) + + var stage_bottom := size.y - 98.0 + var actor_height: float = clampf(size.y * 0.29, 106.0, 210.0) + render_yard_slot("front", front_choice, Vector2(size.x * 0.39, stage_bottom - actor_height), actor_height) + render_yard_slot("rear", rear_choice, Vector2(size.x * 0.57, stage_bottom - actor_height * 0.92), actor_height * 0.92) + + var candidate_dock := HBoxContainer.new() + candidate_dock.name = "CandidateLine" + candidate_dock.position = Vector2(18, size.y - 78) + candidate_dock.size = Vector2(size.x * 0.69, 62) + candidate_dock.add_theme_constant_override("separation", 6) + display_root.add_child(candidate_dock) + for profession in ["Hearthguard", "Wayrunner", "Farstring", "Kindhand"]: + candidate_dock.add_child(candidate_button(profession)) + + var status := game_label(notice if not notice.is_empty() else tr_text("蓝色=前排 绿色=后排", "Blue = front Green = rear"), 11, COLOR_WHITE) + status.name = "YardStatus" + status.position = Vector2(22, size.y - 110) + status.size = Vector2(size.x * 0.62, 28) + status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + display_root.add_child(status) + var depart := game_button("DepartButton", tr_text("出发:第一次远征", "Depart: First Expedition"), depart, true) + depart.position = Vector2(size.x * 0.71, size.y - 78) + depart.size = Vector2(size.x * 0.275, 62) + depart.disabled = front_choice.is_empty() or rear_choice.is_empty() + display_root.add_child(depart) + + +func render_yard_slot(slot: String, profession: String, actor_position: Vector2, actor_height: float) -> void: + var slot_color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0") + var label_text := tr_text("前排", "FRONT") if slot == "front" else tr_text("后排", "REAR") + if profession.is_empty(): + var empty := centered_label("+\n" + label_text, 18, COLOR_WHITE) + empty.name = "Empty_%s" % slot + empty.position = actor_position + Vector2(actor_height * 0.16, actor_height * 0.18) + empty.size = Vector2(actor_height * 0.68, actor_height * 0.62) + empty.add_theme_stylebox_override("normal", dashed_slot_style(slot_color)) + display_root.add_child(empty) + return + actor_at(ACTORS[profession], actor_position, Vector2(actor_height, actor_height), "Yard_%s" % slot) + var plaque := centered_label(label_text + " · " + candidate_name(profession), 11, COLOR_WHITE) + plaque.position = Vector2(actor_position.x - actor_height * 0.05, actor_position.y + actor_height * 0.76) + plaque.size = Vector2(actor_height * 1.1, 34) + plaque.add_theme_stylebox_override("normal", lacquer_style(Color(0.035, 0.06, 0.12, 0.9), slot_color, 2)) + display_root.add_child(plaque) + + +func candidate_button(profession: String) -> Button: + var data: Dictionary = CANDIDATES[profession] + var selected := front_choice == profession or rear_choice == profession + var slot: String = data["slot"] + var slot_color := Color("#7fb5ff") if slot == "front" else Color("#70d6b0") + var button := Button.new() + button.name = "Candidate_%s" % profession + var role := tr_text("前", "F") if slot == "front" else tr_text("后", "R") + button.text = ("✓ " if selected else "") + role + " · " + candidate_name(profession) + "\n" + candidate_role_short(profession) + button.focus_mode = Control.FOCUS_ALL + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.size_flags_stretch_ratio = 1.0 + button.custom_minimum_size = Vector2(104, 60) + button.add_theme_font_size_override("font_size", scaled(10)) + button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE) + button.add_theme_color_override("font_hover_color", COLOR_WHITE) + button.add_theme_color_override("font_pressed_color", COLOR_WHITE) + button.add_theme_stylebox_override("normal", candidate_style(selected, false, slot_color)) + button.add_theme_stylebox_override("hover", candidate_style(selected, true, slot_color)) + button.add_theme_stylebox_override("pressed", candidate_style(true, true, slot_color)) + button.pressed.connect(func(): choose_candidate(profession), CONNECT_DEFERRED) + return button + + +func render_battle() -> void: + add_full_wash(Color(0.015, 0.025, 0.055, 0.1)) + render_hud(tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins")) + var world_bottom := size.y - 58.0 + var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0) + var actor_width: float = actor_height + var contact_reached := battle_phase > 0 + front_base_position = Vector2(size.x * (0.39 if contact_reached else 0.18), world_bottom - actor_height) + rear_base_position = Vector2(size.x * (0.24 if contact_reached else 0.07), world_bottom - actor_height * 0.92) + enemy_base_position = Vector2(size.x * (0.56 if contact_reached else 0.77), world_bottom - actor_height * 0.84) + front_sprite = actor_at(ACTORS[front_choice], front_base_position, Vector2(actor_width, actor_height), "FrontActor") + rear_sprite = actor_at(ACTORS[rear_choice], rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor") + var enemy_texture: Texture2D = RIFTBLADE["death"] if battle_phase >= 5 else RIFTBLADE["hit"] if battle_phase == 4 else RIFTBLADE["attack"] if battle_phase in [1, 2] else RIFTBLADE["idle"] + enemy_sprite = actor_at(enemy_texture, enemy_base_position, Vector2(actor_width * 0.82, actor_height * 0.82), "EnemyActor") + + add_health_bar("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), front_hp, 100, candidate_name(front_choice)) + add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 100, tr_text("裂影刃手", "Riftblade")) + var event := battle_event_text() + var ribbon := PanelContainer.new() + ribbon.name = "BattleEvent" + ribbon.position = Vector2(size.x * 0.27, 116) + ribbon.size = Vector2(size.x * 0.46, 48) + ribbon.add_theme_stylebox_override("panel", lacquer_style(Color(0.04, 0.07, 0.13, 0.94), COLOR_GOLD, 2)) + ribbon.add_child(centered_label(event, 12, COLOR_WHITE)) + display_root.add_child(ribbon) + + if battle_phase == 1: + add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) + elif battle_phase == 2: + add_vfx(VFX["guard"], front_base_position + Vector2(actor_width * 0.55, actor_height * 0.33), actor_height * 0.25) + elif battle_phase == 3: + if rear_choice == "Farstring": + projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT) + else: + add_vfx(VFX["heal"], front_base_position + Vector2(actor_width * 0.55, actor_height * 0.3), actor_height * 0.25) + elif battle_phase == 4: + add_vfx(VFX["hit"], enemy_base_position + Vector2(actor_width * 0.28, actor_height * 0.25), actor_height * 0.27) + + +func render_victory() -> void: + add_full_wash(Color(0.02, 0.035, 0.075, 0.2)) + render_hud(tr_text("第一次远征完成", "First Expedition Complete")) + var actor_height: float = clampf(size.y * 0.29, 98.0, 185.0) + actor_at(ACTORS[front_choice], Vector2(size.x * 0.13, size.y - actor_height - 45), Vector2(actor_height, actor_height), "VictoryFront") + actor_at(ACTORS[rear_choice], Vector2(size.x * 0.29, size.y - actor_height * 0.92 - 45), Vector2(actor_height * 0.92, actor_height * 0.92), "VictoryRear") + actor_at(RIFTBLADE["death"], Vector2(size.x * 0.48, size.y - actor_height * 0.52 - 42), Vector2(actor_height * 0.52, actor_height * 0.52), "VictoryEnemy") + var banner := PanelContainer.new() + banner.name = "VictoryBanner" + var banner_height: float = min(size.y - 112.0, 330.0) + banner.position = Vector2(size.x * 0.58, (size.y - banner_height) * 0.5 + 14.0) + banner.size = Vector2(size.x * 0.38, banner_height) + banner.add_theme_stylebox_override("panel", parchment_style()) + display_root.add_child(banner) + var box := VBoxContainer.new() + box.alignment = BoxContainer.ALIGNMENT_CENTER + box.add_theme_constant_override("separation", 6) + banner.add_child(box) + box.add_child(centered_label("◆ " + tr_text("第一次远征完成", "First Expedition Complete") + " ◆", 22, COLOR_INK)) + box.add_child(centered_label(victory_explanation(), 12, Color("#4c402d"))) + box.add_child(centered_label(tr_text("你已经看懂前排与后排的职责。", "You have learned what front and rear roles do."), 11, Color("#6d5523"))) + var again := game_button("PlayAgainButton", tr_text("重新体验", "Play Again"), request_restart, true) + again.custom_minimum_size.y = 48 + box.add_child(again) + var home := game_button("VictoryHomeButton", tr_text("回到主页", "Home"), go_home, false) + home.custom_minimum_size.y = 46 + box.add_child(home) + + +func render_restart_confirmation() -> void: + var wash := ColorRect.new() + wash.name = "RestartWash" + wash.color = Color(0.01, 0.015, 0.03, 0.76) + wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + display_root.add_child(wash) + var dialog := PanelContainer.new() + dialog.name = "RestartDialog" + dialog.position = Vector2(size.x * 0.31, size.y * 0.24) + dialog.size = Vector2(size.x * 0.38, size.y * 0.48) + dialog.add_theme_stylebox_override("panel", parchment_style()) + display_root.add_child(dialog) + var box := VBoxContainer.new() + box.alignment = BoxContainer.ALIGNMENT_CENTER + box.add_theme_constant_override("separation", 12) + dialog.add_child(box) + box.add_child(centered_label(tr_text("重新开始这次体验?", "Restart this experience?"), 20, COLOR_INK)) + box.add_child(centered_label(tr_text("当前队伍和战斗进度会清空。", "The current squad and battle progress will be cleared."), 13, Color("#55472f"))) + var confirm := game_button("RestartConfirmButton", tr_text("确认重新开始", "Restart"), confirm_restart, true) + confirm.custom_minimum_size.y = 52 + box.add_child(confirm) + var cancel := game_button("RestartCancelButton", tr_text("继续当前体验", "Keep playing"), cancel_restart, false) + cancel.custom_minimum_size.y = 48 + box.add_child(cancel) func run_boot_verification() -> void: if boot_running: return boot_running = true - boot_failed_path = "" boot_verified = false - boot_success_presented = false + boot_failed_path = "" boot_index = 0 + render() for path in active_boot_paths(): - boot_current_path = path - render() - await get_tree().process_frame - if not ResourceLoader.exists(path): - boot_failed_path = path - boot_running = false - render() - return - var resource := ResourceLoader.load(path) - if resource == null: + if not ResourceLoader.exists(path) or ResourceLoader.load(path) == null: boot_failed_path = path boot_running = false render() return boot_index += 1 - boot_current_path = "" + render() + await get_tree().process_frame boot_verified = true boot_running = false render() - boot_success_presented = true - await get_tree().create_timer(0.25).timeout + await get_tree().create_timer(0.35).timeout page = AppPage.TITLE render() @@ -464,722 +518,417 @@ func retry_boot() -> void: call_deferred("run_boot_verification") -func start_new_game() -> void: - if not boot_verified: - return - page = AppPage.RUN - notice = tr_text("新游戏设置已开启;尚未选择契约或创建远征。", "New Game setup opened; no Contract or expedition has been created.") +func start_adventure() -> void: + clear_experience() + page = AppPage.YARD + notice = tr_text("先选前排:盾卫更稳,游侠更灵活。", "Choose the front: Guardian is steady, Runner is agile.") play_cue("confirm") render() -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() +func choose_candidate(profession: String) -> void: + if not CANDIDATES.has(profession): + return + var slot: String = CANDIDATES[profession]["slot"] + if slot == "front": + front_choice = profession + notice = tr_text("%s进入前排。现在选择一名后排。" % candidate_name(profession), "%s joined the front. Now choose a rear role." % candidate_name(profession)) 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 entry_kicker(value: String) -> Label: - var label := Label.new() - label.text = value - label.add_theme_color_override("font_color", Color("#72d9cc")) - label.add_theme_font_size_override("font_size", scaled(11)) - return label - - -func entry_heading(value: String, size: int) -> Label: - var label := Label.new() - label.text = value - label.add_theme_color_override("font_color", COLOR_WHITE) - label.add_theme_font_size_override("font_size", scaled(size)) - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - return label - - -func entry_body(value: String) -> Label: - var label := Label.new() - label.text = value - label.add_theme_color_override("font_color", Color("#d6e2df")) - label.add_theme_font_size_override("font_size", scaled(13)) - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - return label - - -func entry_button(node_name: String, label: String, callback: Callable, accent: bool) -> Button: - var button := make_button(node_name, label, callback, accent, true) - button.add_theme_font_size_override("font_size", scaled(15)) - return button - - -func title_tool_button(node_name: String, label: String, callback: Callable) -> Button: - var button := tool_button(node_name, label, callback, true) - button.custom_minimum_size = Vector2(50, 48) - button.size_flags_horizontal = Control.SIZE_SHRINK_END - return button - - -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, deferred_callback := false) -> 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, CONNECT_DEFERRED if deferred_callback else 0) - return button - - -func make_button(node_name: String, label: String, callback: Callable, accent: bool, deferred_callback := false) -> 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, CONNECT_DEFERRED if deferred_callback else 0) - 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) + rear_choice = profession + notice = tr_text("%s进入后排。队伍已准备好。" % candidate_name(profession), "%s joined the rear. The squad is ready." % candidate_name(profession)) + play_cue("select") 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 depart() -> void: + if front_choice.is_empty() or rear_choice.is_empty(): + return + battle_phase = 0 + battle_clock = 0.0 + battle_playing = true + front_hp = 100 + enemy_hp = 100 + guard_events = 0 + rear_events = 0 + victory_events = 0 + page = AppPage.BATTLE + play_cue("depart") + render() -func select_contract() -> void: - apply_result(run.select_contract(), "select") +func advance_battle_phase() -> void: + if page != AppPage.BATTLE or not battle_playing: + return + battle_phase += 1 + battle_clock = 0.0 + match battle_phase: + 2: + front_hp = 82 if front_choice == "Hearthguard" else 88 + guard_events += 1 + play_cue("hit") + 3: + if rear_choice == "Farstring": + enemy_hp = 64 + else: + front_hp = min(100, front_hp + 12) + rear_events += 1 + play_cue("role") + 4: + enemy_hp = 34 + play_cue("hit") + 5: + enemy_hp = 0 + play_cue("victory") + 6: + battle_playing = false + victory_events += 1 + page = AppPage.VICTORY + render() -func open_offers() -> void: - apply_result(run.open_offers(), "confirm") +func _process(delta: float) -> void: + if page != AppPage.BATTLE or not battle_playing: + return + battle_clock += delta + var duration := 2.5 if battle_phase == 0 else 2.2 + if battle_clock >= duration: + advance_battle_phase() + return + if front_sprite == null or not is_instance_valid(front_sprite): + return + var progress: float = clampf(battle_clock / duration, 0.0, 1.0) + var bob := 0.0 if reduced_motion else sin(battle_clock * 5.0) * 3.0 + if battle_phase == 0 and not reduced_motion: + front_sprite.position.x = lerp(size.x * 0.13, size.x * 0.38, progress) + rear_sprite.position.x = lerp(size.x * 0.03, size.x * 0.20, progress) + enemy_sprite.position.x = lerp(size.x * 0.82, size.x * 0.57, progress) + front_sprite.position.y = front_base_position.y + bob + rear_sprite.position.y = rear_base_position.y - bob * 0.7 + enemy_sprite.position.y = enemy_base_position.y + bob * 0.5 + if projectile != null and is_instance_valid(projectile) and battle_phase == 3 and rear_choice == "Farstring" and not reduced_motion: + projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, progress) -func inspect_offer(id: String) -> void: - inspected_id = id - apply_result(run.inspect_offer(id), "select") +func request_restart() -> void: + restart_confirmation = true + render() -func toggle_offer(id: String) -> void: - apply_result(run.toggle_offer(id), "select") +func cancel_restart() -> void: + restart_confirmation = false + render() -func review_opening() -> void: - apply_result(run.review_opening(), "confirm") +func confirm_restart() -> void: + restart_confirmation = false + clear_experience() + page = AppPage.TITLE + play_cue("confirm") + render() -func revise_opening() -> void: - apply_result(run.revise_opening(), "select") +func go_home() -> void: + restart_confirmation = false + clear_experience() + page = AppPage.TITLE + play_cue("confirm") + render() -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 clear_experience() -> void: + front_choice = "" + rear_choice = "" + notice = "" + battle_phase = 0 + battle_clock = 0.0 + battle_playing = false + front_hp = 100 + enemy_hp = 100 + guard_events = 0 + rear_events = 0 + victory_events = 0 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") + play_cue("select") render() +func battle_event_text() -> String: + match battle_phase: + 0: + return tr_text("小队正在接近敌人…", "The squad advances toward the enemy…") + 1: + return tr_text("危险!裂影刃手准备攻击前排", "Danger! The Riftblade targets the front") + 2: + return tr_text("盾卫挡住重击,伤害降低", "The Guardian braces and reduces the blow") if front_choice == "Hearthguard" else tr_text("游侠侧身闪避并准备反击", "The Runner evades and prepares a counter") + 3: + return tr_text("弓手从后排射中敌人", "The Archer strikes safely from the rear") if rear_choice == "Farstring" else tr_text("治疗举起灵灯,为前排恢复生命", "The Healer raises the lantern and restores the front") + 4: + return tr_text("前后排配合,敌人即将倒下", "Front and rear combine; the enemy is nearly defeated") + 5: + return tr_text("裂影刃手被击败!", "Riftblade defeated!") + return "" + + +func victory_explanation() -> String: + var front_line := tr_text("%s在前排挡住了敌人的攻击。" % candidate_name(front_choice), "%s held the enemy at the front." % candidate_name(front_choice)) + var rear_line := tr_text("%s从后排持续造成伤害。" % candidate_name(rear_choice), "%s dealt damage from the rear." % candidate_name(rear_choice)) if rear_choice == "Farstring" else tr_text("%s从后排恢复了前排生命。" % candidate_name(rear_choice), "%s restored the front from the rear." % candidate_name(rear_choice)) + return front_line + "\n" + rear_line + + +func candidate_name(profession: String) -> String: + if profession.is_empty() or not CANDIDATES.has(profession): + return "" + return CANDIDATES[profession]["name_zh"] if locale == "zh_CN" else CANDIDATES[profession]["name_en"] + + +func candidate_role(profession: String) -> String: + return CANDIDATES[profession]["role_zh"] if locale == "zh_CN" else CANDIDATES[profession]["role_en"] + + +func candidate_role_short(profession: String) -> String: + match profession: + "Hearthguard": + return tr_text("盾卫", "Guardian") + "Wayrunner": + return tr_text("游侠", "Runner") + "Farstring": + return tr_text("弓手", "Archer") + "Kindhand": + return tr_text("治疗", "Healer") + return "" + + +func tr_text(zh: String, en: String) -> String: + return zh if locale == "zh_CN" else en + + +func portrait_layout() -> bool: + return size.y > size.x + + +func scaled(value: int) -> int: + return int(round(float(value) * text_scale)) + + +func add_full_wash(color: Color) -> ColorRect: + var wash := ColorRect.new() + wash.color = color + wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + wash.mouse_filter = Control.MOUSE_FILTER_IGNORE + display_root.add_child(wash) + return wash + + +func actor_at(texture: Texture2D, actor_position: Vector2, actor_size: Vector2, node_name: String) -> TextureRect: + var actor := TextureRect.new() + actor.name = node_name + actor.texture = texture + actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + actor.stretch_mode = TextureRect.STRETCH_SCALE + actor.mouse_filter = Control.MOUSE_FILTER_IGNORE + display_root.add_child(actor) + actor.position = actor_position + actor.size = actor_size + return actor + + +func add_vfx(texture: Texture2D, vfx_position: Vector2, diameter: float) -> TextureRect: + var image := TextureRect.new() + image.name = "BattleVFX" + image.texture = texture + image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image.stretch_mode = TextureRect.STRETCH_SCALE + image.mouse_filter = Control.MOUSE_FILTER_IGNORE + display_root.add_child(image) + image.position = vfx_position + image.size = Vector2(diameter, diameter) + return image + + +func add_signal_label(value: String, label_position: Vector2, color: Color) -> Label: + var label := centered_label(value, 34, color) + label.position = label_position + label.size = Vector2(90, 60) + label.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.04, 0.08, 0.72), color, 2)) + display_root.add_child(label) + return label + + +func add_health_bar(node_name: String, bar_position: Vector2, bar_size: Vector2, value: int, maximum: int, actor_name: String) -> void: + var shell := VBoxContainer.new() + shell.name = node_name + shell.position = bar_position + shell.size = bar_size + Vector2(0, 24) + display_root.add_child(shell) + var caption := game_label("%s %d/%d" % [actor_name, value, maximum], 11, COLOR_WHITE) + shell.add_child(caption) + var bar := ProgressBar.new() + bar.max_value = maximum + bar.value = value + bar.show_percentage = false + bar.custom_minimum_size.y = bar_size.y + bar.add_theme_stylebox_override("background", lacquer_style(Color("#14192a"), Color("#667085"), 1)) + bar.add_theme_stylebox_override("fill", lacquer_style(COLOR_TEAL if value > 35 else COLOR_CRIMSON, COLOR_WHITE, 1)) + shell.add_child(bar) + + +func game_label(value: String, font_size: int, color: Color) -> Label: + var label := Label.new() + label.text = value + label.add_theme_font_size_override("font_size", scaled(font_size)) + label.add_theme_color_override("font_color", color) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + return label + + +func centered_label(value: String, font_size: int, color: Color) -> Label: + var label := game_label(value, font_size, color) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + return label + + +func compact_button(node_name: String, label: String, callback: Callable) -> Button: + var button := game_button(node_name, label, callback, false) + button.custom_minimum_size = Vector2(52, 46) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.add_theme_font_size_override("font_size", scaled(12)) + return button + + +func icon_button(node_name: String, glyph: String, tooltip: String, callback: Callable) -> Button: + var button := game_button(node_name, glyph, callback, false) + button.tooltip_text = tooltip + button.custom_minimum_size = Vector2(52, 44) + button.size_flags_horizontal = Control.SIZE_SHRINK_END + button.add_theme_font_size_override("font_size", scaled(18)) + return button + + +func game_button(node_name: String, label: String, callback: Callable, primary: bool) -> Button: + var button := Button.new() + button.name = node_name + button.text = label + button.focus_mode = Control.FOCUS_ALL + button.custom_minimum_size.y = 48 + button.add_theme_font_size_override("font_size", scaled(14)) + button.add_theme_color_override("font_color", COLOR_INK if primary else COLOR_WHITE) + button.add_theme_color_override("font_hover_color", COLOR_INK if primary else COLOR_WHITE) + button.add_theme_color_override("font_pressed_color", COLOR_INK if primary else COLOR_WHITE) + button.add_theme_color_override("font_disabled_color", Color("#9ba2ad")) + button.add_theme_stylebox_override("normal", ornate_button_style(primary, "normal")) + button.add_theme_stylebox_override("hover", ornate_button_style(primary, "hover")) + button.add_theme_stylebox_override("pressed", ornate_button_style(primary, "pressed")) + button.add_theme_stylebox_override("focus", ornate_button_style(primary, "focus")) + button.add_theme_stylebox_override("disabled", ornate_button_style(false, "disabled")) + button.pressed.connect(callback, CONNECT_DEFERRED) + return button + + +func ornate_button_style(primary: bool, state: String) -> StyleBoxFlat: + var fill := COLOR_GOLD if primary else Color("#1b3154") + var border := COLOR_GOLD_LIGHT if primary else Color("#7e9bb7") + if state == "hover" or state == "focus": + fill = COLOR_GOLD_LIGHT if primary else COLOR_INDIGO_HOVER + border = COLOR_WHITE + elif state == "pressed": + fill = COLOR_PARCHMENT_DARK if primary else Color("#10213c") + border = COLOR_GOLD + elif state == "disabled": + fill = Color("#3d4657") + border = Color("#697180") + var style := lacquer_style(fill, border, 2) + style.shadow_color = Color(0.01, 0.015, 0.03, 0.7) + style.shadow_size = 5 if state != "pressed" else 2 + style.shadow_offset = Vector2(0, 3 if state != "pressed" else 1) + return style + + +func candidate_style(selected: bool, hover: bool, slot_color: Color) -> StyleBoxFlat: + var fill := Color(0.035, 0.065, 0.13, 0.88 if not hover else 0.96) + var border := COLOR_GOLD if selected else slot_color + var style := lacquer_style(fill, COLOR_WHITE if hover else border, 3 if selected else 2) + style.shadow_color = Color(0.01, 0.015, 0.03, 0.75) + style.shadow_size = 4 + style.shadow_offset = Vector2(0, 2) + return style + + +func dashed_slot_style(border: Color) -> StyleBoxFlat: + var style := lacquer_style(Color(0.035, 0.06, 0.12, 0.58), border, 2) + style.corner_radius_top_left = 28 + style.corner_radius_top_right = 28 + style.corner_radius_bottom_left = 28 + style.corner_radius_bottom_right = 28 + style.shadow_color = Color(0.01, 0.015, 0.03, 0.58) + style.shadow_size = 6 + style.shadow_offset = Vector2(0, 3) + return style + + +func lacquer_style(fill: Color, border: Color, width: 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(5) + style.content_margin_left = 10 + style.content_margin_right = 10 + style.content_margin_top = 7 + style.content_margin_bottom = 7 + return style + + +func parchment_style() -> StyleBoxFlat: + var style := lacquer_style(COLOR_PARCHMENT, COLOR_GOLD, 3) + style.corner_radius_top_left = 7 + style.corner_radius_top_right = 7 + style.corner_radius_bottom_left = 7 + style.corner_radius_bottom_right = 7 + style.shadow_color = Color(0.02, 0.02, 0.03, 0.75) + style.shadow_size = 9 + style.shadow_offset = Vector2(0, 5) + style.content_margin_left = 18 + style.content_margin_right = 18 + style.content_margin_top = 16 + style.content_margin_bottom = 16 + return style + + 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) + var frequency: float = { + "select": 360.0, + "confirm": 440.0, + "depart": 300.0, + "hit": 180.0, + "role": 540.0, + "victory": 680.0, + }.get(kind, 400.0) + var duration := 0.13 if kind in ["hit", "victory"] else 0.08 + var frames := int(22050.0 * duration) 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 diff --git a/runtime/tests/p9_0r1_capture_test.gd b/runtime/tests/p9_0r1_capture_test.gd new file mode 100644 index 00000000..9083ca07 --- /dev/null +++ b/runtime/tests/p9_0r1_capture_test.gd @@ -0,0 +1,134 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +const STATES := [ + "title", + "yard_empty", + "yard_front", + "yard_ready", + "battle_pressure", + "victory", +] + +var output_root := "" +var viewport_size := Vector2i(1280, 720) +var locale := "zh_CN" +var text_scale := 1.0 +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + output_root = OS.get_environment("ABG_P9_0R1_CAPTURE_ROOT") + if output_root.is_empty(): + output_root = "/tmp/abg-p9-0r1-captures" + viewport_size = Vector2i( + read_int("ABG_P9_0R1_VIEWPORT_WIDTH", 1280), + read_int("ABG_P9_0R1_VIEWPORT_HEIGHT", 720) + ) + locale = read_string("ABG_P9_0R1_LOCALE", "zh_CN") + text_scale = read_float("ABG_P9_0R1_TEXT_SCALE", 1.0) + DirAccess.make_dir_recursive_absolute(output_root) + var viewport := SubViewport.new() + viewport.size = viewport_size + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var game := MainScene.instantiate() + game.auto_boot = false + viewport.add_child(game) + await settle() + game.locale = locale + game.text_scale = text_scale + game.reduced_motion = true + game.muted = true + game.boot_verified = true + for index in range(STATES.size()): + await capture_state(viewport, game, index + 1, STATES[index]) + game.queue_free() + viewport.queue_free() + await drain_cleanup() + if failures.is_empty(): + print("ABG_P9_0R1_RUNTIME_CAPTURE_OK frames=6 viewport=%sx%s locale=%s text_scale=%.1f renderer=normal input_claim=none root=%s" % [viewport_size.x, viewport_size.y, locale, text_scale, output_root]) + call_deferred("finish", 0) + else: + for failure in failures: + push_error(failure) + call_deferred("finish", 1) + + +func finish(exit_code: int) -> void: + quit(exit_code) + + +func capture_state(viewport: SubViewport, game: Control, index: int, state_name: String) -> void: + game.clear_experience() + apply_state(game, state_name) + game.render() + await settle() + await RenderingServer.frame_post_draw + var image := viewport.get_texture().get_image() + var path := output_root.path_join("%02d-%s.png" % [index, state_name]) + var error := image.save_png(path) + if error != OK: + failures.append("capture %s failed with %s" % [state_name, error]) + + +func apply_state(game: Control, state_name: String) -> void: + match state_name: + "title": + game.page = game.AppPage.TITLE + "yard_empty": + game.page = game.AppPage.YARD + game.notice = game.tr_text("先选前排:盾卫更稳,游侠更灵活。", "Choose the front: Guardian is steady, Runner is agile.") + "yard_front": + game.page = game.AppPage.YARD + game.front_choice = "Hearthguard" + game.notice = game.tr_text("艾连进入前排。现在选择一名后排。", "Elian joined the front. Now choose a rear role.") + "yard_ready": + game.page = game.AppPage.YARD + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.notice = game.tr_text("妮娅进入后排。队伍已准备好。", "Nia joined the rear. The squad is ready.") + "battle_pressure": + game.page = game.AppPage.BATTLE + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.battle_playing = false + game.battle_phase = 2 + game.front_hp = 82 + game.enemy_hp = 100 + "victory": + game.page = game.AppPage.VICTORY + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.front_hp = 82 + game.enemy_hp = 0 + + +func read_string(key: String, fallback: String) -> String: + var value := OS.get_environment(key) + return value if not value.is_empty() else fallback + + +func read_int(key: String, fallback: int) -> int: + var value := OS.get_environment(key) + return int(value) if not value.is_empty() else fallback + + +func read_float(key: String, fallback: float) -> float: + var value := OS.get_environment(key) + return float(value) if not value.is_empty() else fallback + + +func settle() -> void: + for frame in range(10): + await process_frame + + +func drain_cleanup() -> void: + for frame in range(120): + await process_frame diff --git a/runtime/tests/p9_0r1_capture_test.gd.uid b/runtime/tests/p9_0r1_capture_test.gd.uid new file mode 100644 index 00000000..ee67f0a9 --- /dev/null +++ b/runtime/tests/p9_0r1_capture_test.gd.uid @@ -0,0 +1 @@ +uid://bdsp4a43800ei diff --git a/runtime/tests/p9_0r1_domain_test.gd b/runtime/tests/p9_0r1_domain_test.gd new file mode 100644 index 00000000..b3e86e60 --- /dev/null +++ b/runtime/tests/p9_0r1_domain_test.gd @@ -0,0 +1,107 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + var game := MainScene.instantiate() + game.auto_boot = false + root.add_child(game) + await settle() + expect(game.page == game.AppPage.BOOT, "fresh instance starts at Boot") + expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "fresh team is empty") + + game.boot_paths_override.assign(["res://missing/p9_0r1_required_resource.tres"]) + await game.run_boot_verification() + expect(game.page == game.AppPage.BOOT, "failed boot stays at Boot") + expect(game.boot_failed_path == "res://missing/p9_0r1_required_resource.tres", "failed boot retains path") + expect(find_control(game, "BootRetry") != null, "failed boot exposes Retry") + + game.boot_paths_override.clear() + await game.run_boot_verification() + expect(game.boot_verified and game.page == game.AppPage.TITLE, "verified boot reaches Title") + expect(find_control(game, "StartAdventure") != null, "Title exposes one game entry") + + game.start_adventure() + expect(game.page == game.AppPage.YARD, "entry reaches Guild Yard") + game.choose_candidate("Hearthguard") + game.choose_candidate("Wayrunner") + expect(game.front_choice == "Wayrunner", "front candidate replaces same position") + game.choose_candidate("Farstring") + game.choose_candidate("Kindhand") + expect(game.rear_choice == "Kindhand", "rear candidate replaces same position") + game.choose_candidate("Farstring") + expect(game.rear_choice == "Farstring", "rear candidate can be selected again") + + game.depart() + expect(game.page == game.AppPage.BATTLE and game.battle_playing, "ready team begins battle") + for phase in range(6): + game.advance_battle_phase() + expect(game.page == game.AppPage.VICTORY, "fixed tutorial sequence reaches victory") + expect(game.enemy_hp == 0 and game.victory_events == 1, "victory commits once") + expect(game.guard_events == 1 and game.rear_events == 1, "front and rear roles both act") + game.advance_battle_phase() + expect(game.victory_events == 1, "victory cannot duplicate") + + game.request_restart() + expect(game.restart_confirmation, "restart requires confirmation") + game.cancel_restart() + expect(not game.restart_confirmation and game.page == game.AppPage.VICTORY, "cancel preserves current state") + game.request_restart() + game.confirm_restart() + expect(game.page == game.AppPage.TITLE, "confirmed restart returns Title") + expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "restart clears team") + expect(game.front_hp == 100 and game.enemy_hp == 100 and game.victory_events == 0, "restart clears battle state") + + game.start_adventure() + game.choose_candidate("Hearthguard") + game.choose_candidate("Kindhand") + game.depart() + for phase in range(6): + game.advance_battle_phase() + expect(game.page == game.AppPage.VICTORY, "healer route reaches victory") + expect(game.front_hp == 94, "healer visibly restores front after guarded hit") + expect(game.rear_events == 1, "healer contribution recorded") + game.go_home() + expect(game.page == game.AppPage.TITLE and game.front_choice.is_empty(), "Home clears transient experience") + + game.queue_free() + await drain_cleanup() + if failures.is_empty(): + print("ABG_P9_0R1_DOMAIN_OK routes=2 replace=front,rear restart=confirmed home=1 battle_phases=6 victory_once=1") + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +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(40): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.0R1 domain: %s" % message) diff --git a/runtime/tests/p9_0r1_domain_test.gd.uid b/runtime/tests/p9_0r1_domain_test.gd.uid new file mode 100644 index 00000000..4427d7ae --- /dev/null +++ b/runtime/tests/p9_0r1_domain_test.gd.uid @@ -0,0 +1 @@ +uid://bixsojbce2foq diff --git a/runtime/tests/p9_0r1_dynamic_slice_test.gd b/runtime/tests/p9_0r1_dynamic_slice_test.gd new file mode 100644 index 00000000..e06f6d59 --- /dev/null +++ b/runtime/tests/p9_0r1_dynamic_slice_test.gd @@ -0,0 +1,53 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var game: Control +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + game = MainScene.instantiate() + game.auto_boot = false + root.add_child(game) + await settle(12) + game.locale = "zh_CN" + game.text_scale = 1.0 + game.reduced_motion = false + game.muted = true + game.boot_verified = true + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.depart() + var elapsed := 0.0 + while game.page != game.AppPage.VICTORY and elapsed < 22.0: + await process_frame + elapsed += 1.0 / 30.0 + if game.page != game.AppPage.VICTORY: + failures.append("dynamic slice did not reach victory") + if game.guard_events != 1 or game.rear_events != 1 or game.victory_events != 1: + failures.append("dynamic slice role events were not committed exactly once") + await create_timer(1.6).timeout + game.queue_free() + game = null + await settle(120) + if failures.is_empty(): + print("ABG_P9_0R1_DYNAMIC_SLICE_OK duration=%.2f phases=6 advance=1 front_response=1 rear_action=1 enemy_defeat=1 victory=1 renderer=normal" % elapsed) + call_deferred("finish", 0) + else: + for failure in failures: + push_error(failure) + call_deferred("finish", 1) + + +func finish(exit_code: int) -> void: + quit(exit_code) + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame diff --git a/runtime/tests/p9_0r1_layout_test.gd b/runtime/tests/p9_0r1_layout_test.gd new file mode 100644 index 00000000..404419f4 --- /dev/null +++ b/runtime/tests/p9_0r1_layout_test.gd @@ -0,0 +1,128 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +const LANDSCAPE_FIXTURES := [ + {"size": Vector2i(1280, 720), "locale": "zh_CN", "scale": 1.0}, + {"size": Vector2i(844, 390), "locale": "en", "scale": 1.0}, + {"size": Vector2i(844, 390), "locale": "zh_CN", "scale": 1.3}, +] +const PAGES := ["title", "yard", "battle", "victory"] + +var failures: Array[String] = [] +var fixture_count := 0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + for fixture in LANDSCAPE_FIXTURES: + for page_name in PAGES: + await verify_landscape(fixture, page_name) + await verify_portrait() + if failures.is_empty(): + print("ABG_P9_0R1_LAYOUT_OK fixtures=%d landscape=12 portrait=1 pages=4 locales=2 text_130=true min_touch=44" % fixture_count) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func verify_landscape(fixture: Dictionary, page_name: 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() + game.auto_boot = false + viewport.add_child(game) + await settle() + game.locale = fixture.locale + game.text_scale = fixture.scale + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.boot_verified = true + match page_name: + "title": game.page = game.AppPage.TITLE + "yard": game.page = game.AppPage.YARD + "battle": + game.page = game.AppPage.BATTLE + game.battle_playing = false + game.battle_phase = 2 + game.front_hp = 82 + "victory": + game.page = game.AppPage.VICTORY + game.enemy_hp = 0 + 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), page_name] + verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(fixture.size)), label) + expect(find_control(game, "RotateNotice") == null, "%s unexpectedly shows portrait notice" % label) + if page_name != "title": + expect(find_control(game, "HomeButton") != null, "%s missing Home" % label) + expect(find_control(game, "RestartButton") != null, "%s missing Restart" % label) + game.queue_free() + viewport.queue_free() + await drain_cleanup() + + +func verify_portrait() -> void: + var viewport := SubViewport.new() + viewport.size = Vector2i(390, 844) + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var game := MainScene.instantiate() + game.auto_boot = false + viewport.add_child(game) + await settle() + game.page = game.AppPage.YARD + game.render() + await settle() + fixture_count += 1 + expect(find_control(game, "RotateNotice") != null, "portrait missing rotate guidance") + verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(390, 844)), "390x844/portrait") + game.queue_free() + viewport.queue_free() + await drain_cleanup() + + +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() + 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 settle() -> void: + for frame in range(8): + 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.0R1 layout: %s" % message) diff --git a/runtime/tests/p9_0r1_layout_test.gd.uid b/runtime/tests/p9_0r1_layout_test.gd.uid new file mode 100644 index 00000000..adbb2adc --- /dev/null +++ b/runtime/tests/p9_0r1_layout_test.gd.uid @@ -0,0 +1 @@ +uid://be0d7430yxb01 diff --git a/runtime/tests/p9_0r1_touch_test.gd b/runtime/tests/p9_0r1_touch_test.gd new file mode 100644 index 00000000..f2a33d23 --- /dev/null +++ b/runtime/tests/p9_0r1_touch_test.gd @@ -0,0 +1,105 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +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() + game.auto_boot = false + root.add_child(game) + await settle() + game.boot_verified = true + game.page = game.AppPage.TITLE + game.render() + await settle() + + await tap("StartAdventure") + expect(game.page == game.AppPage.YARD, "Title touch reaches yard") + await tap("Candidate_Hearthguard") + await tap("Candidate_Wayrunner") + expect(game.front_choice == "Wayrunner", "touch replaces front") + await tap("Candidate_Farstring") + await tap("Candidate_Kindhand") + expect(game.rear_choice == "Kindhand", "touch replaces rear") + await tap("DepartButton") + expect(game.page == game.AppPage.BATTLE, "touch starts battle") + await tap("RestartButton") + expect(game.restart_confirmation, "restart touch opens confirmation") + await tap("RestartCancelButton") + expect(game.page == game.AppPage.BATTLE and not game.restart_confirmation, "cancel touch preserves battle") + await tap("HomeButton") + expect(game.page == game.AppPage.TITLE, "Home touch reaches Title") + expect(game.front_choice.is_empty() and game.rear_choice.is_empty(), "Home touch clears team") + await tap("StartAdventure") + await tap("Candidate_Hearthguard") + await tap("Candidate_Farstring") + await tap("DepartButton") + await tap("RestartButton") + await tap("RestartConfirmButton") + expect(game.page == game.AppPage.TITLE, "confirmed restart touch reaches Title") + expect(game.front_hp == 100 and game.enemy_hp == 100, "confirmed restart clears HP") + + game.queue_free() + game = null + await drain_cleanup() + if failures.is_empty(): + print("ABG_P9_0R1_TOUCH_OK touch=%d fallback=0 direct=0 replace=2 battle=1 home=1 restart=confirmed" % 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 or not control.is_visible_in_tree() or (control is BaseButton and control.disabled): + failures.append("unavailable touch target %s" % node_name) + return + var position := root.get_final_transform() * control.get_global_rect().get_center() + var down := InputEventScreenTouch.new() + down.index = 0 + down.position = position + down.pressed = true + Input.parse_input_event(down) + await process_frame + var up := InputEventScreenTouch.new() + up.index = 0 + up.position = position + up.pressed = false + Input.parse_input_event(up) + 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(50): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.0R1 touch: %s" % message) diff --git a/runtime/tests/p9_0r1_touch_test.gd.uid b/runtime/tests/p9_0r1_touch_test.gd.uid new file mode 100644 index 00000000..adb70fdc --- /dev/null +++ b/runtime/tests/p9_0r1_touch_test.gd.uid @@ -0,0 +1 @@ +uid://b7i5n0vbinkxx