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