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:
2026-08-12 16:47:44 +08:00
parent 8583abb65e
commit e7eb408e0e
35 changed files with 2259 additions and 17 deletions
+1
View File
@@ -3,6 +3,7 @@ tmp/
build/
dist/
node_modules/
runtime/.godot/
test-results/
prototype/gallery/
*.log
+9 -5
View File
@@ -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
View File
@@ -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.
+19 -4
View File
@@ -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

+40
View File
@@ -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
+21
View File
@@ -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"
+230
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
uid://chbk3xwe4lyft
+934
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://okxc6j802ed0
+12
View File
@@ -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")
+36
View File
@@ -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)]
}
+113
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bllifg47ya0ht
+97
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ckpspse5hv0x4
+104
View File
@@ -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
+144
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b1nisiwbysuxk
+119
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://luid5lum7rbe