diff --git a/docs/endless-runner-contract.md b/docs/endless-runner-contract.md index d4018659..2e9fc421 100644 --- a/docs/endless-runner-contract.md +++ b/docs/endless-runner-contract.md @@ -1,6 +1,6 @@ # Endless Runner — Backend/Frontend Contract -Version 2.5 · 2026-09-30 · GDScript · v2 (stages, turns, bosses) is specified in §11 and overrides §2–§8 where they conflict +Version 2.6 · 2026-10-09 · GDScript · v2 (stages, turns, bosses) is specified in §11 and overrides §2–§8 where they conflict **Engine version policy.** The project pins **Godot 4.7.1** (GDD). The editor installed on the build machine during this cycle is **4.7.0 stable** (`4.7.stable.official.5b4e0cb0f`), so every verification @@ -336,6 +336,7 @@ mouse-hover failures on a clean `main`; the docs validated it on 4.7.1. New runn ## 10. Change log +- 2.6 (2026-10-09): §11.14 — boss cinematics. The intro becomes a camera cinematic: **full** (`boss_intro_seconds` 3.0 → 5.0) the first time a run meets a boss, **short** (`boss_intro_short_seconds` 2.5) on every later meeting and on a checkpoint retry; the kill plays a **defeat** cinematic (`boss_defeat_seconds` 3.6, short 2.0 on a repeat defeat) that holds the boss phase open in place of `boss_outro_seconds`, which now applies to a retreat only. New facade signals `boss_cinematic(kind, mode, seconds)`, `boss_cinematic_skipped(mode)`, `boss_cinematic_ended(mode)`; read-only `boss_cinematic_mode`, `boss_cinematic_progress`, `boss_time_factor`; method `skip_boss_cinematic()` (intros only: jump / `ui_accept` once `boss_cinematic_skip_after` 0.5 s have played, jumping to the last `boss_cinematic_skip_tail` 0.3 s; a defeat is never skippable). While a cinematic holds the player, manual controls are off and the corridor eases to `boss_cinematic_speed_scale` 0.25×. `boss_time_factor` carries the defeat's hit-stop (0.05 for `boss_hitstop_seconds` 0.12) and slow-mo (`boss_slowmo_factor` 0.4 eased back to 1 over `boss_slowmo_seconds` 0.8); `Engine.time_scale` is never touched. `camera_rig.gd` gains `set_boss_cinematic(progress, mode, boss_position, focus_position)` (the lens sits off the mount and aims at the focus; `focus_position` is optional and defaults to the mount) and the pure `boss_shot(p, mode)` (blend 0 at both ends). §11.5 — optional visual hooks `play_cinematic(mode, seconds)` (set-up cue), `set_cinematic_progress(mode, progress)` (every cinematic tick, after a skip, and 1.0 at the end; visuals pose from it) and `set_time_factor(factor)` (ambient motion only), plus `cinematic_focus() -> Vector3` (the camera's aim; default: the centre of the upper third of the rest bounds) and the shared intro timing constants `INTRO_POSE_AT` / `INTRO_HOLD_UNTIL` on `boss_visual_base.gd`. In a run, `play_cinematic(intro_*)` is the only intro cue: `boss_fx` no longer forwards `boss_intro` to `play_intro(seconds)`, which had started visual intros twice. `play_intro` stays an optional hook for standalone and dev fixtures. §11.8 — optional HUD mount `ui/boss_cinematic.tscn`. §11.13 — the checkpoint gains `bosses_met` and `bosses_defeated_kinds`. `boss_intro(kind, name, seconds)` keeps its signature; scoring is unchanged (`boss_defeated` and its bonus land on the same tick as in 2.5). - 2.5 (2026-09-30): §11.13 — boss checkpoint retry. Every boss entry snapshots the run into `boss_checkpoint`; a catch mid-fight keeps it and Game Over offers **Retry Boss** (`retry_boss()`), which reloads `runner_game` on the same seed at the turn into that stage's boss with the score, clocks and tallies restored and full HP. New facade properties `boss_checkpoint`, `checkpoint_retry`, static `pending_boss_checkpoint`; StageManager `begin_at_boss(checkpoint)`; result field `checkpoint_retry`. - 2.4 (2026-09-28): §11.4/§11.5/§11.6/§11.10/§11.12 — boss signature attacks. Beats may carry a `signature` (boss_defs.gd): Flaky Test Swarm `flaky` (the telegraph alternates two candidate lanes for `boss_flaky_candidate_seconds`, then commits at least `boss_commit_min` before fire) and `scatter` (a half-speed orb down the middle that splits into the two outer lanes at track z −`boss_scatter_split_distance`); Merge Conflict Hydra `branching` (staggered heads, each telegraphed up front on its own lane), `sweep` (the low rebase sweep sliding outer lane → outer lane in flight) and `force_push` (phase 2's fourth head: a fast `wide`, telegraphed 1.6× longer); Legacy Monolith `legacy_wall` (a slow wall of slabs with one gap that shifts once to the next lane), `quake` (full-width ground shockwave, jump only) and `debris` (slabs falling onto floor shadows in 1–2 lanes). New facade signals `boss_attack_planned(attack)` and `boss_attack_committed(lane_mask, kind, seconds)`; `boss_attack_telegraph` now carries the candidate mask for a flaky beat; six new projectile variants (`scatter`, `sweep`, `force_push`, `slab`, `quake`, `debris`), each mapped to a base look for visuals that do not opt in; projectiles expose `boss_kind`, `signature`, `motion`, `age` and the motion API `lane_mask_at(t)` / `y_range_at(t)` / `impact_lane_mask()` (boss/boss_attacks.gd); the autopilot and `runner_boss_validator.gd` plan against that API, and the validator gains **B6**. Beats without a `signature` behave exactly as in 2.3. The Hydra taunt now matches its head count (3 heads, 4 from phase 2). - 2.3 (2026-09-22): §11.1/§11.8/§11.10 — the stage-entrance guarantee. The `STAGE n` banner covers the lanes, so for `stage_banner_clear_seconds` (3.5) after every `stage_started`, stage 1 included, no blocking hazard may reach the player, and when that window ends the nearest one is still `stage_banner_reaction_seconds` (1.0) × speed metres away. `chunk_spawner.gd` gains `begin_clear_road(seconds)`, which the StageManager calls immediately before switching placement back on: it clamps a frontier left stale by a drained corridor to at least 6 m ahead (so the first chunk of a stage is never parked at the player's feet), sweeps anything already inbound **silently** (no `hazard_cleared`, no `hazards_blasted`, no bonus, no score, no `hazards_dodged`), and restricts placement to hazard-free `breather` chunks until a hazard-bearing one would arrive late enough. `breather`-tagged chunks are now required to carry zero hazards. `ui/stage_banner.tscn` must be off screen within `stage_banner_clear_seconds − stage_banner_reaction_seconds`. @@ -364,7 +365,7 @@ lose), **tools auto-fire on pickup** (no fire button). Backend detail lives in ` |---|---|---|---|---| | `run` | `start_run()` or after `stage_clear` | `stage_distance` reached **and** no blocking hazard ahead of `stage_clear_line_z` (drain; hard timeout `stage_drain_timeout` then remaining hazards are cleared silently, no bonus) | places chunks until `target − stage_drain_distance`, then only moves/despawns | advances | | `turn` | run drained | `turn_seconds` | idle | frozen | -| `boss` | turn finished | boss HP 0 (`boss_defeated`) or `boss_timeout_seconds` (`boss_retreated`), then `boss_outro_seconds` | idle | frozen | +| `boss` | turn finished | boss HP 0 (`boss_defeated`), then the defeat cinematic (§11.14); or `boss_timeout_seconds` (`boss_retreated`), then `boss_outro_seconds` | idle | frozen | | `stage_clear` | boss resolved | `stage_clear_seconds` | idle | frozen | **Stage-entrance guarantee (v2.3).** The `STAGE n` banner covers the lanes, so the corridor is held clear across @@ -411,6 +412,7 @@ Systems └─ StageManager (systems/stage_manager.gd), inserted after Spawner, before Autopilot HUD mounts (optional, PAUSABLE, `bind(game)`): ui/pursuit_alert.tscn, ui/boss_hud.tscn, ui/stage_banner.tscn, ui/stage_clear.tscn, + ui/boss_cinematic.tscn (v2.6, §11.14), ui/post_fx.tscn (CanvasLayer 9, under the panels) ``` @@ -429,15 +431,18 @@ Signals (additive; §4.1 unchanged): | `stage_cleared(index: int, bonus: int)` | StageManager | after `boss_defeated`/`boss_retreated`; bonus already added to score | | `turn_incoming(direction: int, seconds: float)` | StageManager | once per seam, `turn_warning_seconds` of travel before the bend (or at drain start when the drain is shorter than that); `seconds` = the estimated time to the turn; the direction is the one `turn_started` then repeats | | `turn_started(direction: int, seconds: float)` / `turn_finished()` | StageManager | `direction` −1 left, +1 right, and always the direction `turn_incoming` announced; the seam is hazard-, pickup- and tool-free | -| `boss_intro(kind: StringName, name: String, seconds: float)` | BossEncounter | at the very start of the intro, immediately **before** `boss_spawned`; `seconds` = `boss_intro_seconds`, the entrance-card window in which no attack and no tool is spawned | -| `boss_spawned(kind: StringName, hp: int)` | BossEncounter | right after `boss_intro`: the moment the boss is visible and armed. Attacks begin `seconds` later, when the intro `boss_intro` announced has run down | +| `boss_intro(kind: StringName, name: String, seconds: float)` | BossEncounter | at the very start of the intro, immediately **before** `boss_cinematic` and `boss_spawned`; `seconds` = the intro window in which no attack and no tool is spawned — v2.6: `boss_intro_seconds` on the run's first meeting with this boss, `min(boss_intro_short_seconds, boss_intro_seconds)` on a repeat (§11.14). A skip shortens the window to the skip tail | +| `boss_cinematic(kind: StringName, mode: StringName, seconds: float)` | BossEncounter | v2.6 (§11.14): a cinematic of `seconds` opens. `intro_full` / `intro_short` right after `boss_intro` (same `seconds`) and before `boss_spawned`; `defeat_full` / `defeat_short` right after `boss_defeated`, on the same tick. Never sent for a zero-length cinematic | +| `boss_cinematic_skipped(mode: StringName)` | BossEncounter | v2.6: the player skipped an intro (`intro_full` / `intro_short`; a defeat is never skippable); it jumps to its last `boss_cinematic_skip_tail` seconds, and `boss_cinematic_ended` still follows when that tail has played | +| `boss_cinematic_ended(mode: StringName)` | BossEncounter | v2.6: exactly once per `boss_cinematic`: when it has played out, after a skip's tail, or at once when the fight is aborted (a catch, a new run) | +| `boss_spawned(kind: StringName, hp: int)` | BossEncounter | right after `boss_intro` (and `boss_cinematic`): the moment the boss is visible and armed. Attacks begin `seconds` later, when the intro `boss_intro` announced has run down | | `boss_health_changed(hp: int, max_hp: int)` | BossEncounter | | | `boss_phase_changed(phase: int)` | BossEncounter | 1-based; at 100 % / 66 % / 33 % HP | | `boss_attack_planned(attack: Dictionary)` | BossEncounter | v2.4: immediately **before** every `boss_attack_telegraph`, on the same tick: the full attack that telegraph announces (§11.6 attack dictionary). A branching volley sends one pair per head | | `boss_attack_telegraph(lane_mask: int, kind: StringName, seconds: float)` | BossEncounter | `lane_mask` bit i = lane index i (§11.7); `kind` = projectile variant. v2.4: `lane_mask` is the *telegraphed* lanes — both candidates for a flaky beat, the whole path for a sweep, the first gap's slabs for a legacy wall, the middle for a scatter; `seconds` is the whole telegraph (candidates + commit for a flaky beat) | | `boss_attack_committed(lane_mask: int, kind: StringName, seconds: float)` | BossEncounter | v2.4: a flaky telegraph stopped alternating and settled on `lane_mask` (one of its candidates); `seconds` = what is left before it fires, always ≥ `boss_commit_min`. Commits arrive in telegraph order | | `boss_projectile_fired(projectile: Area3D, lane_mask: int, kind: StringName, speed: float)` | BossEncounter | at the end of the telegraph, once per projectile; `lane_mask` = the lanes that projectile starts in, `kind` = its variant, `speed` = its own closing speed (a signature beat may scale it) | -| `boss_defeated(kind: StringName, bonus: int)` / `boss_retreated(kind: StringName, bonus: int)` | BossEncounter | exactly one of the two per fight | +| `boss_defeated(kind: StringName, bonus: int)` / `boss_retreated(kind: StringName, bonus: int)` | BossEncounter | exactly one of the two per fight; `bonus` is already added to the score. v2.6: a defeat is followed on the same tick by its `boss_cinematic`, and `stage_cleared` waits for that cinematic to end | | `tool_collected(kind: StringName, damage: int)` | BossEncounter | `damage` 0 when the boss's phase resists the tool | | `pursuit_alert(kind: StringName, seconds_to_catch: float)` | HitManager | `warn` at pursuit start, `critical` when `recovery_remaining ≤ pursuit_critical_seconds`, `clear` on recovery or catch | | `pursuer_distance_changed(normalized: float)` | Pursuer | 0 retreated … 1 catching; epsilon-gated | @@ -446,7 +451,10 @@ Read-only properties: `phase`, `stage_index: int`, `stage_name: StringName`, `st `stage_distance_target: float`, `run_clock: float`, `next_turn_direction: int` (−1/+1 from the moment the drain window picks the seam's direction — so already set when `turn_incoming` fires — back to 0 once the bend ends; 0 whenever no turn is pending), `boss: Node3D` (null outside a fight), `boss_kind`, `boss_hp`, -`boss_max_hp`, `boss_phase: int`. Methods: `get_stage_info() -> Dictionary` +`boss_max_hp`, `boss_phase: int`; v2.6 (§11.14) `boss_cinematic_mode: StringName` (`&""` outside a cinematic), +`boss_cinematic_progress: float` (0..1), `boss_time_factor: float` (1 outside a defeat). Methods: v2.6 +`skip_boss_cinematic() -> bool` and `can_skip_boss_cinematic() -> bool` (read-only: true while a skip would take, for +a skip prompt; §11.14), `get_stage_info() -> Dictionary` (`index, name, phase, distance, target, speed_bonus, difficulty_floor`), `get_boss_info() -> Dictionary` (`kind, name, hp, max_hp, phase, phase_count, accepted_tools, pending_attacks`; `phase_count` is the fight's own phase count, 3 today, and 0 in the empty block; each `pending_attacks` row is `{lane_mask, kind, remaining}` plus, @@ -473,13 +481,18 @@ Kinds: `flaky_test_swarm`, `merge_conflict_hydra`, `legacy_monolith`. Base vars | `play_attack(kind: StringName)` | at fire | | `play_hit(tool_kind: StringName, damage: int)` | damaging tool | | `play_resist(tool_kind: StringName)` | resisted tool (damage 0) | -| `play_defeat()` / `play_retreat()` | fight end; the backend frees the boss after `boss_outro_seconds` | +| `play_defeat()` / `play_retreat()` | fight end. v2.6: after a defeat the backend frees the boss once the defeat cinematic is over (§11.14); after a retreat, after `boss_outro_seconds` | +| `play_cinematic(mode: StringName, seconds: float)` | v2.6, optional (`has_method`-guarded): a cinematic opens; this is the cue to set up. `intro_*` arrives straight after `on_spawned()` (alongside `boss_intro`), and `defeat_*` straight after `play_defeat()`. In a run, the encounter's `play_cinematic(intro_*)` is the **only** intro cue: `boss_fx` no longer forwards `boss_intro` to the visual. A visual may keep an optional `play_intro(seconds)` for standalone and dev fixtures, but nothing in a run calls it | +| `set_cinematic_progress(mode: StringName, progress: float)` | v2.6, optional: where the cinematic is, 0..1. It is called with 0 right after `play_cinematic()`, on **every** physics tick while the cinematic runs, once straight after a skip with the jumped progress, and once with 1.0 when it ends (played out, skipped tail done, or aborted). During a cinematic the visual's pose, dissolve, flash and strobe are functions of `progress`: the action is seeked to progress-mapped time, with whatever hold or strobe sub-ranges the model rules give it, never an accumulated clock. That way a pause, a skip and the 6× smokes stay in step for free | +| `set_time_factor(factor: float)` | v2.6, optional: every tick of a cinematic, and 1.0 when it ends: 0.05 through the defeat hit-stop, then slow-mo easing back to 1. It scales only the visual's **ambient** motion during the slow-mo tail (idle sway, particles), never the cinematic pose, which `set_cinematic_progress()` owns | +| `cinematic_focus() -> Vector3` | v2.6, optional: the point, in the visual's local space, the cinematic camera aims at. The encounter reads it **once**, straight after `on_spawned()` and before any cinematic cue, carries it into the Boss mount's space and keeps it for the fight (`boss_encounter.cinematic_focus`), so no later pose can move the aim. The base default (also applied to the graybox and to any visual without the hook) is the centre of the upper third of the combined rest bounds of every visible `MeshInstance3D`: each mesh's own AABB, unskinned, through its transform chain (`boss_visual_base.gd` static `rest_bounds()` / `rest_focus()`). That is about 2.4 / 3.1 / 2.8 m above the mount for the Swarm / Hydra / Monolith. Override it to aim at a head or a core | Envelope: a bounding box ≤ 7 m wide × 3 m deep with its vertical extent inside y ∈ [1.6, 6.1] (bottom edge never below y 1.6; 4.5 m of headroom), core nominally centred around y 2.6 at `hover_z`; ≤ 40 draw calls at peak. **Entrance exception (v2.1):** during the `boss_intro` window the visual may travel in from the far end, but at every frame its bounds must still satisfy x ∈ [−3.5, 3.5] and y ∈ [1.6, 6.1]; only z may extend beyond the box, and only toward the far end (never closer to the player than `hover_z + 3`). Nothing rises from under the floor. The boss has no hitbox. Graybox fallback: a tinted box with a Label3D name plate (backend). +The same envelope holds through every §11.14 cinematic: the camera moves to the boss, the boss never moves to the camera. ### 11.6 Projectiles (extends §8) — `hazards/visuals/projectile_visual.tscn` extends `hazards/hazard_visual_base.gd` @@ -566,7 +579,8 @@ than under `HUD`, and it is the sanctioned home for 3D fight effects, so no pane | `ui/boss_hud.tscn` | boss name plate, HP bar, phase pips | `boss_*` signals, `get_boss_info()` | | `ui/stage_banner.tscn` | `STAGE n / ` banner; deferred until `turn_finished`. It covers the lanes, so it must be **fully off screen** within `stage_banner_clear_seconds − stage_banner_reaction_seconds` (2.5 s at the defaults) of the `stage_started` that raised it — the backend only holds the corridor clear for `stage_banner_clear_seconds` (§11.1), and the reaction time is the player's, not the banner's | `stage_started`, `turn_*` | | `ui/stage_clear.tscn` | stage-clear card (time, tools used, bonus) | `stage_cleared`, `boss_defeated`/`boss_retreated`, `get_stage_info()` | -| `ui/post_fx.tscn` | one full-screen post-processing pass: colour grade, film grain, vignette, chromatic aberration on damage and tool hits, letterbox for `boss_intro` and the stage-clear card, speed streaks, Nitro lift | `run_started`, `damage_taken`, `boss_intro`, the stage-clear signals, `power_up_collected`/`power_up_expired`, `pursuit_started`/`pursuit_progress`/`pursuit_recovered`, `speed_changed`, `paused_changed`, and the tool/boss hit signals when they exist | +| `ui/boss_cinematic.tscn` | v2.6 (§11.14): the boss cinematic overlay (chyron, title card, threat line, hint row, skip prompt, defeat banner). Driven **only** off `boss_cinematic_progress` and `boss_time_factor` — no Tweens or timers of its own — so a pause or a skip can never put it out of step | `boss_cinematic`, `boss_cinematic_skipped`, `boss_cinematic_ended`, `boss_defeated`, the `boss_cinematic_*` properties, `get_boss_info()` | +| `ui/post_fx.tscn` | one full-screen post-processing pass: colour grade, film grain, vignette, chromatic aberration on damage and tool hits, letterbox for every boss cinematic and the stage-clear card, speed streaks, Nitro lift. v2.6 (§11.14): the cinematic letterbox comes in with `boss_cinematic`, follows `boss_cinematic_progress`, eases out over the last `boss_cinematic_skip_tail` once skipped, and closes on `boss_cinematic_ended`; its own clock runs at `boss_time_factor` | `run_started`, `damage_taken`, `tool_collected`, `boss_cinematic` / `boss_cinematic_skipped` / `boss_cinematic_ended` with the `boss_cinematic_progress` / `boss_time_factor` properties (v2.6), `stage_cleared`, `phase_changed`, `run_finished`, `caught`, `power_up_collected`/`power_up_expired`, `pursuit_started`/`pursuit_progress`/`pursuit_recovered`, `speed_changed`, `paused_changed` | | `boss/boss_fx.tscn` | 3D fight effects: telegraphs, tracers, damage numbers; never touches gameplay nodes | `boss_*` signals, `tool_collected`, `get_boss_info()` | `ui/post_fx.tscn` is a `CanvasLayer` at **layer 9** — below `runner_hud.tscn` (10) and every other panel, so the @@ -618,8 +632,8 @@ Stages: stage_base_distance 900.0, stage_distance_step 250.0, stage_distance_ that window ends; must stay below stage_banner_clear_seconds Turn: turn_seconds 2.4, turn_warning_seconds 3.0, turn_yaw_degrees 90.0 (graybox fallback) turn_camera_yaw_degrees 55.0, turn_camera_bank_degrees 12.0, turn_swing_in_fraction 0.3, turn_swing_out_fraction 0.3 -Boss: boss_z -14.0, boss_sway_amplitude 2.4, boss_sway_period 3.2, boss_intro_seconds 3.0, boss_outro_seconds 1.0 - boss_timeout_seconds 55.0, boss_phase_thresholds [1.0, 0.66, 0.33] +Boss: boss_z -14.0, boss_sway_amplitude 2.4, boss_sway_period 3.2, boss_intro_seconds 5.0 (v2.6, was 3.0), + boss_outro_seconds 1.0 (v2.6: a retreat only), boss_timeout_seconds 55.0, boss_phase_thresholds [1.0, 0.66, 0.33] boss_hp_growth 1.35, boss_hp_scale 1.0 (test/tuning multiplier on the base HP), boss_projectile_speed_growth 1.08, boss_projectile_speed_max 34.0 boss_telegraph_growth 0.88, boss_telegraph_min 0.60, boss_attack_interval_growth 0.90, boss_attack_interval_min 0.55 boss_kill_bonus_base 600, boss_kill_bonus_per_stage 200, boss_retreat_bonus_ratio 0.35 @@ -630,6 +644,10 @@ Autopilot: autopilot_telegraph_lead 0.12, autopilot_tool_greed 1.0 Signature (v2.4): boss_flaky_candidate_seconds 0.45, boss_flaky_swap_seconds 0.15 (cosmetic cadence), boss_commit_min 0.60 (a flaky commit is at least this long before fire), boss_scatter_split_distance 10.0 (m), boss_signature_speed_max 44.0, boss_signature_reaction_min 0.35 (the B6 floor) +Cinematic (v2.6, §11.14): boss_intro_short_seconds 2.5 (played as min(short, boss_intro_seconds)), + boss_defeat_seconds 3.6, boss_defeat_short_seconds 2.0 (min(short, full)), + boss_cinematic_skip_after 0.5, boss_cinematic_skip_tail 0.3, boss_cinematic_speed_scale 0.25, + boss_hitstop_seconds 0.12, boss_slowmo_seconds 0.8, boss_slowmo_factor 0.4 ``` Boss base table (slot → kind, HP, projectile speed, telegraph P1/P2/P3, attack interval P1/P2/P3, tool interval): @@ -668,7 +686,9 @@ A catch during the `boss` phase no longer has to throw the run away. `stage_index, seed, score_exact, score, multiplier, best_multiplier, bonus_total, run_clock, elapsed, distance, hazards_dodged, hazards_blasted_total, stages_cleared, turns_taken, bosses_defeated, bosses_escaped, tools_used, boss_damage_dealt, next_turn_direction` (the direction of the bend just taken), plus the systems' Game Over - tallies `power_ups, hits_taken, recoveries, blocked_hits, max_speed_reached, chunks_spawned`. HP is never stored. The + tallies `power_ups, hits_taken, recoveries, blocked_hits, max_speed_reached, chunks_spawned`, and v2.6 + `bosses_met` (every boss kind the run has met, the one this fight opens included) and `bosses_defeated_kinds` (every + boss kind the run has beaten before this fight), both §11.14. HP is never stored. The checkpoint is emptied on `stage_clear` and by `start_run()`; a catch does **not** clear it. So it is non-empty exactly while a fight is live, or after a catch that landed in one — never after a death in `run`, `turn` or `stage_clear`. @@ -686,7 +706,9 @@ A catch during the `boss` phase no longer has to throw the run away. result reports whole-run totals, emits `run_started` / `health_changed` / `score_changed` with those values, and calls `StageManager.begin_at_boss(checkpoint)` instead of `begin()`. - **Lead-in.** `begin_at_boss()` sets the stage, `run_clock` (so `speed_at()` returns the same speed) and the - tallies (`turns_taken` one lower, since the lead-in bend counts it again), applies that stage's speed bonus and + tallies (`turns_taken` one lower, since the lead-in bend counts it again), hands `bosses_met` to the encounter's + `mark_met()` and `bosses_defeated_kinds` to `mark_defeated()` (v2.6, so the retried boss plays the short intro, and + the short defeat when the run had already beaten that kind), applies that stage's speed bonus and difficulty floor, re-seeds `stage_rng` to `seed ^ STAGE_SEED_MIX ^ stage_index` (this path only), keeps spawning off and the seam hazard-free, emits `stage_started` and `turn_incoming(direction, 0.0)` once each, then enters the `turn` on the checkpoint's direction. The turn ends in the normal `_enter_boss()`, which takes a fresh @@ -701,3 +723,88 @@ A catch during the `boss` phase no longer has to throw the run away. `retry_boss()` reloads into turn → boss with stage/score/`run_clock` intact and full HP, plain Retry from zero, no checkpoint after a stage clear or a run-phase catch, Game Over button visibility and focus); `runner_smoke.gd` boss-catch phase reloads a real `runner_game.tscn` from the checkpoint into the same fight. + +### 11.14 Boss cinematics (contract 2.6) + +A boss fight now opens and closes on a Zelda-style camera cinematic. The backend owns the clock, the camera, the hold +on the player and the skip. The frontend draws the cinematic from that clock. + +- **Modes and lengths.** The encounter keeps two per-run kind sets, *met* and *defeated*. Both are emptied by a fresh run + (`StageManager.reset()` → `boss_encounter.reset()`, and the stage-1 `start()` of a run that did not come from a + checkpoint). + + | mode | when | `seconds` | + |---|---|---| + | `intro_full` | the run's first meeting with this boss | `boss_intro_seconds` (5.0) | + | `intro_short` | every later meeting (stage 4 meets stage 1's boss again), and a checkpoint retry of any boss | `min(boss_intro_short_seconds, boss_intro_seconds)` (2.5) | + | `defeat_full` | the run's first defeat of this boss | `boss_defeat_seconds` (3.6) | + | `defeat_short` | every later defeat of it | `min(boss_defeat_short_seconds, boss_defeat_seconds)` (2.0) | + + A zero-length cinematic, for example a test config with `boss_intro_seconds = 0`, is not played. No `boss_cinematic` + and no `boss_cinematic_ended` are sent, and nothing holds the player. A retreat has no cinematic: it keeps + `play_retreat()` and `boss_outro_seconds`. +- **Order.** Intro: `boss_intro(kind, name, s)` → `boss_cinematic(kind, intro_*, s)` → `boss_spawned` → + `boss_health_changed`, all inside `start()`. The `s` is the same in both signals, and no attack or tool spawns + until it has run down. Defeat: `boss_health_changed(0, max)` → `boss_defeated(kind, bonus)` (bonus already + scored, same tick as in 2.5) → `play_defeat()` → `boss_cinematic(kind, defeat_*, s)`, on one tick. The arena is + emptied, and the boss phase holds for `s` before `stage_cleared`. Every `boss_cinematic` is answered by exactly one + `boss_cinematic_ended(mode)`. A kill landed during the intro ends the intro first. +- **Skip.** Only an intro can be skipped. A defeat never can: players mash jump through the fight, and a skip would + routinely eat the payoff. `skip_boss_cinematic() -> bool` works only while `state == running`, the run is not + paused, and the intro has played at least `boss_cinematic_skip_after` (0.5 s) without already being inside its last + `boss_cinematic_skip_tail` (0.3 s). A skip jumps the clock to `seconds − tail`, so the camera return still plays. + It emits `boss_cinematic_skipped(mode)`, and `boss_cinematic_ended(mode)` follows once the tail has played. With a + `boss_cinematic_skip_tail` of 0, both are emitted by the same skip call. The + intro window shrinks with the skip, so the attacks follow the tail. RunnerGame reads + `jump` / `ui_accept` presses in `_unhandled_input` while a skip is possible, calls the skip and marks the event + handled. The controls the skip hands back re-sync held input, so the same press never also jumps. The autopilot + and the smokes never skip. +- **Hold.** From `boss_cinematic` until it is skipped, ends, is aborted, or the run is caught, the StageManager holds + the player (`stage_manager.cinematic_hold`). Manual controls are off, and the corridor eases to + `boss_cinematic_speed_scale` (0.25×) of its curve speed over 0.35 s (`speed_controller.set_cinematic_scale()`; + `run_clock` is frozen in the boss phase anyway). Releasing the hold restores both. `RunnerGame.refresh_player_controls()` is the one rule for every + hand-back (backend-internal): controls are on only while running, with the bot off and no hold. `set_autopilot(false)` + during a hold therefore leaves them off. Score is time-based and the corridor slowdown does not change it. +- **Mirrors.** The StageManager writes these every tick, and inside each cinematic signal before any frontend hears it: + `boss_cinematic_mode` (`&""` outside a cinematic), `boss_cinematic_progress` (0..1) and `boss_time_factor`. + The StageManager does not tick while paused, so a pause freezes all three. A frontend that draws only off + them cannot drift on pause or skip. +- **Intro timing.** `boss_visual_base.gd` `INTRO_POSE_AT` (`intro_full` 0.40, `intro_short` 0.28) is the progress by + which the visual has landed its final intro pose, and the title card slams at the same moment. The pose then holds + **frozen** until `INTRO_HOLD_UNTIL` (`intro_full` 0.66, `intro_short` 0.88), where the camera leaves the hold. The + camera's push lands on the first value and its hold ends on the second, so visuals, HUD and camera share one beat. +- **Visual feed.** The encounter drives the mounted boss visual directly (§11.5). `play_cinematic(mode, seconds)` is + sent once as the set-up cue. `set_cinematic_progress(mode, progress)` follows with 0, then arrives on every physics + tick of the cinematic, straight after a skip with the jumped progress, and with 1.0 at the end; `set_time_factor()` + follows each time. A visual poses from `progress` alone and uses the time factor only for ambient motion, so it + shares the HUD's clock exactly. +- **Time factor.** `boss_time_factor` is 1 outside a defeat. Through a defeat it is 0.05 for + `boss_hitstop_seconds` (0.12), then `boss_slowmo_factor` (0.4) smoothstepped back to 1 over `boss_slowmo_seconds` + (0.8), then 1. The pure `boss_encounter.gd` `time_factor_profile(elapsed, hitstop, slowmo, factor)` defines it. + `Engine.time_scale` is never touched: visuals, effects, HUD and audio multiply the factor into their own delta, + so the accelerated smokes stay deterministic. Gameplay timers and the cinematic clock run in real time. +- **Camera.** Every boss tick the StageManager calls `camera_rig.set_boss_cinematic(progress, mode, boss_position, + focus_position)`, the way `_apply_turn` drives `set_turn`. `boss_position` is the Boss mount's global position + and `focus_position` is the mount plus `boss_encounter.cinematic_focus` (§11.5). It sends `(0, &"")` at every + exit. The pure static `camera_rig.boss_shot(p, mode)` returns `{blend, distance, height, orbit, fov, look_bias}`. + The lens sits off the mount, and it aims at the focus plus a small per-shot `look_bias`. That bias puts the hold + near +1.6 m and the defeat close-up near +1.9 m above the mount on the shipped bosses, and a taller boss is aimed + higher. `blend` is exactly 0 at p = 0 and p = 1, + and for an unknown or empty mode, which is the same invariant as `turn_swing_profile`. The rig mixes position, + rotation and FOV with the follow pose by `blend`, and shake and FOV punch ride on top. Shots: `intro_full` cuts in + low, pushes up at the boss (FOV 70 → 52) to land with the pose at `INTRO_POSE_AT`, freeze-holds under the title card + until `INTRO_HOLD_UNTIL`, widens behind Hoppy for the hint + row, then returns. `intro_short` is a compressed push and return. `defeat_*` punches in through the hit-stop, + orbits the close-up −12° → +12°, pulls back wide over Hoppy for the triumphant beat, then returns. At the default + lengths each return starts where a skip lands, so a skip never pops the camera. +- **Abort.** `boss_encounter.abort()` (a catch, a new run) closes a live cinematic with `boss_cinematic_ended`. The + StageManager also releases the hold on catch, abort and stage clear. +- **Checkpoint.** §11.13 `bosses_met` carries the kinds the run has met plus the boss the snapshot is taken in front + of, and `bosses_defeated_kinds` the kinds it had already beaten. `begin_at_boss()` hands them to + `boss_encounter.mark_met()` and `mark_defeated()`, which the next `start()` keeps even on stage 1. A retry therefore + plays `intro_short`, and `defeat_short` when the run had already beaten that kind. +- **Verification.** `tests/runner_boss_cinematic.gd` covers: full intro on stage 1 and short on stage 4, short after a + checkpoint retry (and a short defeat for a kind the run had already beaten), skip ignored before 0.5 s and accepted + after with no jump, skip always refused during a defeat, pause freezing progress, the defeat + holding `stage_clear` for its full or short length, `boss_shot` blend 0 at both ends, the `time_factor` profile, the `cinematic_focus` default and the intro timing constants, + and an abort mid-cinematic restoring controls and speed. diff --git a/docs/endless-runner-systems.md b/docs/endless-runner-systems.md index f619500c..b8203b33 100644 --- a/docs/endless-runner-systems.md +++ b/docs/endless-runner-systems.md @@ -570,10 +570,16 @@ plus the optional `boss/boss_fx.tscn` frontend mount (contract §11.3, §11.8) t phase) scaled by the cycle escalation (contract §11.10), with `boss_hp_scale` multiplying the base HP before that growth so a test config can shorten a fight without bending the curve; `hp = max_hp`; `phase = 1`; mount the visual (`ResourceLoader.exists` → `load as PackedScene` → set base vars → `add_child` → `on_spawned()`; graybox fallback: a -DANGER box 4×2.4×1.5 with a Label3D name); position `Boss` at `(0, 2.6, boss_z)`; `_intro = boss_intro_seconds`; -emit `boss_intro(kind, name, _intro)` **first** (the entrance card, contract §11.4), then `boss_spawned(kind, hp)` -and `boss_health_changed(hp, max_hp)`; `_attack_timer = attack_interval(phase)`; -`_tool_timer = tool_interval × 0.5`. +DANGER box 4×2.4×1.5 with a Label3D name); position `Boss` at `(0, 2.6, boss_z)`; `_intro = boss_intro_seconds` on +the run's first meeting with this kind, `min(boss_intro_short_seconds, boss_intro_seconds)` on a repeat (contract 2.6 +§11.14; the per-run `_met` set, restored by `mark_met()` on a checkpoint retry, as `_defeated_kinds` is by `mark_defeated()`); +emit `boss_intro(kind, name, _intro)` **first** (the entrance card, contract §11.4), then +`boss_cinematic(kind, intro_full|intro_short, _intro)` (§11.14, skipped for a zero-length intro), then +`boss_spawned(kind, hp)` and `boss_health_changed(hp, max_hp)`; `_attack_timer = attack_interval(phase)`; +`_tool_timer = tool_interval × 0.5`. A cinematic rides the countdown it holds (`_intro`, or `_outro` for a defeat), +so `cinematic_progress()`, `can_skip_cinematic()`, `skip_cinematic()` (intros only: jump to the last `boss_cinematic_skip_tail` +seconds) and `time_factor()` all read the same clock; it closes with `boss_cinematic_ended` when that countdown +reaches 0, and `abort()` closes it early. `tick(delta)`: intro countdown (no attacks) → ACTIVE: `_attack_timer -= delta`; when ≤ 0 pick the next beat of the current pattern (patterns cycle a fixed beat list; lanes from `stage_rng` where the pattern says "random", never @@ -605,10 +611,20 @@ Damage: `apply_tool(kind)` (called by `tool_pickup.collect`): `damage = ToolDefs `visual.set_phase(phase)`, `_attack_timer = min(_attack_timer, 0.6)`; `hp == 0` → `_defeat()`. `_defeat()`: bonus `= boss_kill_bonus_base + boss_kill_bonus_per_stage × (stage_index − 1)` added to score, emit `boss_defeated(kind, bonus)`, `visual.play_defeat()`, clear queued telegraphs, `clear()` live projectiles (no dodge -credit), free live tools, `_outro = boss_outro_seconds`; `_retreat()`: bonus `= kill_bonus × boss_retreat_bonus_ratio × -(1 − hp / max_hp)`, emit `boss_retreated`, `visual.play_retreat()`, same cleanup. After the outro `finished` is -set (a plain bool the StageManager polls), the boss node is freed. `abort()` (on caught) frees everything without -signals or bonuses. +credit), free live tools, `_outro` = the defeat cinematic (`boss_defeat_seconds` on the run's first defeat of the kind, +`min(boss_defeat_short_seconds, boss_defeat_seconds)` after), then `boss_cinematic(kind, defeat_full|defeat_short, +_outro)` on the same tick (contract 2.6 §11.14); `_retreat()`: bonus `= kill_bonus × boss_retreat_bonus_ratio × +(1 − hp / max_hp)`, emit `boss_retreated`, `visual.play_retreat()`, same cleanup, `_outro = boss_outro_seconds` and no +cinematic. After the outro `finished` is set (a plain bool the StageManager polls), the boss node is freed. `abort()` +(on caught) frees everything without fight signals or bonuses; a live cinematic still closes with +`boss_cinematic_ended`. + +StageManager side (§11.14): `boss_cinematic` sets `cinematic_hold` (player controls off through +`RunnerGame.refresh_player_controls()`, `speed_controller.set_cinematic_scale(boss_cinematic_speed_scale)`), and +`boss_cinematic_skipped` / `boss_cinematic_ended`, a catch, a reset and the stage clear release it. Every boss tick +drives `camera_rig.set_boss_cinematic(progress, mode, boss_global_position, focus_global_position)` (the focus is +`boss_encounter.cinematic_focus`, the visual's `cinematic_focus()` read once after `on_spawned()`) and publishes the facade mirrors +`boss_cinematic_mode`, `boss_cinematic_progress`, `boss_time_factor`. `_snapshot()` adds `bosses_met` and `bosses_defeated_kinds`. Both endings can be reached from inside a physics callback — the killing blow is a tool auto-firing in `area_entered`, and a catch aborts the fight the same way — so the arena **never** takes a projectile or a tool out @@ -769,7 +785,9 @@ boss timeout → `boss_retreated` with the partial bonus and never `boss_defeate camera back at zero, no spawn during the bend) plus `turn_incoming` once per seam ahead of `turn_started`, on the same direction and with `seconds ∈ [0, turn_warning_seconds + 0.5]`; the camera swing profile (0 at p = 0 and p = 1, at the peak across the hold window, monotone through each ramp); `boss_intro` exactly once per fight, -before `boss_spawned`, carrying `boss_intro_seconds`, with no telegraph and no tool inside that window; +before `boss_cinematic` and `boss_spawned`, carrying `boss_intro_seconds` on the run's first meeting with that boss and +`min(boss_intro_short_seconds, boss_intro_seconds)` on a repeat meeting or a checkpoint retry, with no telegraph and no +tool inside that window (contract 2.6: the cinematic timings themselves are covered by `tests/runner_boss_cinematic.gd`); `pursuit_alert` ordering and the epsilon gate; caught mid-boss aborts to `idle` with `run_finished` once and boss stats in the result; speed with stage bonus and difficulty floor. `runner_smoke.gd`: duplicated config `stage_base_distance 260`, `time_to_max_speed 20`, `boss_hp_scale 0.4` (so the diff --git a/docs/endless-runner.md b/docs/endless-runner.md index 3129430a..04caa844 100644 --- a/docs/endless-runner.md +++ b/docs/endless-runner.md @@ -930,7 +930,7 @@ Frontend-only implementation of contract 2.1, sections 11.4, 11.9 and 11.10. No - Corridor yaw reaches 55 degrees at progress 0.3, holds through 0.7, then returns to zero at 1.0. Curvature peaks at 0.010. `TURN_SWING_IN`, `TURN_SWING_OUT` and `TURN_YAW` in `corridor.gd` mirror RunConfig defaults and must stay in sync with the backend camera. The end wall spans the corridor and carries a 120-unit directional TURN face. Glyphs sweep laterally, rain accelerates and speed lines intensify in the hold. Endpoints and explicit `(0, 0)` reset restore the same pose. - The turn preview mirrors the backend camera swing: positive direction applies positive yaw and bank, orbiting the player at (0, 0, 0), with 55° yaw, 12° bank, and 0.3 ramp-in/out fractions. Hold alignment is checked in both directions at p = 0.3/0.5/0.7. - `boss_intro(kind, name, seconds)` opens a top-centre card at x=300..980, y=48..128 at 1280x720. It displays `WARNING · BUG DETECTED`, the supplied boss name at 24 units, and a kind-specific taunt typed over one second. Copy is centralized in `boss_hud.gd:TAUNTS`; unknown kinds show only the supplied name. The stage banner stays suppressed, and the health HUD slides in only after the supplied duration. Pause freezes the card and entrance. -- Boss FX forwards the optional frontend `play_intro(seconds)` method to the mounted visual. It resolves `World/TurnRig/BossArena/Boss` first because the facade mirrors `boss` only at the subsequent `boss_spawned` signal; mock facades use the property fallback. Swarm tiles assemble from the vanishing point, Hydra rises and unfurls three heads, and Monolith approaches from the far end before a slam. A DANGER vignette and screen flash accent the impact at 80% of the intro. No frontend code calls camera shake. Every entrance frame keeps x within [-3.5, 3.5] and y within [1.6, 6.1]. Only depth travel toward the far end is allowed, never closer than hover_z + 3. Swarm tiles converge along -Z, Hydra unfolds from a compressed above-floor pose, and Monolith slides along -Z. Transforms reset at the duration boundary. +- (Superseded in contract 2.6: `boss_fx` no longer forwards `boss_intro`, and the encounter's `play_cinematic(intro_*)` is the only intro cue in a run. `play_intro` remains for standalone and dev fixtures.) Boss FX forwards the optional frontend `play_intro(seconds)` method to the mounted visual. It resolves `World/TurnRig/BossArena/Boss` first because the facade mirrors `boss` only at the subsequent `boss_spawned` signal; mock facades use the property fallback. Swarm tiles assemble from the vanishing point, Hydra rises and unfurls three heads, and Monolith approaches from the far end before a slam. A DANGER vignette and screen flash accent the impact at 80% of the intro. No frontend code calls camera shake. Every entrance frame keeps x within [-3.5, 3.5] and y within [1.6, 6.1]. Only depth travel toward the far end is allowed, never closer than hover_z + 3. Swarm tiles converge along -Z, Hydra unfolds from a compressed above-floor pose, and Monolith slides along -Z. Transforms reset at the duration boundary. - Audio has 32 cached recipes. `turn_warning` contains three rising ticks scaled to the warning window. The hard-panned turn whoosh includes its snap at the 0.3 hold boundary. `boss_intro` is a deep rising drone and hit, scaled to the intro window. Intro music ducks by 12 dB, releasing into the existing 4 dB boss duck. The seven polyphonic plus three dedicated voice allocation remains capped at ten. - The mock demo emits both new signals, mounts its boss visual, and delays attacks until the three-second intro has elapsed. Smoke coverage includes `TURN WARNING: PASS` and `BOSS INTRO: PASS`, directional warnings, countdown, correct taunts, fallback copy, visual forwarding/poses, HP handover, banner suppression, pause, audio duck release and the revised turn profile. - Graphical capture fixtures are registered as `endless-runner-turn-warning.png` and `endless-runner-boss-intro.png`. The coordinator captured both PNGs on the host. Existing capture checks retain the corridor budgets of 14 at rest and 18 during a turn. @@ -984,3 +984,41 @@ The standard backend smoke reported `RUNNER SMOKE: FAIL (3 failures / 127 checks ### Performance: W1 world motion clock One scroll clock: the spawner keeps `scroll_travel` / `scroll_travel_previous` (metres scrolled at the latest and the previous physics tick). A bound corridor (`corridor.gd` `bind()`) draws its floor, walls, racks and the starship world at `lerp(previous, latest, Engine.get_physics_interpolation_fraction())`, which is exactly where the interpolated chunk, hazard and pickup transforms are drawn, so floor markers no longer swim against the grid. Unbound corridors (runner menu, fixtures) keep their render-delta clock. The turn bend is interpolated the same way: `set_turn()` calls made inside a physics tick keep the previous tick's signed wave and progress, and `_apply_turn()` draws between them like the interpolated camera swing; calls from outside physics snap. SpeedLines emission has one writer (`_apply_turn()`, changed-only, never on a hidden emitter), and the rain/streak process materials are rewritten only when the speed moves by 0.05 m/s. Each placed chunk calls `reset_physics_interpolation()` after its gap and pickup placement, so it never slides in from `frontier_z` for a frame. Interpolation opt-outs on cosmetic children are consistent with this: a measured `PHYSICS_INTERPOLATION_MODE_OFF` child renders at its interpolated parent's pose (the chunk scroll stays interpolated); OFF only stops interpolation of the child's own local transform. Cameras (game and menu) use near 0.15 / far 200 for 3x the depth precision of the 0.05 default; the farthest geometry (deck plane, fog end at 96 m, boss entrance at -69 m) stays well inside. The pursuer light is written only on a 0.01 energy change, and a retreat requested while the threat is still gliding down is ignored (the next tick cancelled it anyway, after a one-frame light-off flash). Measured and left alone: chunk despawn (remove + free of the largest chunk, 229 nodes, is 0.3 ms rendered) and the per-tick `runner_hazards` group scans (about 0.1 µs each). The starship corridor now leaves the legacy `KeyLight` at its palette tuning and switched off, and lights the deck with an identical `StarshipKeyLight` in the same place; every StarshipWorld part is PAUSABLE, like the rest of the corridor. Measured in a rendered turn, corridor yaw now matches the interpolated camera swing to 0.000° (the latest-tick yaw it used before was up to 3.6° ahead). + +## Contract 2.6: boss cinematics (backend hand-off) + +This section mirrors contract §11.14 for the frontend; the contract is authoritative. The backend owns the clock, the +camera, the player hold and the skip, and the frontend draws from that clock. + +- **What the backend sends.** Intro: `boss_intro(kind, name, s)`, `boss_cinematic(kind, mode, s)`, `boss_spawned`, + `boss_health_changed`. The `mode` is `intro_full` (5.0 s, the run's first meeting with this boss) or `intro_short` + (2.5 s, every repeat meeting and every checkpoint retry). Defeat: `boss_defeated(kind, bonus)`, then + `boss_cinematic(kind, defeat_full | defeat_short, s)` on the same tick (3.6 s first defeat, 2.0 s repeat), and + `stage_cleared` only once it is over. Each one ends with exactly one `boss_cinematic_ended(mode)`. Skipping an intro sends + `boss_cinematic_skipped(mode)` first and still plays the last 0.3 s. A defeat is never skippable. A retreat has no cinematic and keeps its + 1.0 s outro. +- **What to draw from.** `game.boss_cinematic_mode`, `game.boss_cinematic_progress` (0..1) and `game.boss_time_factor`. + All three freeze on pause and jump on a skip, so `ui/boss_cinematic.tscn` should hold no Tweens or timers of its + own. The backend mounts `res://scenes/runner/ui/boss_cinematic.tscn` under `HUD` once it exists, like the other + optional panels (PAUSABLE, idempotent `has_signal`-guarded `bind(game)`). +- **Time factor.** It is 0.05 through the defeat's 0.12 s hit-stop, then eases from 0.4 back to 1 over 0.8 s, and is + 1 everywhere else. Multiply it into your own delta. `Engine.time_scale` is never touched. +- **Boss visual hooks** (optional, `has_method`-guarded): `play_cinematic(mode, seconds)` once, right after + `on_spawned()` / `play_defeat()`, as the set-up cue. Then `set_cinematic_progress(mode, progress)` with 0, on every + physics tick of the cinematic, once after a skip with the jumped progress, and with 1.0 at the end. Pose, dissolve, + flash and strobe come from that `progress`: seek the action to progress-mapped time, never an accumulated clock. + `set_time_factor(factor)` arrives with it and scales only ambient motion (idle sway, particles) during the slow-mo + tail. In a run, `play_cinematic(intro_*)` is the only intro cue: `boss_fx` no longer forwards `boss_intro` to `play_intro(seconds)`, which had started the intro twice. `play_intro` stays an optional hook for standalone and dev fixtures. After a kill the visual is freed when + the defeat cinematic ends. +- **Intro beat.** `boss_visual_base.gd` `INTRO_POSE_AT` (`intro_full` 0.40, `intro_short` 0.28): land the final intro + pose by this progress and slam the title card at the same moment. Hold the pose frozen until `INTRO_HOLD_UNTIL` + (0.66 / 0.88), where the camera leaves the hold. Read the constants; don't copy the numbers. +- **Camera aim.** Optional `cinematic_focus() -> Vector3` (visual-local) is read once after `on_spawned()`. The default + is the centre of the upper third of the rest mesh bounds. Override it to aim at a head or a core. +- **Camera.** The camera is backend-owned (`camera_rig.boss_shot(p, mode)`): a low-angle push with a freeze-hold and a + wide hint shot for the intro, and a punch-in, ±12° orbit and wide pull-back for the defeat. Frontend code does not + move the camera. It may still call `shake()` and `punch_fov()`, which ride on top of the shot. +- **Skip.** Intros only. Jump or `ui_accept` skips once 0.5 s have played (`skip_boss_cinematic()`). The press is + consumed and never jumps. Show the skip prompt only while `game.can_skip_boss_cinematic()` returns true; don't + derive it from elapsed time. The backend guard already returns false while paused or not running, once the intro + has been skipped, inside its last 0.3 s tail (`boss_cinematic_skip_tail`), before 0.5 s, and for every defeat. diff --git a/hoppys-adventure-arcade/Assets/runner/LICENSES.md b/hoppys-adventure-arcade/Assets/runner/LICENSES.md index 8578427c..9e15f46f 100644 --- a/hoppys-adventure-arcade/Assets/runner/LICENSES.md +++ b/hoppys-adventure-arcade/Assets/runner/LICENSES.md @@ -12,3 +12,4 @@ No external downloads. F5 reuses the existing bundled music package. | Procedural runner SFX | scenes/runner/audio/sfx_synth.gd | Project contributors | Original deterministic PCM recipes | Project source terms; no third-party asset licence | Original | | Cyberpunk Street BGM | res://Assets/cyberpunk-street-files/Assets/music/cyberpunk-street.ogg | Luis Zuno / ansimuz | Assets/cyberpunk-street-files/public-license.pdf | CC0 | none; the PDF does not name the music's composer | | Blender Hoppy mascot | Assets/runner/hoppy/** | Project contributors | generated in-repo by tools/blender/build_hoppy.py (Blender 5.2.2) | Project licence | Original work | +| Hydra conflict-marker glyphs | Assets/runner/bosses/merge_conflict_hydra/** | DejaVu fonts contributors / Bitstream | Blender bundled DATAFILES/fonts/DejaVuSansMono.woff2 | [Bitstream Vera and DejaVu Fonts License](https://dejavu-fonts.github.io/License.html); font name and SHA-256 recorded in build.json | Converted <<<<<<<, ======= and >>>>>>> outlines to mesh and baked atlas; no font file redistributed | diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.build.json b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.build.json new file mode 100644 index 00000000..d93ffa0d --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.build.json @@ -0,0 +1,141 @@ +{ + "action_seconds": { + "attack": 0.55, + "defeat": 2.0, + "hurt": 0.4, + "idle": 4.0, + "intro": 2.4, + "phase_change": 1.2, + "retreat": 1.0, + "telegraph": 0.8 + }, + "actions": [ + "intro", + "idle", + "telegraph", + "attack", + "hurt", + "phase_change", + "defeat", + "retreat" + ], + "animation_sample_rate": 120, + "blender": "5.2.2 LTS", + "bone_names": [ + "root", + "core", + "lens", + "carapace_L", + "carapace_R", + "drone_0", + "wing_0_L", + "wing_0_R", + "drone_1", + "wing_1_L", + "wing_1_R", + "drone_2", + "wing_2_L", + "wing_2_R", + "drone_3", + "wing_3_L", + "wing_3_R", + "drone_4", + "wing_4_L", + "wing_4_R", + "drone_5", + "wing_5_L", + "wing_5_R" + ], + "bones": 23, + "budget": { + "bones": 40, + "materials": 4, + "surfaces": 6, + "triangles": 18000 + }, + "glb": { + "bytes": 4494336, + "file": "flaky_test_swarm.glb", + "sha256": "58532337174fa392d9ad66f3b8e4e126c3b823559fe1c250cee3a8e8ecdd16bb" + }, + "image_report": [ + { + "bytes": 71545, + "height": 1024, + "index": 0, + "name": "surface_emission", + "sha256": "e73d398914ca977b3cc6e308074d7ddc7f5a4a48432d12334464b443cca5e56c", + "width": 1024 + }, + { + "bytes": 952956, + "height": 1024, + "index": 1, + "name": "surface_normal", + "sha256": "7cb7fbe9244fd628a58a36a9577e1828ba28509daafc96ab3e5dfb414172b207", + "width": 1024 + }, + { + "bytes": 586199, + "height": 1024, + "index": 2, + "name": "surface_albedo", + "sha256": "4e8a7a69d129858ec7786dfd3ef00e9d111389b3add6e3bd31750b9d18582580", + "width": 1024 + }, + { + "bytes": 742188, + "height": 1024, + "index": 3, + "name": "surface_orm", + "sha256": "6a3f0cff8b8cd8153ac082730f1c071a5c0af4d91d918d21bd8ba7ad6ba9b52b", + "width": 1024 + } + ], + "kind": "flaky_test_swarm", + "kit_revision": 2, + "materials": 1, + "quick": false, + "recipe_revision": 5, + "rest_position_encoding": "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1", + "seed": 73019, + "sockets": [ + "fx_core", + "fx_0", + "fx_1", + "fx_2", + "fx_3", + "fx_4", + "fx_5", + "muzzle_0", + "muzzle_1", + "muzzle_2" + ], + "source_hashes": { + "tools/blender/bosses/boss_kit.py": "35977f71b3d34da34ef7a0bfe064bb840ead47a679277dac4e4d366590a44d4d", + "tools/blender/bosses/bosses_budget.json": "01bdf13be27dbdf54cbb1846cc765978c8c97bb9c951c3867e55f891987a84b8", + "tools/blender/bosses/build_flaky_test_swarm.py": "d42b73b752df028af5ededf63615e36da423642d4ad6fe27c0c4f5e6e858d5a9", + "tools/blender/glb_atlas.py": "6e9467f0befabed73b075a4184ab7cc5aa864649d008b7be24d313726920e35a", + "tools/blender/props/props_kit.py": "30ec723c38e818b659075007c71300e994f5c41a36c6e343e17080c203ccc1ad" + }, + "surfaces": 1, + "textures": { + "albedo": { + "file": "flaky_test_swarm_surface_albedo.png", + "sha256": "4e8a7a69d129858ec7786dfd3ef00e9d111389b3add6e3bd31750b9d18582580" + }, + "emission": { + "file": "flaky_test_swarm_surface_emission.png", + "sha256": "e73d398914ca977b3cc6e308074d7ddc7f5a4a48432d12334464b443cca5e56c" + }, + "normal": { + "file": "flaky_test_swarm_surface_normal.png", + "sha256": "7cb7fbe9244fd628a58a36a9577e1828ba28509daafc96ab3e5dfb414172b207" + }, + "orm": { + "file": "flaky_test_swarm_surface_orm.png", + "sha256": "6a3f0cff8b8cd8153ac082730f1c071a5c0af4d91d918d21bd8ba7ad6ba9b52b" + } + }, + "triangles": 17760 +} diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb new file mode 100644 index 00000000..ee877d51 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb.import b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb.import new file mode 100644 index 00000000..01adcb0a --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb.import @@ -0,0 +1,51 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://21143ipi2tgv" +path="res://.godot/imported/flaky_test_swarm.glb-79a8cead4a59e228b8e63c437b0806d0.scn" + +[deps] + +source_file="res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb" +dest_files=["res://.godot/imported/flaky_test_swarm.glb-79a8cead4a59e228b8e63c437b0806d0.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=true +skins/use_named_skins=true +animation/import=true +animation/fps=120 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={ +"nodes": { +"PATH:AnimationPlayer": { +"optimizer/enabled": false +} +} +} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png new file mode 100644 index 00000000..b53dd47c Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png.import new file mode 100644 index 00000000..123f7c5f --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cvup5hbyla0vu" +path="res://.godot/imported/flaky_test_swarm_surface_albedo.png-186be02c74e2f941637c790d93dfaa8e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_albedo.png" +dest_files=["res://.godot/imported/flaky_test_swarm_surface_albedo.png-186be02c74e2f941637c790d93dfaa8e.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png new file mode 100644 index 00000000..9893a42f Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png.import new file mode 100644 index 00000000..6a412331 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7m5h2ccdsejv" +path="res://.godot/imported/flaky_test_swarm_surface_emission.png-bf139d66256f8c22e5f4ec2036765342.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_emission.png" +dest_files=["res://.godot/imported/flaky_test_swarm_surface_emission.png-bf139d66256f8c22e5f4ec2036765342.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png new file mode 100644 index 00000000..2d7a93f1 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png.import new file mode 100644 index 00000000..a44d349f --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://s1wnahqr20yl" +path="res://.godot/imported/flaky_test_swarm_surface_normal.png-2b9dac9763e239abecd05d1f54b9f25a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_normal.png" +dest_files=["res://.godot/imported/flaky_test_swarm_surface_normal.png-2b9dac9763e239abecd05d1f54b9f25a.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png new file mode 100644 index 00000000..4e41cb46 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png.import new file mode 100644 index 00000000..4e20e46a --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dw2q51lk7ys8y" +path="res://.godot/imported/flaky_test_swarm_surface_orm.png-5dd015d19856197fab7d55920d3fe1dd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm_surface_orm.png" +dest_files=["res://.godot/imported/flaky_test_swarm_surface_orm.png-5dd015d19856197fab7d55920d3fe1dd.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.build.json b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.build.json new file mode 100644 index 00000000..a69b8d0f --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.build.json @@ -0,0 +1,121 @@ +{ + "action_seconds": { + "attack": 0.5, + "defeat": 1.5, + "hurt": 0.3, + "idle": 2.0, + "intro": 1.0, + "phase_change": 1.0, + "retreat": 1.0, + "telegraph": 0.6 + }, + "actions": [ + "intro", + "idle", + "telegraph", + "attack", + "hurt", + "phase_change", + "defeat", + "retreat" + ], + "animation_sample_rate": 120, + "blender": "5.2.2 LTS", + "bone_names": [ + "root", + "core", + "arm" + ], + "bones": 3, + "budget": { + "bones": 40, + "materials": 4, + "surfaces": 6, + "triangles": 18000 + }, + "glb": { + "bytes": 198736, + "file": "kit_selftest.glb", + "sha256": "f20b69d7d397d65184e4dcb9242ec4fcac67b99fefc619fa3ab1dd311de688e1" + }, + "image_report": [ + { + "bytes": 3926, + "height": 256, + "index": 0, + "name": "surface_emission", + "sha256": "48a8e8b91f2395815192200d49059020d9ddbd15b7049f828a28e960ce780103", + "width": 256 + }, + { + "bytes": 32459, + "height": 256, + "index": 1, + "name": "surface_normal", + "sha256": "737569e6e82a734b1c0e6a15a14262c0641da3b801f0f13e9bbdadd41645cd52", + "width": 256 + }, + { + "bytes": 45032, + "height": 256, + "index": 2, + "name": "surface_albedo", + "sha256": "d4f24e289810f7a1888c895147ca9f54d8c2081d469fe9eb8cd0d7c765897475", + "width": 256 + }, + { + "bytes": 28973, + "height": 256, + "index": 3, + "name": "surface_orm", + "sha256": "8d2d624cb37385c1e8fd1261bf686042f17966eaa44ca7f00eb84d2fbabbf8ff", + "width": 256 + } + ], + "kind": "kit_selftest", + "kit_revision": 2, + "materials": 1, + "quick": false, + "recipe_revision": 2, + "rest_position_encoding": "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1", + "seed": 73019, + "sockets": [ + "fx_core", + "fx_0", + "fx_1", + "fx_2", + "fx_3", + "fx_4", + "fx_5", + "muzzle_0", + "muzzle_1", + "muzzle_2" + ], + "source_hashes": { + "tools/blender/bosses/boss_kit.py": "35977f71b3d34da34ef7a0bfe064bb840ead47a679277dac4e4d366590a44d4d", + "tools/blender/bosses/bosses_budget.json": "01bdf13be27dbdf54cbb1846cc765978c8c97bb9c951c3867e55f891987a84b8", + "tools/blender/bosses/build_kit_selftest.py": "c800b3e99c2c0ca5bab12c0da3f1414123581ad9bab5a0e96a48a48ff8a2e13e", + "tools/blender/glb_atlas.py": "6e9467f0befabed73b075a4184ab7cc5aa864649d008b7be24d313726920e35a", + "tools/blender/props/props_kit.py": "30ec723c38e818b659075007c71300e994f5c41a36c6e343e17080c203ccc1ad" + }, + "surfaces": 1, + "textures": { + "albedo": { + "file": "kit_selftest_surface_albedo.png", + "sha256": "d4f24e289810f7a1888c895147ca9f54d8c2081d469fe9eb8cd0d7c765897475" + }, + "emission": { + "file": "kit_selftest_surface_emission.png", + "sha256": "48a8e8b91f2395815192200d49059020d9ddbd15b7049f828a28e960ce780103" + }, + "normal": { + "file": "kit_selftest_surface_normal.png", + "sha256": "737569e6e82a734b1c0e6a15a14262c0641da3b801f0f13e9bbdadd41645cd52" + }, + "orm": { + "file": "kit_selftest_surface_orm.png", + "sha256": "8d2d624cb37385c1e8fd1261bf686042f17966eaa44ca7f00eb84d2fbabbf8ff" + } + }, + "triangles": 668 +} diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.glb b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.glb new file mode 100644 index 00000000..a303db76 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.glb differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.glb.import b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.glb.import new file mode 100644 index 00000000..d3403310 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest.glb.import @@ -0,0 +1,51 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://jxsmyylxxepe" +path="res://.godot/imported/kit_selftest.glb-483f9ebe205385c0a86acbb043c48e60.scn" + +[deps] + +source_file="res://Assets/runner/bosses/kit_selftest/kit_selftest.glb" +dest_files=["res://.godot/imported/kit_selftest.glb-483f9ebe205385c0a86acbb043c48e60.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=true +skins/use_named_skins=true +animation/import=true +animation/fps=120 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={ +"nodes": { +"PATH:AnimationPlayer": { +"optimizer/enabled": false +} +} +} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png new file mode 100644 index 00000000..a4b6deb0 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png.import new file mode 100644 index 00000000..00164ee8 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d2db46r5nrgq" +path="res://.godot/imported/kit_selftest_surface_albedo.png-de6da40e5b5d33afe651b0bc89e5e6d5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/kit_selftest/kit_selftest_surface_albedo.png" +dest_files=["res://.godot/imported/kit_selftest_surface_albedo.png-de6da40e5b5d33afe651b0bc89e5e6d5.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png new file mode 100644 index 00000000..98caebd3 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png.import new file mode 100644 index 00000000..ec5b23e1 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2v10udsqyk7q" +path="res://.godot/imported/kit_selftest_surface_emission.png-d19b1774bcbf54ac067a82ac6870df9a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/kit_selftest/kit_selftest_surface_emission.png" +dest_files=["res://.godot/imported/kit_selftest_surface_emission.png-d19b1774bcbf54ac067a82ac6870df9a.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png new file mode 100644 index 00000000..c6af8a9d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png.import new file mode 100644 index 00000000..3a858dda --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1p60aymk3drm" +path="res://.godot/imported/kit_selftest_surface_normal.png-1b105fb5b8679aa36d7fe1deaf0cedb7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/kit_selftest/kit_selftest_surface_normal.png" +dest_files=["res://.godot/imported/kit_selftest_surface_normal.png-1b105fb5b8679aa36d7fe1deaf0cedb7.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png new file mode 100644 index 00000000..dede554f Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png.import new file mode 100644 index 00000000..932d353e --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3uhfi24jv6cr" +path="res://.godot/imported/kit_selftest_surface_orm.png-acab7a9587b357f7fb7f4db14103cd8f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/kit_selftest/kit_selftest_surface_orm.png" +dest_files=["res://.godot/imported/kit_selftest_surface_orm.png-acab7a9587b357f7fb7f4db14103cd8f.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.build.json b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.build.json new file mode 100644 index 00000000..3de221af --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.build.json @@ -0,0 +1,147 @@ +{ + "action_seconds": { + "attack": 0.8, + "defeat": 2.4, + "hurt": 0.4, + "idle": 4.0, + "intro": 2.4, + "phase_change": 1.2, + "retreat": 1.6, + "telegraph": 0.8 + }, + "actions": [ + "intro", + "idle", + "telegraph", + "attack", + "hurt", + "phase_change", + "defeat", + "retreat" + ], + "animation_sample_rate": 120, + "blender": "5.2.2 LTS", + "bone_names": [ + "root", + "tower", + "rack_0", + "led_0", + "wall_0", + "wall_1", + "wall_2", + "rack_1", + "led_1", + "fan_L", + "fan_R", + "rack_2", + "led_2", + "shell_L", + "shell_R", + "reactor", + "rack_3", + "led_3", + "eye_L", + "cables_L", + "eye_R", + "cables_R", + "arm_L", + "hand_L", + "arm_R", + "hand_R", + "debris_0", + "debris_1", + "debris_2" + ], + "bones": 29, + "budget": { + "bones": 40, + "materials": 4, + "surfaces": 6, + "triangles": 18000 + }, + "glb": { + "bytes": 3715444, + "file": "legacy_monolith.glb", + "sha256": "f84f3051e5d0b47f288032b9a8e1288051f2cbcbe77be0eff996b09eed0caf49" + }, + "image_report": [ + { + "bytes": 36462, + "height": 1024, + "index": 0, + "name": "surface_emission", + "sha256": "3fb58d2698c114808ca89c047b3acd6651df5635f009a1485f81a1603a33a9e4", + "width": 1024 + }, + { + "bytes": 784130, + "height": 1024, + "index": 1, + "name": "surface_normal", + "sha256": "793a55e678c098767c3d0d5cf4e8dbb5c0c8e242cb357f0728d515e3e750d3d2", + "width": 1024 + }, + { + "bytes": 644813, + "height": 1024, + "index": 2, + "name": "surface_albedo", + "sha256": "20c269e72faf0fdd770daf23a78f8c43bf320234d77903e4ddc516712e08f069", + "width": 1024 + }, + { + "bytes": 591890, + "height": 1024, + "index": 3, + "name": "surface_orm", + "sha256": "bb1cea234181bd531a26e05b99c48b39fa0e4fc2303089e13912778aabc71f03", + "width": 1024 + } + ], + "kind": "legacy_monolith", + "kit_revision": 2, + "materials": 1, + "quick": false, + "recipe_revision": 3, + "rest_position_encoding": "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1", + "seed": 73019, + "sockets": [ + "fx_core", + "fx_0", + "fx_1", + "fx_2", + "fx_3", + "fx_4", + "fx_5", + "muzzle_0", + "muzzle_1", + "muzzle_2" + ], + "source_hashes": { + "tools/blender/bosses/boss_kit.py": "35977f71b3d34da34ef7a0bfe064bb840ead47a679277dac4e4d366590a44d4d", + "tools/blender/bosses/bosses_budget.json": "01bdf13be27dbdf54cbb1846cc765978c8c97bb9c951c3867e55f891987a84b8", + "tools/blender/bosses/build_legacy_monolith.py": "f7e214ca0c5ebeb933928185501d182cc5e0684119fb3ba587e3cacb6f9883d4", + "tools/blender/glb_atlas.py": "6e9467f0befabed73b075a4184ab7cc5aa864649d008b7be24d313726920e35a", + "tools/blender/props/props_kit.py": "30ec723c38e818b659075007c71300e994f5c41a36c6e343e17080c203ccc1ad" + }, + "surfaces": 1, + "textures": { + "albedo": { + "file": "legacy_monolith_surface_albedo.png", + "sha256": "20c269e72faf0fdd770daf23a78f8c43bf320234d77903e4ddc516712e08f069" + }, + "emission": { + "file": "legacy_monolith_surface_emission.png", + "sha256": "3fb58d2698c114808ca89c047b3acd6651df5635f009a1485f81a1603a33a9e4" + }, + "normal": { + "file": "legacy_monolith_surface_normal.png", + "sha256": "793a55e678c098767c3d0d5cf4e8dbb5c0c8e242cb357f0728d515e3e750d3d2" + }, + "orm": { + "file": "legacy_monolith_surface_orm.png", + "sha256": "bb1cea234181bd531a26e05b99c48b39fa0e4fc2303089e13912778aabc71f03" + } + }, + "triangles": 14362 +} diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.glb b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.glb new file mode 100644 index 00000000..5e807fcf Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.glb differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.glb.import b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.glb.import new file mode 100644 index 00000000..a7d22123 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith.glb.import @@ -0,0 +1,51 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bbruuvpufjspi" +path="res://.godot/imported/legacy_monolith.glb-f81fcbc9a5a3936e06876d34656e4b1f.scn" + +[deps] + +source_file="res://Assets/runner/bosses/legacy_monolith/legacy_monolith.glb" +dest_files=["res://.godot/imported/legacy_monolith.glb-f81fcbc9a5a3936e06876d34656e4b1f.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=true +skins/use_named_skins=true +animation/import=true +animation/fps=120 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={ +"nodes": { +"PATH:AnimationPlayer": { +"optimizer/enabled": false +} +} +} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png new file mode 100644 index 00000000..6f860189 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png.import new file mode 100644 index 00000000..aef59222 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d0ahvhwfhip4k" +path="res://.godot/imported/legacy_monolith_surface_albedo.png-67899d28f1a13ee464e74268050fe330.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_albedo.png" +dest_files=["res://.godot/imported/legacy_monolith_surface_albedo.png-67899d28f1a13ee464e74268050fe330.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png new file mode 100644 index 00000000..41195dc0 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png.import new file mode 100644 index 00000000..7920053a --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://m7pp5i1ivr3q" +path="res://.godot/imported/legacy_monolith_surface_emission.png-f5915d6c547a16e32813848d5a054f49.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_emission.png" +dest_files=["res://.godot/imported/legacy_monolith_surface_emission.png-f5915d6c547a16e32813848d5a054f49.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png new file mode 100644 index 00000000..355eab8b Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png.import new file mode 100644 index 00000000..22494597 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5esps8dq7fvg" +path="res://.godot/imported/legacy_monolith_surface_normal.png-0c84230a578bc53a1e75a032a53999d7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_normal.png" +dest_files=["res://.godot/imported/legacy_monolith_surface_normal.png-0c84230a578bc53a1e75a032a53999d7.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png new file mode 100644 index 00000000..35d4f7d8 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png.import new file mode 100644 index 00000000..efefbd54 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bewa0ys1hadly" +path="res://.godot/imported/legacy_monolith_surface_orm.png-f01d681303118f67c48aadd9dea1c568.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/legacy_monolith/legacy_monolith_surface_orm.png" +dest_files=["res://.godot/imported/legacy_monolith_surface_orm.png-f01d681303118f67c48aadd9dea1c568.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.build.json b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.build.json new file mode 100644 index 00000000..669afcf3 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.build.json @@ -0,0 +1,147 @@ +{ + "action_seconds": { + "attack": 0.5, + "defeat": 1.8, + "hurt": 0.35, + "idle": 3.2, + "intro": 2.4, + "phase_change": 1.2, + "retreat": 1.0, + "telegraph": 0.8 + }, + "actions": [ + "intro", + "idle", + "telegraph", + "attack", + "hurt", + "phase_change", + "defeat", + "retreat" + ], + "animation_sample_rate": 120, + "blender": "5.2.2 LTS", + "bone_names": [ + "root", + "knot", + "branch_0", + "neck_lo_0", + "neck_hi_0", + "head_0", + "jaw_0", + "branch_1", + "neck_lo_1", + "neck_hi_1", + "head_1", + "jaw_1", + "branch_2", + "neck_lo_2", + "neck_hi_2", + "head_2", + "jaw_2", + "branch_3", + "neck_lo_3", + "neck_hi_3", + "head_3", + "jaw_3" + ], + "bones": 22, + "budget": { + "bones": 40, + "materials": 4, + "surfaces": 6, + "triangles": 18000 + }, + "glb": { + "bytes": 4327608, + "file": "merge_conflict_hydra.glb", + "sha256": "663fe26c4f057c91bff9d8a68fcbb929574de8db8d3cd8517dcd173e89a05782" + }, + "glyph_font": { + "license": "Bitstream Vera and DejaVu Fonts License", + "name": "DejaVu Sans Mono", + "sha256": "eb072b01f0f06ce11530a90cc11f094c60819d65ed47156540e23198ae149612", + "source": "Blender DATAFILES/fonts/DejaVuSansMono.woff2", + "usage": "Converted glyph outlines only; font file is not redistributed." + }, + "image_report": [ + { + "bytes": 75487, + "height": 1024, + "index": 0, + "name": "surface_emission", + "sha256": "596779dcb3a190749909a46d9402bf3c6983c099b673a143f953193806c3745c", + "width": 1024 + }, + { + "bytes": 962270, + "height": 1024, + "index": 1, + "name": "surface_normal", + "sha256": "f36720fba729ee758a0c50b7677de3f1660cf8d22efd65fd0c7fd2ccd8bb6338", + "width": 1024 + }, + { + "bytes": 682778, + "height": 1024, + "index": 2, + "name": "surface_albedo", + "sha256": "d6d491b0172463c85557aa122e1513aebba6e81611995bf485e7387fd73e817a", + "width": 1024 + }, + { + "bytes": 650291, + "height": 1024, + "index": 3, + "name": "surface_orm", + "sha256": "24291cedc30b55c7167bcf14c776f70a578bdb69f932600ddb97cfec3c815584", + "width": 1024 + } + ], + "kind": "merge_conflict_hydra", + "kit_revision": 2, + "materials": 1, + "quick": false, + "recipe_revision": 6, + "rest_position_encoding": "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1", + "seed": 73019, + "sockets": [ + "fx_core", + "fx_0", + "fx_1", + "fx_2", + "fx_3", + "fx_4", + "fx_5", + "muzzle_0", + "muzzle_1", + "muzzle_2" + ], + "source_hashes": { + "tools/blender/bosses/boss_kit.py": "35977f71b3d34da34ef7a0bfe064bb840ead47a679277dac4e4d366590a44d4d", + "tools/blender/bosses/bosses_budget.json": "01bdf13be27dbdf54cbb1846cc765978c8c97bb9c951c3867e55f891987a84b8", + "tools/blender/bosses/build_merge_conflict_hydra.py": "4ed6a734f4778676e23312731b78a73ea5a045239e4fb2bf835eb2d24ccf5d8c", + "tools/blender/glb_atlas.py": "6e9467f0befabed73b075a4184ab7cc5aa864649d008b7be24d313726920e35a", + "tools/blender/props/props_kit.py": "30ec723c38e818b659075007c71300e994f5c41a36c6e343e17080c203ccc1ad" + }, + "surfaces": 1, + "textures": { + "albedo": { + "file": "merge_conflict_hydra_surface_albedo.png", + "sha256": "d6d491b0172463c85557aa122e1513aebba6e81611995bf485e7387fd73e817a" + }, + "emission": { + "file": "merge_conflict_hydra_surface_emission.png", + "sha256": "596779dcb3a190749909a46d9402bf3c6983c099b673a143f953193806c3745c" + }, + "normal": { + "file": "merge_conflict_hydra_surface_normal.png", + "sha256": "f36720fba729ee758a0c50b7677de3f1660cf8d22efd65fd0c7fd2ccd8bb6338" + }, + "orm": { + "file": "merge_conflict_hydra_surface_orm.png", + "sha256": "24291cedc30b55c7167bcf14c776f70a578bdb69f932600ddb97cfec3c815584" + } + }, + "triangles": 16918 +} diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb new file mode 100644 index 00000000..da1f2bf6 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb.import b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb.import new file mode 100644 index 00000000..9619ccc5 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb.import @@ -0,0 +1,51 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://bvivkdqfhdwxw" +path="res://.godot/imported/merge_conflict_hydra.glb-74c47e07fb46745d7dc6030045b7363e.scn" + +[deps] + +source_file="res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb" +dest_files=["res://.godot/imported/merge_conflict_hydra.glb-74c47e07fb46745d7dc6030045b7363e.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +mesh_library/use_node_names_as_mesh_names=false +array_mesh/deduplicate_surfaces=true +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=true +skins/use_named_skins=true +animation/import=true +animation/fps=120 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={ +"nodes": { +"PATH:AnimationPlayer": { +"optimizer/enabled": false +} +} +} +gltf/naming_version=2 +gltf/embedded_image_handling=1 +gltf/texture_map_mode=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png new file mode 100644 index 00000000..daa5b0f9 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png.import new file mode 100644 index 00000000..7ee63a42 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b34gjqkmjeu8v" +path="res://.godot/imported/merge_conflict_hydra_surface_albedo.png-d725427c2593de3f3847446c28068324.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_albedo.png" +dest_files=["res://.godot/imported/merge_conflict_hydra_surface_albedo.png-d725427c2593de3f3847446c28068324.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png new file mode 100644 index 00000000..45781c68 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png.import new file mode 100644 index 00000000..32c4010c --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bomav5bg6fcdk" +path="res://.godot/imported/merge_conflict_hydra_surface_emission.png-82a67cfa939c73fc30aa58119eb52be5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_emission.png" +dest_files=["res://.godot/imported/merge_conflict_hydra_surface_emission.png-82a67cfa939c73fc30aa58119eb52be5.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png new file mode 100644 index 00000000..3161d55d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png.import new file mode 100644 index 00000000..aaa7bb77 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b84wsqp0y55d8" +path="res://.godot/imported/merge_conflict_hydra_surface_normal.png-e37fb6b4286471a5f52a298abac1c4f1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_normal.png" +dest_files=["res://.godot/imported/merge_conflict_hydra_surface_normal.png-e37fb6b4286471a5f52a298abac1c4f1.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=true +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=0 diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png new file mode 100644 index 00000000..4d20255d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png.import b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png.import new file mode 100644 index 00000000..ed2a1367 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sin1hi3er0ey" +path="res://.godot/imported/merge_conflict_hydra_surface_orm.png-0ba41d7e7ac5395193550416f3ea09f3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra_surface_orm.png" +dest_files=["res://.godot/imported/merge_conflict_hydra_surface_orm.png-0ba41d7e7ac5395193550416f3ea09f3.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=true +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=0 diff --git a/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd b/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd index 283201ed..e90d8cb1 100644 --- a/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd +++ b/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd @@ -1,4 +1,6 @@ extends Node +const DefeatBurst = preload("res://scenes/runner/boss/defeat_burst.gd") +const BossVisual = preload("res://scenes/runner/boss/boss_visual_base.gd") const BusChain = preload("res://scenes/runner/audio/hifi/bus_chain.gd") var _whoosh_bus: StringName = &"SFX" var _surface := 0 @@ -21,6 +23,11 @@ const HIGH_PENDING_TTL := 0.30 const GATE := {&"boss_hit": 0.09, &"telegraph_tick": 0.04, &"projectile_whoosh": 0.04, &"tool_pickup": 0.04, &"tool_fire": 0.04, &"boss_phase": 0.04, &"boss_defeat": 0.04} var _scheduled: Array[Dictionary] = [] var _intro_remaining := 0.0 +var _cinematic_mode: StringName = &"" +var _cinematic_seconds := 0.0 +var _cinematic_bursts := 0 +var _resolved_played := false +var _title_played := false var _turn_active := false var _clear_remaining := 0.0 var _turn_mask := 2 @@ -29,6 +36,7 @@ var _boss_duck := 0.0 var _boss_pitch_offset := 0.0 var _whoosh_mask := 2 const HIGH_PRIORITY := { + &"boss_intro_short": true, &"boss_explosion": true, &"boss_resolved": true, &"boss_skip": true, &"boss_title": true, &"turn_warning": true, &"boss_intro": true, &"stage_clear": true, &"turn_whoosh": true, &"boss_defeat": true, &"boss_phase": true, &"tool_fire": true, &"alert_sting": true, &"hit": true, &"caught": true, &"game_over": true, &"shield_block": true, &"pursuit_start": true, &"retreat": true, &"recovered_chime": true, &"powerup": true, @@ -84,7 +92,7 @@ func bind(game: Node) -> void: _normalized = maxf(0.0, float(game.get("normalized_speed"))) _pitch = 1.0 _connections = { - &"turn_incoming": _on_turn_warning, &"boss_intro": _on_boss_intro, + &"turn_incoming": _on_turn_warning, &"boss_cinematic": _on_cinematic, &"boss_cinematic_skipped": _on_cinematic_skipped, &"boss_cinematic_ended": _on_cinematic_ended, &"turn_started": _on_turn, &"turn_finished": _on_turn_finished, &"stage_cleared": _on_stage_clear, &"phase_changed": _on_phase, &"boss_spawned": _on_boss_spawned, &"boss_phase_changed": _on_boss_phase, @@ -113,17 +121,16 @@ func bind(game: Node) -> void: func _disconnect_game() -> void: if is_instance_valid(_game): for event: StringName in _connections: - if _game.is_connected(event, _connections[event]): + if _game.has_signal(event) and _game.is_connected(event, _connections[event]): _game.disconnect(event, _connections[event]) _connections.clear() func _process(delta: float) -> void: if not is_instance_valid(_game) or _paused: return + _tick_cinematic_audio() + if "boss_time_factor" in _game: delta *= float(_game.get("boss_time_factor")) _clock += delta - if _intro_remaining > 0.0: - _intro_remaining = maxf(0.0, _intro_remaining - delta) - if _intro_remaining == 0.0: _set_boss_mix(_boss_active) if _clear_remaining > 0.0: _clear_remaining = maxf(0.0, _clear_remaining - delta) if _clear_remaining == 0.0: _set_boss_mix(_boss_active) @@ -154,7 +161,13 @@ func _process(delta: float) -> void: else: _play(kind) var target := 1.0 + 0.10 * _normalized + (0.05 if _nitro else 0.0) + _boss_pitch_offset - _pitch = 1.0 if _clear_remaining > 0.0 else lerpf(_pitch, target, 1.0 - exp(-5.0 * delta)) + if _cinematic_mode != &"": + var p := clampf(float(_game.get("boss_cinematic_progress")), 0, 1) + # Mechanical tape stop under the held title; restore before the camera returns. + var stop := smoothstep(0.18, 0.40, p) * (1.0 - smoothstep(0.66, 0.90, p)) + _pitch = lerpf(target, 0.18, stop) if String(_cinematic_mode).begins_with("intro") else maxf(0.18, float(_game.get("boss_time_factor"))) + else: + _pitch = 1.0 if _clear_remaining > 0.0 else lerpf(_pitch, target, 1.0 - exp(-5.0 * delta)) if _audio_service != null and _audio_service.has_method("set_music_pitch"): _audio_service.set_music_pitch(_pitch) if _running and _grounded and not _rolling: @@ -232,6 +245,7 @@ func active_voice_count() -> int: return _voices.size() + int(footsteps.playing) + int(drone.playing) + int(heartbeat.playing) func _on_run_started(_seed: int) -> void: + _end_boss_audio() _stop_pursuit() _grounded = true _rolling = false @@ -386,7 +400,7 @@ func _duck(db: float, seconds: float) -> void: _audio_service.duck_music(db, seconds) func _set_boss_mix(active: bool) -> void: - var target := -12.0 if _intro_remaining > 0.0 else -10.0 if _clear_remaining > 0.0 else (-5.0 if _turn_active else (-4.0 if active else 0.0)) + var target := -18.0 if _cinematic_mode != &"" else -12.0 if _intro_remaining > 0.0 else -10.0 if _clear_remaining > 0.0 else (-5.0 if _turn_active else (-4.0 if active else 0.0)) _boss_pitch_offset = 0.04 if active else 0.0 if is_equal_approx(_boss_duck, target): return # Hold the service's baseline so transient ducks return to the fight level. @@ -405,7 +419,8 @@ func _on_boss_phase(_phase: int) -> void: _duck(-4 if _boss_active else -8, 0.35) func _end_boss_audio() -> void: - for kind in [&"boss_intro", &"turn_warning", &"turn_whoosh"]: _stop_cue(kind) + _on_cinematic_ended(_cinematic_mode) + for kind in [&"boss_defeat", &"boss_explosion", &"boss_resolved", &"turn_warning", &"turn_whoosh"]: _stop_cue(kind) _intro_remaining = 0.0 _boss_active = false _turn_active = false @@ -472,11 +487,57 @@ func _on_turn_warning(_direction: int, seconds: float) -> void: # Scale the three authored ticks to the facade's warning window. _pitch_cue(&"turn_warning", 3.0 / maxf(seconds, 0.05)) -func _on_boss_intro(_kind: StringName, _name: String, seconds: float) -> void: - _intro_remaining = maxf(seconds, 0.0) +func _on_cinematic(_kind: StringName, mode: StringName, seconds: float) -> void: + _cinematic_mode = mode + _cinematic_seconds = seconds + _cinematic_bursts = 0 + _resolved_played = false + _title_played = false + _intro_remaining = seconds if String(mode).begins_with("intro") else 0.0 + _set_boss_mix(_boss_active) + if mode == &"intro_full": _play(&"boss_intro") + elif mode == &"intro_short": _play(&"boss_intro_short") + +func _tick_cinematic_audio() -> void: + if _cinematic_mode == &"": return + var p := clampf(float(_game.get("boss_cinematic_progress")), 0, 1) + _intro_remaining = (1 - p) * _cinematic_seconds if String(_cinematic_mode).begins_with("intro") else 0.0 + if String(_cinematic_mode).begins_with("defeat"): + while _cinematic_bursts < DefeatBurst.BEATS.size() and p >= float(DefeatBurst.BEATS[_cinematic_bursts]): + _play(&"boss_explosion") + _cinematic_bursts += 1 + if p >= 0.60 and not _resolved_played: + _resolved_played = true + _play(&"boss_resolved") + else: + if p >= float(BossVisual.INTRO_POSE_AT.get(_cinematic_mode, 1.0)) and not _title_played: + _title_played = true + _play(&"boss_title") + for kind in [&"boss_defeat", &"boss_explosion"]: + _pitch_cue(kind, maxf(0.2, float(_game.get("boss_time_factor")))) + +func _on_cinematic_skipped(_mode: StringName) -> void: + _stop_cue(&"boss_intro") + _stop_cue(&"boss_intro_short") + _stop_cue(&"boss_title") + _title_played = true + _cinematic_mode = &"" + _intro_remaining = 0.0 + _pitch = 1.0 + _set_boss_mix(_boss_active) + _play(&"boss_skip") + +func _on_cinematic_ended(_mode: StringName) -> void: + # Defeat one-shots ring out over stage clear; catches, new runs and teardown use full cleanup. + for kind in [&"boss_intro", &"boss_intro_short", &"boss_title", &"boss_skip"]: + _stop_cue(kind) + for kind in [&"boss_defeat", &"boss_explosion"]: _pitch_cue(kind, 1.0) + _cinematic_mode = &"" + _intro_remaining = 0.0 + _cinematic_bursts = 0 + _pitch = 1.0 _set_boss_mix(_boss_active) - _play(&"boss_intro") - _pitch_cue(&"boss_intro", 3.0 / maxf(seconds, 0.05)) + if _audio_service != null and _audio_service.has_method("set_music_pitch"): _audio_service.set_music_pitch(1.0) func _pitch_cue(kind: StringName, pitch: float) -> void: if _playback == null: return diff --git a/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd b/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd index 4835710a..4ba8b482 100644 --- a/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd +++ b/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd @@ -4,7 +4,12 @@ const RATE := 22050 # seconds, start Hz, end Hz, waveform, gain const RECIPES := { &"turn_warning": [3.0, 700.0, 1400.0, "warning", 0.6], - &"boss_intro": [3.0, 35.0, 150.0, "intro", 0.7], + &"boss_intro": [1.6, 35.0, 150.0, "intro", 0.7], + &"boss_intro_short": [0.6, 55.0, 180.0, "detuned", 0.65], + &"boss_title": [0.28, 160.0, 35.0, "fm", 0.72], + &"boss_explosion": [0.30, 120.0, 25.0, "noise", 0.72], + &"boss_resolved": [1.15, 440.0, 880.0, "arpeggio", 0.68], + &"boss_skip": [0.25, 1800.0, 180.0, "noise", 0.5], &"turn_whoosh": [2.4, 300.0, 2400.0, "turn", 0.6], &"stage_clear": [1.0, 440.0, 880.0, "arpeggio", 0.65], &"telegraph_tick": [0.06, 1600.0, 1600.0, "square", 0.35], @@ -36,7 +41,7 @@ const RECIPES := { &"caught": [0.85, 120.0, 25.0, "noise", 0.78], &"game_over": [1.20, 440.0, 110.0, "chord", 0.65], } -const PERCUSSIVE := {&"tool_fire": true, &"hit": true, &"land": true, &"caught": true, &"boss_hit": true, &"roll": true, &"footstep": true, &"telegraph_tick": true, &"lane_left": true, &"lane_right": true} +const PERCUSSIVE := {&"boss_title": true, &"boss_explosion": true,&"tool_fire": true, &"hit": true, &"land": true, &"caught": true, &"boss_hit": true, &"roll": true, &"footstep": true, &"telegraph_tick": true, &"lane_left": true, &"lane_right": true} const ARPEGGIO := [1.0, 1.25, 1.5, 2.0] # Authored carrier, detuned body, FM transient, filtered noise, sub. const LAYERS := { @@ -59,7 +64,7 @@ static func family_for(kind: StringName) -> StringName: var waveform := String(RECIPES[kind][3]) if waveform in ["sine", "chord", "arpeggio"]: return &"chime" if kind == &"pursuit_drone": return &"drone" - if kind in [&"turn_whoosh", &"projectile_whoosh", &"nitro", &"retreat"]: return &"whoosh" + if kind in [&"turn_whoosh", &"projectile_whoosh", &"nitro", &"retreat", &"boss_skip"]: return &"whoosh" return &"alarm" diff --git a/hoppys-adventure-arcade/scenes/runner/boss/boss_encounter.gd b/hoppys-adventure-arcade/scenes/runner/boss/boss_encounter.gd index 9cb540f3..1c5ec800 100644 --- a/hoppys-adventure-arcade/scenes/runner/boss/boss_encounter.gd +++ b/hoppys-adventure-arcade/scenes/runner/boss/boss_encounter.gd @@ -20,6 +20,9 @@ const ScoreManager = preload("res://scenes/runner/systems/score_manager.gd") ## script preloads this one back, so there is no cycle). const Projectile = preload("res://scenes/runner/hazards/projectile.gd") const ToolPickup = preload("res://scenes/runner/powerups/tool_pickup.gd") +## The cinematic-focus default (contract §11.5) for a visual without the hook, the graybox included; +## the base preloads nothing, so there is no cycle. +const BossVisualBase = preload("res://scenes/runner/boss/boss_visual_base.gd") const PROJECTILE_SCENE := "res://scenes/runner/hazards/projectile.tscn" const TOOL_SCENE := "res://scenes/runner/powerups/tool_pickup.tscn" const VISUAL_DIR := "res://scenes/runner/boss/visuals/" @@ -45,6 +48,14 @@ const PHASE_CHANGE_ATTACK_DELAY := 0.6 const CAMERA_SHAKE_ON_TOOL := 0.35 ## Lanes 0/1/2 (contract §11.7). const LANE_COUNT := 3 +## Contract 2.6 §11.14 cinematic modes: a full intro on the run's first meeting with a boss, a short +## one on every later meeting, and the same split for the defeat. +const CINEMATIC_INTRO_FULL: StringName = &"intro_full" +const CINEMATIC_INTRO_SHORT: StringName = &"intro_short" +const CINEMATIC_DEFEAT_FULL: StringName = &"defeat_full" +const CINEMATIC_DEFEAT_SHORT: StringName = &"defeat_short" +## The time factor during the defeat's hit-stop (contract §11.14 boss_time_factor). +const HIT_STOP_FACTOR := 0.05 # --- read-only state (the facade mirrors these into contract §11.4 properties) --- @@ -72,6 +83,15 @@ var visual: Node3D ## The live boss node (the Boss mount) during a fight, null outside one: the facade mirrors this ## into its own `boss` property (contract §11.4). var boss: Node3D +## The cinematic playing now (contract 2.6 §11.14), &"" when none, and its whole length. An intro +## runs on the `_intro` countdown and a defeat on `_outro`, so the cinematic clock and the fight's +## own windows can never drift apart. +var cinematic_mode: StringName = &"" +var cinematic_seconds := 0.0 +## Where the cinematic camera aims, in the Boss mount's space (contract §11.5): the visual's +## cinematic_focus(), read once straight after on_spawned() at its rest pose and kept for the fight. +## Zero outside a fight. +var cinematic_focus := Vector3.ZERO @onready var boss_root: Node3D = $Boss @onready var projectiles_root: Node3D = $Projectiles @@ -96,6 +116,14 @@ var _last_mask := 0 var _last_tool_lane := -1 var _graybox: Node3D var _scene_cache := {} +## Per-run kind -> true sets (contract §11.14): a boss already met plays the short intro, and one +## already beaten plays the short defeat. +var _met := {} +var _defeated_kinds := {} +## Set by mark_met() / mark_defeated(): the next start() keeps the restored sets even on stage 1, +## where it would otherwise reset the run's tallies. +var _met_restored := false +var _cinematic_skipped := false # --- StageManager entry points --- @@ -127,15 +155,52 @@ func _find_facade() -> RunnerGame: func reset() -> void: abort() _reset_run_stats() + _met_restored = false + +## Contract §11.13/§11.14: a checkpoint retry restores the bosses its run had already met, so the +## retried boss counts as a repeat meeting and plays the short intro. Kept by the next start(), +## stage 1 included. +func mark_met(kinds: Array) -> void: + _mark(_met, kinds) + +## The same for the bosses the run had already beaten (bosses_defeated_kinds), so a retry that +## kills one of them again plays the short defeat. +func mark_defeated(kinds: Array) -> void: + _mark(_defeated_kinds, kinds) + +## The kinds this run has met so far, for the boss checkpoint (contract §11.13 bosses_met). +func met_kinds() -> Array[StringName]: + return _kinds(_met) + +## The kinds this run has beaten so far (contract §11.13 bosses_defeated_kinds). +func defeated_kinds() -> Array[StringName]: + return _kinds(_defeated_kinds) + +func _mark(into: Dictionary, kinds: Array) -> void: + for entry in kinds: + var restored := StringName(entry) + if restored != &"": + into[restored] = true + _met_restored = true + +func _kinds(from: Dictionary) -> Array[StringName]: + var kinds: Array[StringName] = [] + for entry in from: + kinds.append(entry) + return kinds ## Enter the fight for `index` (contract §11.1 stage index, 1-based and never reset inside a run). func start(index: int) -> void: if game == null or not is_instance_valid(game) or config == null: return + # A fight started over a live one still closes the old cinematic, so a hold never outlives it. + _end_cinematic() stage_index = maxi(index, 1) - # Stage 1 is the first fight of a run, so it is also where a restart's counters start again. - if stage_index <= 1: + # Stage 1 is the first fight of a run, so it is also where a restart's counters start again — + # unless a checkpoint retry has just restored this run's meetings. + if stage_index <= 1 and not _met_restored: _reset_run_stats() + _met_restored = false _seed_rng() def = BossDefs.fight_def(stage_index, config) var def_kind: StringName = def.get("kind", &"") @@ -151,7 +216,11 @@ func start(index: int) -> void: _last_tool_lane = -1 _fight_timer = 0.0 _since_tool = 0.0 - _intro = maxf(float(config.boss_intro_seconds), 0.0) + # Contract §11.14: the first meeting this run plays the full intro, every later one the short. + var first_meeting := not _met.has(kind) + _met[kind] = true + _intro = _cinematic_length(float(config.boss_intro_seconds), float(config.boss_intro_short_seconds), + first_meeting) _outro = 0.0 _attack_timer = _attack_interval() _tool_timer = _tool_interval() * 0.5 @@ -160,12 +229,16 @@ func start(index: int) -> void: _mount_boss() boss = boss_root _active = true - # The entrance card goes out first (contract §11.4): boss_intro opens the intro, boss_spawned - # right behind it is the moment the boss is visible and armed, and the attacks start once the - # _intro seconds boss_intro carries have run down. + # The entrance card goes out first (contract §11.4): boss_intro opens the intro, the cinematic + # that plays it follows (§11.14), boss_spawned right behind them is the moment the boss is + # visible and armed, and the attacks start once the _intro seconds boss_intro carries have run + # down. _emit(&"boss_intro", [kind, boss_name, _intro]) + _begin_cinematic(CINEMATIC_INTRO_FULL if first_meeting else CINEMATIC_INTRO_SHORT, _intro) _emit(&"boss_spawned", [kind, hp]) _emit(&"boss_health_changed", [hp, max_hp]) + # Contract §11.5: the visual hears every boss_health_changed, the opening full-HP one included. + _visual_call(&"set_health", [hp, max_hp]) ## One physics tick of the `boss` phase. Projectiles and tools move themselves; this drives the ## intro, the telegraph queue, the attack and tool clocks, the timeout and the outro. @@ -175,10 +248,18 @@ func tick(delta: float) -> void: if _outro > 0.0: _outro -= delta if _outro <= 0.0: + _end_cinematic() _finish() + else: + _push_cinematic_state() return if _intro > 0.0: _intro -= delta + if _intro <= 0.0: + _intro = 0.0 + _end_cinematic() + else: + _push_cinematic_state() return _fight_timer += delta _advance_telegraphs(delta) @@ -187,8 +268,11 @@ func tick(delta: float) -> void: if _fight_timer >= float(config.boss_timeout_seconds): _retreat() -## The run was lost mid-fight: everything goes, with no signal and no bonus (systems spec §10.1). +## The run was lost mid-fight: everything goes, with no bonus and no fight signal (systems spec +## §10.1). A cinematic that was playing still closes with boss_cinematic_ended (contract §11.14), so +## every listener that froze for it lets go. func abort() -> void: + _end_cinematic() _active = false finished = false boss = null @@ -219,6 +303,7 @@ func apply_tool(tool_kind: StringName) -> void: _visual_call(&"play_resist", [tool_kind]) _add_bonus(int(config.tool_bonus), ScoreManager.REASON_TOOL) _emit(&"boss_health_changed", [hp, max_hp]) + _visual_call(&"set_health", [hp, max_hp]) _advance_phase() if hp <= 0: _defeat() @@ -296,6 +381,122 @@ func projectile_count() -> int: func tool_count() -> int: return _live_children(tools_root).size() +# --- cinematics (contract 2.6 §11.14) --- + +## 0..1 through the cinematic playing now, 0 when none is. +func cinematic_progress() -> float: + if cinematic_mode == &"" or cinematic_seconds <= 0.0: + return 0.0 + return clampf(_cinematic_elapsed() / cinematic_seconds, 0.0, 1.0) + +## True once an intro has played boss_cinematic_skip_after seconds, until it is skipped or is already +## inside its last boss_cinematic_skip_tail seconds. A defeat is never skippable (contract §11.14): +## the fight's jump mashing would otherwise eat the payoff. +func can_skip_cinematic() -> bool: + if not _active or not _is_intro(cinematic_mode) or _cinematic_skipped or config == null: + return false + var elapsed := _cinematic_elapsed() + return elapsed >= float(config.boss_cinematic_skip_after) and elapsed < cinematic_seconds - _skip_tail() + +## Jump to the last boss_cinematic_skip_tail seconds, so the camera return still plays, and announce +## it with boss_cinematic_skipped. The intro window shrinks with it: the attacks follow the tail. +## Returns false when the cinematic cannot be skipped right now (always, for a defeat). +func skip_cinematic() -> bool: + if not can_skip_cinematic(): + return false + _cinematic_skipped = true + _intro = _skip_tail() + _emit(&"boss_cinematic_skipped", [cinematic_mode]) + # A zero tail has nothing left to play: the intro ends on the spot, since tick() would otherwise + # step straight into the fight and never close it (contract §11.14: ended follows the tail). + if _intro <= 0.0: + _intro = 0.0 + _end_cinematic() + else: + _push_cinematic_state() + return true + +## The factor the visuals, effects and HUD multiply into their own delta (boss_time_factor; the +## engine time scale is never touched). 1 outside a defeat cinematic. +func time_factor() -> float: + if config == null or not _is_defeat(cinematic_mode): + return 1.0 + return time_factor_profile(_cinematic_elapsed(), float(config.boss_hitstop_seconds), + float(config.boss_slowmo_seconds), float(config.boss_slowmo_factor)) + +## The defeat's time factor `elapsed` seconds in (contract §11.14): HIT_STOP_FACTOR for `hitstop` +## seconds, then `slowmo_factor` smoothstepped back to 1 over `slowmo` seconds, and 1 after. Pure, +## so a test or a preview can read the profile without a fight. +static func time_factor_profile(elapsed: float, hitstop: float, slowmo: float, slowmo_factor: float) -> float: + if elapsed < 0.0: + return 1.0 + var stop := maxf(hitstop, 0.0) + if elapsed < stop: + return HIT_STOP_FACTOR + var span := maxf(slowmo, 0.0) + var t := elapsed - stop + if span <= 0.0 or t >= span: + return 1.0 + return lerpf(clampf(slowmo_factor, HIT_STOP_FACTOR, 1.0), 1.0, smoothstep(0.0, 1.0, t / span)) + +## Seconds into the cinematic, measured off the countdown it rides (`_intro` or `_outro`). +func _cinematic_elapsed() -> float: + if cinematic_mode == &"": + return 0.0 + var remaining := _intro if _is_intro(cinematic_mode) else _outro + return clampf(cinematic_seconds - remaining, 0.0, cinematic_seconds) + +func _skip_tail() -> float: + var tail := float(config.boss_cinematic_skip_tail) if config != null else 0.0 + return clampf(tail, 0.0, cinematic_seconds) + +## The full length on a first meeting / first defeat, else min(short, full); never negative. +func _cinematic_length(full_seconds: float, short_seconds: float, first: bool) -> float: + var full := maxf(full_seconds, 0.0) + return full if first else minf(maxf(short_seconds, 0.0), full) + +## Open a cinematic of `seconds` (contract §11.14). A zero-length one is not played at all: no +## boss_cinematic, no boss_cinematic_ended, and nothing for the stage machine to hold. +func _begin_cinematic(mode: StringName, seconds: float) -> void: + # A kill landed inside the intro (a tool collected by hand) closes the intro before the defeat. + _end_cinematic() + if seconds <= 0.0: + return + cinematic_mode = mode + cinematic_seconds = seconds + _cinematic_skipped = false + _emit(&"boss_cinematic", [kind, mode, seconds]) + _visual_call(&"play_cinematic", [mode, seconds]) + _push_cinematic_state() + +## Close the cinematic, if one is playing, with boss_cinematic_ended. The visual hears progress 1.0 +## and a time factor of 1.0 first, so its last pose is the end of the shot. Idempotent. +func _end_cinematic() -> void: + if cinematic_mode == &"": + return + var mode := cinematic_mode + cinematic_mode = &"" + cinematic_seconds = 0.0 + _cinematic_skipped = false + _visual_call(&"set_cinematic_progress", [mode, 1.0]) + _visual_call(&"set_time_factor", [1.0]) + _emit(&"boss_cinematic_ended", [mode]) + +## The visual's per-tick cinematic feed (contract §11.5): where the cinematic is, then the factor for +## its ambient motion. Sent on the opening tick (progress 0), every tick it runs and after a skip — +## and never without a cinematic, so a retreat's outro feeds the visual nothing. +func _push_cinematic_state() -> void: + if cinematic_mode == &"": + return + _visual_call(&"set_cinematic_progress", [cinematic_mode, cinematic_progress()]) + _visual_call(&"set_time_factor", [time_factor()]) + +static func _is_intro(mode: StringName) -> bool: + return mode == CINEMATIC_INTRO_FULL or mode == CINEMATIC_INTRO_SHORT + +static func _is_defeat(mode: StringName) -> bool: + return mode == CINEMATIC_DEFEAT_FULL or mode == CINEMATIC_DEFEAT_SHORT + # --- attacks --- ## Count every telegraph down; a flaky one commits on the way, and the ones that reach zero fire @@ -598,13 +799,22 @@ func _thresholds() -> Array[float]: return rows return [1.0, 0.66, 0.33] +## The kill pays and announces itself on this tick, exactly as before the cinematics (contract +## §11.14 leaves scoring alone); the defeat cinematic follows and holds the outro for its length. +## The outro is armed first, so a boss_cinematic listener already reads progress 0 and the hit-stop. func _defeat() -> void: var bonus := _kill_bonus() _add_bonus(bonus, ScoreManager.REASON_BOSS_KILL) bosses_defeated += 1 _emit(&"boss_defeated", [kind, bonus]) _visual_call(&"play_defeat", []) - _end_fight() + var first_defeat := not _defeated_kinds.has(kind) + _defeated_kinds[kind] = true + var seconds := _cinematic_length(float(config.boss_defeat_seconds), + float(config.boss_defeat_short_seconds), first_defeat) + _end_fight(seconds) + if _active: + _begin_cinematic(CINEMATIC_DEFEAT_FULL if first_defeat else CINEMATIC_DEFEAT_SHORT, seconds) ## The fight ran out of time: a part of the kill bonus, in proportion to the damage that landed. func _retreat() -> void: @@ -615,21 +825,24 @@ func _retreat() -> void: bosses_escaped += 1 _emit(&"boss_retreated", [kind, bonus]) _visual_call(&"play_retreat", []) - _end_fight() + _end_fight(float(config.boss_outro_seconds)) func _kill_bonus() -> int: return int(config.boss_kill_bonus_base) + int(config.boss_kill_bonus_per_stage) * (stage_index - 1) -## Both endings clear the arena and hold the fight open for the outro; the StageManager waits for -## `finished` rather than for the signal, so the visual gets its play_defeat / play_retreat time. -func _end_fight() -> void: +## Both endings clear the arena and hold the fight open for `outro_seconds`: the defeat cinematic's +## length after a kill, boss_outro_seconds after a retreat (contract §11.14). The StageManager waits +## for `finished` rather than for the signal, so the visual gets its play_defeat / play_retreat time. +func _end_fight(outro_seconds: float) -> void: _pending.clear() _clear_projectiles() _free_tools() _attack_timer = 0.0 _tool_timer = 0.0 - _outro = maxf(float(config.boss_outro_seconds), 0.0) + _intro = 0.0 + _outro = maxf(outro_seconds, 0.0) if _outro <= 0.0: + _end_cinematic() _finish() func _finish() -> void: @@ -644,6 +857,8 @@ func _reset_run_stats() -> void: bosses_escaped = 0 tools_used = 0 boss_damage_dealt = 0 + _met.clear() + _defeated_kinds.clear() # --- the boss node --- @@ -666,6 +881,7 @@ func _mount_boss() -> void: _apply_visual_vars() if visual.has_method("on_spawned"): visual.on_spawned() + _read_cinematic_focus() return if instance != null: push_warning("Runner boss visual at %s root is %s, expected Node3D; using graybox" % [path, instance.get_class()]) @@ -673,6 +889,21 @@ func _mount_boss() -> void: _graybox = _build_graybox() visual = _graybox boss_root.add_child(visual) + _read_cinematic_focus() + +## The camera's aim for this fight (contract §11.5), taken at the rest pose on_spawned() leaves and +## before any cinematic cue: the visual's own cinematic_focus() when it has one (a Vector3), else the +## shared rest_focus() default, carried into the mount's space through the visual's transform. +func _read_cinematic_focus() -> void: + cinematic_focus = Vector3.ZERO + if not is_instance_valid(visual): + return + var local := BossVisualBase.rest_focus(visual) + if visual.has_method("cinematic_focus"): + var value: Variant = visual.call(&"cinematic_focus") + if value is Vector3: + local = value + cinematic_focus = visual.transform * local ## The contract §11.5 base vars, set before on_spawned(). A visual that does not extend ## boss_visual_base.gd simply does not receive them. @@ -727,6 +958,7 @@ func _free_visual() -> void: visual.queue_free() visual = null _graybox = null + cinematic_focus = Vector3.ZERO # --- arena housekeeping --- diff --git a/hoppys-adventure-arcade/scenes/runner/boss/boss_fx.gd b/hoppys-adventure-arcade/scenes/runner/boss/boss_fx.gd index 99f8a3c0..1a3a0f0b 100644 --- a/hoppys-adventure-arcade/scenes/runner/boss/boss_fx.gd +++ b/hoppys-adventure-arcade/scenes/runner/boss/boss_fx.gd @@ -16,6 +16,7 @@ extends Node3D ## DANGER stays the base meaning (the lane slots); the accent is MATRIX / LILAC / ORANGE per boss. ## Everything is clock-driven from PAUSABLE _process deltas, and nothing is allocated per beat: ## records, lane entries and mark instances are all preallocated here. +const DefeatBurst = preload("res://scenes/runner/boss/defeat_burst.gd") const Telegraph = preload("res://scenes/runner/boss/telegraph.tscn") const Tracer = preload("res://scenes/runner/boss/tool_tracer.gd") const Number = preload("res://scenes/runner/boss/damage_number.gd") @@ -23,6 +24,10 @@ const Palette = preload("res://scenes/runner/environment/runner_palette.gd") const Marks = preload("res://scenes/runner/boss/visuals/boss_telegraph_marks.gd") const ProjectileArt = preload("res://scenes/runner/boss/visuals/boss_projectile_art.gd") const BossAttacks = preload("res://scenes/runner/boss/boss_attacks.gd") +var explosions: DefeatBurst +var defeat_mode: StringName = &"" +var _burst_index := 0 +var _boss_model: Node3D var telegraphs: Array[Node3D] = [] var tracers: Array[Node3D] = [] var numbers: Array[Node3D] = [] @@ -51,6 +56,8 @@ var _record_id := 0 func _ready() -> void: process_mode = Node.PROCESS_MODE_PAUSABLE + explosions = DefeatBurst.new() + add_child(explosions) for i in 3: var node := Telegraph.instantiate() as Node3D add_child(node) @@ -78,9 +85,9 @@ func bind(value: Node) -> void: _disconnect() clear() game = value - connections = {&"boss_intro": _intro, &"boss_attack_planned": _planned, &"boss_attack_telegraph": _telegraph, + connections = {&"boss_attack_planned": _planned, &"boss_attack_telegraph": _telegraph, &"boss_attack_committed": _committed, &"tool_collected": _tool, &"boss_defeated": _end, - &"boss_retreated": _end, &"caught": clear, &"run_started": _reset} + &"boss_retreated": _end, &"boss_cinematic": _cinematic, &"boss_cinematic_ended": _cinematic_ended, &"caught": clear, &"run_started": _reset} for event: StringName in connections: if game.has_signal(event): game.connect(event, connections[event]) if game.has_method("get_boss_info"): @@ -168,6 +175,9 @@ func _open(attack: Dictionary, mask: int, kind: StringName, seconds: float, elap return record func _process(delta: float) -> void: + if is_instance_valid(game) and game.get("paused") == true: return + _sample_defeat() + if is_instance_valid(game) and "boss_time_factor" in game: delta *= float(game.get("boss_time_factor")) var any := false for record in records: if not record.active: continue @@ -228,6 +238,7 @@ func _reset(_seed: int) -> void: clear() func clear() -> void: + _cinematic_ended(&"") for node in telegraphs + tracers + numbers: node.clear() cursor = 0 number_cursor = 0 @@ -242,14 +253,39 @@ func _clear_records() -> void: func _exit_tree() -> void: _disconnect() -func _intro(_kind: StringName, _name: String, seconds: float) -> void: - # The visual is already mounted, but the facade mirrors boss at boss_spawned. - # Resolve the documented mount for the earlier intro signal, then mock/v1 fallback. +func _cinematic(kind: StringName, mode: StringName, _seconds: float) -> void: + _cinematic_ended(&"") + if mode not in [&"defeat_full", &"defeat_short"]: return + defeat_mode = mode + explosions.accent = accent_for(kind) var boss := game.get_node_or_null("World/TurnRig/BossArena/Boss") as Node3D - if boss == null: boss = game.get("boss") as Node3D - if not is_instance_valid(boss): return - for visual in boss.get_children(): - if visual.has_method("play_intro"): visual.play_intro(seconds) + if boss == null and "boss" in game: boss = game.get("boss") as Node3D + if boss != null: + for node in boss.find_children("*", "Node3D", true, false): + if node.has_method("socket"): + _boss_model = node + break + for i in DefeatBurst.BEATS.size(): explosions.origins[i] = explosions.to_local(boss.global_position) + _sample_defeat() + +func _sample_defeat() -> void: + if defeat_mode == &"" or not is_instance_valid(game): return + var p := clampf(float(game.get("boss_cinematic_progress")), 0, 1) + while _burst_index < DefeatBurst.BEATS.size() and p >= float(DefeatBurst.BEATS[_burst_index]): + if is_instance_valid(_boss_model): + var socket: Node3D = _boss_model.socket("fx_core" if _burst_index == 6 else "fx_%d" % _burst_index) + if socket != null: explosions.origins[_burst_index] = explosions.to_local(socket.global_position) + if "camera_rig" in game: + var rig: Node = game.get("camera_rig") + if is_instance_valid(rig) and rig.has_method("shake"): rig.shake(0.24 if _burst_index == 6 else 0.12) + _burst_index += 1 + explosions.sample(p) + +func _cinematic_ended(_mode: StringName) -> void: + defeat_mode = &"" + _burst_index = 0 + _boss_model = null + if explosions != null: explosions.clear() func limit_numbers(incoming: Node3D) -> void: # Keep six reusable slots for the established smoke contract; only three draw. diff --git a/hoppys-adventure-arcade/scenes/runner/boss/boss_visual_base.gd b/hoppys-adventure-arcade/scenes/runner/boss/boss_visual_base.gd index acad3e35..b9cf8536 100644 --- a/hoppys-adventure-arcade/scenes/runner/boss/boss_visual_base.gd +++ b/hoppys-adventure-arcade/scenes/runner/boss/boss_visual_base.gd @@ -1,12 +1,20 @@ extends Node3D ## Base for boss/visuals/_visual.tscn (contract §11.5). The backend sets every var below ## before calling on_spawned(), mounts the visual under BossArena/Boss at (0, 2.6, hover_z) and -## frees it after boss_outro_seconds; the visual never frees itself and never moves the Boss node. +## frees it once the fight's outro is over (the defeat cinematic, or boss_outro_seconds after a +## retreat); the visual never frees itself and never moves the Boss node. ## The boss has no hitbox: nothing here can damage the player, so the envelope is a drawing budget, ## not a collision one — <= 7 m wide x 4.5 m tall x 3 m deep, bottom edge never below y 1.6, ## <= 40 draw calls at peak. The hover sway is the visual's own job, in _process (PAUSABLE). @warning_ignore_start("unused_parameter") +## Contract 2.6 §11.14 intro timing, shared by the visuals, the HUD and the camera: by this progress +## the visual has landed its final intro pose and the title card slams; the pose then holds frozen +## until INTRO_HOLD_UNTIL, where the camera leaves the hold (camera_rig.gd BOSS_SHOTS keys its push +## end and its hold end on exactly these values). +const INTRO_POSE_AT := {&"intro_full": 0.40, &"intro_short": 0.28} +const INTRO_HOLD_UNTIL := {&"intro_full": 0.66, &"intro_short": 0.88} + ## flaky_test_swarm, merge_conflict_hydra or legacy_monolith. var kind: StringName = &"" ## Display name for a name plate, e.g. "Flaky Test Swarm". @@ -20,6 +28,9 @@ var hover_z := 0.0 var sway_amplitude := 0.0 var sway_period := 1.0 +var _cinematic_focus_point := Vector3.ZERO +var _cinematic_focus_read := false + ## After the vars above are set, once per fight; reset every tween and particle. func on_spawned() -> void: pass @@ -49,12 +60,97 @@ func play_hit(tool_kind: StringName, damage: int) -> void: func play_resist(tool_kind: StringName) -> void: pass -## HP reached 0; the backend frees this node after boss_outro_seconds. +## HP reached 0. Contract 2.6 §11.14: the backend frees this node once the defeat cinematic that +## follows is over (boss_outro_seconds when that cinematic has zero length). func play_defeat() -> void: pass -## The fight timed out; same lifetime as play_defeat(). +## The fight timed out; the backend frees this node after boss_outro_seconds. func play_retreat() -> void: pass +## Optional (contract 2.6 §11.14): a cinematic of `seconds` opens — the cue to set up. `mode` is +## intro_full / intro_short (straight after on_spawned, alongside boss_intro) or defeat_full / +## defeat_short (straight after play_defeat). The poses themselves follow set_cinematic_progress(). +func play_cinematic(mode: StringName, seconds: float) -> void: + pass + +## Optional (contract 2.6 §11.14): where the cinematic is, 0..1. Called with 0 right after +## play_cinematic(), on every physics tick while it runs, once straight after a skip with the jumped +## progress, and once with 1.0 when it ends. Poses, dissolve, flash and strobe are functions of +## `progress`, never of an accumulated clock, so a pause, a skip and the 6x smokes stay in step. +func set_cinematic_progress(mode: StringName, progress: float) -> void: + pass + +## Optional (contract 2.6 §11.14): the defeat's time factor, every tick of a cinematic and 1.0 when it +## ends — 0.05 through the hit-stop, then slow-mo easing back to 1. It scales only this visual's +## ambient motion (idle sway, particles), never the cinematic pose, which set_cinematic_progress() +## owns. The engine time scale is never touched. +func set_time_factor(factor: float) -> void: + pass + +## Optional (contract 2.6 §11.5): the point, in this visual's local space, the cinematic camera aims +## at. The backend reads it once, straight after on_spawned() and before any cinematic cue. The +## default, the centre of the upper third of the visual's rest bounds, is cached on that first read, +## so no later pose can move the aim. Override it to aim somewhere else (a head, a core). +func cinematic_focus() -> Vector3: + if not _cinematic_focus_read: + _cinematic_focus_point = rest_focus(self) + _cinematic_focus_read = true + return _cinematic_focus_point + +## The default focus for any boss root, the backend graybox included: the centre of the upper third +## of rest_bounds(root), or the root's own origin when it carries no mesh. +static func rest_focus(root: Node3D) -> Vector3: + var bounds := rest_bounds(root) + if not bounds.has_volume(): + return bounds.get_center() + return bounds.position + Vector3(bounds.size.x * 0.5, bounds.size.y * 5.0 / 6.0, bounds.size.z * 0.5) + +## The combined bounds of every visible MeshInstance3D under `root`, in root's local space. Each mesh +## contributes its own mesh AABB — the rest shape, never a skinned pose — through its transform chain +## up to `root`. A root with nothing visible falls back to every mesh, then to an empty AABB. +static func rest_bounds(root: Node3D) -> AABB: + if root == null or not is_instance_valid(root): + return AABB() + var meshes := root.find_children("*", "MeshInstance3D", true, false) + if root is MeshInstance3D: + meshes.push_front(root) + var bounds := _merged_bounds(root, meshes, true) + return bounds if bounds.has_volume() else _merged_bounds(root, meshes, false) + +static func _merged_bounds(root: Node3D, meshes: Array[Node], visible_only: bool) -> AABB: + var merged := AABB() + var any := false + for node in meshes: + var mesh_node := node as MeshInstance3D + if mesh_node == null or mesh_node.mesh == null: + continue + if visible_only and not _visible_under(root, mesh_node): + continue + var box := _chain_to(root, mesh_node) * mesh_node.get_aabb() + merged = box if not any else merged.merge(box) + any = true + return merged + +## `node`'s transform in `root`'s space, multiplied up the parent chain (no tree needed). +static func _chain_to(root: Node3D, node: Node3D) -> Transform3D: + var xform := Transform3D.IDENTITY + var current: Node = node + while current != null and current != root: + var spatial := current as Node3D + if spatial != null: + xform = spatial.transform * xform + current = current.get_parent() + return xform + +static func _visible_under(root: Node3D, node: Node3D) -> bool: + var current: Node = node + while current != null and current != root: + var spatial := current as Node3D + if spatial != null and not spatial.visible: + return false + current = current.get_parent() + return true + @warning_ignore_restore("unused_parameter") diff --git a/hoppys-adventure-arcade/scenes/runner/boss/defeat_burst.gd b/hoppys-adventure-arcade/scenes/runner/boss/defeat_burst.gd new file mode 100644 index 00000000..eee349fc --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/defeat_burst.gd @@ -0,0 +1,58 @@ +extends MultiMeshInstance3D +## Seven socket bursts, one draw. Progress drives every shard, including a seek across beats. +const BEATS := [0.08, 0.15, 0.225, 0.30, 0.385, 0.465, 0.54] +const SHARDS := 15 +const LIFETIME := 0.20 +var origins: Array[Vector3] = [] +var accent := Color("ff8546") + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_PAUSABLE + physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + name = "DefeatExplosions" + cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var mesh := SphereMesh.new() + mesh.radial_segments = 6 + mesh.rings = 3 + mesh.radius = 0.5 + mesh.height = 1.0 + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.vertex_color_use_as_albedo = true + material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + material.no_depth_test = false + material.cull_mode = BaseMaterial3D.CULL_DISABLED + mesh.material = material + multimesh = MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.use_colors = true + multimesh.mesh = mesh + multimesh.instance_count = BEATS.size() * SHARDS + multimesh.custom_aabb = AABB(Vector3(-8, -8, -40), Vector3(16, 20, 80)) + for i in BEATS.size(): origins.append(Vector3.ZERO) + clear() + +func clear() -> void: + visible = false + if multimesh != null: multimesh.visible_instance_count = 0 + +func sample(progress: float) -> void: + var count := 0 + for burst in BEATS.size(): + var t := (progress - float(BEATS[burst])) / LIFETIME + if t < 0 or t >= 1: continue + for shard in SHARDS: + var angle := TAU * float(shard) / SHARDS + burst * 1.7 + var direction := Vector3(cos(angle), sin(angle), sin(angle * 2.7) * 0.6).normalized() + var radius := (0.5 + t * 2.5) * (0.75 + float(shard % 3) * 0.12) + var size := lerpf(0.42, 0.04, t) if shard > 0 else lerpf(1.6, 0.0, t) + var point := origins[burst] + direction * radius if shard > 0 else origins[burst] + multimesh.set_instance_transform(count, Transform3D(Basis.IDENTITY.scaled(Vector3.ONE * size), point)) + # White-hot ignition gives way to the boss accent within the first fifth. + var color := Color.WHITE.lerp(accent, smoothstep(0.05 if shard == 0 else 0.0, 0.20, t)) + color.a = 1.0 - smoothstep(0.30, 1.0, t) + multimesh.set_instance_color(count, color) + count += 1 + multimesh.visible_instance_count = count + visible = count > 0 diff --git a/hoppys-adventure-arcade/scenes/runner/boss/defeat_burst.gd.uid b/hoppys-adventure-arcade/scenes/runner/boss/defeat_burst.gd.uid new file mode 100644 index 00000000..d2d08429 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/defeat_burst.gd.uid @@ -0,0 +1 @@ +uid://c0rfanx1whrgp diff --git a/hoppys-adventure-arcade/scenes/runner/boss/dev/capture_boss_cinematics.gd b/hoppys-adventure-arcade/scenes/runner/boss/dev/capture_boss_cinematics.gd new file mode 100644 index 00000000..034531bc --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/dev/capture_boss_cinematics.gd @@ -0,0 +1,90 @@ +extends SceneTree +## Deterministic real-game review strips. Run graphically with --always-on-top and the GPU lock. +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") +const FULL := [0.02, 0.20, 0.40, 0.445, 0.56, 0.72, 0.85, 0.97] +const SHORT := [0.40, 0.78] +const DEFEAT := [0.015, 0.16, 0.33, 0.56, 0.66, 0.84] +var game: Node +var folder := "" + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + if DisplayServer.get_name() == "headless": + printerr("BOSS CINEMATIC CAPTURE: graphical display required") + quit(1) + return + root.size = Vector2i(1280, 720) + AudioServer.set_bus_mute(0, true) + folder = ProjectSettings.globalize_path("res://../docs/previews/runner/bosses") + DirAccess.make_dir_recursive_absolute(folder) + game = load("res://scenes/runner/runner_game.tscn").instantiate() + game.autostart = false + game.leaderboard_path = "user://capture_boss_cinematics_%d.cfg" % OS.get_process_id() + root.add_child(game) + game.start_run(4242) + game.paused_changed.connect(_ignore_focus_pause) + await process_frame + await process_frame + paused = true + game.paused = false + # Hide the menu route's stage-1 opening card before entering the real boss phase. + game.get_node("HUD/StageBanner").hide() + for stage in [1, 2, 3]: + game.stage_manager.stage_index = stage + game.stage_manager._enter_boss() + var kind := String(BossDefs.boss_kind(stage)) + await capture_sequence(kind, "intro_full", FULL, 4) + game.boss_encounter.tick(5.0) + # Kill through the accepted-tool API; scoring and cinematic start remain backend-owned. + while game.boss_encounter.hp > 0: + var accepted := BossDefs.accepted_tools(game.boss_encounter.def, game.boss_encounter.phase) + game.boss_encounter.apply_tool(accepted[0]) + await capture_sequence(kind, "defeat_full", DEFEAT, 3) + game.boss_encounter.tick(5.0) + game.stage_manager.stage_index = stage + 3 + game.stage_manager._enter_boss() + await capture_sequence(kind, "intro_short", SHORT, 2) + game.boss_encounter.abort() + print("BOSS CINEMATIC CAPTURE: PASS (3 full 8-frame, 3 short pairs, 3 defeat 6-frame strips)") + paused = false + game.free() + root.get_node("AudioService").shutdown() + await process_frame + quit(0) + +func capture_sequence(kind: String, label: String, points: Array, columns: int) -> void: + var strip := Image.create(columns * 640, ceili(float(points.size()) / columns) * 360, false, Image.FORMAT_RGB8) + var last := 0.0 + var seconds: float = game.boss_encounter.cinematic_seconds + for index in points.size(): + var p: float = points[index] + game.boss_encounter.tick(maxf(0, p - last) * seconds) + last = p + game.stage_manager._publish() + game.stage_manager._apply_boss_camera() + game.camera_rig.trauma = 0.0 + game.camera_rig._physics_process(0.0) + game.get_node("HUD/BossCinematic")._process(0.0) + game.get_node("HUD/PostFX")._process(0.0) + game.get_node("World/TurnRig/BossArena/BossFX")._process(0.0) + # Flush bone attachments, labels and controls before the rendered readback. + await process_frame + await process_frame + await RenderingServer.frame_post_draw + var frame := root.get_texture().get_image() + if index == 4 or (label == "intro_short" and index == 0): + frame.save_png(folder.path_join("%s-%s-hero.png" % [kind, label])) + frame.resize(640, 360, Image.INTERPOLATE_LANCZOS) + frame.convert(Image.FORMAT_RGB8) + strip.blit_rect(frame, Rect2i(0, 0, 640, 360), Vector2i(index % columns * 640, index / columns * 360)) + var path := folder.path_join("%s-%s.png" % [kind, label]) + strip.save_png(path) + print("BOSS CAPTURE: ", path, " p=", points) + +func _ignore_focus_pause(value: bool) -> void: + # Automated review is frozen by SceneTree, independently of the focus-loss pause UI. + if value: + game.resume() + paused = true diff --git a/hoppys-adventure-arcade/scenes/runner/boss/dev/capture_boss_cinematics.gd.uid b/hoppys-adventure-arcade/scenes/runner/boss/dev/capture_boss_cinematics.gd.uid new file mode 100644 index 00000000..b75155e7 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/dev/capture_boss_cinematics.gd.uid @@ -0,0 +1 @@ +uid://duhl2xmsqop24 diff --git a/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_materials.gd b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_materials.gd new file mode 100644 index 00000000..4e1e7147 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_materials.gd @@ -0,0 +1,39 @@ +extends RefCounted +## Contract 2.6: private PBR copies share Hoppy's hue-preserving lighting. +const Surface = preload("res://scenes/runner/boss/hifi/boss_surface.gdshader") + +static func surface_from(source: BaseMaterial3D) -> ShaderMaterial: + # COLOR stays available for future art; rest positions use reserved UV2/CUSTOM0. + # BossModel validates those mesh channels before a shader material is installed. + assert(source != null, "Boss expects imported PBR materials") + assert(source.transparency == BaseMaterial3D.TRANSPARENCY_DISABLED, "Boss surfaces must be opaque") + # Flat quick builds have no ORM; baked builds must preserve the glTF channels. + if source.get_texture(BaseMaterial3D.TEXTURE_METALLIC) != null: + assert(source.ao_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_RED, "ORM occlusion must be R") + assert(source.roughness_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_GREEN, "ORM roughness must be G") + assert(source.metallic_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_BLUE, "ORM metallic must be B") + var material := ShaderMaterial.new() + material.shader = Surface + material.resource_name = source.resource_name + material.set_shader_parameter("albedo_map", source.get_texture(BaseMaterial3D.TEXTURE_ALBEDO)) + material.set_shader_parameter("albedo_color", source.albedo_color) + material.set_shader_parameter("orm_map", source.get_texture(BaseMaterial3D.TEXTURE_METALLIC)) + material.set_shader_parameter("roughness_factor", source.roughness) + material.set_shader_parameter("metallic_factor", source.metallic) + material.set_shader_parameter("normal_map", source.get_texture(BaseMaterial3D.TEXTURE_NORMAL)) + material.set_shader_parameter("normal_scale", source.normal_scale) + material.set_shader_parameter("has_normal", source.normal_enabled) + material.set_shader_parameter("emission_map", source.get_texture(BaseMaterial3D.TEXTURE_EMISSION)) + material.set_shader_parameter("emission_color", source.emission) + material.set_shader_parameter("emission_energy", source.emission_energy_multiplier) + material.set_shader_parameter("has_emission", source.emission_enabled) + material.set_shader_parameter("has_emission_map", source.get_texture(BaseMaterial3D.TEXTURE_EMISSION) != null) + material.set_shader_parameter("chroma_lock", 1.0) + material.set_shader_parameter("ambient_fill", Color(1.0, 0.972549, 0.913725)) + material.set_shader_parameter("ambient_energy", 0.34) + material.set_shader_parameter("light_gain", 0.5) + material.set_shader_parameter("front_fill", 0.0) + material.set_shader_parameter("dissolve", 0.0) + material.set_shader_parameter("flash", 0.0) + material.set_shader_parameter("phase_tint", Color.WHITE) + return material diff --git a/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_materials.gd.uid b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_materials.gd.uid new file mode 100644 index 00000000..a82e3aae --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_materials.gd.uid @@ -0,0 +1 @@ +uid://ryn46kvd3xie diff --git a/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_model.gd b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_model.gd new file mode 100644 index 00000000..65169bf2 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_model.gd @@ -0,0 +1,182 @@ +extends Node3D +## Contract 2.6: model adapter. A null load lets the visual build BossArt instead. +const Materials = preload("res://scenes/runner/boss/hifi/boss_materials.gd") +const ACTIONS: Array[StringName] = [&"intro", &"idle", &"telegraph", &"attack", &"hurt", &"phase_change", &"defeat", &"retreat"] +const SOCKETS: Array[StringName] = [&"fx_core", &"fx_0", &"fx_1", &"fx_2", &"fx_3", &"fx_4", &"fx_5", &"muzzle_0", &"muzzle_1", &"muzzle_2"] +const _REST_VALIDATION_META := &"_boss_rest_validation_v2" +static var _rest_position_scan_count := 0 +var model: Node3D +var animation_player: AnimationPlayer +var materials: Array[ShaderMaterial] = [] +var _sockets: Dictionary = {} + +static func rest_positions(mesh: Mesh, surface: int) -> PackedVector3Array: + # Kit v2 reserves UV2.xy for rest xy and CUSTOM0.xy for rest z / ABI version. + _rest_position_scan_count += 1 + var flags: int = mesh.surface_get_format(surface) + if not flags & Mesh.ARRAY_FORMAT_TEX_UV2 or not flags & Mesh.ARRAY_FORMAT_CUSTOM0: + return PackedVector3Array() + var format: int = (flags >> Mesh.ARRAY_FORMAT_CUSTOM0_SHIFT) & Mesh.ARRAY_FORMAT_CUSTOM_MASK + var stride := 2 if format == Mesh.ARRAY_CUSTOM_RG_FLOAT else 4 if format == Mesh.ARRAY_CUSTOM_RGBA_FLOAT else 0 + if stride == 0: + return PackedVector3Array() + var arrays := mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var xy: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV2] + var encoded: PackedFloat32Array = arrays[Mesh.ARRAY_CUSTOM0] + if xy.size() != vertices.size() or encoded.size() != vertices.size() * stride: + return PackedVector3Array() + var result := PackedVector3Array() + for index in vertices.size(): + if not is_equal_approx(encoded[index * stride + 1], 1.0): + return PackedVector3Array() + var point := Vector3(xy[index].x, xy[index].y, encoded[index * stride]) + if not point.is_finite() or point.distance_to(vertices[index]) > 0.0001: + return PackedVector3Array() + result.append(point) + return result + +static func _has_valid_rest_positions(mesh: Mesh, surface: int) -> bool: + # Scalar metadata dies with the Mesh; a static resource cache would retain it at exit. + var cache: Dictionary = mesh.get_meta(_REST_VALIDATION_META, {}) + var mesh_id := mesh.get_instance_id() + if cache.get("mesh_id", 0) != mesh_id: + # Resource.duplicate() copies metadata, but the new mesh needs its own validation. + cache = {"mesh_id": mesh_id, "surfaces": {}} + var invalidate := _invalidate_rest_validation.bind(mesh_id) + if not mesh.changed.is_connected(invalidate): + mesh.changed.connect(invalidate) + var surfaces: Dictionary = cache["surfaces"] + if not surfaces.has(surface): + surfaces[surface] = not rest_positions(mesh, surface).is_empty() + mesh.set_meta(_REST_VALIDATION_META, cache) + return surfaces[surface] + +static func _invalidate_rest_validation(mesh_id: int) -> void: + # Bind only the id so the signal cannot create a Mesh reference cycle. + var mesh := instance_from_id(mesh_id) as Mesh + if mesh != null and mesh.has_meta(_REST_VALIDATION_META): + mesh.remove_meta(_REST_VALIDATION_META) + +func _bind_dissolve_origin() -> void: + # The kit exports identity mesh transforms. Burn outwards from the core in rest space. + var core := socket(&"fx_core") + if core == null: + return + var origin := _rest_transform(core).origin + var radius := 0.01 + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh := node as MeshInstance3D + var bounds := mesh.get_aabb() + var relative := _rest_transform(mesh) + for corner in 8: + radius = maxf(radius, origin.distance_to(relative * bounds.get_endpoint(corner))) + _set_parameter(&"dissolve_origin", origin) + _set_parameter(&"dissolve_radius", radius) + +func _rest_transform(node: Node3D) -> Transform3D: + if node == self: + return Transform3D.IDENTITY + if node is BoneAttachment3D: + var skeleton := node.get_parent() as Skeleton3D + if skeleton != null: + var index := skeleton.find_bone(node.bone_name) + if index >= 0: + return _rest_transform(skeleton) * skeleton.get_bone_global_rest(index) + var parent := node.get_parent() as Node3D + return _rest_transform(parent) * node.transform if parent != null else node.transform + +static func load_model(path: String) -> Node3D: + if not ResourceLoader.exists(path): + return null + var packed := load(path) as PackedScene + if packed == null: + return null + var imported := packed.instantiate() + if not imported is Node3D: + imported.free() + return null + var result = new() + result.name = "BossModel" + result.model = imported + result.add_child(imported) + if not result._bind(): + result.free() + return null + return result + +func _bind() -> bool: + var players := model.find_children("*", "AnimationPlayer", true, false) + var skeletons := model.find_children("*", "Skeleton3D", true, false) + if players.size() != 1 or skeletons.is_empty(): + return false + animation_player = players[0] as AnimationPlayer + var library := AnimationLibrary.new() + for action in ACTIONS: + if not animation_player.has_animation(action): + return false + var clip := animation_player.get_animation(action).duplicate(true) as Animation + clip.loop_mode = Animation.LOOP_LINEAR if action == &"idle" else Animation.LOOP_NONE + library.add_animation(action, clip) + for key in animation_player.get_animation_library_list(): + animation_player.remove_animation_library(key) + animation_player.add_animation_library(&"", library) + animation_player.playback_default_blend_time = 0.08 + # Keep the imported player in place so all track and skeleton paths remain valid. + for key in SOCKETS: + var found := model.find_child(String(key), true, false) as Node3D + if found == null: + return false + _sockets[key] = found + var copies: Dictionary = {} + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh := node as MeshInstance3D + if mesh.mesh == null: + continue + mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + for index in mesh.mesh.get_surface_count(): + # Old/unencoded assets use the visual's procedural fallback until rebuilt. + if not _has_valid_rest_positions(mesh.mesh, index): + return false + var source := mesh.get_active_material(index) as BaseMaterial3D + if source == null or source.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED: + return false + if not copies.has(source): + copies[source] = Materials.surface_from(source) + materials.append(copies[source]) + mesh.set_surface_override_material(index, copies[source]) + _configure(self) + _bind_dissolve_origin() + return not materials.is_empty() + +func _configure(node: Node) -> void: + node.process_mode = Node.PROCESS_MODE_PAUSABLE + node.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + for child in node.get_children(): + _configure(child) + +func play(action: StringName) -> void: + if animation_player != null and animation_player.has_animation(action): + animation_player.play(action) + if is_inside_tree(): + animation_player.advance(0.0) + +func socket(key: StringName) -> Node3D: + return _sockets.get(key) as Node3D + +func set_dissolve(value: float) -> void: + _set_parameter(&"dissolve", clampf(value, 0.0, 1.0)) + +func set_flash(value: float) -> void: + _set_parameter(&"flash", clampf(value, 0.0, 1.0)) + +func set_phase_tint(value: Color) -> void: + _set_parameter(&"phase_tint", value) + +func set_time_factor(value: float) -> void: + if animation_player != null: + animation_player.speed_scale = maxf(value, 0.0) + +func _set_parameter(key: StringName, value: Variant) -> void: + for material in materials: + material.set_shader_parameter(key, value) diff --git a/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_model.gd.uid b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_model.gd.uid new file mode 100644 index 00000000..46dad435 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_model.gd.uid @@ -0,0 +1 @@ +uid://y4y12ykcmkly diff --git a/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_surface.gdshader b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_surface.gdshader new file mode 100644 index 00000000..db51c204 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_surface.gdshader @@ -0,0 +1,67 @@ +shader_type spatial; +render_mode cull_back, depth_draw_opaque, ambient_light_disabled; +#define BOSS_SURFACE +#include "../../player/hifi/hoppy_surface.gdshaderinc" + +uniform float dissolve : hint_range(0.0, 1.0) = 0.0; +uniform vec4 dissolve_color : source_color = vec4(1.0, 0.302, 0.239, 1.0); +uniform float dissolve_edge : hint_range(0.001, 0.2) = 0.085; +uniform float dissolve_scale : hint_range(1.0, 40.0) = 12.5; +uniform vec3 dissolve_origin = vec3(0.0); +uniform float dissolve_radius = 3.5; +uniform vec4 phase_tint : source_color = vec4(1.0); +varying vec3 boss_position; + +void vertex() { + // Kit v2 glTF TEXCOORD_1/2 stay unskinned; VERTEX is already posed here. + boss_position = vec3(UV2, CUSTOM0.x); +} +float boss_hash(vec3 p) { + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); +} +float boss_noise(vec3 p) { + vec3 cell = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(boss_hash(cell), boss_hash(cell + vec3(1.0, 0.0, 0.0)), f.x), + mix(boss_hash(cell + vec3(0.0, 1.0, 0.0)), boss_hash(cell + vec3(1.0, 1.0, 0.0)), f.x), f.y), + mix(mix(boss_hash(cell + vec3(0.0, 0.0, 1.0)), boss_hash(cell + vec3(1.0, 0.0, 1.0)), f.x), + mix(boss_hash(cell + vec3(0.0, 1.0, 1.0)), boss_hash(cell + vec3(1.0)), f.x), f.y), f.z); +} +void fragment() { + // Rest-space cells stay fixed while pause, hit-stop and skip drive the progress. + float outward = clamp(length(boss_position - dissolve_origin) / max(dissolve_radius, 0.01), 0.0, 1.0); + float noise = smoothstep(0.25, 0.75, boss_noise(boss_position * dissolve_scale)) * 0.8 + outward * 0.2; + // Linear dissolve spans cinematic p=0.55..1.0. Calibrated screen coverage at + // p=0.60/0.75/0.90/0.95 is about 93/65/30/10%, including revealed back surfaces. + float d = dissolve * 9.0; + float threshold; + if (d < 1.0) { threshold = mix(0.0, 0.125, d); } + else if (d < 4.0) { threshold = mix(0.125, 0.51, (d - 1.0) / 3.0); } + else if (d < 7.0) { threshold = mix(0.51, 0.82, (d - 4.0) / 3.0); } + else if (d < 8.0) { threshold = mix(0.82, 0.89, d - 7.0); } + else { threshold = mix(0.89, 1.0, d - 8.0); } + if (dissolve >= 1.0 || (dissolve > 0.0 && noise < threshold)) { discard; } + float edge = dissolve > 0.0 ? 1.0 - smoothstep(threshold, threshold + dissolve_edge, noise) : 0.0; + vec3 base = texture(albedo_map, UV).rgb * albedo_color.rgb * phase_tint.rgb; + ALBEDO = mix(base, vec3(1.0), flash * 0.7); + vec3 orm = texture(orm_map, UV).rgb; + AO = orm.r; + AO_LIGHT_AFFECT = 1.0; + ROUGHNESS = clamp(orm.g * roughness_factor, 0.08, 1.0); + METALLIC = orm.b * metallic_factor; + if (has_normal) { + NORMAL_MAP = texture(normal_map, UV).rgb; + NORMAL_MAP_DEPTH = normal_scale; + } + vec3 baked = vec3(0.0); + if (has_emission) { + baked = emission_color.rgb * emission_energy; + if (has_emission_map) { baked *= texture(emission_map, UV).rgb; } + } + vec3 facing = normalize((VIEW_MATRIX * MODEL_MATRIX * vec4(0.0, 0.0, -1.0, 0.0)).xyz); + float front = clamp(dot(NORMAL, facing), 0.0, 1.0); + EMISSION = base * (ambient_fill.rgb * ambient_energy + vec3(front_fill * front)) * orm.r + + baked * phase_tint.rgb + vec3(flash * 0.35) + dissolve_color.rgb * edge * 2.5; +} diff --git a/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_surface.gdshader.uid b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_surface.gdshader.uid new file mode 100644 index 00000000..c2d53a47 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/boss/hifi/boss_surface.gdshader.uid @@ -0,0 +1 @@ +uid://dlw82fdhuxgnh diff --git a/hoppys-adventure-arcade/scenes/runner/boss/visuals/flaky_test_swarm_visual.gd b/hoppys-adventure-arcade/scenes/runner/boss/visuals/flaky_test_swarm_visual.gd index 4225d52f..ac841700 100644 --- a/hoppys-adventure-arcade/scenes/runner/boss/visuals/flaky_test_swarm_visual.gd +++ b/hoppys-adventure-arcade/scenes/runner/boss/visuals/flaky_test_swarm_visual.gd @@ -1,85 +1,378 @@ extends "res://scenes/runner/boss/boss_visual_base.gd" +## The visual owns one clock. Imported clips are sampled manually, including hit-stop. const Art = preload("res://scenes/runner/boss/boss_art.gd") +const BossModel = preload("res://scenes/runner/boss/hifi/boss_model.gd") +const Palette = preload("res://scenes/runner/environment/runner_palette.gd") +const MODEL_PATH := "res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb" +const ACCENT := Color("8fd14f") +## Conservative worker extents and scales from the Blender recipe. These reserve +## the full ambient sway when a runtime scatter expands an already bounded clip. +const DRONE_HALF_EXTENTS := Vector3(0.90, 0.70, 0.64) +const DRONE_SCALES := [0.95, 1.07, 0.93, 1.05, 0.92, 1.10] +const COMMIT_ARC := [Vector3(1.74, 1.10, 0.20), Vector3(1.03, 2.40, -0.25), + Vector3(-1.03, 2.40, 0.25), Vector3(-1.74, 1.10, -0.20), + Vector3(-1.32, 0.0, 0.15), Vector3(1.32, 0.0, -0.15)] +var model_path := MODEL_PATH var _art: Art +var _model: Node3D +var _skeleton: Skeleton3D +var _tag: Label3D +var _drone_bones: Array[int] = [] var show_name_plate := true -var _clock := 0.0 var intro_calls := 0 var _intro_tag_visible := false +var _clock := 0.0 +var _time_factor := 1.0 +var _phase := 1 +var _health := 1.0 +var _state: StringName = &"idle" +var _state_age := 0.0 +var _state_duration := 1.0 +var _terminal := false +var _aim := 0.0 +var _commit_age := -1.0 +var _projectile_kind: StringName = &"" +var _flash_age := 1.0 +var _flash_strength := 0.0 +var _cinematic_mode: StringName = &"" +var _cinematic_age := 0.0 +var _cinematic_seconds := 1.0 func _ready() -> void: process_mode = Node.PROCESS_MODE_PAUSABLE physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF func on_spawned() -> void: - if _art == null: - _art = Art.new() - add_child(_art) - _art.build(&"flaky_test_swarm") - _art.reset() - _art.tag.text = (boss_name if not boss_name.is_empty() else String(kind).replace("_", " ")) - _art.tag.visible = show_name_plate + if _model == null and _art == null: + _model = BossModel.load_model(model_path) + if _model != null: + add_child(_model) + _model.animation_player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + for material: ShaderMaterial in _model.materials: + material.set_shader_parameter("dissolve_color", ACCENT) + var rigs := _model.find_children("*", "Skeleton3D", true, false) + if not rigs.is_empty(): _skeleton = rigs[0] as Skeleton3D + if _skeleton != null: + for i in 6: _drone_bones.append(_skeleton.find_bone("drone_%d" % i)) + _tag = Art.label(self, "", Vector3(0, 3.15, 0), 0.005) + _tag.font_size = 40 + _tag.billboard = BaseMaterial3D.BILLBOARD_DISABLED + else: + _art = Art.new() + add_child(_art) + _art.build(&"flaky_test_swarm") + _tag = _art.tag + _clock = 0.0 + _phase = 1 + _health = 1.0 + _time_factor = 1.0 + _terminal = false + _commit_age = -1.0 + _aim = 0.0 + _cinematic_mode = &"" + _cinematic_age = 0.0 + _flash_age = 1.0 + _flash_strength = 0.0 + _projectile_kind = &"" + _tag.text = boss_name if not boss_name.is_empty() else "Flaky Test Swarm" + _intro_tag_visible = show_name_plate var ancestor := get_parent() while ancestor != null: if ancestor.get_node_or_null("HUD/BossHud") != null or ancestor.get_node_or_null("HUD/BossHUD") != null: - _art.tag.hide() + _intro_tag_visible = false break ancestor = ancestor.get_parent() - set_health(max_hp, max_hp) - _clock = 0.0 - # Backend parents already supply hover_z; standalone mounts use the same depth. + _tag.visible = _intro_tag_visible + if _art != null: _art.reset() + if _model != null: + _model.show() + _model.transform = Transform3D.IDENTITY + _model.set_dissolve(0.0) + _model.set_flash(0.0) + _model.set_phase_tint(Color.WHITE) + _model.set_time_factor(1.0) + _play(&"idle") position = Vector3(0, 0, hover_z - get_parent_node_3d().position.z) if get_parent_node_3d() != null else Vector3(0, 0, hover_z) show() +func _ensure_art() -> void: + if _model == null and _art == null: on_spawned() + +func _play(action: StringName, seconds: float = -1.0) -> void: + _state = action + _state_age = 0.0 + if _model == null: return + var player: AnimationPlayer = _model.animation_player + _state_duration = maxf(seconds if seconds >= 0.0 else player.get_animation(action).length, 0.001) + # Zero blending is intentional: one deterministic seek controls bones and sockets. + player.play(action, 0.0) + if _skeleton != null: _skeleton.reset_bone_poses() + player.seek(0.0, true) + _tag.visible = _intro_tag_visible and action not in [&"intro", &"defeat", &"retreat"] + func set_phase(value: int) -> void: - if _art == null: on_spawned() - _art.phase = clampi(value, 1, phase_count) - _art.react(&"phase_change", 0.9) + _ensure_art() + if _terminal: return + _phase = clampi(value, 1, maxi(phase_count, 1)) + if _art != null: + _art.phase = _phase + _art.react(&"phase_change", 0.9) + else: + _model.set_phase_tint(Color.WHITE.lerp(ACCENT, (_phase - 1) * 0.15)) + _play(&"phase_change") func set_health(hp: int, max_health: int) -> void: - if _art == null: on_spawned() - _art.health = clampf(float(hp) / maxi(max_health, 1), 0, 1) + _ensure_art() + _health = clampf(float(hp) / maxi(max_health, 1), 0, 1) + if _art != null: _art.health = _health func play_telegraph(lane_mask: int, projectile_kind: StringName, seconds: float) -> void: - if _art == null: on_spawned() - _art.signature_telegraph(lane_mask, projectile_kind, seconds) + _ensure_art() + if _terminal: return + _aim = Art.mask_center(lane_mask) + _commit_age = -1.0 + _projectile_kind = projectile_kind + if _art != null: _art.signature_telegraph(lane_mask, projectile_kind, seconds) + else: _play(&"telegraph", seconds) -## Contract 2.4 optional hook (boss_attack_committed): the tiles converge on the committed lane. -func play_commit(lane_mask: int, _projectile_kind: StringName, seconds: float) -> void: - if _art != null: _art.signature_commit(lane_mask, seconds) +## The backend owns flickering candidates and floor marks. Commitment makes the +## six drones converge on the final lane and hold their red failure faces. +func play_commit(lane_mask: int, _kind: StringName, seconds: float) -> void: + _ensure_art() + if _terminal: return + _aim = Art.mask_center(lane_mask) + _commit_age = 0.0 + if _art != null: + _art.signature_commit(lane_mask, seconds) + else: + if _state != &"telegraph": _play(&"telegraph", seconds) + else: _state_duration = _state_age + maxf(seconds, 0.001) func play_attack(projectile_kind: StringName) -> void: + _ensure_art() + if _terminal: return + _commit_age = -1.0 + _projectile_kind = projectile_kind if _art != null: _art.signature_attack(projectile_kind) + else: _play(&"attack") func play_hit(_tool_kind: StringName, _damage: int) -> void: + _ensure_art() + if _terminal: return if _art != null: _art.flash() + else: + _play(&"hurt") + _flash_age = 0.0 + _flash_strength = 1.0 + _model.set_flash(1.0) func play_resist(_tool_kind: StringName) -> void: + _ensure_art() + if _terminal: return if _art != null: _art.flash(true) + else: + _flash_age = 0.0 + _flash_strength = 0.24 + _model.set_flash(_flash_strength) func play_defeat() -> void: + _ensure_art() + _terminal = true + _cinematic_mode = &"" + _flash_strength = 0.0 + _tag.hide() if _art != null: _art.shatter() + else: _play(&"defeat", 0.95) func play_retreat() -> void: + _ensure_art() + _terminal = true + _cinematic_mode = &"" + _tag.hide() if _art != null: _art.react(&"retreat", 0.95) - -func _process(delta: float) -> void: - if _art == null: return - _clock += delta - var was_intro: bool = _art.state == &"intro" - _art.advance(delta) - if was_intro and _art.state != &"intro": _art.tag.visible = _intro_tag_visible - if _art.state == &"intro": return - var period := maxf(sway_period * (1 - 0.12 * (_art.phase - 1)), 0.1) - position.x = sin(_clock * TAU / period) * minf(sway_amplitude, 0.35) - position.y = 0.04 + sin(_clock * 2) * 0.04 - if _art.state == &"phase_change": position.y += 0.12 * sin(PI * minf(_art.age / 0.9, 1)) - if _art.state == &"retreat": position.y += minf(_art.age, 0.95) * 0.12 + else: _play(&"retreat", 0.95) func play_intro(seconds: float) -> void: - if _art == null: on_spawned() + _ensure_art() + if _terminal: return intro_calls += 1 _clock = 0.0 - _intro_tag_visible = _art.tag.visible - _art.tag.hide() - _art.react(&"intro", seconds) - _art.advance(0.0) + _tag.hide() + if _art != null: + _art.react(&"intro", seconds) + _art.advance(0.0) + else: + position.x = 0.0 + position.y = 0.0 + _play(&"intro", seconds) + +func play_cinematic(mode: StringName, seconds: float) -> void: + if mode not in [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"]: return + _ensure_art() + if _terminal and mode in [&"intro_full", &"intro_short"]: return + _cinematic_mode = mode + _cinematic_age = 0.0 + _cinematic_seconds = maxf(seconds, 0.001) + _tag.hide() + if mode in [&"intro_full", &"intro_short"]: + play_intro(_cinematic_seconds * float(INTRO_POSE_AT[mode])) + else: + _terminal = true + _flash_strength = 0.0 + if _art != null: _art.shatter() + else: + _play(&"defeat", _cinematic_seconds) + _model.set_flash(1.0) + _model.set_dissolve(0.0) + set_cinematic_progress(mode, 0.0) + +## Contract 2.6: the encounter owns cinematic time, including pause and skip jumps. +## Time factor only affects normal ambient/action motion in _process. +func set_cinematic_progress(mode: StringName, progress: float) -> void: + if mode != _cinematic_mode or _cinematic_mode.is_empty(): return + _cinematic_age = clampf(progress, 0.0, 1.0) * _cinematic_seconds + if _model != null: + _sample_cinematic() + _apply_formation() + else: + var p := clampf(progress, 0.0, 1.0) + _art.clock = _cinematic_age + if mode in [&"intro_full", &"intro_short"]: + var pose_at := float(INTRO_POSE_AT[mode]) + _art.state = &"intro" + _art.age = minf(p / pose_at, 0.99999) * _art.duration + _art.clock = minf(_cinematic_age, _cinematic_seconds * pose_at) + else: + _art.state = &"defeated" + var hitstop := minf(0.12, _cinematic_seconds) + _art.age = clampf((_cinematic_age - hitstop) / maxf(_cinematic_seconds - hitstop, 0.001), 0, 1) * _art.duration + _art.advance(0.0) + _tag.hide() + if p >= 1.0: + _cinematic_mode = &"" + if not _terminal: + # Preserve the sampled hold; the next normal advance enters idle. + _art.age = _art.duration + _tag.visible = _intro_tag_visible + +func set_time_factor(factor: float) -> void: + _time_factor = maxf(factor, 0.0) + if _model != null: _model.set_time_factor(_time_factor) + +## Optional socket facade for cinematic FX. The same bones drive the mesh. +func socket(key: StringName) -> Node3D: + return _model.socket(key) if _model != null else null + +func _process(delta: float) -> void: + if (_model == null and _art == null) or (is_inside_tree() and get_tree().paused): return + var step := maxf(delta, 0.0) * _time_factor + if step <= 0.0: return + if not _cinematic_mode.is_empty(): return + _clock += step + if _art != null: + var was_intro := _art.state == &"intro" + _art.advance(step) + if was_intro and _art.state != &"intro": _tag.visible = _intro_tag_visible + if _art.state == &"intro": return + var fallback_period := maxf(sway_period * (1 - 0.12 * (_art.phase - 1)), 0.1) + position.x = sin(_clock * TAU / fallback_period) * minf(sway_amplitude, 0.35) + position.y = 0.04 + sin(_clock * 2) * 0.04 + if _art.state == &"phase_change": position.y += 0.12 * sin(PI * minf(_art.age / 0.9, 1)) + if _art.state == &"retreat": position.y += minf(_art.age, 0.95) * 0.12 + return + else: + _state_age += step + _flash_age += step + if _commit_age >= 0.0: _commit_age += step + _sample_action() + _model.set_flash(_flash_strength * (1.0 - clampf(_flash_age / 0.16, 0, 1))) + _apply_formation() + if _state == &"intro" or _terminal: return + var period := maxf(sway_period * (1 - 0.12 * (_phase - 1)), 0.1) + position.x = sin(_clock * TAU / period) * minf(sway_amplitude, 0.18) + position.y = 0.04 + sin(_clock * 2) * 0.04 + +func _seek(action: StringName, progress: float) -> void: + var player: AnimationPlayer = _model.animation_player + if player.current_animation != action: player.play(action, 0.0) + # Imported clips may omit constant tracks. Reset before adding formation offsets. + if _skeleton != null: _skeleton.reset_bone_poses() + player.seek(clampf(progress, 0.0, 1.0) * player.get_animation(action).length, true) + +func _sample_action() -> void: + var progress := _state_age / _state_duration + if _state == &"idle": + _seek(&"idle", fposmod(progress, 1.0)) + elif _terminal: + _seek(_state, progress) + if _state == &"defeat": _model.set_dissolve(clampf((progress - 0.55) / 0.45, 0, 1)) + if progress >= 1.0: _model.hide() + elif progress >= 1.0: + var remainder := _state_age - _state_duration + _play(&"idle") + _state_age = remainder + _seek(&"idle", fposmod(_state_age / _state_duration, 1.0)) + else: + _seek(_state, progress) + +func _sample_cinematic() -> void: + var p := clampf(_cinematic_age / _cinematic_seconds, 0, 1) + if _cinematic_mode in [&"intro_full", &"intro_short"]: + # Land at the shared card cue. Keep the finished pose through + # INTRO_HOLD_UNTIL and the camera return, until normal play resumes. + _seek(&"intro", minf(p / float(INTRO_POSE_AT[_cinematic_mode]), 1.0)) + _tag.hide() + if p >= 1.0: + _cinematic_mode = &"" + # Do not seek idle on this last cinematic frame or apply formation + # offsets to it. _sample_action enters idle on the next normal tick. + _state_age = _state_duration + _tag.visible = _intro_tag_visible + else: + # The struck pose holds for 0.12 s. Collapse starts immediately after it, + # with the opening strobe overlapping the action instead of delaying it. + var hitstop := minf(0.12, _cinematic_seconds) + _seek(&"defeat", maxf((_cinematic_age - hitstop) / maxf(_cinematic_seconds - hitstop, 0.001), 0.0)) + var strobe := 1.0 if _cinematic_age < hitstop else (0.85 if p < 0.25 and int(p * 32.0) % 2 == 0 else 0.0) + _model.set_flash(strobe) + _model.set_dissolve(clampf((p - 0.55) / 0.45, 0, 1)) + if p >= 1.0: + _model.hide() + _state_age = _state_duration + _cinematic_mode = &"" + +func _apply_formation() -> void: + if _skeleton == null or _terminal or _state == &"intro": return + var committed := smoothstep(0.0, 0.12, _commit_age) if _state == &"telegraph" and _commit_age >= 0 else 0.0 + var tighter := (_phase - 1) * 0.035 + var scatter := 0.09 * sin(PI * clampf(_state_age / _state_duration, 0, 1)) if _projectile_kind == &"scatter" and _state in [&"telegraph", &"attack"] else 0.0 + for i in _drone_bones.size(): + var index := _drone_bones[i] + if index < 0: continue + var point := _skeleton.get_bone_pose_position(index) + point.x *= 1.0 - tighter + scatter + point.y = 1.02 + (point.y - 1.02) * (1.0 - tighter + scatter) + if scatter > 0.0: + var rotation := Basis(_skeleton.get_bone_pose_rotation(index)) + var half_size := DRONE_HALF_EXTENTS * float(DRONE_SCALES[i]) + var extent := rotation.x.abs() * half_size.x + rotation.y.abs() * half_size.y + rotation.z.abs() * half_size.z + point.x = clampf(point.x, -3.30 + extent.x, 3.30 - extent.x) + point.y = clampf(point.y, -0.98 + extent.y, 3.38 - extent.y) + if committed > 0.0: + # Each drone keeps an individual slot around the queen. Only the arc + # centre tracks the committed lane, preserving separation in close-up. + var target: Vector3 = COMMIT_ARC[i] + target.x = target.x * (1.0 - tighter) + _aim * 0.08 + target.y = 1.02 + (target.y - 1.02) * (1.0 - tighter) + point = point.lerp(target, committed) + _skeleton.set_bone_pose_rotation(index, Quaternion(Vector3.UP, PI)) + # Keep the lower-left worker clear of the opened phase carapace. + if i == 4 and _phase >= 2: point.x -= 0.15 + _skeleton.set_bone_pose_position(index, point) + # The phase split stays visible after the phase-change action returns to idle. + for side in [-1, 1]: + var index := _skeleton.find_bone("carapace_L" if side < 0 else "carapace_R") + if index >= 0: + var point := _skeleton.get_bone_pose_position(index) + point.x += side * (_phase - 1) * 0.08 + _skeleton.set_bone_pose_position(index, point) diff --git a/hoppys-adventure-arcade/scenes/runner/boss/visuals/legacy_monolith_visual.gd b/hoppys-adventure-arcade/scenes/runner/boss/visuals/legacy_monolith_visual.gd index 5808aa2b..1c30f044 100644 --- a/hoppys-adventure-arcade/scenes/runner/boss/visuals/legacy_monolith_visual.gd +++ b/hoppys-adventure-arcade/scenes/runner/boss/visuals/legacy_monolith_visual.gd @@ -1,72 +1,370 @@ extends "res://scenes/runner/boss/boss_visual_base.gd" const Art = preload("res://scenes/runner/boss/boss_art.gd") +const Model = preload("res://scenes/runner/boss/hifi/boss_model.gd") +const Palette = preload("res://scenes/runner/environment/runner_palette.gd") +const MODEL_PATH := "res://Assets/runner/bosses/legacy_monolith/legacy_monolith.glb" +const SIGNATURE_MODES := {&"slab": &"wall", &"quake": &"quake", &"debris": &"debris"} +var model_path := MODEL_PATH +var _model: Node3D var _art: Art +var _player: AnimationPlayer +var _skeleton: Skeleton3D +var _tag: Label3D var show_name_plate := true -var _clock := 0.0 var intro_calls := 0 +var _clock := 0.0 var _intro_tag_visible := false +var _state: StringName = &"idle" +var _age := 0.0 +var _duration := 4.0 +var _phase := 1 +var _health := 1.0 +var _time_factor := 1.0 +var _cinematic_mode: StringName = &"" +var _mode: StringName = &"" +var _wall_mask := 0 +var _aim := 0.0 +var _terminal := false +var _flash_remaining := 0.0 +var _flash_strength := 0.0 +var _hitstop_pose: Array[Transform3D] = [] +var _emission_base: Array[Color] = [] func _ready() -> void: process_mode = Node.PROCESS_MODE_PAUSABLE physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF +func _ensure() -> void: + if _model == null and _art == null: + on_spawned() + func on_spawned() -> void: - if _art == null: - _art = Art.new() - add_child(_art) - _art.build(&"legacy_monolith") - _art.reset() - _art.tag.text = (boss_name if not boss_name.is_empty() else String(kind).replace("_", " ")) - _art.tag.visible = show_name_plate + if _model == null and _art == null: + _model = Model.load_model(model_path) + if _model != null: + add_child(_model) + # Contract 11.5 leaves 4.5 m of vertical space for the complete posed rig. + _model.position.y = -0.065 + _player = _model.get("animation_player") as AnimationPlayer + _skeleton = _model.find_children("*", "Skeleton3D", true, false)[0] as Skeleton3D + # Contract 2.6: local combat clock, authoritative cinematic progress. + _player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + _player.playback_default_blend_time = 0.0 + for material: ShaderMaterial in _model.get("materials"): + # Art review r2: reactor fire overrides the slate chassis accent. + material.set_shader_parameter("dissolve_color", Palette.ORANGE) + _emission_base.append(material.get_shader_parameter("emission_color")) + _tag = Art.label(self, "", Vector3(0, 3.65, 0), 0.005) + _tag.font_size = 40 + _tag.billboard = BaseMaterial3D.BILLBOARD_DISABLED + else: + _art = Art.new() + add_child(_art) + _art.build(&"legacy_monolith") + _tag = _art.tag + _clock = 0.0 + _phase = 1 + _health = 1.0 + _time_factor = 1.0 + _terminal = false + _cinematic_mode = &"" + _mode = &"" + _wall_mask = 0 + _aim = 0.0 + _flash_remaining = 0.0 + _hitstop_pose.clear() + _tag.text = boss_name if not boss_name.is_empty() else String(kind).replace("_", " ") + _tag.visible = show_name_plate var ancestor := get_parent() while ancestor != null: if ancestor.get_node_or_null("HUD/BossHud") != null or ancestor.get_node_or_null("HUD/BossHUD") != null: - _art.tag.hide() + _tag.hide() break ancestor = ancestor.get_parent() - set_health(max_hp, max_hp) - _clock = 0.0 - # Backend parents already supply hover_z; standalone mounts use the same depth. + _intro_tag_visible = _tag.visible position = Vector3(0, 0, hover_z - get_parent_node_3d().position.z) if get_parent_node_3d() != null else Vector3(0, 0, hover_z) show() + if _art != null: + _art.reset() + _state = &"idle" + _age = 0.0 + else: + _model.show() + _model.call("set_dissolve", 0.0) + _model.call("set_flash", 0.0) + _model.call("set_phase_tint", Color.WHITE) + _model.call("set_time_factor", 1.0) + _play_action(&"idle") + _sample_pose() func set_phase(value: int) -> void: - if _art == null: on_spawned() - _art.phase = clampi(value, 1, phase_count) - _art.react(&"phase_change", 0.9) + _ensure() + _phase = clampi(value, 1, phase_count) + if _art != null: + _art.phase = _phase + _art.react(&"phase_change", 0.9) + return + _model.call("set_phase_tint", Color.WHITE.lerp(Color(1.0, 0.87, 0.72), float(_phase - 1) / 2.0)) + if not _terminal: + _play_action(&"phase_change") func set_health(hp: int, max_health: int) -> void: - if _art == null: on_spawned() - _art.health = clampf(float(hp) / maxi(max_health, 1), 0, 1) + _ensure() + _health = clampf(float(hp) / maxi(max_health, 1), 0, 1) + if _art != null: + _art.health = _health + +func _play_action(action: StringName, seconds: float = -1.0) -> void: + _state = action + _age = 0.0 + var clip := _player.get_animation(action) + _duration = maxf(clip.length if seconds < 0.0 else seconds, 0.001) + _player.stop() + _player.play(action, 0.0) + _player.advance(0.0) func play_telegraph(lane_mask: int, projectile_kind: StringName, seconds: float) -> void: - if _art == null: on_spawned() - _art.signature_telegraph(lane_mask, projectile_kind, seconds) + _ensure() + if _terminal: return + if _art != null: + _art.signature_telegraph(lane_mask, projectile_kind, seconds) + return + _mode = SIGNATURE_MODES.get(projectile_kind, &"") + _wall_mask = lane_mask + _aim = Art.mask_center(lane_mask) + _cinematic_mode = &"" + _play_action(&"telegraph", seconds) -## Contract 2.4 optional hook (boss_attack_committed): only the aim follows. -func play_commit(lane_mask: int, _projectile_kind: StringName, seconds: float) -> void: - if _art != null: _art.signature_commit(lane_mask, seconds) +func play_commit(lane_mask: int, projectile_kind: StringName, seconds: float) -> void: + if _terminal: return + if _art != null: + _art.signature_commit(lane_mask, seconds) + elif _model != null: + _aim = Art.mask_center(lane_mask) + _wall_mask = lane_mask + if _state != &"telegraph": + play_telegraph(lane_mask, projectile_kind, seconds) + else: + _duration = _age + maxf(seconds, 0.001) func play_attack(projectile_kind: StringName) -> void: - if _art != null: _art.signature_attack(projectile_kind) + if _terminal: return + if _art != null: + _art.signature_attack(projectile_kind) + elif _model != null: + _mode = SIGNATURE_MODES.get(projectile_kind, &"") + _play_action(&"attack", 0.55 if _mode == &"quake" else 0.38) func play_hit(_tool_kind: StringName, _damage: int) -> void: - if _art != null: _art.flash() + if _terminal: return + if _art != null: + _art.flash() + elif _model != null: + _flash_remaining = 0.14 + _flash_strength = 1.0 + _play_action(&"hurt") func play_resist(_tool_kind: StringName) -> void: - if _art != null: _art.flash(true) + if _terminal: return + if _art != null: + _art.flash(true) + elif _model != null: + _flash_remaining = 0.12 + _flash_strength = 0.22 func play_defeat() -> void: - if _art != null: _art.shatter() + _ensure() + if _terminal: return + if _art != null: + _art.shatter() + else: + _capture_hitstop() + _play_action(&"defeat") + _terminal = true + _tag.hide() func play_retreat() -> void: - if _art != null: _art.react(&"retreat", 0.95) + if _terminal: return + if _art != null: + _art.react(&"retreat", 0.95) + elif _model != null: + # Finish the clip and dissolve before the backend's 1.0 s outro frees the visual. + _play_action(&"retreat", 0.95) + _terminal = true + if _tag != null: _tag.hide() + +func play_intro(seconds: float) -> void: + _ensure() + if _terminal: return + intro_calls += 1 + _clock = 0.0 + _intro_tag_visible = _tag.visible + _tag.hide() + if _art != null: + _art.react(&"intro", seconds) + _art.advance(0.0) + else: + _cinematic_mode = &"" + position.x = 0.0 + position.y = 0.0 + _play_action(&"intro", seconds) + _sample_pose() + +func play_cinematic(mode: StringName, seconds: float) -> void: + _ensure() + if mode in [&"intro_full", &"intro_short"]: + if _terminal: return + # The legacy intro signal and the new cinematic signal can arrive on the same tick. + if (_art != null and _art.state == &"intro") or (_model != null and _state == &"intro"): + _age = 0.0 + _duration = maxf(seconds, 0.001) + if _art != null: + _art.age = 0.0 + _art.duration = _duration + else: + play_intro(seconds) + elif mode in [&"defeat_full", &"defeat_short"]: + if _model != null: + if _hitstop_pose.is_empty(): _capture_hitstop() + _play_action(&"defeat", seconds) + else: + _art.shatter() + _art.duration = maxf(seconds, 0.001) + _terminal = true + _tag.hide() + else: + return + _cinematic_mode = mode + _duration = maxf(seconds, 0.001) + _flash_remaining = 0.0 + set_cinematic_progress(mode, 0.0) + +func set_cinematic_progress(mode: StringName, progress: float) -> void: + if mode != _cinematic_mode or mode.is_empty(): return + var u := clampf(progress, 0.0, 1.0) + _age = u * _duration + if _model != null: + _sample_pose() + elif _art != null: + _art.age = minf(u / float(INTRO_POSE_AT[mode]), 0.999999) * _duration if mode in INTRO_POSE_AT else u * _duration + _art.clock = _art.age + _art.advance(0.0) + _state = _art.state + if u >= 1.0 and mode in [&"intro_full", &"intro_short"]: + _cinematic_mode = &"" + _tag.visible = _intro_tag_visible + if _model != null: + _play_action(&"idle") + _sample_pose() + else: + _art.react(&"idle", 4.0) + _advance_fallback(0.0) + +func set_time_factor(factor: float) -> void: + _time_factor = maxf(factor, 0.0) + if _model != null: + _model.call("set_time_factor", _time_factor) + +func socket(socket_name: StringName) -> Node3D: + return _model.call("socket", socket_name) as Node3D if _model != null else null + +func _capture_hitstop() -> void: + _hitstop_pose.clear() + for index in _skeleton.get_bone_count(): + _hitstop_pose.append(_skeleton.get_bone_pose(index)) func _process(delta: float) -> void: - if _art == null: return - _clock += delta + if _art == null and _model == null: return + # Pause, skip and accelerated smokes all use the encounter's progress. + if not _cinematic_mode.is_empty(): return + var dt := maxf(delta, 0.0) * _time_factor + if dt <= 0.0: return + _clock += dt + if _art != null: + _advance_fallback(dt) + return + _age += dt + _flash_remaining = maxf(_flash_remaining - dt, 0.0) + if _age >= _duration and _state not in [&"idle", &"defeat", &"retreat"]: + if _state == &"intro": _tag.visible = _intro_tag_visible + _cinematic_mode = &"" + _play_action(&"idle") + _sample_pose() + if _state not in [&"intro", &"defeat", &"retreat"]: + var period := maxf(sway_period * (1 - 0.12 * (_phase - 1)), 0.1) + position.x = sin(_clock * TAU / period) * minf(sway_amplitude, 0.14) + position.y = 0.01 + sin(_clock * 2) * 0.01 + +func _sample_pose() -> void: + var progress := clampf(_age / _duration, 0, 1) + var hitstop := minf(0.12, _duration) + var clip := _player.get_animation(_state) + var sample := fmod(_age, clip.length) if _state == &"idle" else progress * clip.length + if _state == &"intro" and not _cinematic_mode.is_empty(): + # The title-card cue lands on this pose; keep it frozen until the cinematic ends. + sample = minf(progress / float(INTRO_POSE_AT[_cinematic_mode]), 1.0) * clip.length + if _state == &"defeat" and not _cinematic_mode.is_empty(): + sample = clampf((_age - hitstop) / maxf(_duration - hitstop, 0.001), 0.0, 1.0) * clip.length + _skeleton.reset_bone_poses() + _player.seek(sample, true) + _player.advance(0.0) + var frozen := _state == &"defeat" and not _cinematic_mode.is_empty() and _age < hitstop and not _hitstop_pose.is_empty() + if frozen: + for index in _hitstop_pose.size(): + _skeleton.set_bone_pose(index, _hitstop_pose[index]) + else: + _apply_phase_and_signature(progress) + var flash := _flash_strength * minf(_flash_remaining / 0.08, 1.0) + if _state == &"defeat": + if progress < 0.25: + flash = 1.0 if int(progress * 64.0) % 2 == 0 else 0.12 + _model.call("set_dissolve", clampf((progress - 0.55) / 0.45, 0, 1)) + elif _state == &"retreat": + _model.call("set_dissolve", smoothstep(0.72, 1.0, progress)) + _model.call("set_flash", flash) + var heat := minf(_age / 0.12, 1.0) if _state == &"telegraph" else (1.0 if _state == &"attack" else 0.0) + if _state == &"intro": + var intro_progress := minf(progress / float(INTRO_POSE_AT[_cinematic_mode]), 1.0) if not _cinematic_mode.is_empty() else progress + heat = smoothstep(0.64, 0.84, intro_progress) + # Phase tint carries escalation; extra emission washes the CRT orange into yellow. + var emission := 1.0 + heat * 0.7 + if _state == &"defeat": + # Power down the reactor as the head topples; the burn edge stays emissive. + emission *= 1.0 - smoothstep(0.45, 0.70, progress) + elif _state == &"retreat": + emission *= 1.0 - 0.7 * progress + var mats: Array = _model.get("materials") + for index in mats.size(): + mats[index].set_shader_parameter("emission_color", _emission_base[index] * Color.WHITE.lerp(Color(1.0, 0.20, 0.08), heat)) + mats[index].set_shader_parameter("emission_energy", emission) + +func _offset_bone(bone_name: String, shift: Vector3) -> void: + var index := _skeleton.find_bone(bone_name) + if index < 0: return + var parent := _skeleton.get_bone_parent(index) + var basis := _skeleton.get_bone_global_pose(parent).basis if parent >= 0 else Basis.IDENTITY + _skeleton.set_bone_pose_position(index, _skeleton.get_bone_pose_position(index) + basis.inverse() * shift) + +func _apply_phase_and_signature(progress: float) -> void: + var spread := (_phase - 1) * 0.11 + (1.0 - _health) * 0.035 + _offset_bone("shell_L", Vector3(-spread, 0, 0)) + _offset_bone("shell_R", Vector3(spread, 0, 0)) + var charge := progress if _state == &"telegraph" else (1.0 - progress if _state == &"attack" else 0.0) + _offset_bone("reactor", Vector3(_aim * 0.07 * charge, 0, 0)) + if _mode == &"wall": + for lane in 3: + if _wall_mask & (1 << lane): + _offset_bone("wall_%d" % lane, Vector3(0, 0, 0.42 * charge)) + elif _mode == &"debris" and _state == &"telegraph": + for i in 3: + _offset_bone("debris_%d" % i, Vector3(sin(_clock * 43.0 + i), cos(_clock * 37.0 + i), 0) * 0.035 * charge) + +func _advance_fallback(delta: float) -> void: var was_intro: bool = _art.state == &"intro" _art.advance(delta) + _state = _art.state + _age = _art.age + _duration = _art.duration if was_intro and _art.state != &"intro": _art.tag.visible = _intro_tag_visible if _art.state == &"intro": return var period := maxf(sway_period * (1 - 0.12 * (_art.phase - 1)), 0.1) @@ -74,12 +372,3 @@ func _process(delta: float) -> void: position.y = 0.04 + sin(_clock * 2) * 0.04 if _art.state == &"phase_change": position.y += 0.12 * sin(PI * minf(_art.age / 0.9, 1)) if _art.state == &"retreat": position.y += minf(_art.age, 0.95) * 0.12 - -func play_intro(seconds: float) -> void: - if _art == null: on_spawned() - intro_calls += 1 - _clock = 0.0 - _intro_tag_visible = _art.tag.visible - _art.tag.hide() - _art.react(&"intro", seconds) - _art.advance(0.0) diff --git a/hoppys-adventure-arcade/scenes/runner/boss/visuals/merge_conflict_hydra_visual.gd b/hoppys-adventure-arcade/scenes/runner/boss/visuals/merge_conflict_hydra_visual.gd index c7008527..18e25468 100644 --- a/hoppys-adventure-arcade/scenes/runner/boss/visuals/merge_conflict_hydra_visual.gd +++ b/hoppys-adventure-arcade/scenes/runner/boss/visuals/merge_conflict_hydra_visual.gd @@ -1,54 +1,189 @@ extends "res://scenes/runner/boss/boss_visual_base.gd" +## One visual clock drives clips, per-lane reactions, flash and cinematic dissolve. const Art = preload("res://scenes/runner/boss/boss_art.gd") +const BossModel = preload("res://scenes/runner/boss/hifi/boss_model.gd") +const MODEL_PATH := "res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb" +const ACCENT := Color("d46ad4") +const QUEUE := 6 +const HEAD_RECOIL := 0.5 +const PHASE_TWO_DROP := 0.30 +const PHASE_TWO_HEIGHT_SCALE := 0.95 +var model_path := MODEL_PATH var _art: Art +var _model: Node3D +var _skeleton: Skeleton3D +var _tag: Label3D +var _idle_player: AnimationPlayer +var _head_players: Array[AnimationPlayer] = [] var show_name_plate := true -var _clock := 0.0 var intro_calls := 0 var _intro_tag_visible := false -## Pending telegraphs (lane mask, visual clock at fire), preallocated. -const QUEUE := 6 +var _clock := 0.0 +var _phase := 1 +var _health := 1.0 +var _state: StringName = &"idle" +var _age := 0.0 +var _duration := 1.0 +var _time_factor := 1.0 +var _flash_age := 9.0 +var _flash_strength := 0.0 +var _cinematic_mode: StringName = &"" +var _cinematic_age := 0.0 +var _cinematic_seconds := 1.0 +var _terminal := false var _queue_mask := PackedInt32Array([0, 0, 0, 0, 0, 0]) var _queue_due := PackedFloat32Array([0, 0, 0, 0, 0, 0]) +var _head_recoil := PackedFloat32Array([9.0, 9.0, 9.0, 9.0]) +var _head_state: Array[StringName] = [&"", &"", &"", &""] +var _head_age := PackedFloat32Array([0, 0, 0, 0]) +var _head_duration := PackedFloat32Array([1, 1, 1, 1]) +var _last_head := 1 +var _sprout_age := 9.0 +var _fourth_bone := -1 +var _crown_target_x := 0.0 +var _defeat_pose: Array[Transform3D] = [] func _ready() -> void: process_mode = Node.PROCESS_MODE_PAUSABLE physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF -func on_spawned() -> void: - if _art == null: +func cinematic_focus() -> Vector3: + # Raise the faces above the title band while retaining the base rest-focus cache. + return super.cinematic_focus() - Vector3(0, 0.3, 0) + +func _ensure_model() -> void: + if _model != null or _art != null: return + _model = BossModel.load_model(model_path) + if _model == null: _art = Art.new() add_child(_art) _art.build(&"merge_conflict_hydra") - _art.reset() - _art.tag.text = (boss_name if not boss_name.is_empty() else String(kind).replace("_", " ")) - _art.tag.visible = show_name_plate + _tag = _art.tag + return + add_child(_model) + _skeleton = _model.model.find_children("*", "Skeleton3D", true, false)[0] as Skeleton3D + _fourth_bone = _skeleton.find_bone("branch_3") + var main: AnimationPlayer = _model.animation_player + main.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + main.playback_default_blend_time = 0.0 + # Copies are per-instance. Lane clips contain only that branch's tracks. + _idle_player = _clip_player(main, -2) + for i in 4: _head_players.append(_clip_player(main, i)) + for action in [&"telegraph", &"attack", &"hurt"]: + var clip := main.get_animation(action) + for track in range(clip.get_track_count() - 1, -1, -1): + if _branch_track(clip.track_get_path(track)) >= 0: clip.remove_track(track) + for material in _model.materials: material.set_shader_parameter(&"dissolve_color", ACCENT) + # Dev/gallery nameplate; the production BossHUD suppresses this label. + _tag = Art.label(self, "", Vector3(0, 3.15, 0), 0.005) + _tag.font_size = 40 + _tag.billboard = BaseMaterial3D.BILLBOARD_DISABLED + +func _branch_track(path: NodePath) -> int: + if path.get_subname_count() == 0: return -1 + var bone := String(path.get_subname(0)) + for i in 4: + for prefix in ["branch_", "neck_lo_", "neck_hi_", "head_", "jaw_"]: + if bone == prefix + str(i): return i + return -1 + +func _clip_player(source: AnimationPlayer, branch: int) -> AnimationPlayer: + var player := AnimationPlayer.new() + player.name = "HydraRest" if branch == -2 else "HydraHead%d" % branch + player.process_mode = Node.PROCESS_MODE_PAUSABLE + player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + player.playback_default_blend_time = 0.0 + source.get_parent().add_child(player) + player.root_node = source.root_node + var library := AnimationLibrary.new() + var actions: Array = [&"idle"] if branch == -2 else [&"telegraph", &"attack", &"hurt"] + for action in actions: + var clip := source.get_animation(action).duplicate(true) as Animation + if branch >= 0: + for track in range(clip.get_track_count() - 1, -1, -1): + if _branch_track(clip.track_get_path(track)) != branch: clip.remove_track(track) + library.add_animation(action, clip) + player.add_animation_library(&"", library) + return player + +func on_spawned() -> void: + _ensure_model() + if _art != null: _art.reset() + _phase = 1 + _health = 1.0 + _clock = 0.0 + _time_factor = 1.0 + _terminal = false + _cinematic_mode = &"" + _cinematic_age = 0.0 + _flash_age = 9.0 + _sprout_age = 9.0 + _queue_mask.fill(0) + _queue_due.fill(0) + _head_recoil.fill(9.0) + _head_state.fill(&"") + _crown_target_x = 0.0 + _tag.text = boss_name if not boss_name.is_empty() else "Merge Conflict Hydra" + _tag.visible = show_name_plate var ancestor := get_parent() while ancestor != null: if ancestor.get_node_or_null("HUD/BossHud") != null or ancestor.get_node_or_null("HUD/BossHUD") != null: - _art.tag.hide() + _tag.hide() break ancestor = ancestor.get_parent() - set_health(max_hp, max_hp) - _clock = 0.0 - _queue_mask.fill(0) - # Backend parents already supply hover_z; standalone mounts use the same depth. position = Vector3(0, 0, hover_z - get_parent_node_3d().position.z) if get_parent_node_3d() != null else Vector3(0, 0, hover_z) + scale = Vector3.ONE show() + if _model != null: + _model.set_dissolve(0.0) + _model.set_flash(0.0) + _model.set_phase_tint(Color.WHITE) + _model.set_time_factor(1.0) + _action(&"idle") + _process(0.0) + +func _action(action: StringName, seconds: float = -1.0) -> void: + _state = action + _age = 0.0 + _duration = maxf(seconds, 0.001) if seconds >= 0.0 else (float(_model.animation_player.get_animation(action).length) if _model != null else 1.0) + if _model != null: + _model.play(action) func set_phase(value: int) -> void: - if _art == null: on_spawned() - _art.phase = clampi(value, 1, phase_count) - _art.react(&"phase_change", 0.9) + _ensure_model() + var next := clampi(value, 1, phase_count) + if _terminal: return + if _phase < 2 and next >= 2: _sprout_age = 0.0 + _phase = next + if _art != null: + _art.phase = _phase + _art.react(&"phase_change", 0.9) + else: + _model.set_phase_tint(Color.WHITE.lerp(Color("e5bcff"), .12 * (_phase - 1))) + _action(&"phase_change", 1.2) func set_health(hp: int, max_health: int) -> void: - if _art == null: on_spawned() - _art.health = clampf(float(hp) / maxi(max_health, 1), 0, 1) + _ensure_model() + _health = clampf(float(hp) / maxi(max_health, 1), 0, 1) + if _art != null: _art.health = _health + +func _head_action(head: int, action: StringName, seconds: float, delay: float = 0.0) -> void: + _head_state[head] = action + _head_age[head] = -delay + _head_duration[head] = maxf(seconds, .001) + if action == &"attack": _head_recoil[head] = -delay + _last_head = head func play_telegraph(lane_mask: int, projectile_kind: StringName, seconds: float) -> void: - if _art == null: on_spawned() - _art.signature_telegraph(lane_mask, projectile_kind, seconds) - # Remember which lanes fire when, so each head recoils as its own shot leaves (a branching - # volley fires its heads one after another). + _ensure_model() + if _terminal: return + if _art != null: _art.signature_telegraph(lane_mask, projectile_kind, seconds) + _action(&"telegraph", seconds) + for lane in 3: + if lane_mask & (1 << lane): _head_action(lane, &"telegraph", seconds) + if projectile_kind == &"force_push" and _phase >= 2: + _crown_target_x = _lane_midpoint(lane_mask) + _head_action(3, &"telegraph", seconds) var slot := 0 for i in QUEUE: if _queue_due[i] < _queue_due[slot]: slot = i @@ -57,13 +192,17 @@ func play_telegraph(lane_mask: int, projectile_kind: StringName, seconds: float) _queue_mask[slot] = lane_mask _queue_due[slot] = _clock + seconds -## Contract 2.4 optional hook; the Hydra has no flaky beat, so only the aim follows. func play_commit(lane_mask: int, _projectile_kind: StringName, seconds: float) -> void: + if _terminal: return if _art != null: _art.signature_commit(lane_mask, seconds) + for lane in 3: + if lane_mask & (1 << lane) and _head_state[lane] == &"telegraph": + _head_duration[lane] = _head_age[lane] + maxf(seconds, .001) func play_attack(projectile_kind: StringName) -> void: - if _art == null: return - _art.signature_attack(projectile_kind) + if _terminal or (_model == null and _art == null): return + if _art != null: _art.signature_attack(projectile_kind) + _action(&"attack") var slot := -1 for i in QUEUE: if _queue_mask[i] != 0 and (slot < 0 or _queue_due[i] < _queue_due[slot]): slot = i @@ -72,40 +211,212 @@ func play_attack(projectile_kind: StringName) -> void: var order := 0 for lane in 3: if mask & (1 << lane): - _art.recoil_head(_art.head_for_x((lane - 1) * 2.0), order * 0.07) + if _art != null: _art.recoil_head(_art.head_for_x((lane - 1) * 2.0), order * .07) + _head_action(lane, &"attack", HEAD_RECOIL, order * .07) order += 1 + if projectile_kind == &"force_push" and _phase >= 2: + _crown_target_x = _lane_midpoint(mask) + _head_action(3, &"attack", HEAD_RECOIL) + +func _lane_midpoint(mask: int) -> float: + var sum := 0.0 + var count := 0 + for lane in 3: + if mask & (1 << lane): + sum += (lane - 1) * 2.0 + count += 1 + return sum / maxi(count, 1) + +func _steer_crown() -> void: + if _phase < 2 or _head_state[3].is_empty() or _head_age[3] < 0.0: return + var p := clampf(_head_age[3] / _head_duration[3], 0.0, 1.0) + var weight := smoothstep(0.0, .3, p) if _head_state[3] == &"telegraph" else 1.0 - smoothstep(.45, 1.0, p) + for entry in [["neck_lo_3", Vector3.BACK, -.20], ["head_3", Vector3.UP, .24]]: + var bone := _skeleton.find_bone(entry[0]) + var rest := _skeleton.get_bone_global_rest(bone).basis.get_rotation_quaternion() + var turn := Quaternion(entry[1], _crown_target_x * weight * float(entry[2])) + _skeleton.set_bone_pose_rotation(bone, rest.inverse() * turn * rest * _skeleton.get_bone_pose_rotation(bone)) func play_hit(_tool_kind: StringName, _damage: int) -> void: + if _terminal: return if _art != null: _art.flash() + if _model != null: + _action(&"hurt") + _head_action(_last_head, &"hurt", .35) + _flash_age = 0.0 + _flash_strength = 1.0 func play_resist(_tool_kind: StringName) -> void: + if _terminal: return if _art != null: _art.flash(true) + _flash_age = 0.0 + _flash_strength = .22 func play_defeat() -> void: + _ensure_model() + if not _terminal and _skeleton != null: + _defeat_pose.clear() + for i in _skeleton.get_bone_count(): _defeat_pose.append(_skeleton.get_bone_pose(i)) + _terminal = true + _queue_mask.fill(0) + _head_state.fill(&"") + _tag.hide() if _art != null: _art.shatter() + _action(&"defeat", .95) func play_retreat() -> void: - if _art != null: _art.react(&"retreat", 0.95) - -func _process(delta: float) -> void: - if _art == null: return - _clock += delta - var was_intro: bool = _art.state == &"intro" - _art.advance(delta) - if was_intro and _art.state != &"intro": _art.tag.visible = _intro_tag_visible - if _art.state == &"intro": return - var period := maxf(sway_period * (1 - 0.12 * (_art.phase - 1)), 0.1) - # 0.12 m sway cap (was 0.2): the review-r1 wider heads keep the 7 m envelope while swaying. - position.x = sin(_clock * TAU / period) * minf(sway_amplitude, 0.12) - position.y = 0.04 + sin(_clock * 2) * 0.04 - if _art.state == &"phase_change": position.y += 0.12 * sin(PI * minf(_art.age / 0.9, 1)) - if _art.state == &"retreat": position.y += minf(_art.age, 0.95) * 0.12 + _ensure_model() + _terminal = true + _cinematic_mode = &"" + _queue_mask.fill(0) + _head_state.fill(&"") + _tag.hide() + if _art != null: _art.react(&"retreat", .95) + _action(&"retreat", .95) func play_intro(seconds: float) -> void: - if _art == null: on_spawned() + _ensure_model() intro_calls += 1 _clock = 0.0 - _intro_tag_visible = _art.tag.visible - _art.tag.hide() - _art.react(&"intro", seconds) - _art.advance(0.0) + _terminal = false + _cinematic_mode = &"" + if _state != &"intro": _intro_tag_visible = _tag.visible + _tag.hide() + _head_state.fill(&"") + _action(&"intro", seconds) + if _art != null: + _art.react(&"intro", seconds) + _art.advance(0.0) + else: + _sample(_model.animation_player, &"intro", 0.0) + _apply_fourth() + +func play_cinematic(mode: StringName, seconds: float) -> void: + if mode not in [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"]: return + _ensure_model() + if String(mode).begins_with("intro"): + play_intro(seconds) + else: + play_defeat() + if _model != null: _model.set_dissolve(0.0) + _cinematic_mode = mode + _cinematic_seconds = maxf(seconds, .001) + _cinematic_age = 0.0 + _process(0.0) + +## Backend progress owns cinematic time, including skip jumps and slow-motion tails. +func set_cinematic_progress(mode: StringName, progress: float) -> void: + if mode != _cinematic_mode or _cinematic_mode.is_empty(): return + _cinematic_age = clampf(progress, 0.0, 1.0) * _cinematic_seconds + _tick_cinematic() + if _model != null: _apply_fourth() + +func set_time_factor(factor: float) -> void: + _time_factor = maxf(factor, 0.0) + if _model != null: _model.set_time_factor(_time_factor) + +func socket(key: StringName) -> Node3D: + return _model.socket(key) if _model != null else null + +func _sample(player: AnimationPlayer, action: StringName, seconds: float) -> void: + if player.current_animation != action: player.play(action, 0.0) + player.seek(seconds, true) + +func _apply_fourth() -> void: + if _fourth_bone < 0: return + if String(_cinematic_mode).begins_with("defeat") and _cinematic_age < minf(.12, _cinematic_seconds * .08): return + var growth := smoothstep(.24, .94, _sprout_age) if _phase >= 2 else 0.0 + # Keep the crown/centre crest spacing while fitting the entire phase-two rig + # inside the contract's world y [1.6, 6.1], including hurt and force-push poses. + _model.position.y = -PHASE_TWO_DROP * growth + _model.scale.y = lerpf(1.0, PHASE_TWO_HEIGHT_SCALE, growth) + var amount := lerpf(.001, 1.0, growth) + _skeleton.set_bone_pose_scale(_fourth_bone, Vector3.ONE * amount) + +func _process(delta: float) -> void: + if (_model == null and _art == null) or (is_inside_tree() and get_tree().paused): return + var dt := maxf(delta, 0.0) * _time_factor + _clock += dt + _age += dt + _flash_age += dt + _sprout_age += dt + if _art != null and _cinematic_mode.is_empty(): + var was_intro := _art.state == &"intro" + _art.advance(dt) + _state = &"defeat" if _art.state == &"defeated" else _art.state + if was_intro and _art.state != &"intro": _tag.visible = _intro_tag_visible + if _model != null: + _model.set_flash(_flash_strength * (1.0 - smoothstep(0.0, .18, _flash_age))) + if not _cinematic_mode.is_empty(): + _tick_cinematic() + elif _model != null: + _tick_action(dt) + if _model != null: _apply_fourth() + if not _terminal and _state != &"intro": + var period := maxf(sway_period * (1.0 - .12 * (_phase - 1)), .1) + position.x = sin(_clock * TAU / period) * minf(sway_amplitude, .12) + position.y = .04 + sin(_clock * 2) * .04 + +func _tick_action(dt: float) -> void: + var main: AnimationPlayer = _model.animation_player + if _state not in [&"idle", &"defeat", &"retreat"] and _age >= _duration: + if _state == &"intro": _tag.visible = _intro_tag_visible + _action(&"idle") + var clip: Animation = main.get_animation(_state) + var t := fmod(_clock, clip.length) if _state == &"idle" else clampf(_age / _duration, 0.0, 1.0) * clip.length + # Reset all branch channels before sparse head overlays, preventing stale jaw poses. + _sample(_idle_player, &"idle", fmod(_clock, _idle_player.get_animation(&"idle").length)) + _sample(main, _state, t) + for i in 4: + _head_age[i] += dt + if _head_recoil[i] < HEAD_RECOIL: _head_recoil[i] += dt + if not _terminal and _state != &"intro" and _state != &"phase_change": + for i in 4: + if _head_state[i].is_empty() or _head_age[i] < 0.0: continue + if _head_age[i] >= _head_duration[i]: + _head_state[i] = &"" + continue + var head_clip := _head_players[i].get_animation(_head_state[i]) + _sample(_head_players[i], _head_state[i], _head_age[i] / _head_duration[i] * head_clip.length) + _steer_crown() + if _state == &"defeat": _model.set_dissolve(clampf((_age / _duration - .55) / .45, 0.0, 1.0)) + if _state == &"retreat" and _age >= _duration: hide() + +func _tick_cinematic() -> void: + var p := clampf(_cinematic_age / _cinematic_seconds, 0.0, 1.0) + var intro := String(_cinematic_mode).begins_with("intro") + var pose_progress := minf(p / float(INTRO_POSE_AT[_cinematic_mode]), 1.0) if intro else p + if _art != null: + # Reuse the original geometry and reactions when the GLB is unavailable. + _art.state = &"intro" if intro else &"defeated" + _art.duration = 1.0 + _art.age = minf(pose_progress, .9999) if intro else p + # The procedural head and neck sway also freezes when the title pose lands. + _art.clock = minf(p, float(INTRO_POSE_AT[_cinematic_mode])) * _cinematic_seconds if intro else _cinematic_age + _art.advance(0.0) + if intro: + if _model != null: + # Land with the title slam, holding through INTRO_HOLD_UNTIL and camera return. + var clip: Animation = _model.animation_player.get_animation(&"intro") + _sample(_model.animation_player, &"intro", clip.length * pose_progress) + if p >= 1.0: + _cinematic_mode = &"" + _tag.visible = _intro_tag_visible + _action(&"idle") + if _art != null: + _art.react(&"idle", 1.0) + _art.advance(0.0) + else: + if _model != null: + var stop := minf(.12 / _cinematic_seconds, .08) + var clip: Animation = _model.animation_player.get_animation(&"defeat") + if p < stop and _defeat_pose.size() == _skeleton.get_bone_count(): + for i in _defeat_pose.size(): + var held := _defeat_pose[i] + _skeleton.set_bone_pose_position(i, held.origin) + _skeleton.set_bone_pose_rotation(i, held.basis.get_rotation_quaternion()) + _skeleton.set_bone_pose_scale(i, held.basis.get_scale()) + else: + _sample(_model.animation_player, &"defeat", clip.length * smoothstep(stop, .88, p)) + _model.set_flash((1.0 if int(_cinematic_age * 24.0) % 2 == 0 else .08) if p < .25 else 0.0) + _model.set_dissolve(clampf((p - .55) / .45, 0.0, 1.0)) diff --git a/hoppys-adventure-arcade/scenes/runner/data/run_config.gd b/hoppys-adventure-arcade/scenes/runner/data/run_config.gd index df04a2c0..036734df 100644 --- a/hoppys-adventure-arcade/scenes/runner/data/run_config.gd +++ b/hoppys-adventure-arcade/scenes/runner/data/run_config.gd @@ -134,7 +134,27 @@ extends Resource @export var boss_z := -14.0 @export var boss_sway_amplitude := 2.4 @export var boss_sway_period := 3.2 -@export var boss_intro_seconds := 3.0 +## The full boss intro cinematic, played the first time a run meets each boss (contract 2.6 §11.14). +@export var boss_intro_seconds := 5.0 +## The intro on every later meeting in the run, a checkpoint retry included; played as +## min(boss_intro_short_seconds, boss_intro_seconds). +@export var boss_intro_short_seconds := 2.5 +## The defeat cinematic the first time a run beats each boss, and on every later defeat (played as +## min(short, full)). It holds the boss phase open, so stage_clear waits it out. +@export var boss_defeat_seconds := 3.6 +@export var boss_defeat_short_seconds := 2.0 +## An intro can be skipped once it has played this long (a defeat never can); a skip jumps to the +## last boss_cinematic_skip_tail seconds, so the camera return still plays. +@export var boss_cinematic_skip_after := 0.5 +@export var boss_cinematic_skip_tail := 0.3 +## The corridor eases to this fraction of its speed while a cinematic holds the player. +@export var boss_cinematic_speed_scale := 0.25 +## The defeat's time factor (boss_time_factor, never Engine.time_scale): a 0.05 hit-stop for +## boss_hitstop_seconds, then slow-mo from boss_slowmo_factor eased back to 1 over boss_slowmo_seconds. +@export var boss_hitstop_seconds := 0.12 +@export var boss_slowmo_seconds := 0.8 +@export var boss_slowmo_factor := 0.4 +## The retreat outro only; a defeat holds for its cinematic instead (contract 2.6 §11.14). @export var boss_outro_seconds := 1.0 @export var boss_timeout_seconds := 55.0 ## HP fractions that open phases 1, 2 and 3 (contract §11.10). diff --git a/hoppys-adventure-arcade/scenes/runner/player/hifi/hoppy_surface.gdshaderinc b/hoppys-adventure-arcade/scenes/runner/player/hifi/hoppy_surface.gdshaderinc index 0bfc7b30..e8897624 100644 --- a/hoppys-adventure-arcade/scenes/runner/player/hifi/hoppy_surface.gdshaderinc +++ b/hoppys-adventure-arcade/scenes/runner/player/hifi/hoppy_surface.gdshaderinc @@ -22,6 +22,7 @@ uniform float light_gain : hint_range(0.0, 1.0) = 0.5; uniform float front_fill : hint_range(0.0, 1.0) = 0.0; // Cape LED strip: the accessory GLB surface carries COLOR_0 (R = LED, G = segment 0..1). // Only surfaces the visual flags with has_led_mask read it; missing COLOR defaults to white. +#ifndef BOSS_SURFACE uniform bool has_led_mask = false; uniform vec4 led_color : source_color = vec4(0.0, 0.0, 0.0, 1.0); uniform float led_level = 0.0; @@ -66,6 +67,7 @@ void fragment() { float front = clamp(dot(NORMAL, facing), 0.0, 1.0); EMISSION = base * (ambient_fill.rgb * ambient_energy + vec3(front_fill * front)) * orm.r + baked + vec3(flash * 0.35); } +#endif void light() { float luma = dot(LIGHT_COLOR, vec3(0.2126, 0.7152, 0.0722)); vec3 lc = mix(LIGHT_COLOR, vec3(luma), chroma_lock) * light_gain; diff --git a/hoppys-adventure-arcade/scenes/runner/runner_game.gd b/hoppys-adventure-arcade/scenes/runner/runner_game.gd index a702c182..2c2df9c9 100644 --- a/hoppys-adventure-arcade/scenes/runner/runner_game.gd +++ b/hoppys-adventure-arcade/scenes/runner/runner_game.gd @@ -36,6 +36,9 @@ const FRONTEND_MOUNTS := { "res://scenes/runner/ui/boss_hud.tscn", "res://scenes/runner/ui/stage_banner.tscn", "res://scenes/runner/ui/stage_clear.tscn", + ## Contract 2.6 §11.14: the boss cinematic title card / chyron / skip prompt, driven only off + ## boss_cinematic_progress and boss_time_factor. + "res://scenes/runner/ui/boss_cinematic.tscn", ## Full-screen post-processing pass (colour grade, grain, vignette, letterbox, speed ## streaks) on its own layer below the panels; optional like the rest (contract §11.8). "res://scenes/runner/ui/post_fx.tscn" @@ -101,6 +104,15 @@ signal boss_attack_planned(attack: Dictionary) signal boss_attack_committed(lane_mask: int, kind: StringName, seconds: float) signal boss_defeated(kind: StringName, bonus: int) signal boss_retreated(kind: StringName, bonus: int) +## Contract 2.6 §11.14: a boss cinematic of `seconds` opens. `mode` is intro_full / intro_short +## (right after boss_intro, before boss_spawned) or defeat_full / defeat_short (right after +## boss_defeated, on the same tick). +signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) +## The player skipped an intro (a defeat is never skippable): it jumps to its last +## boss_cinematic_skip_tail seconds. +signal boss_cinematic_skipped(mode: StringName) +## It is over (played out, skipped tail done, or aborted by a catch / a new run). +signal boss_cinematic_ended(mode: StringName) signal tool_collected(kind: StringName, damage: int) signal pursuit_alert(kind: StringName, seconds_to_catch: float) signal pursuer_distance_changed(normalized: float) @@ -158,6 +170,12 @@ var boss_hp := 0 var boss_max_hp := 0 ## 1-based; 0 outside a fight. var boss_phase := 0 +## Contract 2.6 §11.14, mirrored by the StageManager off the encounter's clock: the cinematic +## playing (&"" when none), 0..1 through it, and the factor visuals, effects and HUD multiply into +## their own delta (1 outside a defeat; the engine time scale is never touched). +var boss_cinematic_mode: StringName = &"" +var boss_cinematic_progress := 0.0 +var boss_time_factor := 1.0 ## Boss checkpoint (contract §11.13). The StageManager snapshots the run here on every boss entry ## and empties it when the stage clears, so it is non-empty only while a fight is live, or after a @@ -525,13 +543,51 @@ func on_tool_hit() -> void: if camera_rig != null and is_instance_valid(camera_rig) and camera_rig.has_method("shake"): camera_rig.shake(SHAKE_ON_TOOL_HIT) +## Contract 2.6 §11.14: skip the boss intro playing now. It only takes once the intro has run +## boss_cinematic_skip_after seconds, while running and unpaused, and never during a defeat; returns +## true when it did. Jump and ui_accept reach it through _unhandled_input; the autopilot never calls +## it. +func skip_boss_cinematic() -> bool: + if not _can_skip_boss_cinematic(): + return false + return bool(boss_encounter.skip_cinematic()) + +## Contract 2.6 §11.14: true while skip_boss_cinematic() would take — running, unpaused, and an intro +## past boss_cinematic_skip_after but not yet inside its tail. Read-only, for a skip prompt. +func can_skip_boss_cinematic() -> bool: + return _can_skip_boss_cinematic() + +func _can_skip_boss_cinematic() -> bool: + if state != &"running" or paused: + return false + if boss_encounter == null or not is_instance_valid(boss_encounter): + return false + if not boss_encounter.has_method("can_skip_cinematic") or not boss_encounter.has_method("skip_cinematic"): + return false + return bool(boss_encounter.can_skip_cinematic()) + ## Tests and the F7 debug key drive the bot through here. The bot owns the player while it is on, -## so manual input is disabled for as long as it runs and comes back when it is switched off. +## so manual input is disabled for as long as it runs and comes back when it is switched off — +## unless a boss cinematic is holding the player (contract §11.14), which hands it back itself. func set_autopilot(enabled: bool) -> void: if autopilot != null and autopilot.has_method("set_enabled"): autopilot.set_enabled(enabled) - if player != null and player.has_method("set_controls_enabled"): - player.set_controls_enabled(not enabled and state == &"running") + _apply_player_controls(not enabled) + +## Manual controls are live while the run is, the bot is off and no boss cinematic holds the player +## (contract 2.6 §11.14). The StageManager calls this whenever a cinematic takes or gives back +## control, so the one rule decides every hand-back. +func refresh_player_controls() -> void: + var bot_on := autopilot != null and is_instance_valid(autopilot) and &"enabled" in autopilot \ + and bool(autopilot.get(&"enabled")) + _apply_player_controls(not bot_on) + +func _apply_player_controls(manual: bool) -> void: + if player == null or not is_instance_valid(player) or not player.has_method("set_controls_enabled"): + return + var held := stage_manager != null and is_instance_valid(stage_manager) \ + and &"cinematic_hold" in stage_manager and bool(stage_manager.get(&"cinematic_hold")) + player.set_controls_enabled(manual and state == &"running" and not held) # --- run loop --- @@ -680,6 +736,9 @@ func _clear_boss_state() -> void: boss_hp = 0 boss_max_hp = 0 boss_phase = 0 + boss_cinematic_mode = &"" + boss_cinematic_progress = 0.0 + boss_time_factor = 1.0 ## The encounter may expose the boss as a property or as a child named Boss; either is accepted and ## a missing one just leaves `boss` null. @@ -852,6 +911,13 @@ func _instantiate_frontend(path: String) -> Node: return instance func _unhandled_input(event: InputEvent) -> void: + # Contract 2.6 §11.14: jump or confirm skips a boss intro once it may be skipped. The press is + # consumed here, and the controls the skip hands back re-sync held input, so it never also lands + # as a jump. + if (event.is_action_pressed("jump") or event.is_action_pressed("ui_accept")) and _can_skip_boss_cinematic(): + skip_boss_cinematic() + get_viewport().set_input_as_handled() + return # The backend owns `pause`; it toggles only while running, so Space on a focused Game Over # button can never jump and Esc never double-fires with the overlays. if not event.is_action_pressed("pause") or state != &"running": diff --git a/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd b/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd index 0d6cdeb4..8ac5de86 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd @@ -15,6 +15,69 @@ const DEFAULT_TURN_CAMERA_YAW := 55.0 const DEFAULT_TURN_CAMERA_BANK := 12.0 const DEFAULT_TURN_SWING_IN_FRACTION := 0.3 const DEFAULT_TURN_SWING_OUT_FRACTION := 0.3 +## Contract 2.6 §11.14 boss shots, one per cinematic mode. The lens is placed off the Boss mount: +## `distance` metres back toward the player, `height` metres off the mount (below it is a low angle +## looking up), `orbit` degrees of yaw around it. It aims at the visual's cinematic focus (§11.5, +## the centre of the upper third of its rest bounds unless the visual says otherwise) plus +## `look_bias` metres. The default focus sits about 2.4 to 3.1 m above the mount on the three +## shipped bosses, so the biases land the hold near +1.6 m and the defeat close-up near +1.9 m, and +## a taller boss is aimed higher. Keys are [p, distance, height, orbit, fov, look_bias], smoothstepped +## between. The blend ramps in over [0, cut] and back out over [ret, 1]; `ret` is where a skip lands at +## the default lengths (the last boss_cinematic_skip_tail 0.3 s), so a skip never pops the camera. +## The intro push lands on BossVisualBase.INTRO_POSE_AT and the hold ends on INTRO_HOLD_UNTIL. +const BOSS_SHOTS := { + &"intro_full": { + "cut": 0.03, + "ret": 0.94, + "keys": [ + # Cut in low and wide, push up at the boss (FOV 70 -> 52) to land with the pose and the + # title card at 0.40, hold under the card until 0.66, then widen behind Hoppy for the hint + # row. The hold sits far enough back that the Hydra's crests clear the top letterbox. + [0.0, 9.0, -1.5, 0.0, 70.0, -1.9], + [0.4, 6.5, -1.0, 0.0, 52.0, -1.1], + [0.66, 6.3, -0.9, 0.0, 52.0, -1.1], + [0.84, 16.0, 1.0, 0.0, 62.0, -2.4], + [1.0, 16.0, 1.0, 0.0, 62.0, -2.4] + ] + }, + &"intro_short": { + "cut": 0.05, + "ret": 0.88, + "keys": [ + # The same push and hold, compressed: the push lands at 0.28 and the hold runs to the + # return at 0.88. + [0.0, 8.0, -1.3, 0.0, 64.0, -1.9], + [0.28, 6.7, -0.9, 0.0, 54.0, -1.1], + [0.88, 7.4, -0.5, 0.0, 56.0, -1.2], + [1.0, 7.4, -0.5, 0.0, 56.0, -1.2] + ] + }, + &"defeat_full": { + "cut": 0.04, + "ret": 0.917, + "keys": [ + # Punch in through the hit-stop, orbit the close-up -12 -> +12 deg, then pull back wide + # over Hoppy's shoulder for the triumphant beat. The close-up stands far enough back, and + # aims high enough, that the Monolith's eyes and brow stay clear of the top letterbox. + [0.0, 7.0, -0.4, -12.0, 56.0, -1.35], + [0.06, 5.6, -0.3, -12.0, 50.0, -0.8], + [0.5, 5.4, -0.2, 12.0, 50.0, -0.8], + [0.8, 19.0, 2.4, 6.0, 58.0, -2.1], + [1.0, 19.0, 2.4, 6.0, 58.0, -2.1] + ] + }, + &"defeat_short": { + "cut": 0.06, + "ret": 0.85, + "keys": [ + [0.0, 7.0, -0.4, -12.0, 56.0, -1.35], + [0.08, 5.8, -0.3, -12.0, 52.0, -0.8], + [0.45, 5.6, -0.2, 12.0, 52.0, -0.8], + [0.75, 18.0, 2.2, 6.0, 58.0, -2.1], + [1.0, 18.0, 2.2, 6.0, 58.0, -2.1] + ] + } +} @onready var camera: Camera3D = $Camera @@ -39,6 +102,13 @@ var rng := RandomNumberGenerator.new() var turn_progress := 0.0 ## -1 left, +1 right, 0 outside a turn. var turn_direction := 0 +## 0..1 through the boss cinematic and its mode, &"" outside one (contract 2.6 §11.14). +var boss_progress := 0.0 +var boss_mode: StringName = &"" +## The Boss mount, in this rig's space, that the lens is placed off. +var boss_target := Vector3.ZERO +## The visual's cinematic focus, in this rig's space, that the lens aims at (contract §11.5). +var boss_focus := Vector3.ZERO ## Where the rig wants the camera before shake, refreshed in _physics_process. var _rest_position := Vector3(0, 4, 8) @@ -67,6 +137,10 @@ func reset() -> void: fov_punch_decay = 0.0 turn_progress = 0.0 turn_direction = 0 + boss_progress = 0.0 + boss_mode = &"" + boss_target = Vector3.ZERO + boss_focus = Vector3.ZERO _rest_position = base_position if camera != null: camera.position = base_position @@ -137,6 +211,66 @@ func turn_swing_out_fraction() -> float: return DEFAULT_TURN_SWING_OUT_FRACTION return float(config.turn_swing_out_fraction) +## The boss cinematic shot (contract 2.6 §11.14). The StageManager calls this every physics tick of +## the `boss` phase, the way it drives set_turn(), and with (0, &"") once the cinematic is over. +## `boss_position` is the Boss mount's global position and `focus_position` the global point the +## shot aims at (the mount plus the visual's cinematic focus, §11.5); without one the shot aims at +## the mount itself. boss_shot() blends to 0 at p = 0 and p = 1, so the camera hands back to the +## follow exactly where it left it. +func set_boss_cinematic(progress: float, mode: StringName, boss_position: Vector3, + focus_position: Variant = null) -> void: + boss_progress = clampf(progress, 0.0, 1.0) + boss_mode = mode + boss_target = to_local(boss_position) if is_inside_tree() else boss_position + var focus: Vector3 = focus_position if focus_position is Vector3 else boss_position + boss_focus = to_local(focus) if is_inside_tree() else focus + +## Pure shot curve for `mode` at progress `p`: {blend, distance, height, orbit, fov, look_bias} (see +## BOSS_SHOTS). Like turn_swing_profile, `blend` is exactly 0 at p = 0 and p = 1 — the endpoints are +## answered before the ramps — and an unknown or empty mode never blends at all. +static func boss_shot(p: float, mode: StringName) -> Dictionary: + var progress := clampf(p, 0.0, 1.0) + var table: Dictionary = BOSS_SHOTS.get(mode, {}) + var keys: Array = table.get("keys", []) + var shot := {"blend": 0.0, "distance": 0.0, "height": 0.0, "orbit": 0.0, "fov": 0.0, "look_bias": 0.0} + if keys.is_empty(): + return shot + var pose: Array = _shot_pose(keys, progress) + shot["distance"] = float(pose[1]) + shot["height"] = float(pose[2]) + shot["orbit"] = float(pose[3]) + shot["fov"] = float(pose[4]) + shot["look_bias"] = float(pose[5]) + if progress <= 0.0 or progress >= 1.0: + return shot + var cut := clampf(float(table.get("cut", 0.0)), 0.0, 1.0) + var ret := clampf(float(table.get("ret", 1.0)), cut, 1.0) + var blend := 1.0 + if cut > 0.0 and progress < cut: + blend = smoothstep(0.0, 1.0, progress / cut) + elif ret < 1.0 and progress > ret: + blend = smoothstep(0.0, 1.0, (1.0 - progress) / (1.0 - ret)) + shot["blend"] = blend + return shot + +## The key pose at `progress`, smoothstepped between the two keys around it. +static func _shot_pose(keys: Array, progress: float) -> Array: + var first: Array = keys[0] + if progress <= float(first[0]): + return first + for index in range(1, keys.size()): + var to: Array = keys[index] + if progress > float(to[0]): + continue + var from: Array = keys[index - 1] + var span := float(to[0]) - float(from[0]) + var t := smoothstep(0.0, 1.0, (progress - float(from[0])) / span) if span > 0.0 else 1.0 + var pose := [progress] + for column in range(1, from.size()): + pose.append(lerpf(float(from[column]), float(to[column]), t)) + return pose + return keys[keys.size() - 1] + func shake(strength: float, duration := 0.35) -> void: if duration <= 0.0 or strength <= 0.0: return @@ -172,12 +306,17 @@ func _physics_process(delta: float) -> void: offset = Vector3(rng.randf_range(-amount, amount), rng.randf_range(-amount, amount), 0.0) camera.position = _rest_position + offset _apply_turn_swing() + var shot := boss_shot(boss_progress, boss_mode) + var blend := float(shot["blend"]) + _apply_boss_shot(shot, blend, offset) if fov_punch > 0.0: fov_punch = maxf(fov_punch - fov_punch_decay * delta, 0.0) var speed_boost := 0.0 if game != null: speed_boost = speed_fov_boost * clampf(float(game.normalized_speed), 0.0, 1.2) - camera.fov = base_fov + fov_punch + speed_boost + var fov := base_fov + fov_punch + speed_boost + # The punch rides the shot too, so a defeat explosion still kicks the close-up. + camera.fov = lerpf(fov, float(shot["fov"]) + fov_punch, blend) if blend > 0.0 else fov ## Swing the camera around the player and bank it, on top of the follow and shake result. Both ## angles ride turn_swing_profile(progress), so the bend starts and ends at the resting pose exactly @@ -194,3 +333,21 @@ func _apply_turn_swing() -> void: pivot = game.player.position camera.position = pivot + (camera.position - pivot).rotated(Vector3.UP, yaw) camera.rotation = _base_rotation + Vector3(0.0, yaw, bank) + +## Blend the follow pose toward the boss shot (contract 2.6 §11.14): the lens sits off the Boss +## mount where the shot says and aims at the visual's focus plus the shot's look_bias, the shake +## offset rides along, and position and rotation both mix by `blend`, which is 0 outside a cinematic +## and at both of its ends. +func _apply_boss_shot(shot: Dictionary, blend: float, offset: Vector3) -> void: + if blend <= 0.0: + return + var orbit := deg_to_rad(float(shot["orbit"])) + var lens := boss_target + Vector3(0.0, float(shot["height"]), float(shot["distance"])).rotated(Vector3.UP, orbit) + var aim := boss_focus + Vector3(0.0, float(shot["look_bias"]), 0.0) + var follow_rotation := camera.quaternion + camera.position = camera.position.lerp(lens + offset, blend) + var look := aim - lens + if look.is_zero_approx(): + return + var shot_rotation := Basis.looking_at(look, Vector3.UP).get_rotation_quaternion() + camera.quaternion = follow_rotation.slerp(shot_rotation, blend) diff --git a/hoppys-adventure-arcade/scenes/runner/systems/speed_controller.gd b/hoppys-adventure-arcade/scenes/runner/systems/speed_controller.gd index ae7ef989..5e33560a 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/speed_controller.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/speed_controller.gd @@ -1,7 +1,8 @@ extends Node ## Single owner of the authoritative corridor speed and difficulty (systems spec §1.2, §10.2). ## speed = lerp(base + b, max + b, smoothstep(0, 1, run_clock / time_to_max_speed)), then -## +1 m/s per 60 s of tail up to hard_cap + b, then the Nitro multiplier clamped to nitro_speed_cap. +## +1 m/s per 60 s of tail up to hard_cap + b, then the Nitro multiplier clamped to nitro_speed_cap, +## then the boss-cinematic scale (contract 2.6 §11.14, 1 outside a cinematic). ## ## v2: the curve is read at `run_clock` (RUN-phase seconds), not `elapsed`, so speed holds at the ## value it reached through a turn, a boss fight and the stage-clear card. The stage bonus b and the @@ -11,6 +12,8 @@ const RunnerGame = preload("res://scenes/runner/runner_game.gd") const RunConfig = preload("res://scenes/runner/data/run_config.gd") ## The frontend only needs to hear about difficulty when it actually moves (contract §4.1). const DIFFICULTY_EPSILON := 0.01 +## Seconds set_cinematic_scale() takes to reach its target by default (contract 2.6 §11.14). +const CINEMATIC_EASE_SECONDS := 0.35 var game: RunnerGame var config: RunConfig @@ -29,11 +32,18 @@ var difficulty_floor := 0.0 var max_speed_reached := 0.0 var stopping := false var stop_seconds := 0.0 +## 0..1 multiplier on the curve while a boss cinematic holds the player (contract 2.6 §11.14), +## eased toward its target; 1 the rest of the time. It never touches run_clock, so the curve picks +## up exactly where it was once the cinematic lets go. +var cinematic_scale := 1.0 ## Difficulty last pushed through difficulty_changed. var _emitted_difficulty := 0.0 ## Speed the catch deceleration started from, so begin_stop() reaches 0 in stop_seconds. var _stop_from_speed := 0.0 +var _cinematic_target := 1.0 +## Scale units per second toward _cinematic_target. +var _cinematic_rate := 0.0 func setup(runner: RunnerGame) -> void: game = runner @@ -50,6 +60,9 @@ func reset() -> void: stop_seconds = 0.0 _stop_from_speed = 0.0 _emitted_difficulty = 0.0 + cinematic_scale = 1.0 + _cinematic_target = 1.0 + _cinematic_rate = 0.0 speed = base_for_stage() max_speed_reached = speed @@ -61,6 +74,17 @@ func set_stage_bonus(bonus: float) -> void: func set_difficulty_floor(value: float) -> void: difficulty_floor = clampf(value, 0.0, 1.0) +## Contract 2.6 §11.14: ease the corridor to `target` x its curve speed over `seconds` (the +## StageManager asks for boss_cinematic_speed_scale while a cinematic holds the player, then 1). +## A non-positive `seconds` snaps straight to the target. +func set_cinematic_scale(target: float, seconds := CINEMATIC_EASE_SECONDS) -> void: + _cinematic_target = clampf(target, 0.0, 1.0) + if seconds <= 0.0: + cinematic_scale = _cinematic_target + _cinematic_rate = 0.0 + return + _cinematic_rate = absf(_cinematic_target - cinematic_scale) / seconds + ## Base corridor speed for the current stage: what stage_started reports (systems spec §10.1). func base_for_stage() -> float: if config == null: @@ -112,20 +136,32 @@ func _physics_process(delta: float) -> void: # The StageManager owns the RUN-phase clock; mirror it so a standalone controller in a unit # test still advances on its own. A frozen run_clock holds the speed where it was. run_clock = float(game.run_clock) if game != null else run_clock + delta + _ease_cinematic_scale(delta) _advance() elif stopping and state == &"caught": + # A catch lets go of a cinematic hold too (contract §11.14): the scale still eases home, + # while the stop itself decelerates from the speed the corridor had already reached. + _ease_cinematic_scale(delta) _decelerate(delta) else: return max_speed_reached = maxf(max_speed_reached, speed) _publish() -## The difficulty curve, the tail and the Nitro clamp (contract §7 Speed row, §11.2). +## The difficulty curve, the tail and the Nitro clamp (contract §7 Speed row, §11.2), then the +## cinematic scale (§11.14), which can only ever slow the corridor. func _advance() -> void: var curve := speed_at(run_clock) speed = curve if is_equal_approx(multiplier, 1.0) else minf(curve * multiplier, float(config.nitro_speed_cap)) + speed *= cinematic_scale difficulty = difficulty_at(run_clock) +func _ease_cinematic_scale(delta: float) -> void: + if is_equal_approx(cinematic_scale, _cinematic_target): + cinematic_scale = _cinematic_target + return + cinematic_scale = move_toward(cinematic_scale, _cinematic_target, _cinematic_rate * delta) + func _decelerate(delta: float) -> void: if stop_seconds <= 0.0: speed = 0.0 diff --git a/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd b/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd index 7c054a3a..a8849994 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd @@ -14,6 +14,9 @@ extends Node const RunnerGame = preload("res://scenes/runner/runner_game.gd") const RunConfig = preload("res://scenes/runner/data/run_config.gd") const StageDefs = preload("res://scenes/runner/data/stage_defs.gd") +## Only for the kind of the boss a checkpoint is taken in front of (contract §11.13 bosses_met); +## boss_defs.gd preloads no scene script, so there is no cycle. +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") ## Contract §11.1 phases. const PHASE_IDLE: StringName = &"idle" @@ -35,6 +38,10 @@ const DEFAULT_STAGE_CLEAR_SECONDS := 3.0 const DEFAULT_STAGE_CLEAR_BONUS_BASE := 400 const DEFAULT_STAGE_CLEAR_BONUS_PER_STAGE := 150 const DEFAULT_STAGE_BANNER_CLEAR_SECONDS := 3.5 +const DEFAULT_BOSS_CINEMATIC_SPEED_SCALE := 0.25 +const DEFAULT_BOSS_Z := -14.0 +## Contract §11.5: the Boss node's hover height, the camera's fallback target with no boss node. +const BOSS_CENTER_Y := 2.6 var game: RunnerGame var config: RunConfig @@ -53,6 +60,10 @@ var run_clock := 0.0 ## drain window opens and held through the bend, so the direction turn_incoming announced, the one ## turn_started repeats and the one the rigs sweep are always the same (contract §11.4). var next_turn_direction := 0 +## True from boss_cinematic until that cinematic is skipped, ends, is aborted or the run is caught +## (contract 2.6 §11.14): manual controls are off and the corridor eases to +## boss_cinematic_speed_scale for exactly as long as this holds. +var cinematic_hold := false # Result counters (contract §11.4). They are tallied from the facade signals, so they survive the # boss encounter being freed and are correct whether or not an encounter is mounted at all. @@ -97,6 +108,14 @@ func _connect(runner: RunnerGame) -> void: runner.boss_retreated.connect(_on_boss_retreated) if not runner.tool_collected.is_connected(_on_tool_collected): runner.tool_collected.connect(_on_tool_collected) + # Connected before any frontend binds, so the hold and the facade mirrors are already right by the + # time a HUD or a visual hears the same signal (contract §11.14). + if not runner.boss_cinematic.is_connected(_on_boss_cinematic): + runner.boss_cinematic.connect(_on_boss_cinematic) + if not runner.boss_cinematic_skipped.is_connected(_on_boss_cinematic_released): + runner.boss_cinematic_skipped.connect(_on_boss_cinematic_released) + if not runner.boss_cinematic_ended.is_connected(_on_boss_cinematic_released): + runner.boss_cinematic_ended.connect(_on_boss_cinematic_released) ## Called from RunnerGame.start_run() alongside the other system resets; begin() follows once every ## system is back at zero. @@ -118,8 +137,12 @@ func reset(run_seed: int) -> void: _last_turn_direction = 0 _draining = false _turn_announced = false - _abort_boss() + # A fresh run also forgets which bosses the last one met (contract §11.14), which abort() alone + # would leave in place until stage 1's fight. + _reset_boss() + _set_cinematic_hold(false) _apply_turn_reset() + _apply_boss_camera_reset() _set_boss_arena(false) _set_phase(PHASE_IDLE) _publish() @@ -150,6 +173,15 @@ func begin_at_boss(checkpoint: Dictionary) -> void: bosses_escaped = int(checkpoint.get("bosses_escaped", 0)) tools_used = int(checkpoint.get("tools_used", 0)) boss_damage_dealt = int(checkpoint.get("boss_damage_dealt", 0)) + # Contract §11.14: the bosses the run had met, the retried one included, so its intro is short, + # and the ones it had beaten, so killing one of those again plays the short defeat. + var arena := _boss_encounter() + if arena != null and arena.has_method("mark_met"): + var met: Variant = checkpoint.get("bosses_met", []) + arena.mark_met(met if met is Array else []) + if arena != null and arena.has_method("mark_defeated"): + var beaten: Variant = checkpoint.get("bosses_defeated_kinds", []) + arena.mark_defeated(beaten if beaten is Array else []) var controller := game.speed_controller if controller != null and is_instance_valid(controller): if controller.has_method("set_stage_bonus"): @@ -197,6 +229,8 @@ func _snapshot() -> Dictionary: "tools_used": tools_used, "boss_damage_dealt": boss_damage_dealt, "next_turn_direction": _last_turn_direction, + "bosses_met": _bosses_met(), + "bosses_defeated_kinds": _bosses_defeated_kinds(), # The Game Over stats the systems own, so a retry reports the whole run, not just its tail. "power_ups": int(_system_stat(game.get(&"effect_manager"), &"power_ups_collected", 0)), "hits_taken": int(_system_stat(game.get(&"hit_manager"), &"hits_taken", 0)), @@ -206,6 +240,26 @@ func _snapshot() -> Dictionary: "chunks_spawned": int(_system_stat(game.get(&"spawner"), &"chunks_spawned", 0)) } +## Contract §11.13/§11.14 `bosses_met`: every boss this run has met, plus the one the snapshot is +## taken in front of, so a retry of this fight counts as a repeat meeting. +func _bosses_met() -> Array[StringName]: + var kinds: Array[StringName] = [] + var arena := _boss_encounter() + if arena != null and arena.has_method("met_kinds"): + kinds.assign(arena.met_kinds()) + var upcoming := BossDefs.boss_kind(stage_index) + if upcoming != &"" and not kinds.has(upcoming): + kinds.append(upcoming) + return kinds + +## Contract §11.13/§11.14 `bosses_defeated_kinds`: every boss this run has beaten before this fight. +func _bosses_defeated_kinds() -> Array[StringName]: + var kinds: Array[StringName] = [] + var arena := _boss_encounter() + if arena != null and arena.has_method("defeated_kinds"): + kinds.assign(arena.defeated_kinds()) + return kinds + ## A numeric property off one of the facade's systems, or `fallback` when the system (or the ## property) is not there. func _system_stat(node: Variant, property: StringName, fallback: Variant) -> Variant: @@ -296,6 +350,7 @@ func _tick_boss(delta: float) -> void: return if arena.has_method("tick"): arena.tick(delta) + _apply_boss_camera() if _boss_finished(arena): _enter_stage_clear() @@ -362,12 +417,18 @@ func _enter_boss() -> void: if arena != null and arena.has_method("start"): arena.start(stage_index) _boss_active = not _boss_finished(arena) + # The intro's first frame is framed on the tick it opens, not one tick late. + _apply_boss_camera() if not _boss_active: _enter_stage_clear() func _enter_stage_clear() -> void: _phase_timer = 0.0 _boss_active = false + # The encounter always closes its cinematic before it finishes; this only makes the hand-back + # unconditional, like _apply_turn_reset() does for the bend. + _set_cinematic_hold(false) + _apply_boss_camera_reset() _set_boss_arena(false) # The fight is over, so there is nothing left to retry (contract §11.13). if game != null and &"boss_checkpoint" in game: @@ -389,7 +450,9 @@ func _on_caught() -> void: _phase_timer = 0.0 _draining = false _abort_boss() + _set_cinematic_hold(false) _apply_turn_reset() + _apply_boss_camera_reset() _set_boss_arena(false) _set_phase(PHASE_IDLE) _publish() @@ -423,6 +486,17 @@ func _on_tool_collected(_kind: StringName, damage: int) -> void: tools_used += 1 boss_damage_dealt += maxi(damage, 0) +## A boss cinematic opened (contract 2.6 §11.14): hold the player and mirror its clock at once. +func _on_boss_cinematic(_kind: StringName, _mode: StringName, _seconds: float) -> void: + _set_cinematic_hold(true) + _publish() + +## Skipped or ended: the player gets control back on the spot. A skip still has its tail to play, +## so the camera keeps following the encounter's clock until boss_cinematic_ended. +func _on_boss_cinematic_released(_mode: StringName) -> void: + _set_cinematic_hold(false) + _publish() + # --- collaborators --- func _set_phase(value: StringName) -> void: @@ -517,6 +591,78 @@ func _abort_boss() -> void: if arena != null and arena.has_method("abort"): arena.abort() +## abort() plus the encounter's run tallies and meetings, for a fresh run (reset()). +func _reset_boss() -> void: + _boss_active = false + var arena := _boss_encounter() + if arena != null and arena.has_method("reset"): + arena.reset() + elif arena != null and arena.has_method("abort"): + arena.abort() + +## Contract 2.6 §11.14: while a cinematic holds the player, manual controls are off (the facade owns +## that rule, autopilot included) and the corridor eases to boss_cinematic_speed_scale; letting go +## gives both back. Idempotent, so every exit path can call it. +func _set_cinematic_hold(on: bool) -> void: + if cinematic_hold == on: + return + cinematic_hold = on + if game == null: + return + if game.has_method("refresh_player_controls"): + game.refresh_player_controls() + var controller: Node = game.speed_controller + if controller != null and is_instance_valid(controller) and controller.has_method("set_cinematic_scale"): + controller.set_cinematic_scale(_cinematic_speed_scale() if on else 1.0) + +## The camera follows the encounter's cinematic clock on every boss tick, the way _apply_turn drives +## the swing; outside a cinematic it is handed (0, &""), which the shot curve never blends. +func _apply_boss_camera() -> void: + if game == null: + return + var camera: Node = game.camera_rig + if camera == null or not is_instance_valid(camera) or not camera.has_method("set_boss_cinematic"): + return + var arena := _boss_encounter() + var mode: StringName = _cinematic_mode(arena) + var progress := float(arena.cinematic_progress()) if mode != &"" and arena.has_method("cinematic_progress") else 0.0 + camera.set_boss_cinematic(progress, mode, _boss_position(arena), _boss_focus_position(arena)) + +func _apply_boss_camera_reset() -> void: + if game == null: + return + var camera: Node = game.camera_rig + if camera != null and is_instance_valid(camera) and camera.has_method("set_boss_cinematic"): + camera.set_boss_cinematic(0.0, &"", Vector3.ZERO) + +## The cinematic the encounter is playing, &"" with no encounter or none playing. +func _cinematic_mode(arena: Node) -> StringName: + if arena == null or not is_instance_valid(arena) or not (&"cinematic_mode" in arena): + return &"" + return StringName(arena.get(&"cinematic_mode")) + +## The Boss node's global position, or where contract §11.5 hovers it when there is none. +func _boss_position(arena: Node) -> Vector3: + if arena != null and is_instance_valid(arena) and &"boss" in arena: + var node := arena.get(&"boss") as Node3D + if node != null and is_instance_valid(node) and node.is_inside_tree(): + return node.global_position + return Vector3(0.0, BOSS_CENTER_Y, _float_config(&"boss_z", DEFAULT_BOSS_Z)) + +## Contract §11.5: the global point the shot aims at — the Boss mount carrying the encounter's +## cinematic_focus (the visual's rest focus, read at spawn), so a pose can never move the aim. +func _boss_focus_position(arena: Node) -> Vector3: + var focus := Vector3.ZERO + if arena != null and is_instance_valid(arena) and &"cinematic_focus" in arena: + var value: Variant = arena.get(&"cinematic_focus") + if value is Vector3: + focus = value + if arena != null and is_instance_valid(arena) and &"boss" in arena: + var node := arena.get(&"boss") as Node3D + if node != null and is_instance_valid(node) and node.is_inside_tree(): + return node.to_global(focus) + return _boss_position(arena) + focus + # --- drain --- ## A blocking hazard still short of the clear line holds the stage open. @@ -565,6 +711,14 @@ func _publish() -> void: game.stage_distance_target = stage_distance_target game.run_clock = run_clock game.next_turn_direction = next_turn_direction + # Contract 2.6 §11.14 read-only mirrors, straight off the encounter's clock. + var arena := _boss_encounter() + var mode := _cinematic_mode(arena) + game.boss_cinematic_mode = mode + game.boss_cinematic_progress = float(arena.cinematic_progress()) \ + if mode != &"" and arena.has_method("cinematic_progress") else 0.0 + game.boss_time_factor = float(arena.time_factor()) \ + if mode != &"" and arena.has_method("time_factor") else 1.0 ## The stage's base speed, as stage_started reports it (systems spec §10.1). func _stage_base_speed() -> float: @@ -600,6 +754,9 @@ func _stage_clear_seconds() -> float: func _banner_clear_seconds() -> float: return maxf(_float_config(&"stage_banner_clear_seconds", DEFAULT_STAGE_BANNER_CLEAR_SECONDS), 0.0) +func _cinematic_speed_scale() -> float: + return clampf(_float_config(&"boss_cinematic_speed_scale", DEFAULT_BOSS_CINEMATIC_SPEED_SCALE), 0.0, 1.0) + func _float_config(property: StringName, fallback: float) -> float: if config == null or not (property in config): return fallback diff --git a/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.gd b/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.gd new file mode 100644 index 00000000..6022c406 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.gd @@ -0,0 +1,297 @@ +extends CanvasLayer +## Pure presentation of the encounter clock. No local timers, Tweens or gameplay writes. +const Copy = preload("res://scenes/runner/ui/boss_copy.gd") +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") +const BossVisual = preload("res://scenes/runner/boss/boss_visual_base.gd") +const ToolDefs = preload("res://scenes/runner/powerups/tool_defs.gd") +const Style = preload("res://scenes/runner/ui/hud_style.gd") +const Glyph = preload("res://scenes/runner/ui/hifi/utility_glyph.gd") +const Palette = preload("res://scenes/runner/environment/runner_palette.gd") +const TITLE_AT: float = BossVisual.INTRO_POSE_AT[&"intro_full"] +const SHORT_TITLE_AT: float = BossVisual.INTRO_POSE_AT[&"intro_short"] +const RESOLVED_AT := 0.60 +var game: Node +var connections: Dictionary = {} +var mode: StringName = &"" +var seconds := 0.0 +var bonus := 0 +var skipped := false +var root: Control +var title_group: Control +var epithet: Label +var boss_name: Label +var threat: Label +var chyron: Label +var review_header: PanelContainer +var source_path: Label +var hints: VBoxContainer +var skip_prompt: Label +var resolved: Control +var resolved_title: Label +var bonus_label: Label +var flash: ColorRect +var lower_third: TextureRect +var _viewport_size := Vector2.ZERO +var _gameplay_hud: CanvasLayer +var _gameplay_was_visible := true +var _hint_row_count := 0 + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_PAUSABLE + physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + layer = 12 + root = Control.new() + root.name = "Presentation" + root.theme = Style.theme() + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(root) + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + lower_third = TextureRect.new() + lower_third.name = "LowerThirdBacking" + lower_third.mouse_filter = Control.MOUSE_FILTER_IGNORE + var gradient := Gradient.new() + gradient.offsets = PackedFloat32Array([0.0, 0.40, 1.0]) + gradient.colors = PackedColorArray([Color(Palette.NAVY_BG, 0.0), Color(Palette.NAVY_BG, 0.78), Color(Palette.NAVY_BG, 0.94)]) + var backing_texture := GradientTexture2D.new() + backing_texture.gradient = gradient + backing_texture.fill_from = Vector2(0, 0) + backing_texture.fill_to = Vector2(0, 1) + lower_third.texture = backing_texture + root.add_child(lower_third) + flash = ColorRect.new() + flash.color = Color(1, 1, 1, 0) + flash.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(flash) + flash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + review_header = PanelContainer.new() + review_header.name = "ReviewHeader" + review_header.mouse_filter = Control.MOUSE_FILTER_IGNORE + var header_style := Style.panel(Palette.DANGER, 0.96) + header_style.content_margin_top = 2 + header_style.content_margin_bottom = 2 + header_style.shadow_size = 0 + review_header.add_theme_stylebox_override("panel", header_style) + root.add_child(review_header) + var review_row := HBoxContainer.new() + review_row.alignment = BoxContainer.ALIGNMENT_CENTER + review_row.add_theme_constant_override("separation", 10) + review_header.add_child(review_row) + var review_glyph := Style.icon(&"review_comment", Palette.ORANGE, Vector2(26, 26)) + review_row.add_child(review_glyph) + chyron = _label(review_row, "CODERABBIT REVIEW · CRITICAL ISSUE", 18, Palette.ORANGE) + chyron.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + source_path = _label(root, "", 16, Palette.CREAM) + source_path.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + title_group = Control.new() + title_group.name = "TitleCard" + title_group.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(title_group) + epithet = _label(title_group, "", 26, Palette.ORANGE) + boss_name = _label(title_group, "", 64, Palette.CREAM) + threat = _label(root, "", 22, Palette.CREAM) + hints = VBoxContainer.new() + hints.name = "HowToResolve" + hints.add_theme_constant_override("separation", 5) + hints.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(hints) + skip_prompt = _label(root, "▶ SKIP [SPACE / A]", 18, Palette.CREAM) + resolved = Control.new() + resolved.name = "Resolved" + resolved.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(resolved) + resolved_title = _label(resolved, "THREAD RESOLVED", 64, Palette.MINT) + bonus_label = _label(resolved, "", 26, Palette.CREAM) + var title_font := FontVariation.new() + title_font.base_font = Style.theme().default_font + title_font.variation_embolden = 1.2 + title_font.spacing_glyph = 1 + for label in [boss_name, resolved_title]: label.add_theme_font_override("font", title_font) + _layout() + _reset() + +func _label(parent: Node, text: String, font_size: int, color: Color) -> Label: + var label := Style.label(text, font_size, color) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_constant_override("outline_size", 6) + parent.add_child(label) + return label + +func _layout() -> void: + _viewport_size = get_viewport().get_visible_rect().size + var w := _viewport_size.x + var h := _viewport_size.y + var inset := maxf(24, w * 0.05) + # Review metadata lives on the mattes, leaving the boss silhouette clear. + review_header.position = Vector2((w - 550) * 0.5, h * 0.009) + review_header.size = Vector2(550, 34) + source_path.position = Vector2(inset, h * 0.947) + source_path.size = Vector2(w * 0.60, 30) + lower_third.position = Vector2(0, h * 0.52) + lower_third.size = Vector2(w, h * 0.42) + title_group.position = Vector2(inset, h * 0.58) + title_group.size = Vector2(w - inset * 2, 120) + title_group.pivot_offset = title_group.size * 0.5 + epithet.position = Vector2.ZERO + epithet.size = Vector2(title_group.size.x, 34) + boss_name.position = Vector2(0, 32) + boss_name.size = Vector2(title_group.size.x, 80) + boss_name.add_theme_font_size_override("font_size", int(clampf(w * 0.05, 28, 72))) + threat.position = Vector2(inset, h * 0.58 + 114) + threat.size = Vector2(w - inset * 2, 30) + hints.position = Vector2(inset, h * 0.80) + hints.size = Vector2(w - inset * 2, 90) + skip_prompt.position = Vector2(w - 300, h * 0.947) + skip_prompt.size = Vector2(270, 30) + resolved.position = Vector2(inset, h * 0.64) + resolved.size = Vector2(w - inset * 2, 130) + resolved.pivot_offset = resolved.size * 0.5 + resolved_title.size = Vector2(resolved.size.x, 88) + resolved_title.add_theme_font_size_override("font_size", int(clampf(w * 0.055, 32, 76))) + bonus_label.position = Vector2(0, 90) + bonus_label.size = Vector2(resolved.size.x, 34) + +func bind(value: Node) -> void: + if game == value and not connections.is_empty(): return + if is_instance_valid(game): + for event: StringName in connections: + if game.is_connected(event, connections[event]): game.disconnect(event, connections[event]) + game = value + _reset() + connections = {&"boss_cinematic": _cinematic, &"boss_cinematic_skipped": _skipped, + &"boss_cinematic_ended": _ended, &"boss_defeated": _defeated, &"caught": _reset, + &"run_started": _run_started} + if not is_instance_valid(game): return + for event: StringName in connections.keys(): + if game.has_signal(event): game.connect(event, connections[event]) + else: connections.erase(event) + +func _cinematic(kind: StringName, cinematic_mode: StringName, duration: float) -> void: + mode = cinematic_mode + if _gameplay_hud == null: + _gameplay_hud = game.get_node_or_null("HUD/RunnerHud") as CanvasLayer + if _gameplay_hud != null: _gameplay_was_visible = _gameplay_hud.visible + if _gameplay_hud != null: _gameplay_hud.hide() + seconds = maxf(duration, 0.001) + skipped = false + var stage := int(game.get("stage_index")) if "stage_index" in game else 1 + var def: Dictionary = BossDefs.base_def(stage) + # Signal fixtures can introduce any kind independently of the current stage. + if StringName(def.kind) != kind: + def = {} + for row: Dictionary in BossDefs.SLOTS: + if StringName(row.kind) == kind: def = row + epithet.text = Copy.epithet(kind, BossDefs.cycle(stage) + 1).to_upper() + boss_name.text = String(def.get("name", String(kind).replace("_", " "))).to_upper() + threat.text = Copy.threat(kind) + source_path.text = Copy.source_path(kind) + bonus_label.text = "+%s BONUS" % String.num_int64(bonus) + _build_hints(def) + root.show() + _process(0.0) + +func _build_hints(def: Dictionary) -> void: + for child in hints.get_children(): + hints.remove_child(child) + child.queue_free() + var chip := PanelContainer.new() + chip.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + chip.mouse_filter = Control.MOUSE_FILTER_IGNORE + var backing := Style.panel(Palette.SLATE, 0.90) + backing.content_margin_top = 4 + backing.content_margin_bottom = 4 + chip.add_theme_stylebox_override("panel", backing) + hints.add_child(chip) + var content := HBoxContainer.new() + content.add_theme_constant_override("separation", 22) + chip.add_child(content) + _label(content, "HOW TO RESOLVE", 20, Palette.ORANGE) + var phase_rows := VBoxContainer.new() + phase_rows.add_theme_constant_override("separation", 4) + content.add_child(phase_rows) + var rows := Copy.hint_rows(def) + var restricted := rows.any(func(row): return not row.all_tools) + for row: Dictionary in rows: + if restricted and row.all_tools: continue + if not restricted and int(row.phase) != 1: continue + if not restricted and not String(row.hint).is_empty(): + _label(phase_rows, row.hint, 22, Palette.CREAM) + var line := HBoxContainer.new() + line.alignment = BoxContainer.ALIGNMENT_CENTER + line.add_theme_constant_override("separation", 18) + phase_rows.add_child(line) + _label(line, "PHASE %d" % row.phase if restricted else "EVERY TOOL LANDS", 20, Palette.CREAM) + for tool: StringName in row.tools: + var tool_pair := HBoxContainer.new() + tool_pair.add_theme_constant_override("separation", 5) + line.add_child(tool_pair) + var glyph := Glyph.new() + glyph.kind = tool + glyph.tint = Palette.ORANGE + glyph.custom_minimum_size = Vector2(36, 36) + glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE + tool_pair.add_child(glyph) + if restricted: _label(tool_pair, ToolDefs.label(tool).to_upper(), 20, Palette.CREAM) + _hint_row_count = phase_rows.get_child_count() + +func _process(_delta: float) -> void: + if not is_instance_valid(game) or mode == &"" or game.get("paused") == true: return + if _viewport_size != get_viewport().get_visible_rect().size: _layout() + var p := clampf(float(game.get("boss_cinematic_progress")), 0, 1) + var short := mode == &"intro_short" + var intro := mode in [&"intro_full", &"intro_short"] + var age := p * seconds + var title_at := SHORT_TITLE_AT if short else TITLE_AT + var title_age := (p - title_at) * seconds + var out := 1.0 - smoothstep(0.92 if not short else 0.86, 1.0, p) + lower_third.visible = not skipped and (p >= title_at if intro else p >= RESOLVED_AT) + lower_third.modulate.a = out + title_group.visible = intro and p >= title_at and not skipped + title_group.scale = Vector2.ONE * lerpf(1.8, 1.0, 1.0 - pow(1.0 - clampf(title_age / 0.18, 0, 1), 3)) + title_group.modulate.a = out + chyron.visible = intro and not short and not skipped + review_header.visible = chyron.visible + source_path.visible = chyron.visible + review_header.modulate.a = smoothstep(0.03, 0.12, p) * out + source_path.modulate.a = review_header.modulate.a * 0.8 + threat.visible = intro and not short and p >= TITLE_AT and not skipped + threat.visible_characters = maxi(0, int((title_age - 0.18) * 40.0)) + threat.modulate.a = out + var hint_advance := 0.25 / seconds if _hint_row_count > 1 else 0.0 + hints.visible = intro and not short and p >= 0.70 - hint_advance and not skipped + hints.modulate.a = smoothstep(0.70 - hint_advance, 0.72 - hint_advance, p) * out + # Only query the facade guard, which includes running, pause, skip arm and skip tail. + skip_prompt.visible = intro and not skipped and game.has_method("can_skip_boss_cinematic") and bool(game.call("can_skip_boss_cinematic")) + resolved.visible = not intro and p >= RESOLVED_AT + var resolved_age := (p - RESOLVED_AT) * seconds + resolved.scale = Vector2.ONE * lerpf(1.8, 1.0, 1.0 - pow(1.0 - clampf(resolved_age / 0.2, 0, 1), 3)) + resolved.modulate.a = out + var flash_amount := maxf(0.0, 1.0 - title_age / 0.11) * 0.45 if intro and title_age >= 0 and not skipped else 0.0 + if not intro: flash_amount = maxf(0, 1 - age / 0.12) * 0.85 + flash.color.a = flash_amount + +func _defeated(_kind: StringName, value: int) -> void: + bonus = value + +func _skipped(_mode: StringName) -> void: + skipped = true + _process(0.0) + +func _ended(_mode: StringName) -> void: + _reset() + +func _run_started(_seed: int) -> void: + bonus = 0 + _reset() + +func _reset() -> void: + if is_instance_valid(_gameplay_hud): _gameplay_hud.visible = _gameplay_was_visible + _gameplay_hud = null + mode = &"" + seconds = 0.0 + skipped = false + if root != null: root.hide() + +func _exit_tree() -> void: + bind(null) diff --git a/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.gd.uid b/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.gd.uid new file mode 100644 index 00000000..e671ae51 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.gd.uid @@ -0,0 +1 @@ +uid://b0yko26gtc2o6 diff --git a/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.tscn b/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.tscn new file mode 100644 index 00000000..206ded79 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/boss_cinematic.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/runner/ui/boss_cinematic.gd" id="1"] + +[node name="BossCinematic" type="CanvasLayer"] +process_mode = 1 +layer = 12 +script = ExtResource("1") diff --git a/hoppys-adventure-arcade/scenes/runner/ui/boss_copy.gd b/hoppys-adventure-arcade/scenes/runner/ui/boss_copy.gd new file mode 100644 index 00000000..7e9c4304 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/boss_copy.gd @@ -0,0 +1,36 @@ +extends RefCounted +## The only narrative copy table. Tool eligibility is always read from BossDefs. +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") +const ToolDefs = preload("res://scenes/runner/powerups/tool_defs.gd") +const COPY := { + &"flaky_test_swarm": {"epithet": "The Nondeterministic Horde", "threat": "Passed locally. Fails on CI.", "path": "tests/ci/pipeline.spec:42", "hint": "WAIT FOR THE FLICKER TO COMMIT"}, + &"merge_conflict_hydra": {"epithet": "Three Branches, Zero Agreement", "threat": "<<<<<<< HEAD: cut one, two more rebase.", "path": "src/branches/merge.ts:128"}, + &"legacy_monolith": {"epithet": "@deprecated Since 2011", "threat": "Still in production. Still load-bearing.", "path": "legacy/core/production.ts:2011"}, +} + +## cycle is human-facing: first lap = 1, second lap = 2. +static func epithet(kind: StringName, cycle: int = 1) -> String: + var text := String(COPY.get(kind, {}).get("epithet", "Unresolved issue")) + return text + (" · REGRESSED v%d" % cycle if cycle >= 2 else "") + +static func threat(kind: StringName) -> String: + return String(COPY.get(kind, {}).get("threat", "Review required.")) + +static func source_path(kind: StringName) -> String: + return String(COPY.get(kind, {}).get("path", "src/unknown:1")) + +## One row per phase, using the resolved accepted_tools list from BossDefs. +static func hint_rows(def: Dictionary) -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + var count := int(def.get("phase_count", BossDefs.PHASE_COUNT)) + for phase in range(1, count + 1): + var accepted := BossDefs.accepted_tools(def, phase) + var tools: Array = Array(accepted) + var all_tools := tools.size() == ToolDefs.kinds().size() + var names := PackedStringArray() + for tool: StringName in tools: names.append(ToolDefs.label(tool).to_upper()) + var hint := String(COPY.get(StringName(def.get("kind", &"")), {}).get("hint", "")) + var text := (hint + " · " if not hint.is_empty() else "") + "EVERY TOOL LANDS" if all_tools else " + ".join(names) + rows.append({"phase": phase, "accepted_tools": accepted.duplicate(), "tools": tools, + "all_tools": all_tools, "hint": hint, "text": text}) + return rows diff --git a/hoppys-adventure-arcade/scenes/runner/ui/boss_copy.gd.uid b/hoppys-adventure-arcade/scenes/runner/ui/boss_copy.gd.uid new file mode 100644 index 00000000..63b33913 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/boss_copy.gd.uid @@ -0,0 +1 @@ +uid://20wb1nrgqwd2 diff --git a/hoppys-adventure-arcade/scenes/runner/ui/boss_hud.gd b/hoppys-adventure-arcade/scenes/runner/ui/boss_hud.gd index 202f109b..45a3cb1d 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/boss_hud.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/boss_hud.gd @@ -3,20 +3,7 @@ const Segments = preload("res://scenes/runner/ui/hifi/bar_segments.gd") const Style = preload("res://scenes/runner/ui/hud_style.gd") const Palette = preload("res://scenes/runner/environment/runner_palette.gd") const Glyph = preload("res://scenes/runner/ui/hud_glyph.gd") -# Frontend copy lives in this table only. Unknown kinds show the supplied name. -const TAUNTS := { - &"flaky_test_swarm": "PASSED LOCALLY. FAILS ON CI.", - &"merge_conflict_hydra": "<<<<<<< HEAD \u2014 3 BRANCHES, SOON 4. ZERO AGREEMENT.", - &"legacy_monolith": "@deprecated SINCE 2011. STILL IN PRODUCTION.", -} -var intro_card: PanelContainer -var intro_name: Label -var intro_taunt: Label -var intro_age := 0.0 -var intro_seconds := 0.0 var intro_active := false -var intro_flash: ColorRect -var intro_vignette: ColorRect var game: Node var connections: Dictionary = {} var phase_row: HBoxContainer @@ -81,12 +68,12 @@ func _ready() -> void: track.set_border_width_all(1) chip.add_theme_stylebox_override("background", track) hide() - _build_intro() func bind(value: Node) -> void: + if game == value and not connections.is_empty(): return _disconnect() game = value - connections = {&"tool_collected": _tool_icon, &"boss_intro": _intro, &"phase_changed": _game_phase, &"boss_spawned": _spawn, &"boss_health_changed": _health, &"boss_phase_changed": _phase, &"boss_defeated": _end, &"boss_retreated": _retreat, &"caught": _caught} + connections = {&"tool_collected": _tool_icon, &"boss_cinematic": _cinematic, &"boss_cinematic_ended": _cinematic_ended, &"phase_changed": _game_phase, &"boss_spawned": _spawn, &"boss_health_changed": _health, &"boss_phase_changed": _phase, &"boss_defeated": _end, &"boss_retreated": _retreat, &"caught": _caught} for event: StringName in connections: if game.has_signal(event): game.connect(event, connections[event]) if game.has_method("get_boss_info"): @@ -163,8 +150,8 @@ func _phase(value: int) -> void: pip_flash.tween_property(panel, "modulate", Color.WHITE, 0.9) func _end(_kind: StringName, _bonus: int) -> void: - title.text = "BUG FIXED" - _slide_out() + # The cinematic owns the defeat payoff; zero-length defeats also retire the HP plate. + _caught() func _retreat(_kind: StringName, _bonus: int) -> void: title.text = "BOSS ESCAPED" @@ -188,87 +175,24 @@ func _caught() -> void: func _process(delta: float) -> void: if is_instance_valid(game) and game.get("paused") == true: return - if intro_active: - intro_age = minf(intro_seconds, intro_age + delta) - intro_taunt.visible_characters = int(intro_taunt.text.length() * minf(intro_age, 1.0)) - var impact := maxf(0.0, 1.0 - absf(intro_age - intro_seconds * 0.8) / 0.18) - intro_flash.color.a = impact * 0.24 - intro_vignette.material.set_shader_parameter("strength", 0.3 + impact * 0.6) - intro_vignette.material.set_shader_parameter("pulse", 0.5 + 0.5 * sin(intro_age * 8.0)) - if intro_age >= intro_seconds: - _cancel_intro() - if _fight_active: _slide_in() if not visible or not _fight_active: return _clock += delta border.border_color = Palette.DANGER.lerp(Palette.CREAM, (0.5 + 0.5 * sin(_clock * 9)) if _target < 0.25 else 0) func _exit_tree() -> void: _disconnect() - for node in [intro_card, intro_flash, intro_vignette]: - if is_instance_valid(node): node.queue_free() - -func _build_intro() -> void: - intro_vignette = ColorRect.new() - intro_flash = ColorRect.new() - for effect in [intro_vignette, intro_flash]: - get_parent().add_child(effect) - effect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - effect.mouse_filter = Control.MOUSE_FILTER_IGNORE - effect.hide() - var material := ShaderMaterial.new() - material.shader = preload("res://scenes/runner/shaders/vignette.gdshader") - material.set_shader_parameter("color", Palette.DANGER) - intro_vignette.material = material - intro_flash.color = Color(Palette.DANGER, 0.0) - intro_card = PanelContainer.new() - intro_card.name = "BossIntroCard" - # The intro is a sibling under CanvasLayer, so it needs its own theme boundary. - intro_card.theme = theme - get_parent().add_child(intro_card) - intro_card.mouse_filter = Control.MOUSE_FILTER_IGNORE - intro_card.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP) - intro_card.offset_left = -340 - intro_card.offset_right = 340 - # Clear the existing 6.5% cinematic letterbox while staying above y=130. - intro_card.offset_top = 48 - intro_card.offset_bottom = 128 - intro_card.add_theme_stylebox_override("panel", Style.panel(Palette.DANGER, 0.95)) - Style.polish(intro_card, false) - var column := VBoxContainer.new() - intro_card.add_child(column) - var row := HBoxContainer.new() - column.add_child(row) - var eyebrow := Style.label("WARNING · BUG DETECTED", 18, Palette.DANGER) - intro_name = Style.label("", 24) - intro_name.size_flags_horizontal = Control.SIZE_EXPAND_FILL - intro_taunt = Style.label("", 18, Palette.ORANGE) - intro_taunt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - for label in [eyebrow, intro_name, intro_taunt]: - label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - label.mouse_filter = Control.MOUSE_FILTER_IGNORE - if label == intro_taunt: column.add_child(label) - else: row.add_child(label) - intro_card.hide() -func _intro(kind: StringName, boss_name: String, seconds: float) -> void: +func _cinematic(_kind: StringName, mode: StringName, _seconds: float) -> void: + intro_active = mode in [&"intro_full", &"intro_short"] if slide != null: slide.kill() hide() - intro_age = 0.0 - intro_seconds = maxf(seconds, 0.0) - intro_active = true - intro_name.text = boss_name - intro_taunt.text = TAUNTS.get(kind, "") - intro_taunt.visible_characters = 0 - intro_flash.color.a = 0.0 - intro_card.show() - intro_vignette.show() - intro_flash.show() - _process(0.0) + +func _cinematic_ended(mode: StringName) -> void: + _cancel_intro() + if mode in [&"intro_full", &"intro_short"] and _fight_active: _slide_in() func _cancel_intro() -> void: intro_active = false - for node in [intro_card, intro_flash, intro_vignette]: - if is_instance_valid(node): node.hide() func _game_phase(value: StringName) -> void: if value in [&"idle", &"run"]: _caught() diff --git a/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd b/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd index cf430909..137229c4 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd @@ -19,6 +19,10 @@ var suspended: Dictionary = {} var signal_handlers: Dictionary = {} var signal_paused := false var grain_seed := 17 +var cinematic_hold := false +var cinematic_released := false +var cinematic_seconds := 1.0 +var cinematic_skip_tail := 0.3 func _init() -> void: layer = 9 @@ -37,7 +41,7 @@ func _init() -> void: &"pursuit_started": _pursuit_started, &"pursuit_progress": _pursuit_progress, &"pursuit_recovered": _recovered, &"caught": _caught, &"speed_changed": _speed, &"power_up_collected": _power_up, &"power_up_expired": _expired, - &"boss_intro": _boss_intro, &"stage_cleared": _stage_clear, + &"boss_cinematic": _boss_cinematic, &"boss_cinematic_skipped": _boss_skipped, &"boss_cinematic_ended": _boss_ended, &"stage_cleared": _stage_clear, &"phase_changed": _phase, &"run_finished": _finished, &"paused_changed": _paused, } _apply() @@ -85,6 +89,8 @@ func _apply() -> void: func _process(delta: float) -> void: if signal_paused: return + if is_instance_valid(game) and "boss_time_factor" in game: + delta *= float(game.get("boss_time_factor")) clock += delta for key: String in channels.keys(): var channel: Dictionary = channels[key] @@ -94,6 +100,9 @@ func _process(delta: float) -> void: values[key] = lerpf(channel.from, channel.to, 1.0 - (1.0 - t) * (1.0 - t)) if t >= 1.0: channels.erase(key) + if cinematic_hold and is_instance_valid(game): + var p := clampf(float(game.get("boss_cinematic_progress")), 0, 1) + values.letterbox = smoothstep(0.0, 0.18, p * cinematic_seconds) if not cinematic_released else 1.0 - smoothstep(1.0 - minf(cinematic_skip_tail / cinematic_seconds, 1.0), 1.0, p) _apply() func _write(value: float, key: String) -> void: @@ -141,6 +150,8 @@ func _animate(key: String, target: float, seconds: float = 0.18, hold: float = - _suspend(key, tween) func _reset() -> void: + cinematic_hold = false + cinematic_released = false for key: String in transitions.keys(): _cancel(key) channels.clear() @@ -191,9 +202,26 @@ func _expired(kind: StringName) -> void: if kind == &"nitro": _animate("bloom_lift", 0.0, 0.35) -func _boss_intro(_kind: StringName, _name: String, seconds: float) -> void: - var entrance := minf(0.2, maxf(seconds, 0.0)) - _animate("letterbox", 1.0, entrance, maxf(0.0, seconds - entrance)) +func _boss_cinematic(_kind: StringName, _mode: StringName, seconds: float) -> void: + _cancel("letterbox") + cinematic_hold = true + cinematic_released = false + cinematic_seconds = maxf(seconds, 0.001) + cinematic_skip_tail = 0.3 + if is_instance_valid(game) and "config" in game: + var config: Resource = game.get("config") + if config != null and "boss_cinematic_skip_tail" in config: + cinematic_skip_tail = maxf(0.001, float(config.get("boss_cinematic_skip_tail"))) + _process(0.0) + +func _boss_skipped(_mode: StringName) -> void: + cinematic_released = true + _process(0.0) + +func _boss_ended(_mode: StringName) -> void: + cinematic_hold = false + cinematic_released = false + _animate("letterbox", 0.0, 0.18) func _stage_clear(_index: int, _bonus: int) -> void: # Matches the existing stage_clear.gd card's 2.8 second lifetime. diff --git a/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd b/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd index 97e99756..0518434c 100644 --- a/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd +++ b/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd @@ -15,6 +15,7 @@ const ToolDefs = preload("res://scenes/runner/powerups/tool_defs.gd") const HazardDefs = preload("res://scenes/runner/hazards/hazard_defs.gd") const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") const Palette = preload("res://scenes/runner/environment/runner_palette.gd") +const DefeatBurst = preload("res://scenes/runner/boss/defeat_burst.gd") const Burst = preload("res://scenes/runner/powerups/visuals/hifi/collect_burst.gd") const Telegraph = preload("res://scenes/runner/boss/telegraph.tscn") const Tracer = preload("res://scenes/runner/boss/tool_tracer.gd") @@ -36,7 +37,7 @@ const HOPPY_CLIPS: Array[StringName] = [&"lane_dash_left", &"lane_dash_right", & ## One copy per boss per state: a signature telegraph (commit, DANGER heat, charge), the attack ## after it (ring, slam, recoil, all phases' heads), the hurt flash (a BaseMaterial3D emission ## variant) and the defeat. -const BOSS_STATES: Array[StringName] = [&"telegraph", &"attack", &"hurt", &"defeated"] +const BOSS_STATES: Array[StringName] = [&"telegraph", &"attack", &"hurt", &"defeated", &"dissolve"] ## The boss-attack signals BossFX binds to (contract 2.4 §11.8); the warm-up emits them itself. const FX_SIGNALS: Array[StringName] = [&"boss_attack_planned", &"boss_attack_telegraph"] const Vignette = preload("res://scenes/runner/shaders/vignette.gdshader") @@ -68,7 +69,7 @@ static var done: bool: static var build_ms := 0.0 static var draw_ms := 0.0 static var census: Dictionary = {} -## Process-lifetime references to every material the warm-up drew. BaseMaterial3D, +## Process-lifetime references to boss PackedScenes and every material the warm-up drew. BaseMaterial3D, ## ParticleProcessMaterial and code-built shaders free their compiled program with their last ## user, so a warmed program would otherwise be recompiled by the next pickup, pit or burst ## (measured: a new spark system cost 75 ms again once every earlier one was freed). @@ -215,7 +216,7 @@ static func _drop_doomed(host: Node) -> void: if is_instance_valid(node) and node.is_queued_for_deletion() and node.is_inside_tree(): node.get_parent().remove_child(node) -## Application exit: drop the retained materials while the RenderingServer still exists. +## Application exit: drop retained scenes and materials while the RenderingServer still exists. static func release() -> void: retained.clear() @@ -483,13 +484,19 @@ static func _signature_variants(kind: StringName) -> Array[StringName]: func _boss(kind: StringName, state: StringName) -> void: var visual := _instance(BOSS_DIR + String(kind) + "_visual.tscn") if visual == null: return + # Keep the imported scene itself alive after these transient copies are freed, so the next + # intro reuses its meshes and animation data without reparsing the GLB at the cut. + var model_path: String = visual.get_script().get_script_constant_map().get("MODEL_PATH", "") + if not model_path.is_empty() and ResourceLoader.exists(model_path): + var model := load(model_path) as PackedScene + if model != null: retained[model] = true var values := {&"kind": kind, &"boss_name": String(kind), &"max_hp": 10, &"phase_count": BossDefs.PHASE_COUNT, &"hover_z": 0.0, &"sway_amplitude": 0.0, &"sway_period": 1.0} for key: StringName in values: if key in visual: visual.set(key, values[key]) _place(visual, "boss") _hook(visual, &"on_spawned") - var late := state in [&"attack", &"defeated"] + var late := state in [&"attack", &"defeated", &"dissolve"] _hook(visual, &"set_phase", [BossDefs.PHASE_COUNT if late else 1]) _hook(visual, &"set_health", [3 if late else 10, 10]) var variants := _signature_variants(kind) @@ -501,13 +508,16 @@ func _boss(kind: StringName, state: StringName) -> void: &"hurt": _hook(visual, &"play_attack", [variant]) _hook(visual, &"play_hit", [&"unit_test", 1]) - &"defeated": - _hook(visual, &"play_resist", [&"lint_rule"]) - _hook(visual, &"play_defeat") + &"defeated", &"dissolve": + _hook(visual, &"play_cinematic", [&"defeat_full", 3.6]) + _hook(visual, &"set_cinematic_progress", [&"defeat_full", 0.03 if state == &"defeated" else 0.74]) # One art tick applies the state (DANGER heat, ring, slam, hurt emission flash) to the materials. _hook(visual, &"_process", [0.05]) func _boss_fx() -> void: + var explosions := DefeatBurst.new() + _place(explosions, "boss_explosions") + explosions.sample(0.32) var telegraph := Telegraph.instantiate() as Node3D _place(telegraph, "boss_fx") _hook(telegraph, &"start", [1, &"orb", 1.0]) diff --git a/hoppys-adventure-arcade/tests/assets/a5_check.gd b/hoppys-adventure-arcade/tests/assets/a5_check.gd index 4ba88915..7f23f41a 100644 --- a/hoppys-adventure-arcade/tests/assets/a5_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a5_check.gd @@ -1,4 +1,6 @@ extends SceneTree +const BossBounds = preload("res://tests/boss_pose_bounds.gd") +const BossModel = preload("res://scenes/runner/boss/hifi/boss_model.gd") const Forge = preload("res://scenes/runner/boss/hifi/forge.gd") const Art = preload("res://scenes/runner/boss/boss_art.gd") const Capture = preload("res://scenes/runner/boss/dev/capture_a5.gd") @@ -8,6 +10,7 @@ const HazardDefs = preload("res://scenes/runner/hazards/hazard_defs.gd") const ProjectileArt = preload("res://scenes/runner/boss/visuals/boss_projectile_art.gd") const Palette = preload("res://scenes/runner/environment/runner_palette.gd") var boss_peak_draws := 0 +var pose_context := "" const KINDS := ["flaky_test_swarm", "merge_conflict_hydra", "legacy_monolith"] var checks := 0 var failures := 0 @@ -82,6 +85,7 @@ func inspect(visual: Node3D, entrance: bool) -> Vector2i: return Vector2i(draws, tris) func run() -> void: + await check_glb_bosses() await process_frame await process_frame for kind in KINDS: @@ -89,6 +93,13 @@ func run() -> void: mount.position = Vector3(0, 2.6, -14) root.add_child(mount) var visual: Node3D = load("res://scenes/runner/boss/visuals/%s_visual.tscn" % kind).instantiate() + # Retain every historical procedural art assertion against the supported fallback. + if "model_path" in visual: visual.model_path = "res://missing-boss-fallback.glb" + else: + visual._art = Art.new() + visual.add_child(visual._art) + visual._art.build(StringName(kind)) + visual._tag = visual._art.tag visual.kind = StringName(kind) visual.boss_name = kind.replace("_", " ") visual.max_hp = 100 @@ -843,6 +854,10 @@ func check_sizes() -> void: root.add_child(mount) visual.hover_z = -14.0 mount.add_child(visual) + visual._art = Art.new() + visual.add_child(visual._art) + visual._art.build(&"merge_conflict_hydra") + visual._tag = visual._art.tag visual.on_spawned() var art: Node3D = visual._art var narrowest := INF @@ -992,3 +1007,99 @@ func check_projectiles() -> void: visual.on_spawned() check(not (visual._art is ProjectileArt) and visual.get_node_or_null("Artwork") != null, "C4 base look fallback %s %s" % spec) visual.free() + +## Accepted GLBs run the same public phases, tool reactions, signatures and cinematic modes. +## Old geometry-specific art rounds above still run in full against forced missing-GLB fallback. +func check_glb_bosses() -> void: + for kind in KINDS: + var path := "res://Assets/runner/bosses/%s/%s.glb" % [kind, kind] + check(ResourceLoader.exists(path), "accepted GLB exists " + kind) + var mount := Node3D.new() + mount.position = Vector3(0, 2.6, -14) + root.add_child(mount) + var visual: Node3D = load("res://scenes/runner/boss/visuals/%s_visual.tscn" % kind).instantiate() + visual.kind = StringName(kind) + visual.hover_z = -14 + visual.sway_amplitude = 2.4 + mount.add_child(visual) + visual.on_spawned() + visual.set_process(false) + var model: Node3D = visual.get("_model") + check(model != null and visual.get("_art") == null, "GLB owns production geometry " + kind) + if model == null: + mount.free() + continue + for action: StringName in BossModel.ACTIONS: + check(model.animation_player.has_animation(action), "GLB action " + kind + " " + action) + for socket: StringName in BossModel.SOCKETS: + check(model.socket(socket) != null, "GLB socket " + kind + " " + socket) + var report: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(path.replace(".glb", ".build.json"))) + check(int(report.get("triangles", 999999)) <= 18000 and int(report.get("materials", 999)) <= 4 and int(report.get("bones", 999)) <= 40, "GLB authored budget " + kind) + for phase in [1, 2, 3]: + visual.set_phase(phase) + visual.set_health(100 - phase * 25, 100) + visual._process(0.3) + pose_context = "phase=%d at .3s" % phase + inspect_glb(visual) + var signature_peak := Vector2i.ZERO + for pair: Array in boss_attacks(KINDS.find(kind)): + visual.on_spawned() + visual.set_phase(pair[0]) + var attack: Dictionary = pair[1] + visual.play_telegraph(int(attack.telegraph_mask), StringName(attack.variant), float(attack.telegraph)) + visual._process(float(attack.telegraph) * 0.5) + pose_context = "phase=%d variant=%s mask=%d half-telegraph" % [pair[0], attack.variant, attack.telegraph_mask] + inspect_glb(visual) + visual.play_commit(int(attack.telegraph_mask), StringName(attack.variant), 0.25) + visual._process(0.15) + pose_context += " commit+.15s" + inspect_glb(visual) + visual.play_attack(StringName(attack.variant)) + for frame in 4: + visual._process(0.08) + pose_context = "phase=%d variant=%s mask=%d attack frame=%d at %.2fs" % [pair[0], attack.variant, attack.telegraph_mask, frame, (frame + 1) * .08] + inspect_glb(visual) + for tool in [&"review_comment", &"lint_rule", &"unit_test", &"auto_fix_patch"]: + visual.play_hit(tool, 15) + visual._process(0.08) + check(float(model.materials[0].get_shader_parameter("flash")) > 0, "GLB hit flash") + visual.play_resist(tool) + visual._process(0.08) + inspect_glb(visual) + for mode in [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"]: + visual.on_spawned() + visual.play_cinematic(mode, 3.6) + for frame in 37: + pose_context = "%s frame=%d/36" % [mode, frame] + visual.set_cinematic_progress(mode, float(frame) / 36) + inspect_glb(visual) + if String(mode).begins_with("defeat"): + check(not model.visible or float(model.materials[0].get_shader_parameter("dissolve")) >= 0.999, "GLB dissolves at cinematic end") + visual.on_spawned() + var clock_before: float = visual.get("_clock") + visual.set_process(true) + paused = true + await process_frame + await process_frame + check(visual.get("_clock") == clock_before, "GLB animation freezes on pause") + paused = false + mount.free() + +func inspect_glb(visual: Node3D) -> void: + var bounds := AABB() + var first := true + var tris := 0 + var draws := 0 + for mesh: MeshInstance3D in visual.find_children("*", "MeshInstance3D", true, false): + if not mesh.is_visible_in_tree(): continue + var box := BossBounds.mesh_bounds(mesh) + bounds = box if first else bounds.merge(box) + first = false + tris += triangles(mesh.mesh) + draws += mesh.mesh.get_surface_count() + if first: return + check(bounds.position.x >= -3.501 and bounds.end.x <= 3.501, "GLB posed X envelope %s %s %s " % [visual.kind, visual.get("_state"), visual.get("_cinematic_mode")] + str(bounds)) + check(bounds.position.y >= 1.599 and bounds.end.y <= 6.101, "GLB posed Y envelope %s %s %s " % [visual.kind, visual.get("_state"), pose_context] + str(bounds)) + check(bounds.size.z <= 3.001, "GLB posed depth envelope " + str(bounds)) + check(tris <= 18000 and draws <= 6, "GLB runtime mesh budget") + boss_peak_draws = maxi(boss_peak_draws, draws) diff --git a/hoppys-adventure-arcade/tests/assets/boss_flaky_test_swarm_check.gd b/hoppys-adventure-arcade/tests/assets/boss_flaky_test_swarm_check.gd new file mode 100644 index 00000000..a50495e0 --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_flaky_test_swarm_check.gd @@ -0,0 +1,1045 @@ +extends SceneTree + +const MODEL_SCRIPT_PATH := "res://scenes/runner/boss/hifi/boss_model.gd" +const MODEL_PATH := "res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.glb" +const BUILD_PATH := "res://Assets/runner/bosses/flaky_test_swarm/flaky_test_swarm.build.json" +const VISUAL_SCENE_PATH := "res://scenes/runner/boss/visuals/flaky_test_swarm_visual.tscn" +const FlakyVisual = preload("res://scenes/runner/boss/visuals/flaky_test_swarm_visual.gd") +const BossVisualBase = preload("res://scenes/runner/boss/boss_visual_base.gd") +const ProjectileArt = preload("res://scenes/runner/boss/visuals/boss_projectile_art.gd") +const ACTIONS: Array[StringName] = [&"intro", &"idle", &"telegraph", &"attack", &"hurt", &"phase_change", &"defeat", &"retreat"] +const INTRO_RED_DRONES: Array[int] = [0, 2, 3, 5] +const SOCKETS: Array[StringName] = [&"fx_core", &"fx_0", &"fx_1", &"fx_2", &"fx_3", &"fx_4", &"fx_5", &"muzzle_0", &"muzzle_1", &"muzzle_2"] +const MAX_TRIANGLES := 18000 +const MAX_BONES := 40 +const PROCEDURAL_BOUNDS := Vector3(6.093414, 3.931789, 1.010101) +const MAX_REST_DEPTH := 1.7 +const MAX_REST_FRONT_Z := 0.8601 +const WORLD_HOVER_Z := -14.0 +const WORLD_X_MIN := -3.5 +const WORLD_X_MAX := 3.5 +const WORLD_Y_MIN := 1.6 +const WORLD_Y_MAX := 6.1 +const WORLD_DEPTH_MAX := 3.0 +const BOUNDS_SAMPLE_RATE := 120.0 + +var checks := 0 +var failures := 0 +var _cpu_skin_groups: Dictionary = {} +var _bounds_world_summary := { + "samples": 0, + "min": Vector3(INF, INF, INF), + "max": Vector3(-INF, -INF, -INF), + "max_depth": 0.0, + "min_y_source": "", + "max_y_source": "", + "front_source": "", + "depth_source": "", +} + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("BOSS FLAKY SWARM: " + message) + + +func skeleton_pose_snapshot(skeleton: Skeleton3D) -> Array[Transform3D]: + skeleton.force_update_all_bone_transforms() + var snapshot: Array[Transform3D] = [] + for bone in skeleton.get_bone_count(): + snapshot.append(skeleton.get_bone_global_pose(bone)) + return snapshot + + +func check_skeleton_pose_matches(skeleton: Skeleton3D, snapshot: Array[Transform3D], label: String) -> void: + skeleton.force_update_all_bone_transforms() + check(skeleton.get_bone_count() == snapshot.size(), label + " keeps the same skeleton bone count") + for bone in mini(skeleton.get_bone_count(), snapshot.size()): + check(skeleton.get_bone_global_pose(bone).is_equal_approx(snapshot[bone]), "%s preserves bone %d pose" % [label, bone]) + + +func node3d_transform_snapshot(root_node: Node) -> Array[Transform3D]: + var snapshot: Array[Transform3D] = [] + var pending: Array[Node] = [root_node] + while not pending.is_empty(): + var node: Node = pending.pop_back() + if node is Node3D: + snapshot.append((node as Node3D).transform) + for child in node.get_children(): + pending.append(child) + return snapshot + + +func check_node3d_transforms_match(root_node: Node, snapshot: Array[Transform3D], label: String) -> void: + var current := node3d_transform_snapshot(root_node) + check(current.size() == snapshot.size(), label + " keeps the same fallback geometry node count") + for index in mini(current.size(), snapshot.size()): + check(current[index].is_equal_approx(snapshot[index]), "%s preserves fallback node %d transform" % [label, index]) + + +func check_intro_hold_pose(visual: Node, adapter: Node3D, skeleton: Skeleton3D, mode: StringName, seconds: float, probe_progress: float) -> void: + var pose_progress := float(BossVisualBase.INTRO_POSE_AT[mode]) + var hold_end_progress := float(BossVisualBase.INTRO_HOLD_UNTIL[mode]) + visual.call("on_spawned") + visual.call("play_cinematic", mode, seconds) + visual.call("set_cinematic_progress", mode, pose_progress) + var completed_pose := skeleton_pose_snapshot(skeleton) + check(StringName(visual.get("_state")) == &"intro", String(mode) + " stays in intro at its shared final-pose cue") + for progress in [probe_progress, hold_end_progress, 1.0]: + visual.call("set_cinematic_progress", mode, progress) + check_skeleton_pose_matches(skeleton, completed_pose, "%s hold progress %.2f" % [String(mode), progress]) + if adapter != null: + var player := adapter.get("animation_player") as AnimationPlayer + check(player != null and player.assigned_animation == &"intro", String(mode) + " keeps the intro clip assigned through cinematic completion") + check(StringName(visual.get("_cinematic_mode")).is_empty(), String(mode) + " clears cinematic mode at progress 1") + check(StringName(visual.get("_state")) == &"intro", String(mode) + " preserves the completed intro state on the last cinematic frame") + visual.call("_process", 1.0 / BOUNDS_SAMPLE_RATE) + check(StringName(visual.get("_state")) == &"idle", String(mode) + " enters idle on the next normal process tick") + + +func has_shader_param(material: ShaderMaterial, parameter: StringName) -> bool: + if material == null or material.shader == null: + return false + for uniform: Dictionary in material.shader.get_shader_uniform_list(): + if StringName(uniform.get("name", "")) == parameter: + return true + return false + + +func check_build_report() -> Dictionary: + check(FileAccess.file_exists(BUILD_PATH), "full-bake build report exists") + if not FileAccess.file_exists(BUILD_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(BUILD_PATH)) + check(parsed is Dictionary, "build report parses as an object") + if not parsed is Dictionary: + return {} + var report: Dictionary = parsed + check(report.get("quick", true) == false, "asset report describes the full bake") + check(int(report.get("animation_sample_rate", -1)) == 120, "actions were baked at 120 Hz") + check(int(report.get("triangles", -1)) >= 1 and int(report.get("triangles", MAX_TRIANGLES + 1)) <= MAX_TRIANGLES, "triangle budget is at most 18000") + check(int(report.get("materials", -1)) == 1, "full bake uses one atlas material") + check(int(report.get("bones", -1)) >= 7 and int(report.get("bones", MAX_BONES + 1)) <= MAX_BONES, "skeleton has a root and six drone bones within budget") + check(int(report.get("surfaces", -1)) == 1, "full bake exports one surface") + var actions: Array = report.get("actions", []) + for action in ACTIONS: + check(actions.has(String(action)) or actions.has(action), "build report includes action " + String(action)) + var sockets: Array = report.get("sockets", []) + for socket_name in SOCKETS: + check(sockets.has(String(socket_name)) or sockets.has(socket_name), "build report includes socket " + String(socket_name)) + return report + + +func imported_census(model: Node3D) -> Dictionary: + var triangles := 0 + var surfaces := 0 + var source_materials: Dictionary = {} + var mesh_count := 0 + var skeletons: Array[Node] = model.find_children("*", "Skeleton3D", true, false) + for node in model.find_children("*", "MeshInstance3D", true, false): + var instance := node as MeshInstance3D + if instance.mesh == null: + continue + mesh_count += 1 + for surface in instance.mesh.get_surface_count(): + surfaces += 1 + var arrays := instance.mesh.surface_get_arrays(surface) + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + triangles += (indices.size() if not indices.is_empty() else vertices.size()) / 3 + var material := instance.mesh.surface_get_material(surface) + if material != null: + source_materials[material.get_instance_id()] = true + var bones := 0 + for skeleton_node in skeletons: + bones += (skeleton_node as Skeleton3D).get_bone_count() + return {"triangles": triangles, "materials": source_materials.size(), "bones": bones, "surfaces": surfaces, "meshes": mesh_count, "skeletons": skeletons} + + +func bounds_in_model_space(model: Node3D) -> AABB: + var found := false + var result := AABB() + var model_inverse := model.global_transform.affine_inverse() + for node in model.find_children("*", "MeshInstance3D", true, false): + var instance := node as MeshInstance3D + if instance.mesh == null: + continue + var local_bounds := instance.get_aabb() + for x in [local_bounds.position.x, local_bounds.end.x]: + for y in [local_bounds.position.y, local_bounds.end.y]: + for z in [local_bounds.position.z, local_bounds.end.z]: + var point := model_inverse * instance.global_transform * Vector3(x, y, z) + if not found: + result = AABB(point, Vector3.ZERO) + found = true + else: + result = result.expand(point) + return result + + +func cpu_skin_rest_bounds(model: Node3D) -> AABB: + var rigs: Array = model.find_children("*", "Skeleton3D", true, false) + if rigs.is_empty(): + return AABB() + var skeleton := rigs[0] as Skeleton3D + skeleton.reset_bone_poses() + skeleton.force_update_all_bone_transforms() + var model_inverse := model.global_transform.affine_inverse() + var found := false + var result := AABB() + for node in model.find_children("*", "MeshInstance3D", true, false): + var instance := node as MeshInstance3D + if instance.mesh == null: + continue + var skin := instance.skin + for surface in instance.mesh.get_surface_count(): + var arrays := instance.mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] + var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] + var influences := bones.size() / vertices.size() if not vertices.is_empty() else 0 + for vertex_index in vertices.size(): + var point := instance.global_transform * vertices[vertex_index] + if skin != null and influences > 0 and weights.size() == bones.size(): + var skinned_point := Vector3.ZERO + var total_weight := 0.0 + for influence in influences: + var offset := vertex_index * influences + influence + var weight := weights[offset] + if weight <= 0.0: + continue + var bind_index := bones[offset] + if bind_index < 0 or bind_index >= skin.get_bind_count(): + continue + var bone_index := skin.get_bind_bone(bind_index) + if bone_index < 0: + continue + var bone_transform := skeleton.global_transform * skeleton.get_bone_global_pose(bone_index) * skin.get_bind_pose(bind_index) + skinned_point += bone_transform * vertices[vertex_index] * weight + total_weight += weight + if total_weight > 0.0: + var remainder := maxf(1.0 - total_weight, 0.0) + point = skinned_point + point * remainder + point = model_inverse * point + if not found: + result = AABB(point, Vector3.ZERO) + found = true + else: + result = result.expand(point) + return result + + +func cpu_skin_groups(mesh: Mesh) -> Array: + if _cpu_skin_groups.has(mesh): + return _cpu_skin_groups[mesh] + var grouped: Dictionary = {} + for surface in mesh.get_surface_count(): + var arrays := mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var bone_ids: PackedInt32Array = arrays[Mesh.ARRAY_BONES] + var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] + var stride := bone_ids.size() / vertices.size() if not vertices.is_empty() else 0 + for vertex_index in vertices.size(): + var ids := PackedInt32Array() + var gains := PackedFloat32Array() + for influence in stride: + var offset := vertex_index * stride + influence + if offset >= weights.size() or weights[offset] <= 0.0: + continue + ids.append(bone_ids[offset]) + gains.append(weights[offset]) + var key := var_to_str([ids, gains]) + if not grouped.has(key): + grouped[key] = {"ids": ids, "gains": gains, "points": {}} + grouped[key].points[vertices[vertex_index]] = true + var compact: Array = [] + for group: Dictionary in grouped.values(): + group.points = PackedVector3Array(group.points.keys()) + compact.append(group) + _cpu_skin_groups[mesh] = compact + return compact + + +func cpu_skin_world_bounds(model_adapter: Node3D) -> AABB: + var model := model_adapter.get("model") as Node3D + if model == null: + return AABB() + var bounds := AABB() + var found := false + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + var skeleton := mesh_instance.get_node_or_null(mesh_instance.skeleton) as Skeleton3D + if mesh_instance.skin == null or skeleton == null: + var transformed := mesh_instance.global_transform * mesh_instance.get_aabb() + for corner in 8: + var point := transformed.get_endpoint(corner) + bounds = AABB(point, Vector3.ZERO) if not found else bounds.expand(point) + found = true + continue + skeleton.force_update_all_bone_transforms() + var transforms: Array[Transform3D] = [] + for bind_index in mesh_instance.skin.get_bind_count(): + var bone_index := mesh_instance.skin.get_bind_bone(bind_index) + if bone_index < 0: + bone_index = skeleton.find_bone(mesh_instance.skin.get_bind_name(bind_index)) + if bone_index < 0: + continue + transforms.append(skeleton.global_transform * skeleton.get_bone_global_pose(bone_index) * mesh_instance.skin.get_bind_pose(bind_index)) + for group: Dictionary in cpu_skin_groups(mesh_instance.mesh): + var pose := Transform3D(Basis(Vector3.ZERO, Vector3.ZERO, Vector3.ZERO), Vector3.ZERO) + for influence in group.ids.size(): + var bind_index: int = group.ids[influence] + if bind_index < 0 or bind_index >= transforms.size(): + continue + var part: Transform3D = transforms[bind_index] + var gain: float = group.gains[influence] + pose.origin += part.origin * gain + pose.basis.x += part.basis.x * gain + pose.basis.y += part.basis.y * gain + pose.basis.z += part.basis.z * gain + for local_point: Vector3 in group.points: + var world_point := pose * local_point if not group.ids.is_empty() else mesh_instance.global_transform * local_point + bounds = AABB(world_point, Vector3.ZERO) if not found else bounds.expand(world_point) + found = true + return bounds + + +func update_world_envelope(envelope: Dictionary, bounds: AABB, scenario: String) -> void: + if envelope.samples == 0: + envelope.min = bounds.position + envelope.max = bounds.end + envelope.max_depth = bounds.size.z + envelope.min_y_source = scenario + envelope.max_y_source = scenario + envelope.front_source = scenario + envelope.depth_source = scenario + else: + if bounds.position.x < envelope.min.x: + envelope.min.x = bounds.position.x + if bounds.position.y < envelope.min.y: + envelope.min.y = bounds.position.y + envelope.min_y_source = scenario + if bounds.position.z < envelope.min.z: + envelope.min.z = bounds.position.z + if bounds.end.x > envelope.max.x: + envelope.max.x = bounds.end.x + if bounds.end.y > envelope.max.y: + envelope.max.y = bounds.end.y + envelope.max_y_source = scenario + if bounds.end.z > envelope.max.z: + envelope.max.z = bounds.end.z + envelope.front_source = scenario + if bounds.size.z > envelope.max_depth: + envelope.max_depth = bounds.size.z + envelope.depth_source = scenario + envelope.samples += 1 + if _bounds_world_summary.samples == 0: + _bounds_world_summary.min = bounds.position + _bounds_world_summary.max = bounds.end + _bounds_world_summary.max_depth = bounds.size.z + _bounds_world_summary.min_y_source = scenario + _bounds_world_summary.max_y_source = scenario + _bounds_world_summary.front_source = scenario + _bounds_world_summary.depth_source = scenario + else: + if bounds.position.x < _bounds_world_summary.min.x: + _bounds_world_summary.min.x = bounds.position.x + if bounds.position.y < _bounds_world_summary.min.y: + _bounds_world_summary.min.y = bounds.position.y + _bounds_world_summary.min_y_source = scenario + if bounds.position.z < _bounds_world_summary.min.z: + _bounds_world_summary.min.z = bounds.position.z + if bounds.end.x > _bounds_world_summary.max.x: + _bounds_world_summary.max.x = bounds.end.x + if bounds.end.y > _bounds_world_summary.max.y: + _bounds_world_summary.max.y = bounds.end.y + _bounds_world_summary.max_y_source = scenario + if bounds.end.z > _bounds_world_summary.max.z: + _bounds_world_summary.max.z = bounds.end.z + _bounds_world_summary.front_source = scenario + if bounds.size.z > _bounds_world_summary.max_depth: + _bounds_world_summary.max_depth = bounds.size.z + _bounds_world_summary.depth_source = scenario + _bounds_world_summary.samples += 1 + + +func new_world_envelope() -> Dictionary: + return {"samples": 0, "min": Vector3.ZERO, "max": Vector3.ZERO, "max_depth": 0.0, "min_y_source": "", "max_y_source": "", "front_source": "", "depth_source": ""} + + +func assert_world_envelope(envelope: Dictionary, scenario: String) -> void: + check(envelope.samples > 0, "%s produced CPU-skinned world bounds" % scenario) + if envelope.samples <= 0: + return + var min_point: Vector3 = envelope.min + var max_point: Vector3 = envelope.max + check(min_point.x >= WORLD_X_MIN, "%s world min X %.6f >= %.2f" % [scenario, min_point.x, WORLD_X_MIN]) + check(max_point.x <= WORLD_X_MAX, "%s world max X %.6f <= %.2f" % [scenario, max_point.x, WORLD_X_MAX]) + check(min_point.y >= WORLD_Y_MIN, "%s world min Y %.6f >= %.2f" % [scenario, min_point.y, WORLD_Y_MIN]) + check(max_point.y <= WORLD_Y_MAX, "%s world max Y %.6f <= %.2f" % [scenario, max_point.y, WORLD_Y_MAX]) + check(float(envelope.max_depth) <= WORLD_DEPTH_MAX, "%s world depth %.6f <= %.2f" % [scenario, float(envelope.max_depth), WORLD_DEPTH_MAX]) + check(max_point.z <= WORLD_HOVER_Z + 3.0, "%s front Z %.6f <= hover_z + 3 (%.2f)" % [scenario, max_point.z, WORLD_HOVER_Z + 3.0]) + + +func check_model(model_adapter: Node3D, report: Dictionary) -> void: + var model := model_adapter.get("model") as Node3D + check(model != null, "BossModel adapter exposes the imported model") + if model == null: + return + var census := imported_census(model) + for key in ["triangles", "materials", "bones", "surfaces"]: + check(int(report.get(key, -1)) == int(census[key]), "build report %s matches imported census (%d vs %d)" % [key, int(report.get(key, -1)), int(census[key])]) + check(census.meshes == 1, "baked swarm is one merged skinned mesh") + check(census.skeletons.size() == 1, "baked swarm has one skeleton") + if census.skeletons.size() == 1: + var skeleton := census.skeletons[0] as Skeleton3D + var root_index := skeleton.find_bone("root") + check(root_index >= 0, "skeleton exposes root bone") + for drone_index in 6: + var bone_index := skeleton.find_bone("drone_%d" % drone_index) + check(bone_index >= 0, "skeleton exposes drone_%d" % drone_index) + if root_index >= 0 and bone_index >= 0: + check(skeleton.get_bone_parent(bone_index) == root_index, "drone_%d is directly parented to the root" % drone_index) + var model_bounds := bounds_in_model_space(model) + check(not model_bounds.size.is_zero_approx(), "imported mesh has measurable bounds") + for axis in 2: + check(model_bounds.size[axis] >= PROCEDURAL_BOUNDS[axis] * 0.8 and model_bounds.size[axis] <= PROCEDURAL_BOUNDS[axis] * 1.2, "imported XY bounds axis %d stays within 20%% of procedural reference" % axis) + check(model_bounds.size.z <= MAX_REST_DEPTH, "imported rest depth stays within the 1.7 m exception") + check(model_bounds.end.z <= MAX_REST_FRONT_Z, "imported front edge stays at or behind z=0.8601 m") + var skinned_bounds := cpu_skin_rest_bounds(model) + check(not skinned_bounds.size.is_zero_approx(), "CPU skinning produces measurable rest-pose bounds") + for axis in 2: + check(skinned_bounds.size[axis] >= PROCEDURAL_BOUNDS[axis] * 0.8 and skinned_bounds.size[axis] <= PROCEDURAL_BOUNDS[axis] * 1.2, "CPU-skinned rest XY bounds axis %d stay within 20%% of procedural reference" % axis) + check(skinned_bounds.size.z <= MAX_REST_DEPTH, "CPU-skinned rest depth stays within the 1.7 m exception") + check(skinned_bounds.end.z <= MAX_REST_FRONT_Z, "CPU-skinned front edge stays at or behind z=0.8601 m") + var animation_player := model_adapter.get("animation_player") as AnimationPlayer + check(animation_player != null, "BossModel adapter exposes AnimationPlayer") + if animation_player != null: + for action in ACTIONS: + check(animation_player.has_animation(action), "runtime clip exists: " + String(action)) + if animation_player.has_animation(action): + var clip := animation_player.get_animation(action) + check(clip.length > 0.0 and clip.get_track_count() > 0, "runtime clip has keyed motion: " + String(action)) + var idle := animation_player.get_animation(&"idle") if animation_player.has_animation(&"idle") else null + check(idle != null and idle.loop_mode != Animation.LOOP_NONE, "idle clip loops") + for socket_name in SOCKETS: + check(model_adapter.call("socket", socket_name) is Node3D, "adapter resolves socket " + String(socket_name)) + var materials: Variant = model_adapter.get("materials") + check(materials is Array and materials.size() == 1, "runtime adapter rebuilds the one atlas material") + if materials is Array: + for material: Variant in materials: + check(material is ShaderMaterial, "runtime surface uses the boss shader material") + if material is ShaderMaterial: + for parameter in [&"dissolve", &"flash", &"phase_tint"]: + check(has_shader_param(material, parameter), "runtime shader exposes " + String(parameter)) + model_adapter.call("set_time_factor", 0.25) + if animation_player != null: + check(is_equal_approx(animation_player.speed_scale, 0.25), "model adapter applies the requested animation time factor") + model_adapter.call("set_time_factor", 1.0) + + +func check_animated_drone_socket(model_adapter: Node3D) -> void: + var player := model_adapter.get("animation_player") as AnimationPlayer + var socket := model_adapter.call("socket", &"fx_0") as Node3D + var model := model_adapter.get("model") as Node3D + var skeletons: Array = model.find_children("*", "Skeleton3D", true, false) if model != null else [] + check(player != null and socket != null and not skeletons.is_empty(), "drone socket and animation skeleton are available") + if player == null or socket == null or skeletons.is_empty() or not player.has_animation(&"attack"): + return + player.play(&"attack", 0.0) + player.speed_scale = 0.0 + player.seek(0.0, true) + await process_frame + for skeleton_node in skeletons: + (skeleton_node as Skeleton3D).force_update_all_bone_transforms() + var start := socket.global_position + var attack := player.get_animation(&"attack") + player.seek(attack.length * 0.5, true) + await process_frame + for skeleton_node in skeletons: + (skeleton_node as Skeleton3D).force_update_all_bone_transforms() + check(socket.global_position.distance_to(start) > 0.01, "fx_0 follows its drone bone during attack") + player.speed_scale = 1.0 + player.stop() + + +func make_visual(model_path: String) -> Dictionary: + var packed := load(VISUAL_SCENE_PATH) as PackedScene + check(packed != null, "Flaky Test Swarm visual scene loads") + if packed == null: + return {} + var anchor := Node3D.new() + root.add_child(anchor) + var visual := packed.instantiate() as Node3D + check(visual != null, "Flaky Test Swarm visual instantiates") + if visual == null: + anchor.queue_free() + return {} + visual.set("model_path", model_path) + anchor.add_child(visual) + visual.call("on_spawned") + return {"anchor": anchor, "visual": visual} + + +func make_world_bounds_visual() -> Dictionary: + var packed := load(VISUAL_SCENE_PATH) as PackedScene + check(packed != null, "bounds visual scene loads") + if packed == null: + return {} + var anchor := Node3D.new() + anchor.position = Vector3(0.0, 2.6, WORLD_HOVER_Z) + root.add_child(anchor) + var visual := packed.instantiate() as Node3D + check(visual != null, "bounds visual instantiates") + if visual == null: + anchor.queue_free() + return {} + visual.set("hover_z", WORLD_HOVER_Z) + visual.set("model_path", MODEL_PATH) + visual.set("sway_amplitude", 2.4) + visual.set("sway_period", 3.2) + anchor.add_child(visual) + visual.call("on_spawned") + check(visual.global_position.is_equal_approx(Vector3(0.0, 2.6, WORLD_HOVER_Z)), "bounds visual mounts at (0, 2.6, -14)") + return {"anchor": anchor, "visual": visual} + + +func sample_bounds_interval(visual: Node3D, model_adapter: Node3D, envelope: Dictionary, scenario: String, seconds: float, include_ambient_extrema := false) -> void: + var full_steps := int(floor(seconds * BOUNDS_SAMPLE_RATE + 0.00001)) + var sample_index := 0 + for frame in range(full_steps + 1): + if frame > 0: + visual.call("_process", 1.0 / BOUNDS_SAMPLE_RATE) + var bounds := cpu_skin_world_bounds(model_adapter) + add_world_bounds_sample(visual, envelope, bounds, "%s frame %d" % [scenario, sample_index], include_ambient_extrema) + sample_index += 1 + var endpoint := float(full_steps) / BOUNDS_SAMPLE_RATE + var remainder := seconds - endpoint + if remainder > 0.000001: + visual.call("_process", remainder) + var final_bounds := cpu_skin_world_bounds(model_adapter) + add_world_bounds_sample(visual, envelope, final_bounds, "%s endpoint" % scenario, include_ambient_extrema) + + +func add_world_bounds_sample(visual: Node3D, envelope: Dictionary, bounds: AABB, scenario: String, include_ambient_extrema: bool) -> void: + if not include_ambient_extrema: + update_world_envelope(envelope, bounds, scenario) + return + var mounted_position := Vector3(0.0, 2.6, WORLD_HOVER_Z) + var current_ambient := visual.global_position - mounted_position + var unshifted := AABB(bounds.position - current_ambient, bounds.size) + for sway_x in [-0.18, 0.18]: + for sway_y in [0.0, 0.08]: + var candidate_shift := Vector3(sway_x, sway_y, 0.0) + var candidate := AABB(unshifted.position + candidate_shift, unshifted.size) + update_world_envelope(envelope, candidate, "%s ambient x=%.2f y=%.2f" % [scenario, sway_x, sway_y]) + + +func run_world_action_sweep(visual: Node, model_adapter: Node3D, player: AnimationPlayer, phase: int, action: StringName) -> void: + visual.call("on_spawned") + visual.call("set_phase", phase) + var seconds := player.get_animation(action).length + match action: + &"intro": visual.call("play_intro", seconds) + &"idle": visual.call("_play", &"idle", seconds) + &"telegraph": visual.call("play_telegraph", 0b010, &"flaky", seconds) + &"attack": visual.call("play_attack", &"flaky") + &"hurt": visual.call("play_hit", &"flaky", 1) + &"phase_change": visual.call("_play", &"phase_change", seconds) + &"defeat": + visual.call("play_defeat") + seconds = float(visual.get("_state_duration")) + &"retreat": + visual.call("play_retreat") + seconds = float(visual.get("_state_duration")) + var envelope := new_world_envelope() + var scenario := "phase %d action %s" % [phase, String(action)] + sample_bounds_interval(visual, model_adapter, envelope, scenario, seconds, action != &"intro") + assert_world_envelope(envelope, scenario) + + +func run_world_direct_clip_sweep(visual: Node, model_adapter: Node3D, player: AnimationPlayer, phase: int, action: StringName) -> void: + visual.call("on_spawned") + visual.call("set_phase", phase) + var clip := player.get_animation(action) + var seconds := clip.length + visual.call("_play", action, seconds) + var envelope := new_world_envelope() + var scenario := "phase %d direct baked clip %s" % [phase, String(action)] + var frames := int(round(seconds * BOUNDS_SAMPLE_RATE)) + for frame in range(frames + 1): + var progress := float(frame) / float(frames) + visual.set("_state_age", progress * seconds) + visual.call("_seek", action, progress) + visual.call("_apply_formation") + var bounds := cpu_skin_world_bounds(model_adapter) + update_world_envelope(envelope, bounds, "%s frame %d" % [scenario, frame]) + assert_world_envelope(envelope, scenario + " at 120 Hz") + + +func run_world_signature_sweep(visual: Node, model_adapter: Node3D, phase: int, projectile_kind: StringName, commit: bool) -> void: + visual.call("on_spawned") + visual.call("set_phase", phase) + var seconds := 0.8 + var envelope := new_world_envelope() + var scenario := "phase %d signature %s%s" % [phase, String(projectile_kind), " commit" if commit else " telegraph"] + visual.call("play_telegraph", 0b010, projectile_kind, seconds) + if commit: + sample_bounds_interval(visual, model_adapter, envelope, scenario + " preview", seconds * 0.5, true) + visual.call("play_commit", 0b100, projectile_kind, seconds * 0.5) + sample_bounds_interval(visual, model_adapter, envelope, scenario + " committed", seconds * 0.5, true) + else: + sample_bounds_interval(visual, model_adapter, envelope, scenario, seconds, true) + visual.call("play_attack", projectile_kind) + var player := model_adapter.get("animation_player") as AnimationPlayer + if player != null and player.has_animation(&"attack"): + var attack_seconds := player.get_animation(&"attack").length + sample_bounds_interval(visual, model_adapter, envelope, scenario + " then attack", attack_seconds, true) + assert_world_envelope(envelope, scenario) + + +func run_world_cinematic_sweep(visual: Node, model_adapter: Node3D, phase: int, mode: StringName, seconds: float) -> void: + var envelope := new_world_envelope() + var frames := int(round(seconds * BOUNDS_SAMPLE_RATE)) + var inherited_offsets: Array[Vector2] = [Vector2.ZERO] + if mode in [&"defeat_full", &"defeat_short"]: + inherited_offsets = [Vector2(-0.18, 0.0), Vector2(-0.18, 0.08), Vector2(0.18, 0.0), Vector2(0.18, 0.08)] + for offset in inherited_offsets: + visual.call("on_spawned") + visual.call("set_phase", phase) + visual.position += Vector3(offset.x, offset.y, 0.0) + visual.call("play_cinematic", mode, seconds) + for frame in range(frames + 1): + var progress := float(frame) / float(frames) + visual.call("set_cinematic_progress", mode, progress) + var bounds := cpu_skin_world_bounds(model_adapter) + update_world_envelope(envelope, bounds, "phase %d %s offset %s progress %.6f" % [phase, String(mode), offset, progress]) + var scenario := "phase %d cinematic %s at 120 Hz" % [phase, String(mode)] + assert_world_envelope(envelope, scenario) + + +func check_world_pose_envelopes() -> void: + if not FileAccess.file_exists(MODEL_PATH): + check(false, "world bounds sweeps require imported Flaky Test Swarm model") + return + var instance := make_world_bounds_visual() + if instance.is_empty(): + return + var visual := instance.visual as Node + var model_adapter := visual.get("_model") as Node3D + var player := model_adapter.get("animation_player") as AnimationPlayer if model_adapter != null else null + check(model_adapter != null and player != null, "world bounds sweeps use the imported model adapter and AnimationPlayer") + if model_adapter == null or player == null: + instance.anchor.queue_free() + return + for action in ACTIONS: + check(player.has_animation(action), "world bounds sweep clip exists: %s" % String(action)) + for phase in [1, 2, 3]: + for action in ACTIONS: + if player.has_animation(action): + run_world_action_sweep(visual, model_adapter, player, phase, action) + run_world_direct_clip_sweep(visual, model_adapter, player, phase, action) + run_world_signature_sweep(visual, model_adapter, phase, &"flaky", false) + run_world_signature_sweep(visual, model_adapter, phase, &"scatter", false) + run_world_signature_sweep(visual, model_adapter, phase, &"flaky", true) + for mode in [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"]: + var duration := 5.0 if mode == &"intro_full" else 2.5 if mode == &"intro_short" else 3.6 if mode == &"defeat_full" else 2.0 + run_world_cinematic_sweep(visual, model_adapter, phase, mode, duration) + run_world_cinematic_sweep(visual, model_adapter, phase, &"intro_full", 3.6) + run_world_cinematic_sweep(visual, model_adapter, phase, &"intro_short", 3.6) + print("BOSS FLAKY SWARM WORLD ENVELOPE: %d samples min=%s (Y at %s) max=%s (Y at %s, front at %s) depth=%.6f at %s" % [ + _bounds_world_summary.samples, + _bounds_world_summary.min, + _bounds_world_summary.min_y_source, + _bounds_world_summary.max, + _bounds_world_summary.max_y_source, + _bounds_world_summary.front_source, + float(_bounds_world_summary.max_depth), + _bounds_world_summary.depth_source, + ]) + instance.anchor.queue_free() + + +func advance_visual(visual: Node, seconds: float, step := 1.0 / 120.0) -> void: + var remaining := seconds + while remaining > 0.000001: + var delta := minf(step, remaining) + visual.call("_process", delta) + remaining -= delta + + +func shader_value(adapter: Node, parameter: StringName) -> float: + var materials: Variant = adapter.get("materials") + if materials is Array: + for material: Variant in materials: + if material is ShaderMaterial: + return float(material.get_shader_parameter(parameter)) + return 0.0 + + +func drone_positions(skeleton: Skeleton3D, bone_indices: Array) -> Array[Vector3]: + var result: Array[Vector3] = [] + for bone_index in bone_indices: + result.append(skeleton.get_bone_pose_position(int(bone_index))) + return result + + +func average_spread(points: Array[Vector3]) -> float: + var total := 0.0 + for point in points: + total += Vector2(point.x, point.y - 1.02).length() + return total / maxf(points.size(), 1) + + +func formation_centroid(points: Array[Vector3]) -> Vector2: + var total := Vector2.ZERO + for point in points: + total += Vector2(point.x, point.y) + return total / maxf(points.size(), 1) + + +func mean_formation_radius(points: Array[Vector3]) -> float: + var centre := formation_centroid(points) + var total := 0.0 + for point in points: + total += Vector2(point.x, point.y).distance_to(centre) + return total / maxf(points.size(), 1) + + +func minimum_pairwise_distance(points: Array[Vector3]) -> float: + var minimum := INF + for first in points.size(): + for second in range(first + 1, points.size()): + minimum = minf(minimum, Vector2(points[first].x, points[first].y).distance_to(Vector2(points[second].x, points[second].y))) + return minimum + + +func cinematic_snapshot(visual: Node, adapter: Node, skeleton: Skeleton3D, drone_bones: Array) -> Dictionary: + var player := adapter.get("animation_player") as AnimationPlayer + var positions := drone_positions(skeleton, drone_bones) + var rotations: Array[Quaternion] = [] + for bone_index in drone_bones: + rotations.append(skeleton.get_bone_pose_rotation(int(bone_index))) + return { + "state": StringName(visual.get("_state")), + "age": float(visual.get("_cinematic_age")), + "clip": player.current_animation_position if player != null else -1.0, + "positions": positions, + "rotations": rotations, + "flash": shader_value(adapter, &"flash"), + "dissolve": shader_value(adapter, &"dissolve"), + } + + +func check_same_snapshot(first: Dictionary, second: Dictionary, message: String) -> void: + check(first.state == second.state, message + " keeps the same action state") + check(is_equal_approx(first.clip, second.clip), message + " keeps the same clip position") + check(is_equal_approx(first.flash, second.flash), message + " keeps the same flash") + check(is_equal_approx(first.dissolve, second.dissolve), message + " keeps the same dissolve") + var first_positions: Array = first.positions + var second_positions: Array = second.positions + var first_rotations: Array = first.rotations + var second_rotations: Array = second.rotations + for index in mini(first_positions.size(), second_positions.size()): + check(first_positions[index].distance_to(second_positions[index]) < 0.0001, message + " keeps drone %d position" % index) + check(first_rotations[index].is_equal_approx(second_rotations[index]), message + " keeps drone %d rotation" % index) + + +func compare_cinematic_progress_paths(visual: Node, adapter: Node3D, skeleton: Skeleton3D, drone_bones: Array, mode: StringName, seconds: float) -> void: + visual.call("on_spawned") + visual.call("play_cinematic", mode, seconds) + visual.call("set_cinematic_progress", mode, 0.0) + visual.call("set_cinematic_progress", mode, 0.6) + var skipped := cinematic_snapshot(visual, adapter, skeleton, drone_bones) + visual.call("on_spawned") + visual.call("play_cinematic", mode, seconds) + visual.call("set_cinematic_progress", mode, 0.0) + visual.call("set_cinematic_progress", mode, 0.25) + visual.call("set_cinematic_progress", mode, 0.6) + var stepped := cinematic_snapshot(visual, adapter, skeleton, drone_bones) + check_same_snapshot(skipped, stepped, String(mode) + " direct skip matches sequential progress") + visual.call("set_time_factor", 0.05) + visual.call("set_cinematic_progress", mode, 0.6) + var slow_factor := cinematic_snapshot(visual, adapter, skeleton, drone_bones) + visual.call("set_time_factor", 1.0) + visual.call("set_cinematic_progress", mode, 0.6) + var normal_factor := cinematic_snapshot(visual, adapter, skeleton, drone_bones) + check_same_snapshot(slow_factor, normal_factor, String(mode) + " progress is independent of time factor") + var before_process := cinematic_snapshot(visual, adapter, skeleton, drone_bones) + for delta in [0.01, 0.08, 0.4, 1.0]: + visual.call("_process", delta) + var after_process := cinematic_snapshot(visual, adapter, skeleton, drone_bones) + check_same_snapshot(before_process, after_process, String(mode) + " does not drift during _process") + visual.call("on_spawned") + + +func check_model_signature_beats() -> void: + var instance := make_visual(MODEL_PATH) + if instance.is_empty(): + return + var visual := instance.visual as Node + var skeleton := visual.get("_skeleton") as Skeleton3D + var drone_bones: Array = visual.get("_drone_bones") + check(skeleton != null and drone_bones.size() == 6, "model visual exposes all six drone bones for signature checks") + if skeleton == null or drone_bones.size() != 6: + instance.anchor.queue_free() + return + visual.call("play_telegraph", 0b101, &"orb", 1.0) + visual.call("_process", 0.2) + var before_commit := drone_positions(skeleton, drone_bones) + visual.call("play_commit", 0b100, &"orb", 0.3) + visual.call("_process", 0.12) + var committed := drone_positions(skeleton, drone_bones) + var before_centre := formation_centroid(before_commit) + var committed_centre := formation_centroid(committed) + var expected_committed_centroid_x := float(visual.get("_aim")) * 0.08 + for index in 6: + var rotation := skeleton.get_bone_pose_rotation(int(drone_bones[index])) + check(rotation.is_equal_approx(Quaternion(Vector3.UP, PI)), "commit turns drone %d to its red failure face" % index) + check(committed_centre.x > before_centre.x + 0.05, "+2 lane commit moves formation centroid toward positive X") + check(absf(committed_centre.x - expected_committed_centroid_x) < 0.03, "full commit centers the formation at aim times 0.08") + check(mean_formation_radius(committed) < mean_formation_radius(before_commit), "committed formation contracts around its spaced lane focus") + check(minimum_pairwise_distance(committed) >= 0.85, "committed drone centers stay at least 0.85 m apart") + visual.call("_sample_action") + visual.call("_apply_formation") + var fixed_sample_a := drone_positions(skeleton, drone_bones) + visual.call("_sample_action") + visual.call("_apply_formation") + var fixed_sample_b := drone_positions(skeleton, drone_bones) + for index in 6: + check(fixed_sample_a[index].distance_to(fixed_sample_b[index]) < 0.0001, "repeated fixed seek does not accumulate drone %d formation offsets" % index) + visual.call("on_spawned") + visual.call("play_telegraph", 0b010, &"orb", 0.8) + visual.set("_state_age", 0.4) + visual.call("_sample_action") + visual.call("_apply_formation") + var orb_spread := average_spread(drone_positions(skeleton, drone_bones)) + visual.call("on_spawned") + visual.call("play_telegraph", 0b010, &"scatter", 0.8) + visual.set("_state_age", 0.4) + visual.call("_sample_action") + visual.call("_apply_formation") + var scatter_spread := average_spread(drone_positions(skeleton, drone_bones)) + check(scatter_spread > orb_spread + 0.01, "scatter signature widens the GLB drone formation versus orb at matching progress") + visual.call("on_spawned") + visual.call("_process", 0.01) + var carapace_left := skeleton.find_bone("carapace_L") + var carapace_right := skeleton.find_bone("carapace_R") + check(carapace_left >= 0 and carapace_right >= 0, "model exposes persistent phase-split carapace bones") + if carapace_left >= 0 and carapace_right >= 0: + var phase_one_left := skeleton.get_bone_pose_position(carapace_left).x + var phase_one_right := skeleton.get_bone_pose_position(carapace_right).x + visual.call("set_phase", 3) + visual.call("_process", 1.21) + check(is_equal_approx(skeleton.get_bone_pose_position(carapace_left).x - phase_one_left, -0.16), "persistent left carapace split adds 0.08 m per phase") + check(is_equal_approx(skeleton.get_bone_pose_position(carapace_right).x - phase_one_right, 0.16), "persistent right carapace split adds 0.08 m per phase") + instance.anchor.queue_free() + + +func check_signature_contract() -> void: + check(ProjectileArt.look_for(&"orb", &"flaky_test_swarm") == &"swarm_tile", "flaky signature still selects the stuttering tile projectile") + check(ProjectileArt.look_for(&"scatter", &"flaky_test_swarm") == &"swarm_scatter", "scatter signature still selects the splitting projectile") + var looks: Dictionary = ProjectileArt.LOOKS + check(looks.get(&"swarm_tile", []).has(&"flaky"), "swarm tile keeps the flaky signature key") + check(looks.get(&"swarm_scatter", []).has(&"scatter"), "swarm scatter keeps the scatter signature key") + + +func check_loaded_visual() -> void: + if not FileAccess.file_exists(MODEL_PATH): + return + var instance := make_visual(MODEL_PATH) + if instance.is_empty(): + return + var visual := instance.visual as Node + check(visual.process_mode == Node.PROCESS_MODE_PAUSABLE, "model visual remains pausable") + var adapter := visual.get("_model") as Node3D + check(adapter != null, "visual uses the imported model adapter") + check(visual.get("_art") == null, "procedural art stays unused when model loading succeeds") + if adapter == null: + instance.anchor.queue_free() + return + var player := adapter.get("animation_player") as AnimationPlayer + check(player != null and player.current_animation == &"idle", "spawn enters the idle model action") + if player != null: + var before_pause := player.current_animation_position + paused = true + await process_frame + check(is_equal_approx(player.current_animation_position, before_pause), "pausing the scene tree freezes model animation") + var state_before_direct_tick: float = visual.get("_state_age") + visual.call("_process", 0.4) + check(is_equal_approx(float(visual.get("_state_age")), state_before_direct_tick), "visual process guard freezes a direct tick while the tree is paused") + paused = false + visual.call("set_time_factor", 0.5) + var clock_before: float = visual.get("_state_age") + visual.call("_process", 0.4) + var clock_after: float = visual.get("_state_age") + check(is_equal_approx(clock_after - clock_before, 0.2), "visual state clock advances by delta times its time factor exactly once") + visual.call("set_time_factor", 1.0) + visual.call("play_cinematic", &"intro_full", 2.0) + check(StringName(visual.get("_cinematic_mode")) == &"intro_full", "intro_full cinematic mode starts") + if player != null: + check(player.current_animation == &"intro", "intro_full starts the authored intro clip") + visual.call("set_cinematic_progress", &"intro_full", 0.0) + visual.call("set_cinematic_progress", &"intro_full", 0.6) + check(StringName(visual.get("_state")) == &"intro", "intro_full assembles the drones through its first 55 percent") + visual.call("set_cinematic_progress", &"intro_full", 0.9) + check(StringName(visual.get("_state")) == &"intro", "intro_full holds its completed intro pose through the final hold") + visual.call("set_cinematic_progress", &"intro_full", 1.0) + check(StringName(visual.get("_cinematic_mode")).is_empty() and StringName(visual.get("_state")) == &"intro", "intro_full completes while preserving its final intro pose") + visual.call("_process", 1.0 / BOUNDS_SAMPLE_RATE) + check(StringName(visual.get("_state")) == &"idle", "intro_full returns to idle on the next normal process tick") + visual.call("play_cinematic", &"intro_short", 1.0) + check(StringName(visual.get("_cinematic_mode")) == &"intro_short", "intro_short cinematic mode starts") + visual.call("set_cinematic_progress", &"intro_short", 0.0) + visual.call("set_cinematic_progress", &"intro_short", 0.6) + visual.call("set_cinematic_progress", &"intro_short", 0.9) + check(StringName(visual.get("_state")) == &"intro", "intro_short holds its completed intro pose through the final hold") + visual.call("set_cinematic_progress", &"intro_short", 1.0) + check(StringName(visual.get("_cinematic_mode")).is_empty() and StringName(visual.get("_state")) == &"intro", "intro_short completes while preserving its final intro pose") + visual.call("_process", 1.0 / BOUNDS_SAMPLE_RATE) + check(StringName(visual.get("_state")) == &"idle", "intro_short returns to idle on the next normal process tick") + visual.call("play_cinematic", &"intro_full", 2.0) + visual.call("set_cinematic_progress", &"intro_full", 0.2) + visual.call("play_defeat") + visual.call("play_cinematic", &"intro_short", 1.0) + check(bool(visual.get("_terminal")) and StringName(visual.get("_state")) == &"defeat", "death during intro blocks a later intro cinematic from reviving the boss") + check(StringName(visual.get("_cinematic_mode")).is_empty(), "death during intro clears the active cinematic") + visual.call("on_spawned") + check(not bool(visual.get("_terminal")) and player != null and player.current_animation == &"idle", "on_spawned respawns the terminal visual into idle") + var drone_bones: Array = visual.get("_drone_bones") + var skeleton := visual.get("_skeleton") as Skeleton3D + if skeleton != null and drone_bones.size() == 6: + visual.call("on_spawned") + visual.call("play_cinematic", &"intro_full", 2.0) + visual.call("set_cinematic_progress", &"intro_full", 0.62) + for drone_index in 6: + var expected_rotation := Quaternion(Vector3.UP, PI) if INTRO_RED_DRONES.has(drone_index) else Quaternion.IDENTITY + var intro_rotation := skeleton.get_bone_pose_rotation(int(drone_bones[drone_index])) + check(absf(intro_rotation.dot(expected_rotation)) > 0.999, "intro hold drone %d faces %s" % [drone_index, "red" if INTRO_RED_DRONES.has(drone_index) else "green"]) + compare_cinematic_progress_paths(visual, adapter, skeleton, drone_bones, &"intro_full", 2.0) + compare_cinematic_progress_paths(visual, adapter, skeleton, drone_bones, &"intro_short", 1.0) + compare_cinematic_progress_paths(visual, adapter, skeleton, drone_bones, &"defeat_full", 2.0) + compare_cinematic_progress_paths(visual, adapter, skeleton, drone_bones, &"defeat_short", 1.0) + check_intro_hold_pose(visual, adapter, skeleton, &"intro_full", 5.0, 0.65) + check_intro_hold_pose(visual, adapter, skeleton, &"intro_short", 2.5, 0.85) + for mode in [&"defeat_full", &"defeat_short"]: + visual.call("on_spawned") + visual.call("play_cinematic", mode, 2.0) + check(StringName(visual.get("_cinematic_mode")) == mode, String(mode) + " cinematic mode starts") + visual.call("set_cinematic_progress", mode, 0.0) + visual.call("set_cinematic_progress", mode, 0.15) + check(shader_value(adapter, &"flash") > 0.0, String(mode) + " strobes its flash during the opening quarter") + check(shader_value(adapter, &"dissolve") <= 0.01, String(mode) + " keeps dissolve off before its final phase") + visual.call("set_cinematic_progress", mode, 0.25) + visual.call("set_cinematic_progress", mode, 0.6) + check(player == null or player.assigned_animation == &"defeat", String(mode) + " enters its authored defeat collapse") + check(shader_value(adapter, &"dissolve") > 0.0, String(mode) + " begins dissolve in its final phase") + visual.call("set_cinematic_progress", mode, 0.9) + check(shader_value(adapter, &"dissolve") < 0.99, String(mode) + " dissolve remains partial at 90 percent") + visual.call("set_cinematic_progress", mode, 1.0) + check(StringName(visual.get("_cinematic_mode")).is_empty(), String(mode) + " completes its cinematic sequence") + check(shader_value(adapter, &"dissolve") >= 0.99, String(mode) + " completes its final dissolve") + instance.anchor.queue_free() + + +func check_fallback_visual() -> void: + var instance := make_visual("res://Assets/runner/bosses/flaky_test_swarm/missing.glb") + if instance.is_empty(): + return + var visual := instance.visual as Node + visual.set("show_name_plate", false) + visual.call("on_spawned") + check(visual.get("_model") == null, "missing model path selects procedural fallback") + var art := visual.get("_art") as Node + check(art != null, "procedural fallback art is constructed") + check(int(visual.get("intro_calls")) == 0, "intro counter starts intact on fallback") + if art != null: + check(not (art.get("tag") as Label3D).visible, "show_name_plate still controls fallback name plate") + visual.call("play_telegraph", 0b101, &"orb", 0.8) + if art != null: + check(art.get("state") == &"telegraph", "fallback handles the original telegraph beat") + check(is_equal_approx(float(art.get("aim")), 0.0), "fallback keeps the flaky two-lane aim centered") + visual.call("play_commit", 0b001, &"orb", 0.3) + if art != null: + check(float(art.get("commit_age")) == 0.0, "fallback keeps the flaky commit beat") + visual.call("play_attack", &"orb") + if art != null: + check(art.get("state") == &"attack", "fallback keeps the signature attack beat") + visual.call("play_intro", 1.2) + check(int(visual.get("intro_calls")) == 1, "play_intro still increments intro_calls") + visual.call("on_spawned") + visual.call("play_cinematic", &"intro_full", 2.0) + visual.call("set_cinematic_progress", &"intro_full", 0.6) + if art != null: + check(art.get("state") == &"intro", "fallback cinematic samples intro progress") + var intro_pose_age := float(art.get("age")) + var intro_pose_clock := float(art.get("clock")) + var intro_pose_transforms := node3d_transform_snapshot(art) + var intro_hold_end := float(BossVisualBase.INTRO_HOLD_UNTIL[&"intro_full"]) + var intro_hold_probe := 0.65 + visual.call("_process", 0.35) + check(is_equal_approx(float(art.get("age")), intro_pose_age), "fallback cinematic process does not advance intro age") + check(is_equal_approx(float(art.get("clock")), intro_pose_clock), "fallback cinematic process does not advance intro clock") + check_node3d_transforms_match(art, intro_pose_transforms, "fallback intro cinematic process freeze") + visual.call("set_cinematic_progress", &"intro_full", intro_hold_probe) + check(is_equal_approx(float(art.get("age")), intro_pose_age), "fallback intro hold keeps the completed pose age") + check(is_equal_approx(float(art.get("clock")), intro_pose_clock), "fallback intro hold keeps the completed pose clock") + check_node3d_transforms_match(art, intro_pose_transforms, "fallback intro hold at %.0f percent" % (intro_hold_probe * 100.0)) + visual.call("set_cinematic_progress", &"intro_full", intro_hold_end) + check_node3d_transforms_match(art, intro_pose_transforms, "fallback intro hold at shared hold end") + visual.call("set_cinematic_progress", &"intro_full", 1.0) + check_node3d_transforms_match(art, intro_pose_transforms, "fallback intro hold at cinematic end") + check(art.get("state") == &"intro", "fallback keeps intro state on the last cinematic frame") + visual.call("_process", 1.0 / BOUNDS_SAMPLE_RATE) + check(art.get("state") == &"idle", "fallback enters idle on the next normal process tick") + visual.call("set_cinematic_progress", &"intro_full", 1.0) + visual.call("play_cinematic", &"defeat_full", 2.0) + visual.call("set_cinematic_progress", &"defeat_full", 0.6) + if art != null: + check(art.get("state") == &"defeated", "fallback cinematic samples defeat progress") + visual.call("set_cinematic_progress", &"defeat_full", 1.0) + instance.anchor.queue_free() + + +func run() -> void: + await process_frame + check(FileAccess.file_exists(MODEL_SCRIPT_PATH), "BossModel loader script exists") + var report := check_build_report() + check_signature_contract() + if FileAccess.file_exists(MODEL_SCRIPT_PATH) and FileAccess.file_exists(MODEL_PATH): + var model_script: Script = load(MODEL_SCRIPT_PATH) + var adapter := model_script.call("load_model", MODEL_PATH) as Node3D if model_script != null else null + check(adapter != null, "full-bake GLB loads through BossModel") + if adapter != null: + root.add_child(adapter) + await process_frame + check_model(adapter, report) + await check_animated_drone_socket(adapter) + adapter.queue_free() + else: + check(false, "full-bake GLB exists for runtime model checks") + await check_loaded_visual() + if FileAccess.file_exists(MODEL_PATH): + check_model_signature_beats() + check_world_pose_envelopes() + check_fallback_visual() + finish() + + +func finish() -> void: + await process_frame + _cpu_skin_groups.clear() + call_deferred("finish_after_cleanup") + + +func finish_after_cleanup() -> void: + print("BOSS FLAKY SWARM CHECK: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/assets/boss_flaky_test_swarm_check.gd.uid b/hoppys-adventure-arcade/tests/assets/boss_flaky_test_swarm_check.gd.uid new file mode 100644 index 00000000..4f93c65d --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_flaky_test_swarm_check.gd.uid @@ -0,0 +1 @@ +uid://m85vev3vyfxcz diff --git a/hoppys-adventure-arcade/tests/assets/boss_kit_check.gd b/hoppys-adventure-arcade/tests/assets/boss_kit_check.gd new file mode 100644 index 00000000..6590ff83 --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_kit_check.gd @@ -0,0 +1,570 @@ +extends SceneTree + +const MODEL_SCRIPT_PATH := "res://scenes/runner/boss/hifi/boss_model.gd" +const BossModelScript := preload("res://scenes/runner/boss/hifi/boss_model.gd") +const SELFTEST_PATH := "res://Assets/runner/bosses/kit_selftest/kit_selftest.glb" +const BUILD_PATH := "res://Assets/runner/bosses/kit_selftest/kit_selftest.build.json" +const REST_VALIDATION_META := &"_boss_rest_validation_v2" +const ACTIONS: Array[StringName] = [&"intro", &"idle", &"telegraph", &"attack", &"hurt", &"phase_change", &"defeat", &"retreat"] +const SOCKETS: Array[StringName] = [&"fx_core", &"fx_0", &"fx_1", &"fx_2", &"fx_3", &"fx_4", &"fx_5", &"muzzle_0", &"muzzle_1", &"muzzle_2"] +const MAX_TRIANGLES := 18000 +const MAX_MATERIALS := 4 +const MAX_BONES := 40 +const MAX_SURFACES := 6 +const DISSOLVE_PROGRESS := [0.0, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90, 0.95, 1.0] +const DISSOLVE_RETENTION_BANDS := { + 0.60: Vector2(0.82, 0.87), + 0.75: Vector2(0.52, 0.58), + 0.90: Vector2(0.18, 0.23), + 0.95: Vector2(0.01, 0.05), +} + +var checks := 0 +var failures := 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("BOSS KIT: " + message) + + +func has_shader_param(material: ShaderMaterial, name: StringName) -> bool: + if material == null or material.shader == null: + return false + for uniform: Dictionary in material.shader.get_shader_uniform_list(): + if StringName(uniform.get("name", "")) == name: + return true + return false + + +func check_build_report() -> Dictionary: + check(FileAccess.file_exists(BUILD_PATH), "host-generated kit self-test build report exists") + if not FileAccess.file_exists(BUILD_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(BUILD_PATH)) + check(parsed is Dictionary, "build report parses as an object") + if not parsed is Dictionary: + return {} + var report: Dictionary = parsed + check(report.get("quick", true) == false, "self-test uses the full bake") + check(int(report.get("kit_revision", 0)) >= 2, "self-test uses the rest-position kit revision") + check(report.get("rest_position_encoding", "") == "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1", "report declares the rest-position attribute ABI") + check(int(report.get("animation_sample_rate", -1)) == 120, "build report samples animations at 120 Hz") + check(int(report.get("triangles", -1)) >= 0 and int(report.get("triangles", MAX_TRIANGLES + 1)) <= MAX_TRIANGLES, "build report triangle budget <= 18000") + check(int(report.get("materials", -1)) >= 0 and int(report.get("materials", MAX_MATERIALS + 1)) <= MAX_MATERIALS, "build report material budget <= 4") + check(int(report.get("bones", -1)) >= 0 and int(report.get("bones", MAX_BONES + 1)) <= MAX_BONES, "build report bone budget <= 40") + check(int(report.get("surfaces", -1)) >= 0 and int(report.get("surfaces", MAX_SURFACES + 1)) <= MAX_SURFACES, "build report surface budget <= 6") + var actions: Array = report.get("actions", []) + for action in ACTIONS: + check(actions.has(String(action)) or actions.has(action), "build report includes action " + String(action)) + var sockets: Array = report.get("sockets", []) + for socket_name in SOCKETS: + check(sockets.has(String(socket_name)) or sockets.has(socket_name), "build report includes socket " + String(socket_name)) + return report + + +func mesh_skeleton_census(model: Node3D) -> Dictionary: + var triangle_count := 0 + var surface_count := 0 + var materials: Dictionary = {} + var bone_count := 0 + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + for surface in mesh_instance.mesh.get_surface_count(): + surface_count += 1 + var arrays := mesh_instance.mesh.surface_get_arrays(surface) + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + triangle_count += (indices.size() if not indices.is_empty() else vertices.size()) / 3 + var source := mesh_instance.mesh.surface_get_material(surface) + if source != null: + materials[source.get_instance_id()] = true + for node in model.find_children("*", "Skeleton3D", true, false): + bone_count += (node as Skeleton3D).get_bone_count() + return {"triangles": triangle_count, "materials": materials.size(), "bones": bone_count, "surfaces": surface_count} + + +func check_mesh_skeleton_census(model: Node3D, report: Dictionary) -> void: + var census := mesh_skeleton_census(model) + for key in ["triangles", "materials", "bones", "surfaces"]: + check(int(report.get(key, -1)) == int(census[key]), "build report %s matches imported census (%d vs %d)" % [key, int(report.get(key, -1)), int(census[key])]) + + +func check_rest_positions(model: Node3D, model_script: Script) -> void: + for node: MeshInstance3D in model.find_children("*", "MeshInstance3D", true, false): + for surface in node.mesh.get_surface_count(): + var arrays := node.mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var restored: PackedVector3Array = model_script.call("rest_positions", node.mesh, surface) + check(restored.size() == vertices.size(), "every imported vertex preserves encoded rest xyz and version marker") + var error := 0.0 + for index in mini(vertices.size(), restored.size()): + error = maxf(error, vertices[index].distance_to(restored[index])) + check(error <= 0.0001, "rest coordinates survive glTF import within 0.1 mm") + # An older mesh must use procedural fallback instead of silently burning from (0,0,0). + var old_mesh := ArrayMesh.new() + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array([Vector3.ZERO, Vector3.RIGHT, Vector3.UP]) + old_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + check(model_script.call("rest_positions", old_mesh, 0).is_empty(), "missing rest-position attributes are rejected") + + +func rest_scan_count() -> int: + return BossModelScript._rest_position_scan_count + + +func source_meshes(model: Node3D) -> Array[Mesh]: + var result: Array[Mesh] = [] + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh := (node as MeshInstance3D).mesh + if mesh != null and not result.has(mesh): + result.append(mesh) + return result + + +func source_surface_count(meshes: Array[Mesh]) -> int: + var count := 0 + for mesh in meshes: + count += mesh.get_surface_count() + return count + + +func make_valid_rest_mesh() -> ArrayMesh: + var mesh := ArrayMesh.new() + var valid := [] + valid.resize(Mesh.ARRAY_MAX) + valid[Mesh.ARRAY_VERTEX] = PackedVector3Array([Vector3.ZERO, Vector3.RIGHT, Vector3.UP]) + valid[Mesh.ARRAY_TEX_UV2] = PackedVector2Array([Vector2.ZERO, Vector2.RIGHT, Vector2(0, 1)]) + valid[Mesh.ARRAY_CUSTOM0] = PackedFloat32Array([0.0, 1.0, 0.0, 1.0, 0.0, 1.0]) + var rest_flags := Mesh.ARRAY_FORMAT_CUSTOM0 | (Mesh.ARRAY_CUSTOM_RG_FLOAT << Mesh.ARRAY_FORMAT_CUSTOM0_SHIFT) + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, valid, [], {}, rest_flags) + return mesh + + +func make_rest_validation_mesh() -> ArrayMesh: + var mesh := make_valid_rest_mesh() + var invalid := [] + invalid.resize(Mesh.ARRAY_MAX) + invalid[Mesh.ARRAY_VERTEX] = PackedVector3Array([Vector3.ZERO, Vector3.RIGHT, Vector3.UP]) + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, invalid) + return mesh + + +func cache_validity(model_script: Script, mesh: Mesh, surface: int) -> bool: + return bool(model_script.call("_has_valid_rest_positions", mesh, surface)) + + +func check_rest_validation_cache(model_script: Script) -> void: + var mesh := make_rest_validation_mesh() + var before := rest_scan_count() + check(cache_validity(model_script, mesh, 0), "first valid surface cache lookup accepts encoded rest data") + check(rest_scan_count() == before + 1, "first lookup freshly scans its surface") + check(cache_validity(model_script, mesh, 0), "repeat lookup returns cached valid surface") + check(rest_scan_count() == before + 1, "repeat lookup does not rescan a valid surface") + check(not cache_validity(model_script, mesh, 1), "invalid second surface is rejected") + check(rest_scan_count() == before + 2, "second surface is validated independently") + check(not cache_validity(model_script, mesh, 1), "repeat lookup returns cached invalid surface") + check(rest_scan_count() == before + 2, "cached invalid surface does not rescan") + check(mesh.has_meta(REST_VALIDATION_META), "validated surfaces are recorded on the Mesh") + var duplicate := mesh.duplicate() as Mesh + check(duplicate != null and duplicate.get_instance_id() != mesh.get_instance_id(), "duplicate is a distinct Mesh resource") + if duplicate != null: + # Force the copied stale owner id so this assertion covers duplicate metadata even + # if a future Godot version stops copying custom metadata automatically. + var copied_cache: Dictionary = mesh.get_meta(REST_VALIDATION_META) + duplicate.set_meta(REST_VALIDATION_META, copied_cache.duplicate(true)) + var before_duplicate := rest_scan_count() + check(cache_validity(model_script, duplicate, 0), "duplicate with stale metadata validates its own valid surface") + check(rest_scan_count() == before_duplicate + 1, "duplicate owner id forces a fresh scan") + check(cache_validity(model_script, duplicate, 0), "duplicate then caches its valid surface") + check(rest_scan_count() == before_duplicate + 1, "duplicate valid surface is warm after its first scan") + check(not cache_validity(model_script, duplicate, 1), "duplicate independently rejects its invalid surface") + check(rest_scan_count() == before_duplicate + 2, "duplicate caches each surface independently") + mesh.emit_changed() + check(not mesh.has_meta(REST_VALIDATION_META), "Mesh.changed invalidates its validation metadata") + var before_changed := rest_scan_count() + check(cache_validity(model_script, mesh, 0), "changed Mesh revalidates a valid surface") + check(rest_scan_count() == before_changed + 1, "changed Mesh rescans its valid surface") + check(not cache_validity(model_script, mesh, 1), "changed Mesh revalidates an invalid surface") + check(rest_scan_count() == before_changed + 2, "changed Mesh rescans each surface independently") + duplicate = null + mesh = null + var temporary_ref := cached_temporary_mesh_ref(model_script) + await process_frame + check(temporary_ref.get_ref() == null, "validation cache does not keep a temporary Mesh alive") + + +func cached_temporary_mesh_ref(model_script: Script) -> WeakRef: + var temporary := make_rest_validation_mesh() + check(cache_validity(model_script, temporary, 0), "temporary Mesh receives a validation cache entry") + check(temporary.has_meta(REST_VALIDATION_META), "temporary Mesh stores cache metadata locally") + return weakref(temporary) + + +func make_skeleton_guard_scene(include_skeleton: bool) -> PackedScene: + var scene_root := Node3D.new() + var player := AnimationPlayer.new() + var library := AnimationLibrary.new() + for action in ACTIONS: + library.add_animation(action, Animation.new()) + player.add_animation_library(&"", library) + scene_root.add_child(player) + player.owner = scene_root + for socket_name in SOCKETS: + var socket := Node3D.new() + socket.name = String(socket_name) + scene_root.add_child(socket) + socket.owner = scene_root + var surface_mesh := make_valid_rest_mesh() + surface_mesh.surface_set_material(0, StandardMaterial3D.new()) + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "FixtureMesh" + mesh_instance.mesh = surface_mesh + scene_root.add_child(mesh_instance) + mesh_instance.owner = scene_root + if include_skeleton: + var skeleton := Skeleton3D.new() + skeleton.name = "FixtureSkeleton" + scene_root.add_child(skeleton) + skeleton.owner = scene_root + var packed := PackedScene.new() + var pack_error := packed.pack(scene_root) + scene_root.free() + check(pack_error == OK, "skeleton guard fixture packs successfully") + return packed if pack_error == OK else null + + +func check_skeletonless_fallback(model_script: Script) -> void: + var fixture_id := "%d_%d" % [OS.get_process_id(), Time.get_ticks_usec()] + var missing_skeleton_path := "user://boss_kit_no_skeleton_%s.tscn" % fixture_id + var skeleton_path := "user://boss_kit_with_skeleton_%s.tscn" % fixture_id + var no_skeleton_scene := make_skeleton_guard_scene(false) + var with_skeleton_scene := make_skeleton_guard_scene(true) + var no_skeleton_saved := false + var skeleton_saved := false + if no_skeleton_scene != null: + no_skeleton_saved = ResourceSaver.save(no_skeleton_scene, missing_skeleton_path) == OK + check(no_skeleton_saved, "skeleton-less bind fixture saves as a scratch PackedScene") + if not skeleton_saved and with_skeleton_scene != null: + skeleton_saved = ResourceSaver.save(with_skeleton_scene, skeleton_path) == OK + check(skeleton_saved, "skeleton-present control fixture saves as a scratch PackedScene") + if no_skeleton_saved: + var missing_skeleton_instance: Variant = model_script.call("load_model", missing_skeleton_path) + check(missing_skeleton_instance == null, "skeleton-less scene returns null for procedural fallback") + if missing_skeleton_instance is Node: + missing_skeleton_instance.free() + if skeleton_saved: + var skeleton_instance: Variant = model_script.call("load_model", skeleton_path) + check(skeleton_instance is Node3D, "skeleton-present control satisfies the remaining bind prerequisites") + if skeleton_instance is Node: + skeleton_instance.free() + var no_skeleton_removed := not FileAccess.file_exists(missing_skeleton_path) or DirAccess.remove_absolute(ProjectSettings.globalize_path(missing_skeleton_path)) == OK + var skeleton_removed := not FileAccess.file_exists(skeleton_path) or DirAccess.remove_absolute(ProjectSettings.globalize_path(skeleton_path)) == OK + check(no_skeleton_removed, "skeleton-less scratch scene is removed") + check(skeleton_removed, "skeleton-present scratch scene is removed") + + +func dissolve_mask(viewport: SubViewport, instance: Node3D, progress: float, label: String) -> PackedByteArray: + # Convert global cinematic progress to linear dissolve input; the shader remaps that input to its threshold. + instance.call("set_dissolve", clampf((progress - 0.55) / 0.45, 0.0, 1.0)) + # Seeks reach the rendered skin and BoneAttachments on physics updates. + for frame in 3: + await physics_frame + await RenderingServer.frame_post_draw + var pixels := viewport.get_texture().get_image() + pixels.convert(Image.FORMAT_RGBA8) + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--capture-dir="): + var directory := argument.trim_prefix("--capture-dir=") + DirAccess.make_dir_recursive_absolute(directory) + pixels.save_png(directory.path_join("%s-p%03d.png" % [label, roundi(progress * 100)])) + var bytes := pixels.get_data() + var mask := PackedByteArray() + mask.resize(pixels.get_width() * pixels.get_height()) + for index in mask.size(): + mask[index] = 1 if bytes[index * 4 + 3] > 127 else 0 + return mask + + +func check_retention_band(progress: float, visible: int, intact: int, pose_name: String) -> float: + var fraction := float(visible) / maxi(intact, 1) + if DISSOLVE_RETENTION_BANDS.has(progress): + var band: Vector2 = DISSOLVE_RETENTION_BANDS[progress] + check(fraction >= band.x and fraction <= band.y, + "%s pose retains %.0f%% to %.0f%% at global cinematic progress %.2f (observed %.4f)" % + [pose_name, band.x * 100.0, band.y * 100.0, progress, fraction]) + return fraction + + +func check_dissolve_smoothness(masks: Dictionary, intact: int, pose_name: String) -> void: + var previous_progress := 0.55 + var previous_visible: int = masks[previous_progress].count(1) + for index in range(2, DISSOLVE_PROGRESS.size()): + var progress: float = DISSOLVE_PROGRESS[index] + var visible: int = masks[progress].count(1) + var decrease := float(previous_visible - visible) / maxi(intact, 1) + check(visible <= previous_visible, + "%s dissolve coverage is monotonic from progress %.2f to %.2f" % [pose_name, previous_progress, progress]) + check(decrease <= 0.19, + "%s dissolve coverage decreases by at most 19%% from progress %.2f to %.2f (observed %.4f)" % + [pose_name, previous_progress, progress, decrease]) + previous_progress = progress + previous_visible = visible + + +func check_dissolve_render(instance: Node3D) -> void: + if DisplayServer.get_name() == "headless": + check(not OS.get_cmdline_user_args().has("--require-gpu"), "--require-gpu requires a graphical renderer") + print("BOSS KIT GPU: SKIP (headless; rerun graphically with -- --require-gpu)") + return + # Transparent offscreen rendering measures actual shader discard, not a CPU approximation. + var viewport := SubViewport.new() + viewport.size = Vector2i(320, 320) + viewport.own_world_3d = true + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + instance.reparent(viewport) + instance.call("set_flash", 0.0) + instance.call("set_phase_tint", Color.WHITE) + var player := instance.get("animation_player") as AnimationPlayer + player.stop() + player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + var skeleton := instance.find_children("*", "Skeleton3D", true, false)[0] as Skeleton3D + skeleton.reset_bone_poses() + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 2.2 + camera.position = Vector3(0.3, 0.65, 4.0) + viewport.add_child(camera) + camera.current = true + var rest_camera := camera.transform + var masks := {} + for progress: float in DISSOLVE_PROGRESS: + masks[progress] = await dissolve_mask(viewport, instance, progress, "rest") + var intact: int = masks[0.0].count(1) + check(intact > 10000, "GPU fixture has a measurable intact silhouette") + check_dissolve_smoothness(masks, intact, "rest") + var rest_retention := {} + for progress: float in DISSOLVE_RETENTION_BANDS: + rest_retention[progress] = check_retention_band(progress, masks[progress].count(1), intact, "rest") + check(masks[1.0].count(1) == 0, "dissolve 1 removes every pixel") + # This exported defeat is a uniform collapse into fx_core. Counter-transform the + # camera to hold projected geometry fixed, isolating the mask from silhouette changes. + player.play(&"defeat", 0.0) + player.seek(player.get_animation(&"defeat").length, true) + player.advance(0.0) + var bone := skeleton.find_bone("root") + var collapse := skeleton.get_bone_global_pose(bone) * skeleton.get_bone_global_rest(bone).affine_inverse() + check(collapse.basis.get_scale().is_equal_approx(Vector3.ONE * 0.25), "exported defeat collapses to one-quarter scale") + camera.position = collapse * rest_camera.origin + camera.basis = collapse.basis.orthonormalized() * rest_camera.basis + camera.size = 2.2 * collapse.basis.get_scale().x + var defeat_intact := 0 + var max_changed := 0 + var defeat_retention := {} + var defeat_masks := {} + for progress: float in DISSOLVE_PROGRESS: + var actual := await dissolve_mask(viewport, instance, progress, "defeat") + defeat_masks[progress] = actual + var expected: PackedByteArray = masks[progress] + var changed := 0 + for index in actual.size(): + if actual[index] != expected[index]: changed += 1 + max_changed = maxi(max_changed, changed) + check(float(changed) / maxi(intact, 1) <= 0.01, "dissolve mask stays pose-invariant at global progress %.2f" % progress) + if progress == 0.0: defeat_intact = actual.count(1) + if DISSOLVE_RETENTION_BANDS.has(progress): + defeat_retention[progress] = check_retention_band(progress, actual.count(1), defeat_intact, "defeat") + if progress == 1.0: + check(actual.count(1) == 0, "dissolve removes every defeat-pose pixel at global progress 1.0") + check_dissolve_smoothness(defeat_masks, defeat_intact, "defeat") + print("BOSS KIT GPU: rest_p95=%.4f defeat_p95=%.4f max_mask_delta=%d/%d" % [rest_retention[0.95], defeat_retention[0.95], max_changed, intact]) + instance.reparent(root) + viewport.free() + + +func check_imported_orm(model: Node3D) -> void: + var packed_orm_count := 0 + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + for surface in mesh_instance.mesh.get_surface_count(): + var source := mesh_instance.mesh.surface_get_material(surface) as BaseMaterial3D + if source == null or source.get_texture(BaseMaterial3D.TEXTURE_METALLIC) == null: + continue + packed_orm_count += 1 + check(source.get_texture(BaseMaterial3D.TEXTURE_AMBIENT_OCCLUSION) == source.get_texture(BaseMaterial3D.TEXTURE_METALLIC), "imported occlusion and metallic slots use packed ORM") + check(source.get_texture(BaseMaterial3D.TEXTURE_ROUGHNESS) == source.get_texture(BaseMaterial3D.TEXTURE_METALLIC), "imported roughness and metallic slots use packed ORM") + check(source.ao_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_RED, "imported ORM red channel is occlusion") + check(source.roughness_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_GREEN, "imported ORM green channel is roughness") + check(source.metallic_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_BLUE, "imported ORM blue channel is metallic") + check(packed_orm_count > 0, "imported GLB contains at least one packed ORM surface") + + +func check_instance_isolation(first: Node3D, second: Node3D) -> void: + var first_materials: Array = first.get("materials") + var second_materials: Array = second.get("materials") + check(first_materials.size() > 0, "loaded model exposes shader materials") + check(first_materials.size() == second_materials.size(), "instances expose matching material sets") + for index in mini(first_materials.size(), second_materials.size()): + var first_material: Variant = first_materials[index] + var second_material: Variant = second_materials[index] + check(first_material is ShaderMaterial and second_material is ShaderMaterial, "runtime material is a ShaderMaterial") + if not first_material is ShaderMaterial or not second_material is ShaderMaterial: + continue + check(first_material != second_material, "mutable shader materials are per-instance") + for parameter in [&"dissolve", &"flash", &"phase_tint"]: + check(has_shader_param(first_material, parameter), "shader declares " + String(parameter)) + check(has_shader_param(second_material, parameter), "peer shader declares " + String(parameter)) + check(first_material.get_shader_parameter(parameter) == second_material.get_shader_parameter(parameter), "instances begin with equal " + String(parameter)) + first_material.set_shader_parameter(&"flash", 0.37) + check(not is_equal_approx(float(second_material.get_shader_parameter(&"flash")), 0.37), "mutating one instance does not change peer flash") + first_material.set_shader_parameter(&"flash", 0.0) + + +func check_socket_animation(instance: Node3D, animation_player: AnimationPlayer) -> void: + var socket := instance.call("socket", &"muzzle_0") as Node3D + check(socket != null, "arm-attached muzzle socket exists") + if socket == null or not animation_player.has_animation(&"attack"): + return + var model := instance.get("model") as Node3D + var skeletons: Array = model.find_children("*", "Skeleton3D", true, false) + animation_player.stop() + animation_player.play(&"attack", 0.0) + animation_player.speed_scale = 0.0 + var attack_clip: Animation = animation_player.get_animation(&"attack") + animation_player.seek(0.0, true) + await process_frame + for skeleton_node in skeletons: + (skeleton_node as Skeleton3D).force_update_all_bone_transforms() + var start := socket.global_position + var arm_index := (skeletons[0] as Skeleton3D).find_bone("arm") + var arm_rotation_start := (skeletons[0] as Skeleton3D).get_bone_pose_rotation(arm_index) + animation_player.seek(attack_clip.length * 0.5, true) + await process_frame + for skeleton_node in skeletons: + (skeleton_node as Skeleton3D).force_update_all_bone_transforms() + var arm_rotation_middle := (skeletons[0] as Skeleton3D).get_bone_pose_rotation(arm_index) + check(not arm_rotation_middle.is_equal_approx(arm_rotation_start), "arm bone rotates during attack") + check(socket.global_position.distance_to(start) > 0.01, "muzzle socket follows its animated arm bone during attack") + animation_player.speed_scale = 1.0 + + +func run() -> void: + await process_frame + check(FileAccess.file_exists(SELFTEST_PATH), "host-generated kit self-test GLB exists") + var build_report := check_build_report() + check(FileAccess.file_exists(MODEL_SCRIPT_PATH), "BossModel loader script exists") + if not FileAccess.file_exists(MODEL_SCRIPT_PATH): + finish() + return + var model_script: Script = load(MODEL_SCRIPT_PATH) + check(model_script != null, "BossModel script loads") + if model_script == null: + finish() + return + var fallback: Variant = model_script.call("load_model", "res://Assets/runner/bosses/kit_selftest/missing.glb") + check(fallback == null, "missing model path returns null for procedural fallback") + check_skeletonless_fallback(model_script) + if not FileAccess.file_exists(SELFTEST_PATH): + finish() + return + var packed_scene := load(SELFTEST_PATH) as PackedScene + check(packed_scene != null, "self-test PackedScene is held for repeated loads") + var scans_before_first := rest_scan_count() + var first: Variant = model_script.call("load_model", SELFTEST_PATH) + check(first is Node3D, "self-test GLB loads through BossModel") + if not first is Node3D: + finish() + return + var first_model := first.get("model") as Node3D + var first_meshes := source_meshes(first_model) + check(rest_scan_count() - scans_before_first == source_surface_count(first_meshes), + "first load scans each unique source mesh surface once") + root.add_child(first) + var scans_before_second := rest_scan_count() + var second: Variant = model_script.call("load_model", SELFTEST_PATH) + check(second is Node3D, "second self-test instance loads") + if not second is Node3D: + first.free() + finish() + return + check(rest_scan_count() == scans_before_second, "second load reuses cached rest validation") + root.add_child(second) + var imported_model := first_model + var second_meshes := source_meshes(second.get("model") as Node3D) + check(second_meshes.size() == first_meshes.size(), "repeated loads have matching source mesh sets") + for index in mini(first_meshes.size(), second_meshes.size()): + check(second_meshes[index] == first_meshes[index], "repeated loads share held source Mesh %d" % index) + check_imported_orm(imported_model) + check_mesh_skeleton_census(imported_model, build_report) + await check_rest_validation_cache(model_script) + check_rest_positions(imported_model, model_script) + var animation_player: Variant = first.get("animation_player") + check(animation_player is AnimationPlayer, "loaded instance exposes AnimationPlayer") + if animation_player is AnimationPlayer: + for action in ACTIONS: + check(animation_player.has_animation(action), "runtime animation exists: " + String(action)) + var idle: Animation = animation_player.get_animation(&"idle") if animation_player.has_animation(&"idle") else null + check(idle != null and idle.loop_mode != Animation.LOOP_NONE, "idle animation loops") + first.call("play", &"idle") + check(animation_player.current_animation == &"idle", "play(idle) selects idle") + first.call("set_time_factor", 0.25) + check(is_equal_approx(animation_player.speed_scale, 0.25), "time factor scales animation playback") + first.call("set_time_factor", 1.0) + await check_socket_animation(first, animation_player) + else: + for action in ACTIONS: + check(false, "runtime animation cannot be checked without AnimationPlayer: " + String(action)) + for socket_name in SOCKETS: + var socket: Variant = first.call("socket", socket_name) + check(socket is Node3D, "socket resolves to Node3D: " + String(socket_name)) + var materials: Variant = first.get("materials") + check(materials is Array, "loaded instance exposes materials array") + if materials is Array: + check(materials.size() > 0, "GLB surfaces have rebuilt materials") + for material: Variant in materials: + check(material is ShaderMaterial, "rebuilt material uses boss shader") + if material is ShaderMaterial: + check(has_shader_param(material, &"dissolve"), "boss shader exposes dissolve") + check(has_shader_param(material, &"flash"), "boss shader exposes flash") + check(has_shader_param(material, &"phase_tint"), "boss shader exposes phase_tint") + check_instance_isolation(first, second) + if materials is Array: + first.call("set_dissolve", 0.23) + first.call("set_flash", 0.61) + first.call("set_phase_tint", Color(0.2, 0.6, 0.9, 1.0)) + for material: Variant in materials: + if material is ShaderMaterial: + check(is_equal_approx(float(material.get_shader_parameter(&"dissolve")), 0.23), "set_dissolve updates rebuilt surfaces") + check(is_equal_approx(float(material.get_shader_parameter(&"flash")), 0.61), "set_flash updates rebuilt surfaces") + check(material.get_shader_parameter(&"phase_tint").is_equal_approx(Color(0.2, 0.6, 0.9, 1.0)), "set_phase_tint updates rebuilt surfaces") + for material: Variant in second.get("materials"): + if material is ShaderMaterial: + check(is_equal_approx(float(material.get_shader_parameter(&"dissolve")), 0.0), "peer dissolve remains unchanged") + check(is_equal_approx(float(material.get_shader_parameter(&"flash")), 0.0), "peer flash remains unchanged") + check(material.get_shader_parameter(&"phase_tint").is_equal_approx(Color.WHITE), "peer phase tint remains unchanged") + await check_dissolve_render(first) + first.free() + second.free() + finish() + + +func finish() -> void: + call_deferred("finish_after_cleanup") + + +func finish_after_cleanup() -> void: + print("BOSS KIT CHECK: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/assets/boss_kit_check.gd.uid b/hoppys-adventure-arcade/tests/assets/boss_kit_check.gd.uid new file mode 100644 index 00000000..4b516413 --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_kit_check.gd.uid @@ -0,0 +1 @@ +uid://dew25t70r8rs5 diff --git a/hoppys-adventure-arcade/tests/assets/boss_legacy_monolith_check.gd b/hoppys-adventure-arcade/tests/assets/boss_legacy_monolith_check.gd new file mode 100644 index 00000000..585365ff --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_legacy_monolith_check.gd @@ -0,0 +1,1044 @@ +extends SceneTree + +const MODEL_SCRIPT_PATH := "res://scenes/runner/boss/hifi/boss_model.gd" +const VISUAL_SCRIPT_PATH := "res://scenes/runner/boss/visuals/legacy_monolith_visual.gd" +const MODEL_PATH := "res://Assets/runner/bosses/legacy_monolith/legacy_monolith.glb" +const BUILD_PATH := "res://Assets/runner/bosses/legacy_monolith/legacy_monolith.build.json" +const ACTIONS: Array[StringName] = [&"intro", &"idle", &"telegraph", &"attack", &"hurt", &"phase_change", &"defeat", &"retreat"] +const SOCKETS: Array[StringName] = [&"fx_core", &"fx_0", &"fx_1", &"fx_2", &"fx_3", &"fx_4", &"fx_5", &"muzzle_0", &"muzzle_1", &"muzzle_2"] +const MAX_TRIANGLES := 18000 +const MAX_BONES := 40 +const ACCENT := Color("6a7ba2") +const REST_POSITION_ENCODING := "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1" +const BASELINE_BOUNDS := AABB(Vector3(-3.1, -0.945, -0.825), Vector3(6.2, 3.93, 1.825)) +const EXPECTED_MOUNT := Vector3(0.0, 2.6, -14.0) +const POSE_BOUNDS := preload("res://tests/boss_pose_bounds.gd") +const ENVELOPE_X_MIN := -3.5 +const ENVELOPE_X_MAX := 3.5 +const ENVELOPE_Y_MIN := 1.6 +const ENVELOPE_Y_MAX := 6.1 +const ENVELOPE_DEPTH_MAX := 3.0 +const ENVELOPE_Z_FRONT_MARGIN := 3.0 +const ENVELOPE_SAMPLE_RATE := 120.0 + +var checks := 0 +var failures := 0 + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("LEGACY MONOLITH: " + message) + + +func report_array_contains(values: Array, value: StringName) -> bool: + return values.has(String(value)) or values.has(value) + + +func sha256(path: String) -> String: + if not FileAccess.file_exists(path): + return "" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return "" + var context := HashingContext.new() + if context.start(HashingContext.HASH_SHA256) != OK: + return "" + context.update(file.get_buffer(file.get_length())) + return context.finish().hex_encode() + + +func is_sha256(value: String) -> bool: + if value.length() != 64: + return false + for character in value.to_lower(): + if not "0123456789abcdef".contains(character): + return false + return true + + +func check_report() -> Dictionary: + check(FileAccess.file_exists(BUILD_PATH), "full-bake build report exists") + if not FileAccess.file_exists(BUILD_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(BUILD_PATH)) + check(parsed is Dictionary, "build report parses as an object") + if not parsed is Dictionary: + return {} + var report: Dictionary = parsed + check(report.get("kind", "") == "legacy_monolith", "report kind is Legacy Monolith") + check(report.get("quick", true) == false, "report describes a full bake") + check(int(report.get("kit_revision", 0)) >= 2, "full bake includes kit v2 rest-position data") + check(report.get("rest_position_encoding", "") == REST_POSITION_ENCODING, "report declares the shared rest-position attribute encoding") + check(int(report.get("animation_sample_rate", -1)) == 120, "actions are sampled at 120 Hz") + check(int(report.get("triangles", MAX_TRIANGLES + 1)) > 0 and int(report.get("triangles", MAX_TRIANGLES + 1)) <= MAX_TRIANGLES, "reported triangle count is within the 18,000 budget") + check(int(report.get("materials", -1)) == 1, "full bake has exactly one material") + check(int(report.get("surfaces", -1)) == 1, "full bake has exactly one surface") + check(int(report.get("bones", MAX_BONES + 1)) > 0 and int(report.get("bones", MAX_BONES + 1)) <= MAX_BONES, "reported bone count is within the 40-bone budget") + var budget: Dictionary = report.get("budget", {}) + check(int(budget.get("triangles", -1)) == MAX_TRIANGLES, "report records the 18,000-triangle budget") + check(int(budget.get("materials", -1)) == 4, "report records the four-material budget") + check(int(budget.get("bones", -1)) == MAX_BONES, "report records the 40-bone budget") + check(int(budget.get("surfaces", -1)) == 6, "report records the six-surface budget") + for action in ACTIONS: + check(report_array_contains(report.get("actions", []), action), "report includes action " + String(action)) + for socket_name in SOCKETS: + check(report_array_contains(report.get("sockets", []), socket_name), "report includes socket " + String(socket_name)) + var hashes: Dictionary = report.get("source_hashes", {}) + check(not hashes.is_empty(), "report includes source provenance hashes") + for source_path: String in ["tools/blender/bosses/build_legacy_monolith.py", "tools/blender/bosses/boss_kit.py", "tools/blender/bosses/bosses_budget.json", "tools/blender/glb_atlas.py", "tools/blender/props/props_kit.py"]: + check(hashes.has(source_path) and is_sha256(String(hashes.get(source_path, ""))), "provenance includes a SHA-256 for " + source_path) + var source_file := "res://" + source_path + check(FileAccess.file_exists(source_file), "provenance source exists: " + source_path) + if FileAccess.file_exists(source_file): + check(String(hashes.get(source_path, "")) == sha256(source_file), "provenance hash matches source: " + source_path) + var glb: Dictionary = report.get("glb", {}) + check(glb.get("file", "") == "legacy_monolith.glb", "report names the expected GLB") + check(int(glb.get("bytes", 0)) > 0, "report records a nonzero GLB size") + check(is_sha256(String(glb.get("sha256", ""))), "report records a GLB SHA-256") + check(FileAccess.file_exists(MODEL_PATH), "full-bake GLB exists") + if FileAccess.file_exists(MODEL_PATH): + check(int(glb.get("bytes", -1)) == FileAccess.get_file_as_bytes(MODEL_PATH).size(), "reported GLB byte count matches the file") + check(String(glb.get("sha256", "")) == sha256(MODEL_PATH), "reported GLB hash matches the file") + var textures: Dictionary = report.get("textures", {}) + for texture_key in ["albedo", "normal", "orm", "emission"]: + var texture: Dictionary = textures.get(texture_key, {}) + var file_name := String(texture.get("file", "")) + var texture_path := "res://Assets/runner/bosses/legacy_monolith/" + file_name + check(not file_name.is_empty() and FileAccess.file_exists(texture_path), "report's " + texture_key + " texture exists") + check(is_sha256(String(texture.get("sha256", ""))), "report's " + texture_key + " texture has a SHA-256") + if FileAccess.file_exists(texture_path): + check(String(texture.get("sha256", "")) == sha256(texture_path), "reported " + texture_key + " hash matches the file") + return report + + +func mesh_skeleton_census(model: Node3D) -> Dictionary: + var triangle_count := 0 + var surface_count := 0 + var materials: Dictionary = {} + var bone_count := 0 + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + for surface in mesh_instance.mesh.get_surface_count(): + surface_count += 1 + var arrays := mesh_instance.mesh.surface_get_arrays(surface) + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + triangle_count += (indices.size() if not indices.is_empty() else vertices.size()) / 3 + var source := mesh_instance.mesh.surface_get_material(surface) + if source != null: + materials[source.get_instance_id()] = true + for node in model.find_children("*", "Skeleton3D", true, false): + bone_count += (node as Skeleton3D).get_bone_count() + return {"triangles": triangle_count, "materials": materials.size(), "bones": bone_count, "surfaces": surface_count} + + +func check_mesh_skeleton_census(model: Node3D, report: Dictionary) -> void: + var census := mesh_skeleton_census(model) + check(census.triangles > 0 and census.triangles <= MAX_TRIANGLES, "imported GLB is within the 18,000-triangle budget") + check(census.materials == 1, "imported GLB has exactly one material") + check(census.surfaces == 1, "imported GLB has exactly one surface") + check(census.bones > 0 and census.bones <= MAX_BONES, "imported GLB is within the 40-bone budget") + for key in ["triangles", "materials", "bones", "surfaces"]: + check(int(report.get(key, -1)) == int(census[key]), "reported %s matches imported GLB census (%d vs %d)" % [key, int(report.get(key, -1)), int(census[key])]) + var bounds := mesh_bounds_in_space(model, model) + check(bounds.size.x >= BASELINE_BOUNDS.size.x * 0.8 and bounds.size.x <= BASELINE_BOUNDS.size.x * 1.2, "imported model width stays within 20% of procedural baseline") + check(bounds.size.y >= BASELINE_BOUNDS.size.y * 0.8 and bounds.size.y <= BASELINE_BOUNDS.size.y * 1.2, "imported model height stays within 20% of procedural baseline") + check(bounds.size.z >= BASELINE_BOUNDS.size.z * 0.8 and bounds.size.z <= BASELINE_BOUNDS.size.z * 1.2, "imported model depth stays within 20% of procedural baseline") + + +func check_imported_orm(model: Node3D) -> void: + var packed_orm_surfaces := 0 + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + for surface in mesh_instance.mesh.get_surface_count(): + var source := mesh_instance.mesh.surface_get_material(surface) as BaseMaterial3D + if source == null or source.get_texture(BaseMaterial3D.TEXTURE_METALLIC) == null: + continue + packed_orm_surfaces += 1 + var orm := source.get_texture(BaseMaterial3D.TEXTURE_METALLIC) + check(source.get_texture(BaseMaterial3D.TEXTURE_AMBIENT_OCCLUSION) == orm, "imported AO slot shares packed ORM texture") + check(source.get_texture(BaseMaterial3D.TEXTURE_ROUGHNESS) == orm, "imported roughness slot shares packed ORM texture") + check(source.ao_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_RED, "imported ORM red channel is occlusion") + check(source.roughness_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_GREEN, "imported ORM green channel is roughness") + check(source.metallic_texture_channel == BaseMaterial3D.TEXTURE_CHANNEL_BLUE, "imported ORM blue channel is metallic") + check(packed_orm_surfaces == 1, "single imported surface contains packed ORM") + + +func check_rest_positions(model: Node3D, model_script: Script) -> void: + for node: MeshInstance3D in model.find_children("*", "MeshInstance3D", true, false): + if node.mesh == null: + continue + for surface in node.mesh.get_surface_count(): + var arrays := node.mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var rest: PackedVector3Array = model_script.call("rest_positions", node.mesh, surface) + check(rest.size() == vertices.size() and not rest.is_empty(), "every imported vertex carries shared rest-position attributes") + var maximum_error := 0.0 + for index in mini(vertices.size(), rest.size()): + maximum_error = maxf(maximum_error, vertices[index].distance_to(rest[index])) + check(maximum_error <= 0.0001, "rest-position attributes match imported vertices within 0.1 mm") + + +func has_shader_param(material: ShaderMaterial, name: StringName) -> bool: + if material == null or material.shader == null: + return false + for uniform: Dictionary in material.shader.get_shader_uniform_list(): + if StringName(uniform.get("name", "")) == name: + return true + return false + + +func add_transformed_box(accumulator: Dictionary, box: AABB, transform: Transform3D) -> void: + for corner in 8: + var point := transform * box.get_endpoint(corner) + if not accumulator.has("bounds"): + accumulator["bounds"] = AABB(point, Vector3.ZERO) + else: + var bounds: AABB = accumulator["bounds"] + bounds = bounds.expand(point) + accumulator["bounds"] = bounds + + +func mesh_bounds_in_space(container: Node3D, space: Node3D) -> AABB: + var accumulator := {} + for node in container.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + var relative := space.global_transform.affine_inverse() * mesh_instance.global_transform + add_transformed_box(accumulator, mesh_instance.mesh.get_aabb(), relative) + return accumulator.get("bounds", AABB()) + + +func procedural_art_bounds(art: Node) -> AABB: + var accumulator := {} + var art_node := art as Node3D + var batches: Array = art.get("batches") + for entry: Dictionary in batches: + for pose: Transform3D in entry.get("poses", []): + add_transformed_box(accumulator, AABB(Vector3(-0.5, -0.5, -0.5), Vector3.ONE), pose) + for node in art.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + var relative: Transform3D = art_node.global_transform.affine_inverse() * mesh_instance.global_transform + add_transformed_box(accumulator, mesh_instance.mesh.get_aabb(), relative) + return accumulator.get("bounds", AABB()) + + +func check_bounds_match(actual: AABB, expected: AABB, tolerance: float, label: String) -> void: + check(actual.position.distance_to(expected.position) <= tolerance, label + " bounds origin matches procedural baseline") + check(actual.size.distance_to(expected.size) <= tolerance, label + " bounds size matches procedural baseline") + + +func check_adapter(adapter: Node3D, report: Dictionary) -> void: + check(adapter != null, "BossModel loads the Legacy Monolith GLB") + if adapter == null: + return + var model := adapter.get("model") as Node3D + check(model != null, "adapter exposes imported model") + if model == null: + return + check_mesh_skeleton_census(model, report) + check_imported_orm(model) + var model_script := load(MODEL_SCRIPT_PATH) as Script + check_rest_positions(model, model_script) + var player := adapter.get("animation_player") as AnimationPlayer + check(player != null, "adapter exposes AnimationPlayer") + if player != null: + for action in ACTIONS: + check(player.has_animation(action), "runtime animation exists: " + String(action)) + var idle: Animation = player.get_animation(&"idle") if player.has_animation(&"idle") else null + check(idle != null and idle.loop_mode != Animation.LOOP_NONE, "idle animation loops") + adapter.call("play", &"idle") + check(player.current_animation == &"idle", "adapter can select idle") + for socket_name in SOCKETS: + var socket: Variant = adapter.call("socket", socket_name) + check(socket is Node3D, "adapter resolves socket " + String(socket_name)) + var materials: Array = adapter.get("materials") + check(materials.size() == 1, "adapter exposes the single rebuilt shader material") + for material: Variant in materials: + check(material is ShaderMaterial, "runtime material uses boss shader") + if material is ShaderMaterial: + for parameter in [&"dissolve", &"flash", &"phase_tint"]: + check(has_shader_param(material, parameter), "boss shader exposes " + String(parameter)) + adapter.call("set_dissolve", 0.27) + adapter.call("set_flash", 0.63) + adapter.call("set_phase_tint", Color(0.2, 0.6, 0.9, 1.0)) + for material: Variant in materials: + if material is ShaderMaterial: + check(is_equal_approx(float(material.get_shader_parameter(&"dissolve")), 0.27), "adapter sets dissolve") + check(is_equal_approx(float(material.get_shader_parameter(&"flash")), 0.63), "adapter sets flash") + check(material.get_shader_parameter(&"phase_tint").is_equal_approx(Color(0.2, 0.6, 0.9, 1.0)), "adapter sets phase tint") + + +func property_exists(node: Object, property_name: StringName) -> bool: + for property: Dictionary in node.get_property_list(): + if StringName(property.get("name", "")) == property_name: + return true + return false + + +func inherited_timing_map(constant_name: StringName) -> Dictionary: + var base_script := load("res://scenes/runner/boss/boss_visual_base.gd") as Script + if base_script == null: + return {} + var constants := base_script.get_script_constant_map() + var value: Variant = constants.get(constant_name, {}) + return value if value is Dictionary else {} + + +func skeleton_pose_snapshot(adapter: Node3D) -> Array[Transform3D]: + var poses: Array[Transform3D] = [] + if adapter == null: + return poses + var skeletons := adapter.find_children("*", "Skeleton3D", true, false) + if skeletons.is_empty(): + return poses + var skeleton := skeletons[0] as Skeleton3D + skeleton.force_update_all_bone_transforms() + for bone in skeleton.get_bone_count(): + poses.append(skeleton.get_bone_pose(bone)) + return poses + + +func bone_pose_snapshots_match(left: Array[Transform3D], right: Array[Transform3D], tolerance: float = 0.0001) -> bool: + if left.is_empty() or left.size() != right.size(): + return false + for index in left.size(): + if not left[index].is_equal_approx(right[index]) or left[index].origin.distance_to(right[index].origin) > tolerance: + return false + return true + + +func check_defeat_hitstop_timing(visual: Node3D, adapter: Node3D, mode: StringName, duration: float) -> void: + visual.call("on_spawned") + visual.call("play_telegraph", 5, &"quake", 0.8) + drive_visual(visual, 0.12) + var combat_pose := skeleton_pose_snapshot(adapter) + var player := adapter.get("animation_player") as AnimationPlayer + var defeat_clip := player.get_animation(&"defeat") if player != null else null + check(not combat_pose.is_empty() and defeat_clip != null, "%s hit-stop check captures a combat pose and imported defeat clip" % String(mode)) + if combat_pose.is_empty() or defeat_clip == null: + return + visual.call("play_cinematic", mode, duration) + for elapsed in [0.10, 0.119]: + visual.call("set_cinematic_progress", mode, elapsed / duration) + check(bone_pose_snapshots_match(combat_pose, skeleton_pose_snapshot(adapter)), "%s preserves the captured combat pose through %.3f seconds" % [String(mode), elapsed]) + check(visual_flash(visual) > 0.0 and is_equal_approx(visual_dissolve(visual), 0.0), "%s strobe remains active while dissolve stays independent during hit-stop" % String(mode)) + visual.call("set_cinematic_progress", mode, 0.14 / duration) + check(player.current_animation_position > 0.0, "%s defeat collapse starts after the 0.12 second hit-stop" % String(mode)) + check(is_equal_approx(visual_dissolve(visual), 0.0), "%s defeat dissolve remains independent from the early collapse" % String(mode)) + visual.call("set_cinematic_progress", mode, 1.0) + check(is_equal_approx(player.current_animation_position, defeat_clip.length), "%s defeat collapse reaches the imported clip endpoint at progress 1" % String(mode)) + check(is_equal_approx(visual_dissolve(visual), 1.0), "%s defeat dissolve reaches 1 at progress 1" % String(mode)) + visual.call("on_spawned") + + +func drive_visual(visual: Node, delta: float) -> void: + visual.call("_process", delta) + + +func visual_dissolve(visual: Node) -> float: + var adapter := visual.get("_model") as Node3D + if adapter != null: + var materials: Array = adapter.get("materials") + if not materials.is_empty() and materials[0] is ShaderMaterial: + return float(materials[0].get_shader_parameter(&"dissolve")) + var art := visual.get("_art") as Node + if art != null: + for node in art.find_children("*", "MeshInstance3D", true, false): + var material := (node as MeshInstance3D).material_override as ShaderMaterial + if has_shader_param(material, &"dissolve"): + return float(material.get_shader_parameter(&"dissolve")) + return -1.0 + + +func visual_flash(visual: Node) -> float: + var adapter := visual.get("_model") as Node3D + if adapter == null: + return -1.0 + var materials: Array = adapter.get("materials") + if materials.is_empty() or not materials[0] is ShaderMaterial: + return -1.0 + return float(materials[0].get_shader_parameter(&"flash")) + + +func visual_emission_energy(visual: Node) -> float: + var adapter := visual.get("_model") as Node3D + if adapter == null: + return -1.0 + var materials: Array = adapter.get("materials") + if materials.is_empty() or not materials[0] is ShaderMaterial: + return -1.0 + return float(materials[0].get_shader_parameter(&"emission_energy")) + + +func visual_dissolve_color(visual: Node) -> Color: + var adapter := visual.get("_model") as Node3D + if adapter == null: + return Color.TRANSPARENT + var materials: Array = adapter.get("materials") + if materials.is_empty() or not materials[0] is ShaderMaterial: + return Color.TRANSPARENT + return materials[0].get_shader_parameter(&"dissolve_color") + + +func new_envelope_summary() -> Dictionary: + return {"bounds": AABB(), "has_bounds": false, "frames": 0, "max_frame_depth": 0.0, "max_depth_label": "", "violations": {}, "details": {}, "hitstop_failures": []} + + +func cpu_skinned_world_bounds(container: Node) -> AABB: + var bounds := AABB() + var found := false + for node in container.find_children("*", "MeshInstance3D", true, false): + var mesh := node as MeshInstance3D + if mesh.mesh == null: + continue + var mesh_bounds: AABB = POSE_BOUNDS.mesh_bounds(mesh) + bounds = mesh_bounds if not found else bounds.merge(mesh_bounds) + found = true + return bounds if found else AABB() + + +func record_envelope_bounds(summary: Dictionary, bounds: AABB, label: String, hover_z: float) -> void: + if not summary.has_bounds: + summary.bounds = bounds + summary.has_bounds = true + else: + var accumulated: AABB = summary.bounds + summary.bounds = accumulated.merge(bounds) + summary.frames += 1 + if bounds.size.z > float(summary.max_frame_depth): + summary.max_frame_depth = bounds.size.z + summary.max_depth_label = label + var min_x := bounds.position.x + var max_x := bounds.position.x + bounds.size.x + var min_y := bounds.position.y + var max_y := bounds.position.y + bounds.size.y + var max_z := bounds.position.z + bounds.size.z + var violations: Array[String] = [] + if min_x < ENVELOPE_X_MIN - 0.001: violations.append("x_min=%.3f<%.1f" % [min_x, ENVELOPE_X_MIN]) + if max_x > ENVELOPE_X_MAX + 0.001: violations.append("x_max=%.3f>%.1f" % [max_x, ENVELOPE_X_MAX]) + if min_y < ENVELOPE_Y_MIN - 0.001: violations.append("y_min=%.3f<%.1f" % [min_y, ENVELOPE_Y_MIN]) + if max_y > ENVELOPE_Y_MAX + 0.001: violations.append("y_max=%.3f>%.1f" % [max_y, ENVELOPE_Y_MAX]) + if bounds.size.z > ENVELOPE_DEPTH_MAX + 0.001: violations.append("depth=%.3f>%.1f" % [bounds.size.z, ENVELOPE_DEPTH_MAX]) + if max_z > hover_z + ENVELOPE_Z_FRONT_MARGIN + 0.001: + violations.append("front_z=%.3f>hover_z+3=%.3f" % [max_z, hover_z + ENVELOPE_Z_FRONT_MARGIN]) + for violation in violations: + var counts: Dictionary = summary.violations + counts[violation.get_slice("=", 0)] = int(counts.get(violation.get_slice("=", 0), 0)) + 1 + summary.violations = counts + var details: Dictionary = summary.details + details[violation.get_slice("=", 0)] = "%s (%s)" % [label, violation] + summary.details = details + + +func record_envelope_sample(summary: Dictionary, container: Node, label: String, hover_z: float, expand_for_ambient: bool = false) -> void: + var bounds := cpu_skinned_world_bounds(container) + if expand_for_ambient: + bounds.position.x -= 0.14 + bounds.size.x += 0.28 + bounds.size.y += 0.02 + record_envelope_bounds(summary, bounds, label, hover_z) + + +func envelope_report(summary: Dictionary) -> String: + if not summary.has_bounds: + return "no skinned mesh frames sampled" + var bounds: AABB = summary.bounds + var extrema := "x[%.3f, %.3f] y[%.3f, %.3f] z[%.3f, %.3f] depth=%.3f" % [ + bounds.position.x, bounds.position.x + bounds.size.x, + bounds.position.y, bounds.position.y + bounds.size.y, + bounds.position.z, bounds.position.z + bounds.size.z, float(summary.max_frame_depth), + ] + if summary.violations.is_empty(): + return "%d frames, %s, max-frame-depth=%.3f at %s" % [summary.frames, extrema, float(summary.max_frame_depth), summary.max_depth_label] + var failures_by_bound: Array[String] = [] + for key: String in summary.violations: + failures_by_bound.append("%s x%d at %s" % [key, summary.violations[key], summary.details[key]]) + failures_by_bound.sort() + return "%d frames, %s, max-frame-depth=%.3f at %s, violations: %s" % [summary.frames, extrema, float(summary.max_frame_depth), summary.max_depth_label, "; ".join(failures_by_bound)] + + +func finish_envelope_scenario(summary: Dictionary, label: String) -> void: + var ok: bool = summary.has_bounds and summary.frames > 0 and summary.violations.is_empty() and summary.hitstop_failures.is_empty() + check(ok, "%s world envelope: %s%s" % [label, envelope_report(summary), " hit-stop: " + "; ".join(summary.hitstop_failures) if not summary.hitstop_failures.is_empty() else ""]) + if ok: + print("LEGACY ENVELOPE: PASS %s | %s" % [label, envelope_report(summary)]) + + +func sample_visual_action_frame(visual: Node, action: StringName, seconds: float, duration: float) -> void: + visual.set("_state", action) + visual.set("_age", seconds) + visual.set("_duration", maxf(duration, 0.001)) + visual.set("_clock", seconds) + visual.call("_sample_pose") + + +func sample_all_action_envelopes(visual: Node3D, adapter: Node3D) -> void: + var player := adapter.get("animation_player") as AnimationPlayer + var model := adapter.get("model") as Node3D + if player == null or model == null: + check(false, "world envelope action sweep has AnimationPlayer and model") + return + for action in ACTIONS: + var clip := player.get_animation(action) + if clip == null: + continue + var duration := maxf(clip.length, 1.0 / ENVELOPE_SAMPLE_RATE) + var last_frame := int(ceil(duration * ENVELOPE_SAMPLE_RATE)) + var summary := new_envelope_summary() + for phase in range(1, 4): + for health in [0, 100]: + visual.position.x = 0.0 + visual.position.y = 0.0 + visual.call("set_phase", phase) + visual.call("set_health", health, 100) + visual.set("_mode", &"") + visual.set("_wall_mask", 0) + visual.set("_aim", 0.0) + visual.call("_play_action", action, duration) + for frame in range(last_frame + 1): + var seconds := minf(float(frame) / ENVELOPE_SAMPLE_RATE, duration) + sample_visual_action_frame(visual, action, seconds, duration) + record_envelope_sample(summary, model, "%s phase%d hp%d frame%d/%.3fs" % [String(action), phase, health, frame, seconds], float(visual.get("hover_z")), action != &"intro") + finish_envelope_scenario(summary, "all 120 Hz frames for %s, phases 1-3, health 0/100" % String(action)) + + +func sample_signature_envelopes(visual: Node3D, adapter: Node3D) -> void: + var model := adapter.get("model") as Node3D + var player := adapter.get("animation_player") as AnimationPlayer + if model == null or player == null: + check(false, "signature envelope sweep has imported model and AnimationPlayer") + return + var kinds: Array[StringName] = [&"slab", &"quake", &"debris"] + for kind in kinds: + var telegraph_summary := new_envelope_summary() + var attack_summary := new_envelope_summary() + for mask in range(1, 8): + for phase in range(1, 4): + for health in [0, 100]: + visual.call("on_spawned") + visual.position.x = 0.0 + visual.position.y = 0.0 + visual.call("set_phase", phase) + visual.call("set_health", health, 100) + visual.call("play_telegraph", mask, kind, 0.8) + var telegraph_frames := int(ceil(0.8 * ENVELOPE_SAMPLE_RATE)) + for frame in range(telegraph_frames + 1): + var seconds := minf(float(frame) / ENVELOPE_SAMPLE_RATE, 0.8) + sample_visual_action_frame(visual, &"telegraph", seconds, 0.8) + record_envelope_sample(telegraph_summary, model, "telegraph %s mask%d phase%d hp%d frame%d" % [String(kind), mask, phase, health, frame], float(visual.get("hover_z")), true) + visual.call("play_attack", kind) + var attack_duration := maxf(float(visual.get("_duration")), 0.001) + var attack_frames := int(ceil(attack_duration * ENVELOPE_SAMPLE_RATE)) + for frame in range(attack_frames + 1): + var seconds := minf(float(frame) / ENVELOPE_SAMPLE_RATE, attack_duration) + sample_visual_action_frame(visual, &"attack", seconds, attack_duration) + record_envelope_sample(attack_summary, model, "attack %s mask%d phase%d hp%d frame%d" % [String(kind), mask, phase, health, frame], float(visual.get("hover_z")), true) + finish_envelope_scenario(telegraph_summary, "all telegraph masks/phases/health for %s at 120 Hz" % String(kind)) + finish_envelope_scenario(attack_summary, "all attack masks/phases/health for %s at 120 Hz" % String(kind)) + + +func sample_runtime_one_shot(visual: Node3D, adapter: Node3D, action: StringName, label: String) -> void: + var player := adapter.get("animation_player") as AnimationPlayer + var model := adapter.get("model") as Node3D + if player == null or model == null or not player.has_animation(action): + return + var clip := player.get_animation(action) + var duration := maxf(clip.length, 1.0 / ENVELOPE_SAMPLE_RATE) + var summary := new_envelope_summary() + for progress in [0.0, 0.25, 0.5, 0.75, 1.0]: + sample_visual_action_frame(visual, action, duration * progress, duration) + record_envelope_sample(summary, model, "%s p%.2f" % [label, progress], float(visual.get("hover_z"))) + finish_envelope_scenario(summary, "runtime one-shot %s" % label) + + +func sample_idle_sway_envelopes(visual: Node3D, adapter: Node3D) -> void: + var model := adapter.get("model") as Node3D + var period := maxf(float(visual.get("sway_period")), 0.1) + for phase in [1, 2, 3]: + for health in [0, 100]: + visual.call("on_spawned") + visual.call("set_phase", phase) + visual.call("set_health", health, 100) + visual.call("_play_action", &"idle") + var summary := new_envelope_summary() + var last_frame := int(ceil(period * 2.0 * ENVELOPE_SAMPLE_RATE)) + for frame in range(last_frame + 1): + if frame > 0: + visual.call("_process", 1.0 / ENVELOPE_SAMPLE_RATE) + record_envelope_sample(summary, model, "idle phase%d hp%d frame%d/%.3fs" % [phase, health, frame, float(frame) / ENVELOPE_SAMPLE_RATE], float(visual.get("hover_z"))) + finish_envelope_scenario(summary, "two idle sway periods phase%d hp%d" % [phase, health]) + + +func sample_cinematic_envelope(visual: Node3D, adapter: Node3D, mode: StringName, duration: float, phase: int, health: int, summary: Dictionary) -> void: + var model := adapter.get("model") as Node3D + visual.call("on_spawned") + visual.call("set_phase", phase) + visual.call("set_health", health, 100) + var captured_combat_bounds := AABB() + if mode in [&"defeat_full", &"defeat_short"]: + # Capture a real combat pose before the cinematic cue freezes it for hit-stop. + visual.call("play_telegraph", 5, &"quake", 0.8) + visual.set("_age", 0.36) + visual.call("_sample_pose") + captured_combat_bounds = cpu_skinned_world_bounds(model) + visual.call("play_cinematic", mode, duration) + var last_frame := int(ceil(duration * ENVELOPE_SAMPLE_RATE)) + for frame in range(last_frame + 1): + var seconds := minf(float(frame) / ENVELOPE_SAMPLE_RATE, duration) + var progress := seconds / duration + visual.call("set_cinematic_progress", mode, progress) + record_envelope_sample(summary, model, "%s frame%d p%.5f" % [String(mode), frame, progress], float(visual.get("hover_z"))) + if frame == 0 and mode in [&"defeat_full", &"defeat_short"]: + var hitstop_bounds := cpu_skinned_world_bounds(model) + if hitstop_bounds.position.distance_to(captured_combat_bounds.position) > 0.005 or hitstop_bounds.size.distance_to(captured_combat_bounds.size) > 0.005: + summary.hitstop_failures.append("%s phase%d hp%d did not preserve the combat bounds" % [String(mode), phase, health]) + + +func check_cinematic_envelopes(visual: Node3D, adapter: Node3D) -> void: + for mode in [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"]: + var duration := 5.0 if mode == &"intro_full" else (2.5 if mode == &"intro_short" else (3.6 if mode == &"defeat_full" else 2.0)) + var summary := new_envelope_summary() + for phase in range(1, 4): + for health in [0, 100]: + sample_cinematic_envelope(visual, adapter, mode, duration, phase, health, summary) + finish_envelope_scenario(summary, "runtime cinematic %s every 120 Hz across phases/health" % String(mode)) + + +func check_retreat_completion_timing(visual: Node3D, adapter: Node3D) -> void: + visual.call("on_spawned") + visual.call("play_retreat") + var player := adapter.get("animation_player") as AnimationPlayer + var retreat_clip := player.get_animation(&"retreat") if player != null else null + check(retreat_clip != null, "retreat completion timing has its imported clip") + if retreat_clip == null: + return + drive_visual(visual, 0.8) + var mid_dissolve := visual_dissolve(visual) + check(mid_dissolve > 0.0 and mid_dissolve < 1.0, "retreat dissolve is progressing before its completion endpoint") + drive_visual(visual, 0.15) + check(is_equal_approx(float(visual.get("_duration")), 0.95), "public retreat uses the backend-aligned 0.95 second duration") + check(is_equal_approx(player.current_animation_position, retreat_clip.length), "retreat samples the imported clip endpoint by 0.95 seconds") + check(is_equal_approx(visual_dissolve(visual), 1.0), "retreat dissolve reaches 1 by 0.95 seconds") + + +func check_world_envelope_contract(visual: Node3D, adapter: Node3D) -> void: + check(FileAccess.file_exists("res://tests/boss_pose_bounds.gd"), "CPU skinning bounds helper is available (tests/boss_pose_bounds.gd dependency)") + if adapter == null or not FileAccess.file_exists("res://tests/boss_pose_bounds.gd"): + return + visual.call("on_spawned") + visual.set("sway_amplitude", 0.14) + visual.call("set_time_factor", 1.0) + sample_all_action_envelopes(visual, adapter) + sample_signature_envelopes(visual, adapter) + sample_idle_sway_envelopes(visual, adapter) + for phase in [1, 2, 3]: + for health in [0, 100]: + visual.call("on_spawned") + visual.call("set_health", health, 100) + visual.call("set_phase", phase) + sample_runtime_one_shot(visual, adapter, &"phase_change", "phase_change phase%d hp%d" % [phase, health]) + visual.call("play_hit", &"whip", 1) + sample_runtime_one_shot(visual, adapter, &"hurt", "hit/hurt phase%d hp%d" % [phase, health]) + visual.call("on_spawned") + visual.call("set_phase", phase) + visual.call("set_health", health, 100) + visual.call("_play_action", &"idle") + visual.call("play_resist", &"whip") + sample_runtime_one_shot(visual, adapter, &"idle", "resist idle pose phase%d hp%d" % [phase, health]) + visual.call("on_spawned") + visual.call("set_phase", phase) + visual.call("set_health", health, 100) + visual.call("play_retreat") + sample_runtime_one_shot(visual, adapter, &"retreat", "retreat phase%d hp%d" % [phase, health]) + check_retreat_completion_timing(visual, adapter) + check_cinematic_envelopes(visual, adapter) + visual.call("on_spawned") + + +func check_visual_contract() -> void: + var script := load(VISUAL_SCRIPT_PATH) as Script + check(script != null, "Legacy Monolith visual script loads") + if script == null: + return + var source := script.source_code + check(not source.contains("create_tween") and not source.contains("Tween.new"), "visual has no independent Tween animation") + var intro_pose_at := inherited_timing_map(&"INTRO_POSE_AT") + var intro_hold_until := inherited_timing_map(&"INTRO_HOLD_UNTIL") + check(is_equal_approx(float(intro_pose_at.get(&"intro_full", -1.0)), 0.40) and is_equal_approx(float(intro_pose_at.get(&"intro_short", -1.0)), 0.28), "visual inherits the shared full/short intro pose timing map") + check(is_equal_approx(float(intro_hold_until.get(&"intro_full", -1.0)), 0.66) and is_equal_approx(float(intro_hold_until.get(&"intro_short", -1.0)), 0.88), "visual inherits the shared full/short intro hold timing map") + var visual := script.new() as Node3D + check(visual != null, "visual instance can be created") + if visual == null: + return + visual.set("kind", &"legacy_monolith") + visual.set("hover_z", -14.0) + var mount := Node3D.new() + mount.position = EXPECTED_MOUNT + root.add_child(mount) + mount.add_child(visual) + check(property_exists(visual, &"model_path"), "visual exposes public model_path") + if property_exists(visual, &"model_path"): + visual.set("model_path", MODEL_PATH) + visual.call("on_spawned") + visual.set_process(false) + check(visual.global_position.distance_to(EXPECTED_MOUNT) <= 0.02, "visual remains mounted at (0, 2.6, -14)") + var adapter := visual.get("_model") as Node3D + var art := visual.get("_art") as Node + check((adapter != null) != (art != null), "visual selects exactly one of GLB adapter or procedural fallback") + check(adapter != null, "configured valid model path selects the GLB adapter") + var phase_one_emission := visual_emission_energy(visual) + check(is_equal_approx(phase_one_emission, 1.0), "phase one idle emission stays at the baked baseline") + check(visual_dissolve_color(visual).is_equal_approx(Color("f78949")), "visual sets reactor orange as the dissolve edge color") + if adapter != null: + var mounted_model := adapter.get("model") as Node3D + if mounted_model != null: + check(mounted_model.position.length() <= 0.02, "imported model root stays at the existing boss mount") + if adapter != null: + var model := adapter.get("model") as Node3D + if model != null: + var child_tweens := visual.find_children("*", "Tween", true, false) + check(child_tweens.is_empty(), "model visual tree contains no Tween nodes") + visual.call("set_phase", 2) + check(StringName(visual.get("_state")) == &"phase_change", "phase change enters phase_change state") + check(int(visual.get("_phase")) == 2, "phase state records phase two") + if adapter != null: + var materials: Array = adapter.get("materials") + if not materials.is_empty() and materials[0] is ShaderMaterial: + var tint: Variant = materials[0].get_shader_parameter(&"phase_tint") + check(tint is Color and not tint.is_equal_approx(Color.WHITE), "phase change applies a phase tint") + var skeletons := adapter.find_children("*", "Skeleton3D", true, false) + if not skeletons.is_empty(): + var skeleton := skeletons[0] as Skeleton3D + var shell_index := skeleton.find_bone("shell_L") + check(shell_index >= 0, "phase crack offsets target the left shell bone") + if shell_index >= 0: + drive_visual(visual, 0.05) + var phase_two_shell := skeleton.get_bone_pose_position(shell_index) + visual.call("set_phase", 3) + drive_visual(visual, 0.05) + check(skeleton.get_bone_pose_position(shell_index).distance_to(phase_two_shell) > 0.05, "phase three spreads shell cracks farther than phase two") + drive_visual(visual, 1.2) + check(StringName(visual.get("_state")) == &"idle", "phase change returns to idle") + check(is_equal_approx(visual_emission_energy(visual), phase_one_emission), "phase three idle does not boost emission above phase one") + visual.call("play_intro", 4.0) + check(StringName(visual.get("_state")) == &"intro", "intro enters intro state") + check(is_equal_approx(float(visual.get("_duration")), 4.0), "intro honors requested duration") + visual.call("set_time_factor", 0.0) + var frozen_age := float(visual.get("_age")) + var frozen_clock := float(visual.get("_clock")) if property_exists(visual, &"_clock") else 0.0 + var frozen_position := 0.0 + if adapter != null: + var player := adapter.get("animation_player") as AnimationPlayer + if player != null: + frozen_position = player.current_animation_position + drive_visual(visual, 0.2) + check(is_equal_approx(float(visual.get("_age")), frozen_age), "time factor zero freezes visual animation age") + if property_exists(visual, &"_clock"): + check(is_equal_approx(float(visual.get("_clock")), frozen_clock), "time factor zero freezes the visual clock") + if adapter != null: + var player := adapter.get("animation_player") as AnimationPlayer + if player != null: + check(is_equal_approx(player.current_animation_position, frozen_position), "time factor zero freezes imported animation playback") + visual.call("set_time_factor", 1.0) + drive_visual(visual, 4.1) + check(StringName(visual.get("_state")) == &"idle", "intro returns to idle after its duration") + visual.call("play_telegraph", 7, &"quake", 0.6) + drive_visual(visual, 0.14) + var telegraph_heat := visual_emission_energy(visual) + check(telegraph_heat >= 1.68, "telegraph heat remains fully hot") + drive_visual(visual, 0.6) + visual.call("play_cinematic", &"intro_full", 5.0) + check(StringName(visual.get("_cinematic_mode")) == &"intro_full", "full intro cinematic mode is recorded") + check(StringName(visual.get("_state")) == &"intro", "full cinematic enters intro state") + var full_pose_progress := float(intro_pose_at.get(&"intro_full", 0.40)) + var full_hold_end := float(intro_hold_until.get(&"intro_full", 0.66)) + visual.call("set_cinematic_progress", &"intro_full", full_pose_progress * 0.5) + var full_mid_window_poses := skeleton_pose_snapshot(adapter) + var full_mid_window_position := (adapter.get("animation_player") as AnimationPlayer).current_animation_position if adapter != null else 0.0 + visual.call("set_cinematic_progress", &"intro_full", full_pose_progress) + var full_landed_poses := skeleton_pose_snapshot(adapter) + visual.call("set_cinematic_progress", &"intro_full", 0.65) + var full_hold_poses := skeleton_pose_snapshot(adapter) + var intro_hold_heat := visual_emission_energy(visual) + visual.call("set_cinematic_progress", &"intro_full", full_hold_end) + var full_hold_end_poses := skeleton_pose_snapshot(adapter) + var intro_late_hold_heat := visual_emission_energy(visual) + check(bone_pose_snapshots_match(full_landed_poses, full_hold_poses) and bone_pose_snapshots_match(full_landed_poses, full_hold_end_poses), "full intro holds every bone from INTRO_POSE_AT through INTRO_HOLD_UNTIL") + check(intro_hold_heat >= 1.68 and is_equal_approx(intro_hold_heat, intro_late_hold_heat), "full intro eyes and reactor stay hot through the shared hold") + visual.call("set_cinematic_progress", &"intro_full", 0.94) + check(bone_pose_snapshots_match(full_landed_poses, skeleton_pose_snapshot(adapter)) and is_equal_approx(float(visual.get("_age")), 4.7), "full intro skip jump lands on the held pose and requested cinematic time") + var frozen_cinematic_age := float(visual.get("_age")) + var frozen_cinematic_pose := (adapter.get("animation_player") as AnimationPlayer).current_animation_position if adapter != null else 0.0 + visual.call("set_time_factor", 0.5) + drive_visual(visual, 0.75) + check(is_equal_approx(float(visual.get("_age")), frozen_cinematic_age), "time factor and process delta do not advance cinematic age") + if adapter != null: + var player := adapter.get("animation_player") as AnimationPlayer + check(is_equal_approx(player.current_animation_position, frozen_cinematic_pose), "time factor and process delta do not advance cinematic pose") + visual.call("set_cinematic_progress", &"intro_full", 0.9) + var full_hold_position := (adapter.get("animation_player") as AnimationPlayer).current_animation_position if adapter != null else 0.0 + visual.call("set_cinematic_progress", &"intro_full", 0.95) + if adapter != null: + check(is_equal_approx((adapter.get("animation_player") as AnimationPlayer).current_animation_position, full_hold_position), "full intro holds the completed action pose") + visual.call("set_cinematic_progress", &"intro_full", 1.0) + check(StringName(visual.get("_state")) == &"idle", "full intro cinematic returns to idle") + visual.call("play_cinematic", &"intro_short", 2.5) + check(StringName(visual.get("_cinematic_mode")) == &"intro_short", "short intro cinematic mode is recorded") + var short_pose_progress := float(intro_pose_at.get(&"intro_short", 0.28)) + var short_hold_end := float(intro_hold_until.get(&"intro_short", 0.88)) + visual.call("set_cinematic_progress", &"intro_short", short_pose_progress * 0.5) + var short_mid_window_poses := skeleton_pose_snapshot(adapter) + var short_mid_window_position := (adapter.get("animation_player") as AnimationPlayer).current_animation_position if adapter != null else 0.0 + check(bone_pose_snapshots_match(full_mid_window_poses, short_mid_window_poses), "full/short intro poses match at equal fractions of each shared action window") + check(is_equal_approx(full_mid_window_position, short_mid_window_position), "equal action-window fractions sample the same imported animation time") + check((5.0 * full_pose_progress) > 2.0 * (2.5 * short_pose_progress), "short intro reaches its held pose more than twice as quickly in wall time") + visual.call("set_cinematic_progress", &"intro_short", short_pose_progress) + var short_landed_poses := skeleton_pose_snapshot(adapter) + visual.call("set_cinematic_progress", &"intro_short", 0.85) + var short_hold_poses := skeleton_pose_snapshot(adapter) + var short_hold_heat := visual_emission_energy(visual) + visual.call("set_cinematic_progress", &"intro_short", short_hold_end) + var short_hold_end_poses := skeleton_pose_snapshot(adapter) + var short_late_hold_heat := visual_emission_energy(visual) + check(bone_pose_snapshots_match(short_landed_poses, short_hold_poses) and bone_pose_snapshots_match(short_landed_poses, short_hold_end_poses), "short intro holds every bone from INTRO_POSE_AT through INTRO_HOLD_UNTIL") + check(bone_pose_snapshots_match(full_hold_poses, short_hold_poses), "full .65 and short .85 holds share identical bone poses") + check(short_hold_heat >= 1.68 and is_equal_approx(short_hold_heat, short_late_hold_heat), "short intro eyes and reactor stay hot through the shared hold") + visual.call("set_cinematic_progress", &"intro_short", 0.96) + check(bone_pose_snapshots_match(short_landed_poses, skeleton_pose_snapshot(adapter)) and is_equal_approx(float(visual.get("_age")), 2.4), "short intro skip jump lands on the held pose and requested cinematic time") + visual.call("set_cinematic_progress", &"intro_short", 1.0) + check(StringName(visual.get("_state")) == &"idle", "short intro cinematic returns to idle") + visual.call("play_cinematic", &"defeat_full", 2.0) + visual.call("set_cinematic_progress", &"defeat_full", 0.005) + var strobe_on := visual_flash(visual) + var defeat_pose_at_strobe := (adapter.get("animation_player") as AnimationPlayer).current_animation_position if adapter != null else 0.0 + visual.call("set_cinematic_progress", &"defeat_full", 0.02) + var strobe_off := visual_flash(visual) + check(strobe_on >= 0.8 and strobe_off < strobe_on, "full defeat begins with a hit-stop pose and a flash strobe") + if adapter != null: + check(is_equal_approx((adapter.get("animation_player") as AnimationPlayer).current_animation_position, defeat_pose_at_strobe), "defeat hit-stop holds the captured pose") + var frozen_defeat_age := float(visual.get("_age")) + var frozen_defeat_dissolve := visual_dissolve(visual) + visual.call("set_time_factor", 1.0) + drive_visual(visual, 0.75) + check(is_equal_approx(float(visual.get("_age")), frozen_defeat_age), "process delta cannot advance defeat cinematic progress") + check(is_equal_approx(visual_dissolve(visual), frozen_defeat_dissolve), "process delta cannot advance defeat dissolve") + visual.call("set_cinematic_progress", &"defeat_full", 0.6) + var dissolve_at_60 := visual_dissolve(visual) + visual.call("set_cinematic_progress", &"defeat_full", 0.9) + var dissolve_at_90 := visual_dissolve(visual) + visual.call("set_cinematic_progress", &"defeat_full", 1.0) + var final_dissolve := visual_dissolve(visual) + check(final_dissolve >= 0.99, "full cinematic defeat reaches dissolve 1") + check(dissolve_at_60 >= 0.0 and dissolve_at_60 < final_dissolve, "full cinematic defeat progresses to dissolve during its final section") + check(dissolve_at_90 > dissolve_at_60, "full cinematic dissolve advances toward completion") + check(bool(visual.get("_terminal")) and StringName(visual.get("_state")) == &"defeat", "full cinematic defeat remains terminal") + visual.call("set_cinematic_progress", &"defeat_full", 0.45) + var emission_at_45 := visual_emission_energy(visual) + var dissolve_at_45 := visual_dissolve(visual) + visual.call("set_cinematic_progress", &"defeat_full", 0.8) + var emission_at_80 := visual_emission_energy(visual) + var dissolve_at_80 := visual_dissolve(visual) + check(is_equal_approx(emission_at_45, 1.0) and emission_at_80 <= 0.01, "defeat emissive atlas fades to zero by normalized progress 0.80") + check(dissolve_at_45 <= 0.01 and is_equal_approx(dissolve_at_80, (0.8 - 0.55) / 0.45), "emission fade leaves the cinematic dissolve curve unchanged") + visual.call("play_cinematic", &"defeat_short", 1.0) + check(StringName(visual.get("_cinematic_mode")) == &"defeat_short", "short defeat cinematic mode is recorded") + visual.call("set_cinematic_progress", &"defeat_short", 0.9) + check(is_equal_approx(visual_dissolve(visual), dissolve_at_90), "short and full defeat share normalized dissolve progress") + visual.call("set_cinematic_progress", &"defeat_short", 1.0) + check(visual_dissolve(visual) >= 0.99, "short cinematic defeat reaches dissolve 1") + visual.call("on_spawned") + check(StringName(visual.get("_state")) == &"idle" and not bool(visual.get("_terminal")), "on_spawned resets the defeated visual for replay") + check(is_equal_approx(visual_dissolve(visual), 0.0), "on_spawned resets dissolve for replay") + check(is_equal_approx(visual_emission_energy(visual), phase_one_emission), "on_spawned restores the normal glow for replay") + check_defeat_hitstop_timing(visual, adapter, &"defeat_full", 3.6) + check_defeat_hitstop_timing(visual, adapter, &"defeat_short", 2.0) + var second_visual := script.new() as Node3D + second_visual.set("kind", &"legacy_monolith") + second_visual.set("hover_z", -14.0) + root.add_child(second_visual) + second_visual.set("model_path", MODEL_PATH) + second_visual.call("on_spawned") + check(visual_dissolve_color(second_visual).is_equal_approx(Color("f78949")), "second visual instance also uses reactor orange for dissolve") + var first_materials: Array = adapter.get("materials") if adapter != null else [] + var second_adapter := second_visual.get("_model") as Node3D + var second_materials: Array = second_adapter.get("materials") if second_adapter != null else [] + check(not first_materials.is_empty() and not second_materials.is_empty() and first_materials[0] != second_materials[0], "visual instances keep dissolve materials isolated") + second_visual.free() + + var fallback_visual := script.new() as Node3D + fallback_visual.set("kind", &"legacy_monolith") + fallback_visual.set("hover_z", -14.0) + var fallback_mount := Node3D.new() + fallback_mount.position = EXPECTED_MOUNT + root.add_child(fallback_mount) + fallback_mount.add_child(fallback_visual) + check(property_exists(fallback_visual, &"model_path"), "fallback visual exposes public model_path") + if property_exists(fallback_visual, &"model_path"): + fallback_visual.set("model_path", "res://Assets/runner/bosses/legacy_monolith/missing.glb") + fallback_visual.call("on_spawned") + check(fallback_visual.get("_model") == null, "missing configured model path falls back instead of mounting a partial adapter") + check(fallback_visual.get("_art") != null, "missing configured model path builds procedural Legacy Monolith art") + check(fallback_visual.global_position.distance_to(EXPECTED_MOUNT) <= 0.02, "procedural fallback preserves the boss mount") + if fallback_visual.get("_art") != null: + var fallback_bounds := procedural_art_bounds(fallback_visual.get("_art")) + check_bounds_match(fallback_bounds, BASELINE_BOUNDS, 0.08, "procedural fallback") + check_world_envelope_contract(visual, adapter) + mount.free() + fallback_mount.free() + + +func check_socket_motion(adapter: Node3D) -> void: + var socket := adapter.call("socket", &"muzzle_1") as Node3D + var player := adapter.get("animation_player") as AnimationPlayer + var model := adapter.get("model") as Node3D + check(socket != null and player != null and model != null, "animated arm socket test has its socket, player, and model") + if socket == null or player == null or model == null or not player.has_animation(&"attack"): + return + var skeletons := model.find_children("*", "Skeleton3D", true, false) + check(not skeletons.is_empty(), "imported model has a skeleton for its arm socket") + if skeletons.is_empty(): + return + var skeleton := skeletons[0] as Skeleton3D + var arm_index := skeleton.find_bone("arm_L") + check(arm_index >= 0, "Legacy Monolith skeleton includes the left arm bone") + if arm_index < 0: + return + player.stop() + player.speed_scale = 0.0 + player.play(&"attack", 0.0) + player.seek(0.0, true) + await process_frame + skeleton.force_update_all_bone_transforms() + var start := socket.global_position + var initial_arm_pose := skeleton.get_bone_global_pose(arm_index) + player.seek(player.get_animation(&"attack").length * 0.5, true) + await process_frame + skeleton.force_update_all_bone_transforms() + var middle_arm_pose := skeleton.get_bone_global_pose(arm_index) + check(not middle_arm_pose.is_equal_approx(initial_arm_pose), "arm bone changes pose during attack") + check(socket.global_position.distance_to(start) > 0.01, "arm-mounted muzzle socket moves after an animation frame") + player.speed_scale = 1.0 + + +func sample_action(adapter: Node3D, action: StringName, progress: float) -> Skeleton3D: + var player := adapter.get("animation_player") as AnimationPlayer + var model := adapter.get("model") as Node3D + var skeletons := model.find_children("*", "Skeleton3D", true, false) + if player == null or skeletons.is_empty() or not player.has_animation(action): + return null + var skeleton := skeletons[0] as Skeleton3D + player.stop() + player.speed_scale = 0.0 + player.play(action, 0.0) + player.seek(player.get_animation(action).length * clampf(progress, 0.0, 1.0), true) + await process_frame + skeleton.force_update_all_bone_transforms() + return skeleton + + +func bone_offset(skeleton: Skeleton3D, bone_name: String) -> Transform3D: + var index := skeleton.find_bone(bone_name) + if index < 0: + return Transform3D.IDENTITY + return skeleton.get_bone_global_rest(index).affine_inverse() * skeleton.get_bone_global_pose(index) + + +func bone_world_shift(skeleton: Skeleton3D, bone_name: String) -> Vector3: + var index := skeleton.find_bone(bone_name) + if index < 0: + return Vector3.ZERO + return skeleton.get_bone_global_pose(index).origin - skeleton.get_bone_global_rest(index).origin + + +func bone_rotation_amount(offset: Transform3D) -> float: + return offset.basis.get_rotation_quaternion().angle_to(Quaternion.IDENTITY) + + +func check_reviewed_animation_poses(adapter: Node3D) -> void: + var intro_start := await sample_action(adapter, &"intro", 0.0) + check(intro_start != null, "intro start pose samples from imported GLB") + if intro_start == null: + return + var root_start := bone_world_shift(intro_start, "root") + check(absf(root_start.y) <= 0.05 and root_start.z <= -3.8, "intro begins at floor height and enters from at least 3.8 m toward the far end") + + var intro_end := await sample_action(adapter, &"intro", 1.0) + if intro_end != null: + var tower := bone_offset(intro_end, "tower") + var arm_left := bone_offset(intro_end, "arm_L") + var arm_right := bone_offset(intro_end, "arm_R") + check(bone_rotation_amount(tower) >= 0.06, "intro end leans the tower forward") + check(bone_rotation_amount(arm_left) >= 0.25 and bone_rotation_amount(arm_right) >= 0.25, "intro end raises both arms into the power-up stance") + check(bone_world_shift(intro_end, "arm_L").y >= 0.08 and bone_world_shift(intro_end, "arm_R").y >= 0.08, "intro end lifts both arm joints from rest") + + var hurt := await sample_action(adapter, &"hurt", 0.375) + if hurt != null: + var rack := bone_offset(hurt, "rack_1") + check(rack.origin.x >= 0.28, "hurt slides rack one at least 0.28 m sideways") + check(absf(rack.origin.z) <= 0.03, "hurt rack jolt has no significant view-axis displacement") + check(bone_rotation_amount(rack) >= 0.055, "hurt yaws rack one") + + var defeat := await sample_action(adapter, &"defeat", 0.98) + if defeat != null: + var head := bone_offset(defeat, "rack_3") + var rack_two := bone_offset(defeat, "rack_2") + check(head.origin.y <= -1.0, "defeat drops the head rack at least 1 m") + check(bone_rotation_amount(head) >= 0.70, "defeat pitches the head rack forward") + check(rack_two.origin.x <= -0.40, "defeat shears rack two at least 0.4 m sideways") + + var slam_plateau_a := await sample_action(adapter, &"attack", 0.32) + var slam_plateau_b := await sample_action(adapter, &"attack", 0.40) + if slam_plateau_a != null and slam_plateau_b != null: + var fist_a := bone_world_shift(slam_plateau_a, "hand_L") + var fist_b := bone_world_shift(slam_plateau_b, "hand_L") + var arm_a := bone_world_shift(slam_plateau_a, "arm_L") + var arm_b := bone_world_shift(slam_plateau_b, "arm_L") + check(arm_a.y <= -0.39 and arm_a.z >= 0.30 and fist_a.y <= -0.39, "quake slam drives the arm and fist down while reaching forward (arm=%s fist=%s)" % [arm_a, fist_a]) + check(fist_a.distance_to(fist_b) <= 0.04, "quake slam holds a plateau from 0.32 to 0.40 normalized time") + check(arm_a.distance_to(arm_b) <= 0.04, "quake slam holds the arm pose through the impact plateau") + + +func run() -> void: + await process_frame + check(FileAccess.file_exists(MODEL_SCRIPT_PATH), "BossModel loader script exists") + check(FileAccess.file_exists(VISUAL_SCRIPT_PATH), "Legacy Monolith visual script exists") + var report := check_report() + if FileAccess.file_exists(MODEL_SCRIPT_PATH): + var model_script := load(MODEL_SCRIPT_PATH) as Script + check(model_script != null, "BossModel loader script loads") + if model_script != null: + check(model_script.call("load_model", "res://Assets/runner/bosses/legacy_monolith/missing.glb") == null, "BossModel returns null for a missing model path") + if FileAccess.file_exists(MODEL_PATH): + var adapter := model_script.call("load_model", MODEL_PATH) as Node3D + if adapter != null: + root.add_child(adapter) + check_adapter(adapter, report) + if adapter != null: + await check_socket_motion(adapter) + await check_reviewed_animation_poses(adapter) + adapter.free() + check_visual_contract() + finish() + + +func finish() -> void: + call_deferred("finish_after_cleanup") + + +func finish_after_cleanup() -> void: + print("LEGACY MONOLITH CHECK: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/assets/boss_legacy_monolith_check.gd.uid b/hoppys-adventure-arcade/tests/assets/boss_legacy_monolith_check.gd.uid new file mode 100644 index 00000000..c0a1310c --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_legacy_monolith_check.gd.uid @@ -0,0 +1 @@ +uid://2bok17koh4sx diff --git a/hoppys-adventure-arcade/tests/assets/boss_merge_conflict_hydra_check.gd b/hoppys-adventure-arcade/tests/assets/boss_merge_conflict_hydra_check.gd new file mode 100644 index 00000000..b090e071 --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_merge_conflict_hydra_check.gd @@ -0,0 +1,1335 @@ +extends SceneTree + +const MODEL_SCRIPT_PATH := "res://scenes/runner/boss/hifi/boss_model.gd" +const BossVisualBase = preload("res://scenes/runner/boss/boss_visual_base.gd") +const RunConfigScript = preload("res://scenes/runner/data/run_config.gd") +const VISUAL_SCRIPT_PATH := "res://scenes/runner/boss/visuals/merge_conflict_hydra_visual.gd" +const VISUAL_SCENE_PATH := "res://scenes/runner/boss/visuals/merge_conflict_hydra_visual.tscn" +const GLB_PATH := "res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.glb" +const BUILD_PATH := "res://Assets/runner/bosses/merge_conflict_hydra/merge_conflict_hydra.build.json" +const ACTIONS: Array[StringName] = [&"intro", &"idle", &"telegraph", &"attack", &"hurt", &"phase_change", &"defeat", &"retreat"] +const SOCKETS: Array[StringName] = [&"fx_core", &"fx_0", &"fx_1", &"fx_2", &"fx_3", &"fx_4", &"fx_5", &"muzzle_0", &"muzzle_1", &"muzzle_2"] +const MAX_TRIANGLES := 18000 +const MAX_BONES := 40 +const PROCEDURAL_SIZE := Vector3(6.431928, 3.8582, 1.893) +const BOUNDS_HZ := 120 +const MOUNT_Y := 2.6 +const MOUNT_Z := -14.0 +const HOVER_X_LIMIT := 0.12 +const WORLD_MIN := Vector3(-3.5, 1.6, MOUNT_Z - 3.0) +const WORLD_MAX := Vector3(3.5, 6.1, MOUNT_Z + 3.0) + +var checks := 0 +var failures := 0 +var _skinned_points_by_mesh: Dictionary = {} +var _invalid_skin_meshes: Dictionary = {} +var _validated_skin_meshes: Dictionary = {} +var _pose_bounds_summaries: Array[Dictionary] = [] + + +func _initialize() -> void: + call_deferred("run") + + +func check(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("BOSS MERGE CONFLICT HYDRA CHECK: " + message) + + +func has_shader_param(material: ShaderMaterial, name: StringName) -> bool: + if material == null or material.shader == null: + return false + for uniform: Dictionary in material.shader.get_shader_uniform_list(): + if StringName(uniform.get("name", "")) == name: + return true + return false + + +func read_build_report() -> Dictionary: + check(FileAccess.file_exists(BUILD_PATH), "full-bake build report exists") + if not FileAccess.file_exists(BUILD_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(BUILD_PATH)) + check(parsed is Dictionary, "build report parses as an object") + if not parsed is Dictionary: + return {} + var report: Dictionary = parsed + check(report.get("quick", true) == false, "asset was produced by a full bake") + check(int(report.get("kit_revision", 0)) >= 2, "asset uses the kit v2 rest-position ABI") + check(report.get("rest_position_encoding", "") == "TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1", + "build report declares the kit v2 rest-position attribute encoding") + check(int(report.get("triangles", MAX_TRIANGLES + 1)) <= MAX_TRIANGLES, "build report triangles <= 18000") + check(int(report.get("bones", MAX_BONES + 1)) <= MAX_BONES, "build report bones <= 40") + check(int(report.get("materials", -1)) == 1, "build report has one baked material") + check(int(report.get("surfaces", -1)) == 1, "build report has one baked surface") + for action in ACTIONS: + var actions: Array = report.get("actions", []) + check(actions.has(String(action)) or actions.has(action), "build report includes action " + String(action)) + for socket_name in SOCKETS: + var sockets: Array = report.get("sockets", []) + check(sockets.has(String(socket_name)) or sockets.has(socket_name), "build report includes socket " + String(socket_name)) + return report + + +func mesh_skeleton_census(model: Node3D) -> Dictionary: + var triangles := 0 + var surfaces := 0 + var materials: Dictionary = {} + var bones := 0 + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + for surface_index in mesh_instance.mesh.get_surface_count(): + surfaces += 1 + var arrays := mesh_instance.mesh.surface_get_arrays(surface_index) + var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + triangles += (indices.size() if not indices.is_empty() else vertices.size()) / 3 + var material := mesh_instance.mesh.surface_get_material(surface_index) + if material != null: + materials[material.get_instance_id()] = true + for node in model.find_children("*", "Skeleton3D", true, false): + bones += (node as Skeleton3D).get_bone_count() + return {"triangles": triangles, "surfaces": surfaces, "materials": materials.size(), "bones": bones} + + +func check_census(model: Node3D, report: Dictionary) -> void: + var census := mesh_skeleton_census(model) + check(census.triangles <= MAX_TRIANGLES, "imported GLB triangle census <= 18000 (%d)" % census.triangles) + check(census.bones <= MAX_BONES, "imported GLB bone census <= 40 (%d)" % census.bones) + check(census.materials == 1, "imported GLB has exactly one baked material") + check(census.surfaces == 1, "imported GLB has exactly one baked surface") + for key in ["triangles", "bones", "materials", "surfaces"]: + check(int(report.get(key, -1)) == int(census[key]), "build report %s matches imported census" % key) + var actual_size := rest_mesh_bounds(model).size + print("HYDRA REST BOUNDS: actual=%s baseline=%s" % [str(actual_size), str(PROCEDURAL_SIZE)]) + for axis in 3: + var reference := PROCEDURAL_SIZE[axis] + check(actual_size[axis] >= reference * 0.8 and actual_size[axis] <= reference * 1.2, + "rest mesh axis %d stays within 20 percent of procedural baseline (%.3f vs %.3f)" % [axis, actual_size[axis], reference]) + + +func check_rest_position_abi(model: Node3D, model_script: Script) -> void: + for node: MeshInstance3D in model.find_children("*", "MeshInstance3D", true, false): + if node.mesh == null: + continue + for surface in node.mesh.get_surface_count(): + var arrays := node.mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var restored: PackedVector3Array = model_script.call("rest_positions", node.mesh, surface) + check(restored.size() == vertices.size(), "every imported vertex has valid kit v2 rest xyz and marker") + var max_error := 0.0 + for index in mini(vertices.size(), restored.size()): + max_error = maxf(max_error, vertices[index].distance_to(restored[index])) + check(max_error <= 0.0001, "kit v2 rest coordinates survive import within 0.1 mm") + var old_mesh := ArrayMesh.new() + var old_arrays: Array = [] + old_arrays.resize(Mesh.ARRAY_MAX) + old_arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array([Vector3.ZERO, Vector3.RIGHT, Vector3.UP]) + old_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, old_arrays) + check(model_script.call("rest_positions", old_mesh, 0).is_empty(), "legacy mesh without kit v2 rest attributes is rejected") + + +func rest_mesh_bounds(model: Node3D) -> AABB: + var result := AABB() + var has_point := false + var model_from_world := model.global_transform.affine_inverse() + for node in model.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + var mesh_from_model := model_from_world * mesh_instance.global_transform + var local_bounds := mesh_instance.mesh.get_aabb() + for corner in 8: + var point := mesh_from_model * local_bounds.get_endpoint(corner) + if not has_point: + result = AABB(point, Vector3.ZERO) + has_point = true + else: + result = result.expand(point) + return result + + +func skinned_point_groups(mesh: Mesh) -> Array[Dictionary]: + if _skinned_points_by_mesh.has(mesh): + return _skinned_points_by_mesh[mesh] + var grouped: Dictionary = {} + for surface in mesh.get_surface_count(): + var arrays := mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] + var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] + if vertices.is_empty() or bones.is_empty() or weights.is_empty(): + continue + var stride := bones.size() / vertices.size() + for vertex_index in vertices.size(): + var ids := PackedInt32Array() + var gains := PackedFloat32Array() + for influence in stride: + var weight := weights[vertex_index * stride + influence] + if weight <= 0.0: + continue + ids.append(bones[vertex_index * stride + influence]) + gains.append(weight) + var key := var_to_str([ids, gains]) + if not grouped.has(key): + grouped[key] = {"ids": ids, "gains": gains, "points": {}} + grouped[key]["points"][vertices[vertex_index]] = true + var compact: Array[Dictionary] = [] + for group: Dictionary in grouped.values(): + var points := PackedVector3Array(group["points"].keys()) + compact.append({"ids": group["ids"], "gains": group["gains"], "points": points}) + _skinned_points_by_mesh[mesh] = compact + return compact + + +func cpu_skinned_mesh_bounds(mesh_instance: MeshInstance3D) -> AABB: + if mesh_instance.mesh == null: + return AABB() + if mesh_instance.skin == null: + return mesh_instance.global_transform * mesh_instance.get_aabb() + var skeleton := mesh_instance.get_node_or_null(mesh_instance.skeleton) as Skeleton3D + if skeleton == null: + return invalid_skin_bounds(mesh_instance, "skinned mesh has no resolved Skeleton3D") + var mesh_id := mesh_instance.get_instance_id() + if not _validated_skin_meshes.has(mesh_id): + _validated_skin_meshes[mesh_id] = true + var mesh_from_skeleton := skeleton.global_transform.affine_inverse() * mesh_instance.global_transform + if not mesh_from_skeleton.is_equal_approx(Transform3D.IDENTITY): + return invalid_skin_bounds(mesh_instance, "skinned mesh transform relative to Skeleton3D is not identity") + skeleton.force_update_all_bone_transforms() + var transforms: Array[Transform3D] = [] + for bind in mesh_instance.skin.get_bind_count(): + var bone := skeleton.find_bone(mesh_instance.skin.get_bind_name(bind)) + if bone < 0: + bone = mesh_instance.skin.get_bind_bone(bind) + if bone < 0: + return invalid_skin_bounds(mesh_instance, "skin bind %d (%s) has no skeleton bone" % [bind, mesh_instance.skin.get_bind_name(bind)]) + transforms.append(skeleton.global_transform * skeleton.get_bone_global_pose(bone) * mesh_instance.skin.get_bind_pose(bind)) + var result := AABB() + var first := true + for group: Dictionary in skinned_point_groups(mesh_instance.mesh): + var pose := Transform3D(Basis(Vector3.ZERO, Vector3.ZERO, Vector3.ZERO), Vector3.ZERO) + var ids: PackedInt32Array = group["ids"] + var gains: PackedFloat32Array = group["gains"] + for influence in ids.size(): + var bone_index := ids[influence] + if bone_index < 0 or bone_index >= transforms.size(): + return invalid_skin_bounds(mesh_instance, "vertex influence references invalid skin bind %d" % bone_index) + var part: Transform3D = transforms[bone_index] + var gain := gains[influence] + pose.origin += part.origin * gain + pose.basis.x += part.basis.x * gain + pose.basis.y += part.basis.y * gain + pose.basis.z += part.basis.z * gain + for point: Vector3 in group["points"]: + var world := pose * point + result = AABB(world, Vector3.ZERO) if first else result.expand(world) + first = false + return result + + +func invalid_skin_bounds(mesh_instance: MeshInstance3D, reason: String) -> AABB: + var mesh_id := mesh_instance.get_instance_id() + if not _invalid_skin_meshes.has(mesh_id): + _invalid_skin_meshes[mesh_id] = true + check(false, "CPU skin bounds reject invalid %s: %s" % [mesh_instance.get_path(), reason]) + return AABB() + + +func cpu_skinned_visual_bounds(visual: Node3D) -> AABB: + var adapter := visual.get("_model") as Node3D + if adapter == null: + return AABB() + var result := AABB() + var first := true + for node in adapter.find_children("*", "MeshInstance3D", true, false): + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh == null: + continue + var mesh_bounds := cpu_skinned_mesh_bounds(mesh_instance) + for corner in 8: + var point := mesh_bounds.get_endpoint(corner) + result = AABB(point, Vector3.ZERO) if first else result.expand(point) + first = false + return result + + +func new_pose_bounds_summary(label: String) -> Dictionary: + return { + "label": label, + "samples": 0, + "minimum": Vector3(INF, INF, INF), + "maximum": Vector3(-INF, -INF, -INF), + "violations": 0, + "failing_frames": [] + } + + +func include_pose_bounds_sample(summary: Dictionary, visual: Node3D, frame: int, far_z_exception: bool) -> void: + var original_position := visual.position + var hover_x := minf(float(visual.get("sway_amplitude")), HOVER_X_LIMIT) + var label_parts: Array[String] = [] + var pose_bounds := cpu_skinned_visual_bounds(visual) + for offset_x in [-hover_x, hover_x]: + for offset_y in [0.0, 0.08]: + var translation := Vector3(offset_x - original_position.x, offset_y - original_position.y, 0.0) + var bounds := AABB(pose_bounds.position + translation, pose_bounds.size) + summary["samples"] = int(summary["samples"]) + 1 + var minimum: Vector3 = summary["minimum"] + var maximum: Vector3 = summary["maximum"] + minimum.x = minf(minimum.x, bounds.position.x) + minimum.y = minf(minimum.y, bounds.position.y) + minimum.z = minf(minimum.z, bounds.position.z) + maximum.x = maxf(maximum.x, bounds.end.x) + maximum.y = maxf(maximum.y, bounds.end.y) + maximum.z = maxf(maximum.z, bounds.end.z) + summary["minimum"] = minimum + summary["maximum"] = maximum + var hover_z := float(visual.get("hover_z")) + var problems: Array[String] = [] + if bounds.position.x < WORLD_MIN.x or bounds.end.x > WORLD_MAX.x: + problems.append("x[%.3f,%.3f]" % [bounds.position.x, bounds.end.x]) + if bounds.position.y < WORLD_MIN.y or bounds.end.y > WORLD_MAX.y: + problems.append("y[%.3f,%.3f]" % [bounds.position.y, bounds.end.y]) + if not far_z_exception and bounds.size.z > 3.0: + problems.append("depth %.3f > 3.000" % bounds.size.z) + if bounds.end.z > hover_z + 3.0: + problems.append("zmax %.3f > %.3f" % [bounds.end.z, hover_z + 3.0]) + if not far_z_exception and bounds.position.z < hover_z - 3.0: + problems.append("zmin %.3f < %.3f" % [bounds.position.z, hover_z - 3.0]) + if not problems.is_empty(): + summary["violations"] = int(summary["violations"]) + 1 + if label_parts.size() < 8: + label_parts.append("f%d x=%.2f y=%.2f %s" % [frame, offset_x, offset_y, ",".join(problems)]) + if not label_parts.is_empty(): + var failing_frames: Array[String] = summary["failing_frames"] + if failing_frames.size() < 12: + failing_frames.append_array(label_parts) + summary["failing_frames"] = failing_frames + + +func finish_pose_bounds_summary(summary: Dictionary) -> void: + var label := String(summary["label"]) + var minimum: Vector3 = summary["minimum"] + var maximum: Vector3 = summary["maximum"] + var violation_count := int(summary["violations"]) + var failures_text := "" + if violation_count > 0: + failures_text = " failing=" + str(summary["failing_frames"]) + check(violation_count == 0, + "%s stays inside world envelope, violations=%d%s" % [label, violation_count, failures_text]) + print("HYDRA POSE BOUNDS: %s samples=%d world_min=%s world_max=%s violations=%d%s" % [ + label, int(summary["samples"]), str(minimum), str(maximum), violation_count, failures_text + ]) + _pose_bounds_summaries.append(summary) + + +func sample_pose_sequence(visual: Node3D, label: String, frame_count: int, far_z_exception: bool = false) -> void: + var summary := new_pose_bounds_summary(label) + for frame in frame_count + 1: + include_pose_bounds_sample(summary, visual, frame, far_z_exception) + if frame < frame_count: + visual.call("_process", 1.0 / float(BOUNDS_HZ)) + finish_pose_bounds_summary(summary) + + +func sample_cinematic_pose_sequence(visual: Node3D, mode: StringName, seconds: float, label: String) -> void: + visual.call("play_cinematic", mode, seconds) + var frames := maxi(1, roundi(seconds * BOUNDS_HZ)) + var summary := new_pose_bounds_summary(label) + for frame in frames + 1: + var progress := float(frame) / float(frames) + visual.call("set_cinematic_progress", mode, progress) + include_pose_bounds_sample(summary, visual, frame, mode == &"intro_full" or mode == &"intro_short") + finish_pose_bounds_summary(summary) + + +func cinematic_default_seconds(mode: StringName) -> float: + var config: Resource = RunConfigScript.new() + match mode: + &"intro_full": return float(config.get("boss_intro_seconds")) + &"intro_short": return minf(float(config.get("boss_intro_short_seconds")), float(config.get("boss_intro_seconds"))) + &"defeat_full": return float(config.get("boss_defeat_seconds")) + &"defeat_short": return minf(float(config.get("boss_defeat_short_seconds")), float(config.get("boss_defeat_seconds"))) + return 0.0 + + +func reset_bounds_fixture(visual: Node3D, phase: int) -> void: + visual.call("on_spawned") + if phase >= 2: + visual.call("set_phase", 2) + visual.set("_sprout_age", 1.3) + visual.call("_process", 0.0) + if phase >= 3: + visual.call("set_phase", 3) + visual.call("_process", 0.0) + visual.call("_action", &"idle") + visual.set("_clock", 0.0) + visual.set("_age", 0.0) + visual.call("_process", 0.0) + + +func sample_bounds_action_suite(visual: Node3D, phase: int) -> void: + var prefix := "phase%d" % phase + var adapter := visual.get("_model") as Node3D + var animation_player := adapter.get("animation_player") as AnimationPlayer if adapter != null else null + if animation_player == null: + check(false, prefix + " bounds suite has an AnimationPlayer") + return + var intro_length := animation_player.get_animation(&"intro").length + reset_bounds_fixture(visual, phase) + visual.call("play_intro", intro_length) + sample_pose_sequence(visual, prefix + "/intro", roundi(intro_length * BOUNDS_HZ), true) + reset_bounds_fixture(visual, phase) + var idle_length := animation_player.get_animation(&"idle").length + sample_pose_sequence(visual, prefix + "/idle-full-period", roundi(idle_length * BOUNDS_HZ)) + reset_bounds_fixture(visual, phase) + visual.call("play_telegraph", 0b111, &"default", 0.8) + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/telegraph", roundi(0.8 * BOUNDS_HZ)) + reset_bounds_fixture(visual, phase) + visual.call("play_telegraph", 0b111, &"default", 0.05) + visual.call("_process", 0.0) + visual.call("play_attack", &"default") + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/attack-and-delayed-recoil-tail", roundi(0.64 * BOUNDS_HZ)) + reset_bounds_fixture(visual, phase) + if phase >= 2: + visual.call("play_telegraph", 0b110, &"force_push", 0.8) + else: + visual.call("play_telegraph", 0b010, &"default", 0.8) + visual.call("_process", 0.0) + visual.call("play_attack", &"force_push" if phase >= 2 else &"default") + visual.call("_process", 0.0) + visual.call("play_hit", &"test_tool", 1) + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/hurt", roundi(0.35 * BOUNDS_HZ)) + reset_bounds_fixture(visual, phase) + visual.call("play_telegraph", 0b111, &"default", 0.8) + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/mid-telegraph-hit/telegraph", roundi(0.4 * BOUNDS_HZ)) + visual.call("play_hit", &"test_tool", 1) + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/mid-telegraph-hit/hurt", roundi(0.35 * BOUNDS_HZ)) + for lane in [0, 2]: + reset_bounds_fixture(visual, phase) + visual.call("play_telegraph", 1 << lane, &"default", 0.05) + visual.call("_process", 0.0) + visual.call("play_attack", &"default") + visual.call("_process", 0.0) + visual.call("play_hit", &"test_tool", 1) + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/outer-lane-%d-hurt" % lane, roundi(0.35 * BOUNDS_HZ)) + reset_bounds_fixture(visual, phase) + visual.call("play_defeat") + sample_pose_sequence(visual, prefix + "/defeat", roundi(float(visual.get("_duration")) * BOUNDS_HZ)) + reset_bounds_fixture(visual, phase) + visual.call("play_retreat") + sample_pose_sequence(visual, prefix + "/retreat", roundi(float(visual.get("_duration")) * BOUNDS_HZ)) + for mode in [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"]: + reset_bounds_fixture(visual, phase) + var seconds := cinematic_default_seconds(mode) + sample_cinematic_pose_sequence(visual, mode, seconds, prefix + "/" + String(mode)) + if phase >= 2: + for pair in [{"mask": 0b011, "name": "left-centre"}, {"mask": 0b110, "name": "centre-right"}]: + reset_bounds_fixture(visual, phase) + visual.call("play_telegraph", int(pair["mask"]), &"force_push", 0.8) + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/force_push_" + String(pair["name"]) + "_telegraph", roundi(0.8 * BOUNDS_HZ)) + visual.call("play_attack", &"force_push") + visual.call("_process", 0.0) + sample_pose_sequence(visual, prefix + "/force_push_" + String(pair["name"]) + "_attack", roundi(0.5 * BOUNDS_HZ)) + + +func check_phase_one_identity_and_lane_reset(visual: Node3D) -> void: + reset_bounds_fixture(visual, 1) + visual.call("set_phase", 2) + visual.call("_process", 1.3) + visual.call("set_phase", 3) + visual.call("_process", 1.2) + visual.call("_action", &"idle") + visual.call("_process", 0.0) + var model := visual.get("_model") as Node3D + var skeleton := visual.get("_skeleton") as Skeleton3D + var fourth_bone := int(visual.get("_fourth_bone")) + var phase3_composed := ( + model != null + and is_equal_approx(model.position.y, -0.30) + and is_equal_approx(model.scale.y, 0.95) + ) + check(phase3_composed, "respawn fixture starts with the phase-three model drop and compression") + check(skeleton != null and fourth_bone >= 0 and skeleton.get_bone_pose_scale(fourth_bone).is_equal_approx(Vector3.ONE), + "respawn fixture starts with the grown crown") + visual.call("play_telegraph", 0b110, &"force_push", 0.8) + visual.call("_process", 0.1) + visual.call("play_telegraph", 0b011, &"force_push", 0.8) + visual.call("_process", 0.1) + visual.call("play_attack", &"force_push") + visual.call("_process", 0.1) + var active_heads: Array = visual.get("_head_state") + var pending_queue: PackedInt32Array = visual.get("_queue_mask") + var active_overlay := false + for state: StringName in active_heads: + active_overlay = active_overlay or not state.is_empty() + var queued_attack := false + for mask in pending_queue: + queued_attack = queued_attack or mask != 0 + check(active_overlay and queued_attack, "respawn fixture has an active force-push overlay and pending queued lanes") + visual.call("on_spawned") + var head_state: Array = visual.get("_head_state") + var head_recoil: PackedFloat32Array = visual.get("_head_recoil") + var queue_mask: PackedInt32Array = visual.get("_queue_mask") + var lanes_clear := head_state.size() == 4 and head_recoil.size() == 4 and queue_mask.size() == 6 + for lane in mini(head_state.size(), 4): + lanes_clear = lanes_clear and StringName(head_state[lane]).is_empty() and is_equal_approx(head_recoil[lane], 9.0) + for slot in queue_mask.size(): + lanes_clear = lanes_clear and queue_mask[slot] == 0 + check(lanes_clear and int(visual.get("_phase")) == 1, + "phase-three spawn reset clears every overlay, recoil timer and queued action") + visual.call("_process", 0.0) + model = visual.get("_model") as Node3D + skeleton = visual.get("_skeleton") as Skeleton3D + fourth_bone = int(visual.get("_fourth_bone")) + check(model != null and is_zero_approx(model.position.y) and model.scale.is_equal_approx(Vector3.ONE), + "respawn after phase three restores model position and scale to identity") + check(skeleton != null and fourth_bone >= 0 and skeleton.get_bone_pose_scale(fourth_bone).is_equal_approx(Vector3.ONE * 0.001), + "respawn after phase three restores the crown gate to 0.001") + + +func snapshot_skeleton_pose(skeleton: Skeleton3D) -> Array[Transform3D]: + var result: Array[Transform3D] = [] + for bone in skeleton.get_bone_count(): + result.append(skeleton.get_bone_pose(bone)) + return result + + +func skeleton_pose_matches(skeleton: Skeleton3D, snapshot: Array[Transform3D]) -> bool: + if skeleton.get_bone_count() != snapshot.size(): + return false + for bone in snapshot.size(): + if not skeleton.get_bone_pose(bone).is_equal_approx(snapshot[bone]): + return false + return true + + +func check_sprout_interrupt_hitstop(visual: Node3D) -> void: + reset_bounds_fixture(visual, 1) + visual.call("set_phase", 2) + visual.call("_process", 0.4) + var adapter := visual.get("_model") as Node3D + var skeleton := visual.get("_skeleton") as Skeleton3D + check(adapter != null and skeleton != null and float(visual.get("_sprout_age")) < 0.94, + "hit-stop fixture interrupts crown sprout before growth completes") + visual.call("play_cinematic", &"defeat_full", 2.4) + visual.call("set_time_factor", 0.05) + var adapter_pose: Transform3D = adapter.transform if adapter != null else Transform3D.IDENTITY + var bones := snapshot_skeleton_pose(skeleton) if skeleton != null else [] + var summary := new_pose_bounds_summary("phase2/sprout-interrupted-defeat-hitstop") + for frame in 4: + var progress := float(frame) * 0.01 + visual.call("_process", 1.0 / float(BOUNDS_HZ)) + visual.call("set_cinematic_progress", &"defeat_full", progress) + include_pose_bounds_sample(summary, visual, frame, false) + var held := adapter != null and adapter.transform.is_equal_approx(adapter_pose) + if skeleton != null: + held = held and skeleton_pose_matches(skeleton, bones) + check(held, "whole model holds its sprout pose during defeat hit-stop at progress %.2f" % progress) + finish_pose_bounds_summary(summary) + + +func install_pristine_authored_adapter(visual: Node3D, model_script: Script) -> bool: + var previous := visual.get("_model") as Node3D + if previous != null: + visual.remove_child(previous) + previous.free() + var adapter: Variant = model_script.call("load_model", GLB_PATH) + if not adapter is Node3D: + check(false, "pristine authored-animation adapter loads from GLB") + return false + visual.add_child(adapter) + visual.set("_model", adapter) + var skeletons: Array = adapter.model.find_children("*", "Skeleton3D", true, false) + check(skeletons.size() == 1, "pristine authored adapter contains exactly one skeleton") + if skeletons.size() != 1: + return false + visual.set("_skeleton", skeletons[0]) + visual.set("_fourth_bone", (skeletons[0] as Skeleton3D).find_bone("branch_3")) + return true + + +func check_pristine_authored_clip_bounds(visual: Node3D) -> void: + var model_script := load(MODEL_SCRIPT_PATH) as Script + check(model_script != null, "authored-animation scan loads BossModel script") + if model_script == null: + return + for phase in [1, 2, 3]: + if not install_pristine_authored_adapter(visual, model_script): + return + visual.set("_phase", phase) + visual.set("_sprout_age", 1.3 if phase >= 2 else 9.0) + visual.set("_cinematic_mode", &"") + visual.set("_state", &"idle") + var adapter := visual.get("_model") as Node3D + var skeleton := visual.get("_skeleton") as Skeleton3D + var player := adapter.get("animation_player") as AnimationPlayer + player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL + player.playback_default_blend_time = 0.0 + for action in ACTIONS: + var clip := player.get_animation(action) + var frames := maxi(1, ceili(clip.length * BOUNDS_HZ)) + var summary := new_pose_bounds_summary("authored-phase%d/%s" % [phase, String(action)]) + skeleton.reset_bone_poses() + adapter.position = Vector3.ZERO + adapter.scale = Vector3.ONE + player.play(action, 0.0) + for frame in frames + 1: + var time := minf(float(frame) / float(BOUNDS_HZ), clip.length) + player.seek(time, true) + visual.call("_apply_fourth") + include_pose_bounds_sample(summary, visual, frame, action == &"intro") + finish_pose_bounds_summary(summary) + + +func check_every_frame_pose_bounds() -> void: + _pose_bounds_summaries.clear() + var packed := load(VISUAL_SCENE_PATH) as PackedScene + check(packed != null, "every-frame bounds fixture loads Hydra visual scene") + if packed == null: + return + var mount := Node3D.new() + mount.name = "HydraBoundsMount" + mount.position = Vector3(0.0, MOUNT_Y, MOUNT_Z) + root.add_child(mount) + var visual := packed.instantiate() as Node3D + check(visual != null, "every-frame bounds fixture instantiates visual") + if visual == null: + mount.free() + return + visual.set("hover_z", MOUNT_Z) + visual.set("sway_amplitude", 2.4) + visual.set("sway_period", 3.2) + mount.add_child(visual) + visual.call("on_spawned") + check(int(visual.get("phase_count")) == 3, "bounds fixture exercises the Hydra three-phase contract") + check_phase_one_identity_and_lane_reset(visual) + for phase in [1, 2, 3]: + if phase == 2: + reset_bounds_fixture(visual, 1) + visual.call("set_phase", 2) + sample_pose_sequence(visual, "phase1-to-2/phase_change", roundi(1.2 * BOUNDS_HZ)) + elif phase == 3: + reset_bounds_fixture(visual, 2) + visual.call("set_phase", 3) + sample_pose_sequence(visual, "phase2-to-3/phase_change", roundi(1.2 * BOUNDS_HZ)) + sample_bounds_action_suite(visual, phase) + check_sprout_interrupt_hitstop(visual) + check_pristine_authored_clip_bounds(visual) + var overall_minimum := Vector3(INF, INF, INF) + var overall_maximum := Vector3(-INF, -INF, -INF) + var aggregate_violations := 0 + for summary: Dictionary in _pose_bounds_summaries: + var minimum: Vector3 = summary["minimum"] + var maximum: Vector3 = summary["maximum"] + overall_minimum.x = minf(overall_minimum.x, minimum.x) + overall_minimum.y = minf(overall_minimum.y, minimum.y) + overall_minimum.z = minf(overall_minimum.z, minimum.z) + overall_maximum.x = maxf(overall_maximum.x, maximum.x) + overall_maximum.y = maxf(overall_maximum.y, maximum.y) + overall_maximum.z = maxf(overall_maximum.z, maximum.z) + aggregate_violations += int(summary["violations"]) + print("HYDRA POSE ENVELOPE: phases=1,2,3 hz=%d aggregate_world_min=%s aggregate_world_max=%s sequences=%d violations=%d" % [ + BOUNDS_HZ, str(overall_minimum), str(overall_maximum), _pose_bounds_summaries.size(), aggregate_violations + ]) + mount.free() + + +func check_model_adapter(model_script: Script, report: Dictionary) -> Node3D: + check(FileAccess.file_exists(GLB_PATH), "Hydra GLB exists") + check(FileAccess.file_exists(MODEL_SCRIPT_PATH), "BossModel adapter script exists") + if not FileAccess.file_exists(GLB_PATH) or model_script == null: + return null + var instance: Variant = model_script.call("load_model", GLB_PATH) + check(instance is Node3D, "GLB loads through BossModel") + if not instance is Node3D: + return null + root.add_child(instance) + var model := instance.get("model") as Node3D + check(model != null, "adapter exposes imported model") + if model == null: + instance.free() + return null + await process_frame + check_census(model, report) + check_rest_position_abi(model, model_script) + var animation_player := instance.get("animation_player") as AnimationPlayer + check(animation_player != null, "adapter exposes AnimationPlayer") + if animation_player != null: + for action in ACTIONS: + check(animation_player.has_animation(action), "runtime action exists: " + String(action)) + var idle: Animation = animation_player.get_animation(&"idle") if animation_player.has_animation(&"idle") else null + check(idle != null and idle.loop_mode != Animation.LOOP_NONE, "idle action loops") + var muzzle_xs := PackedFloat32Array() + for socket_name in SOCKETS: + var socket := instance.call("socket", socket_name) as Node3D + check(socket != null, "adapter resolves socket " + String(socket_name)) + if socket != null and String(socket_name).begins_with("muzzle_"): + var lane := int(String(socket_name).trim_prefix("muzzle_")) + var expected_x := float((lane - 1) * 2) + muzzle_xs.append(socket.global_position.x) + check(absf(socket.global_position.x - expected_x) < 0.5, + "%s socket stays near lane x=%.0f (actual %.3f)" % [String(socket_name), expected_x, socket.global_position.x]) + print("HYDRA MUZZLE X: %s expected=(-2, 0, 2)" % str(muzzle_xs)) + check(instance.has_method("set_time_factor"), "adapter exposes set_time_factor") + if instance.has_method("set_time_factor") and animation_player != null: + instance.call("set_time_factor", 0.25) + check(is_equal_approx(animation_player.speed_scale, 0.25), "adapter forwards time factor to animation player") + instance.call("set_time_factor", 1.0) + var materials: Array = instance.get("materials") + check(materials.size() == 1, "adapter creates one per-instance shader material") + for material: Variant in materials: + check(material is ShaderMaterial, "rebuilt model material is a ShaderMaterial") + if material is ShaderMaterial: + for parameter in [&"dissolve", &"flash", &"phase_tint"]: + check(has_shader_param(material, parameter), "boss shader declares " + String(parameter)) + return instance + + +func check_material_isolation(model_script: Script) -> void: + var first: Variant = model_script.call("load_model", GLB_PATH) + var second: Variant = model_script.call("load_model", GLB_PATH) + check(first is Node3D and second is Node3D, "two model instances load for shader isolation") + if not first is Node3D or not second is Node3D: + if first is Node3D: first.free() + if second is Node3D: second.free() + return + root.add_child(first) + root.add_child(second) + var first_materials: Array = first.get("materials") + var second_materials: Array = second.get("materials") + check(first_materials.size() == 1 and second_materials.size() == 1, "each instance has one mutable shader material") + if first_materials.size() == 1 and second_materials.size() == 1 and first_materials[0] is ShaderMaterial and second_materials[0] is ShaderMaterial: + var first_material := first_materials[0] as ShaderMaterial + var second_material := second_materials[0] as ShaderMaterial + check(first_material != second_material, "shader material is unique per model instance") + first.call("set_flash", 0.37) + check(not is_equal_approx(float(second_material.get_shader_parameter(&"flash")), 0.37), "flash mutation does not leak to peer instance") + first.call("set_dissolve", 0.42) + check(not is_equal_approx(float(second_material.get_shader_parameter(&"dissolve")), 0.42), "dissolve mutation does not leak to peer instance") + var tint := Color(0.2, 0.6, 0.9, 1.0) + first.call("set_phase_tint", tint) + check(not second_material.get_shader_parameter(&"phase_tint").is_equal_approx(tint), "phase tint mutation does not leak to peer instance") + first.free() + second.free() + + +func run() -> void: + await process_frame + check(FileAccess.file_exists(VISUAL_SCRIPT_PATH), "Hydra visual script exists") + check(FileAccess.file_exists(VISUAL_SCENE_PATH), "Hydra visual scene exists") + var report := read_build_report() + check(FileAccess.file_exists(MODEL_SCRIPT_PATH), "BossModel script exists") + if not FileAccess.file_exists(MODEL_SCRIPT_PATH): + finish() + return + var model_script := load(MODEL_SCRIPT_PATH) as Script + check(model_script != null, "BossModel script loads") + var adapter: Node3D = null + if model_script != null: + adapter = await check_model_adapter(model_script, report) + check_material_isolation(model_script) + if adapter != null: + adapter.free() + if FileAccess.file_exists(VISUAL_SCENE_PATH): + await check_visual_runtime() + check_every_frame_pose_bounds() + check_head_overlay_isolation() + check_crown_lane_targeting() + check_missing_model_fallback() + await check_defeat_remnant_render() + finish() + + +func check_visual_runtime() -> void: + var packed := load(VISUAL_SCENE_PATH) as PackedScene + check(packed != null, "Hydra visual PackedScene loads") + if packed == null: + return + var visual := packed.instantiate() as Node3D + check(visual != null, "Hydra visual scene instantiates") + if visual == null: + return + root.add_child(visual) + visual.call("on_spawned") + check_cinematic_focus_override(visual, "imported GLB") + check(visual.get("_model") is Node3D, "visual mounts BossModel adapter without fallback art") + check(visual.process_mode == Node.PROCESS_MODE_PAUSABLE, "visual uses pausable process mode") + check(visual.has_method("play_cinematic"), "visual exposes cinematic intro and defeat hook") + check(visual.has_method("set_time_factor"), "visual exposes time-factor hook") + check(visual.has_method("set_cinematic_progress"), "visual exposes externally supplied cinematic progress") + var skeleton := visual.get("_skeleton") as Skeleton3D + check(skeleton != null, "visual retains Hydra skeleton reference") + if skeleton != null: + var fourth_index := skeleton.find_bone("branch_3") + check(fourth_index >= 0, "skeleton contains phase-two fourth-head branch bone") + if fourth_index >= 0: + var fourth_rest_scale := skeleton.get_bone_pose_scale(fourth_index) + check(fourth_rest_scale.length() < 0.01, "fourth head begins hidden before phase two") + visual.call("set_phase", 2) + visual.call("_process", 0.8) + var fourth_phase2_scale := skeleton.get_bone_pose_scale(fourth_index) + check(fourth_phase2_scale.length() > 0.1, "phase two reveals fourth head") + visual.call("play_hit", &"test_tool", 1) + visual.call("_process", 0.2) + check(skeleton.get_bone_pose_scale(fourth_index).length() > 0.1, "fourth head stays visible during later hurt action") + check_lane_queues(visual) + await check_cinematics_and_time(visual) + visual.free() + + +func check_cinematic_focus_override(visual: Node3D, fixture_name: String) -> void: + var base_focus := BossVisualBase.rest_focus(visual) + var expected_focus := base_focus - Vector3(0, 0.3, 0) + var actual_focus: Vector3 = visual.call("cinematic_focus") + check(actual_focus.is_equal_approx(expected_focus), + "%s cinematic focus is cached rest focus lowered by 0.3m" % fixture_name) + + +func check_lane_queues(visual: Node3D) -> void: + var recoil: PackedFloat32Array = visual.get("_head_recoil") + check(recoil.size() == 4, "visual tracks recoil independently for four heads") + if recoil.size() != 4: + return + visual.call("play_telegraph", 0b101, &"default", 0.0) + visual.call("_process", 0.0) + visual.call("play_attack", &"default") + recoil = visual.get("_head_recoil") + check(recoil[0] != 9.0 and recoil[2] != 9.0, "left and right lane attacks recoil their own heads") + check(recoil[1] == 9.0 and recoil[3] == 9.0, "unselected lane heads keep inactive recoil state") + visual.call("play_telegraph", 0b010, &"default", 0.0) + visual.call("_process", 0.0) + visual.call("play_attack", &"default") + recoil = visual.get("_head_recoil") + check(recoil[1] != 9.0, "center lane attack selects the center head") + check(recoil[0] != 9.0 and recoil[2] != 9.0, "independent prior lane recoils remain active") + + +func intro_glb_pose_matches(visual: Node3D, bones: Array[Transform3D], model_pose: Transform3D, visual_pose: Transform3D, animation_position: float) -> bool: + var skeleton := visual.get("_skeleton") as Skeleton3D + var model := visual.get("_model") as Node3D + var player := model.get("animation_player") as AnimationPlayer if model != null else null + return ( + skeleton != null + and model != null + and player != null + and skeleton_pose_matches(skeleton, bones) + and model.transform.is_equal_approx(model_pose) + and visual.transform.is_equal_approx(visual_pose) + and is_equal_approx(player.current_animation_position, animation_position) + ) + + +func check_intro_pose_hold_glb(visual: Node3D, mode: StringName, seconds: float, held_progress: float) -> void: + visual.call("on_spawned") + visual.call("set_time_factor", 1.0) + visual.call("play_cinematic", mode, seconds) + var intro_pose_at := float(BossVisualBase.INTRO_POSE_AT[mode]) + var seconds_per_frame := 1.0 / (seconds * float(BOUNDS_HZ)) + var pose_frame := roundi(intro_pose_at / seconds_per_frame) + var before_pose_progress := float(pose_frame - 1) * seconds_per_frame + visual.call("set_cinematic_progress", mode, before_pose_progress) + var model := visual.get("_model") as Node3D + var player := model.get("animation_player") as AnimationPlayer if model != null else null + var clip := player.get_animation(&"intro") as Animation if player != null else null + check(player != null and clip != null and player.current_animation_position < clip.length - 0.0001, + "%s intro has not landed one 120Hz frame before INTRO_POSE_AT" % mode) + visual.call("set_cinematic_progress", mode, intro_pose_at) + var skeleton := visual.get("_skeleton") as Skeleton3D + model = visual.get("_model") as Node3D + player = model.get("animation_player") as AnimationPlayer if model != null else null + check(skeleton != null and model != null and player != null and clip != null, + "%s intro hold fixture exposes GLB skeleton, adapter and player" % mode) + if skeleton == null or model == null or player == null or clip == null: + return + var bones := snapshot_skeleton_pose(skeleton) + var model_pose := model.transform + var visual_pose := visual.transform + var animation_position := player.current_animation_position + var was_paused := paused + paused = true + await process_frame + check(intro_glb_pose_matches(visual, bones, model_pose, visual_pose, animation_position), + "%s intro pose holds at INTRO_POSE_AT during pause without new progress" % mode) + paused = was_paused + var hold_frame_count := roundi((held_progress - intro_pose_at) / seconds_per_frame) + var held_through_interval := true + for frame in hold_frame_count: + visual.call("_process", 1.0 / float(BOUNDS_HZ)) + var progress := intro_pose_at + float(frame + 1) * seconds_per_frame + visual.call("set_cinematic_progress", mode, minf(progress, held_progress)) + if not intro_glb_pose_matches(visual, bones, model_pose, visual_pose, animation_position): + held_through_interval = false + check(held_through_interval, + "%s intro keeps every bone and model transform unchanged at time_factor 1 from INTRO_POSE_AT %.2f through %.2f" % [mode, intro_pose_at, held_progress]) + + +func check_cinematics_and_time(visual: Node3D) -> void: + visual.call("set_time_factor", 0.5) + visual.call("play_cinematic", &"intro_full", 2.0) + check(StringName(visual.get("_cinematic_mode")) == &"intro_full", "full intro mode is retained") + check(is_equal_approx(float(visual.get("_cinematic_seconds")), 2.0), "full intro retains requested duration") + visual.call("set_cinematic_progress", &"intro_full", 0.4) + var supplied_age := float(visual.get("_cinematic_age")) + check(is_equal_approx(supplied_age, 0.8), "intro progress maps directly to its requested duration") + var model: Node3D = visual.get("_model") + var animation_player: AnimationPlayer = model.get("animation_player") if model != null else null + var intro_clip: Animation = animation_player.get_animation(&"intro") if animation_player != null else null + check(animation_player != null and intro_clip != null, "intro cinematic has a loaded animation clip") + if animation_player != null and intro_clip != null: + visual.call("set_cinematic_progress", &"intro_full", 0.8) + var held_intro_position := animation_player.current_animation_position + check(is_equal_approx(held_intro_position, intro_clip.length), "intro progress enters and holds its final pose") + var skeleton := visual.get("_skeleton") as Skeleton3D + if skeleton != null: + for head in 3: + var jaw_index := skeleton.find_bone("jaw_%d" % head) + check(jaw_index >= 0, "intro hold jaw bone exists for head %d" % head) + if jaw_index >= 0: + var jaw_angle := skeleton.get_bone_pose_rotation(jaw_index).get_euler().x + check(absf(wrapf(jaw_angle - 0.7, -PI, PI)) < 0.15, + "sampled intro hold keeps head %d jaw open near 0.7 rad (%.3f)" % [head, jaw_angle]) + visual.call("set_cinematic_progress", &"intro_full", 0.95) + check(is_equal_approx(animation_player.current_animation_position, held_intro_position), "intro final pose stays held through the title tail") + visual.call("_process", 0.5) + check(is_equal_approx(float(visual.get("_cinematic_age")), 1.9), "ordinary process delta does not advance externally driven cinematic progress") + visual.call("set_time_factor", 0.0) + visual.call("set_cinematic_progress", &"intro_full", 1.0) + check(StringName(visual.get("_cinematic_mode")) == &"" and StringName(visual.get("_state")) == &"idle", + "externally supplied skip reaches intro completion even at zero time factor") + visual.call("set_time_factor", 0.5) + visual.call("play_intro", 1.0) + var standalone_age := float(visual.get("_age")) + visual.call("_process", 0.2) + check(StringName(visual.get("_cinematic_mode")) == &"" and is_equal_approx(float(visual.get("_age")) - standalone_age, 0.1), + "standalone play_intro keeps its visual-clock timing") + visual.call("play_cinematic", &"intro_full", 2.0) + visual.call("set_cinematic_progress", &"intro_full", 0.4) + var frozen_age := float(visual.get("_cinematic_age")) + var held_pause_pose: Transform3D = (visual.get("_skeleton") as Skeleton3D).get_bone_pose(0) + var was_paused := paused + paused = true + await process_frame + check(is_equal_approx(float(visual.get("_cinematic_age")), frozen_age), "tree pause freezes cinematic state") + check((visual.get("_skeleton") as Skeleton3D).get_bone_pose(0).is_equal_approx(held_pause_pose), "pause without new progress preserves sampled pose") + paused = was_paused + visual.call("play_cinematic", &"defeat_short", 1.2) + visual.call("set_cinematic_progress", &"defeat_short", 0.3) + check(StringName(visual.get("_cinematic_mode")) == &"defeat_short", "short defeat mode is retained") + check(is_equal_approx(float(visual.get("_cinematic_age")), 0.36), "short defeat progress maps to compressed duration") + visual.call("play_cinematic", &"defeat_full", 4.0) + var skeleton := visual.get("_skeleton") as Skeleton3D + var defeat_pose: Array = visual.get("_defeat_pose") + check(skeleton != null and defeat_pose.size() == skeleton.get_bone_count(), "defeat snapshots every skeleton bone before terminal animation") + visual.call("set_time_factor", 0.0) + visual.call("set_cinematic_progress", &"defeat_full", 0.02) + if skeleton != null and defeat_pose.size() == skeleton.get_bone_count(): + var snapshot_held := true + for bone_index in defeat_pose.size(): + var expected_pose: Transform3D = defeat_pose[bone_index] + if not skeleton.get_bone_pose(bone_index).is_equal_approx(expected_pose): + snapshot_held = false + check(snapshot_held, "defeat cinematic holds the captured pose through the initial hit-stop") + visual.call("_process", 10.0) + check(is_equal_approx(float(visual.get("_cinematic_age")), 0.08), "process delta does not move defeat progress at zero time factor") + visual.call("set_cinematic_progress", &"defeat_full", 0.75) + model = visual.get("_model") + var materials: Array = model.get("materials") if model != null else [] + var dissolve_progress := -1.0 + for material: Variant in materials: + if material is ShaderMaterial: + dissolve_progress = float(material.get_shader_parameter(&"dissolve")) + check(dissolve_progress > 0.0 and dissolve_progress < 1.0, "externally supplied defeat progress dissolves progressively in its final 45 percent") + visual.call("set_cinematic_progress", &"defeat_full", 0.9) + dissolve_progress = -1.0 + for material: Variant in materials: + if material is ShaderMaterial: + dissolve_progress = float(material.get_shader_parameter(&"dissolve")) + check(is_equal_approx(dissolve_progress, 0.77778), "linear defeat dissolve at progress 0.90 is 0.77778") + visual.call("set_cinematic_progress", &"defeat_full", 1.0) + check(is_equal_approx(float(visual.get("_cinematic_age")), 4.0), "coarse progress jump clamps at terminal cinematic duration") + check(bool(visual.get("_terminal")) and StringName(visual.get("_state")) == &"defeat", + "completed defeat remains terminal instead of returning to idle") + for material: Variant in materials: + if material is ShaderMaterial: + check(is_equal_approx(float(material.get_shader_parameter(&"dissolve")), 1.0), "coarse defeat completion reaches full dissolve") + visual.call("_process", 2.0) + check(bool(visual.get("_terminal")) and StringName(visual.get("_state")) == &"defeat", + "additional coarse frames preserve the terminal defeated state") + for mode in [&"intro_full", &"intro_short"]: + await check_intro_pose_hold_glb(visual, mode, cinematic_default_seconds(mode), float(BossVisualBase.INTRO_HOLD_UNTIL[mode])) + + +func check_head_overlay_isolation() -> void: + var packed := load(VISUAL_SCENE_PATH) as PackedScene + if packed == null: + check(false, "head overlay comparison can load Hydra visual scene") + return + var active := packed.instantiate() as Node3D + var control := packed.instantiate() as Node3D + check(active != null and control != null, "paired Hydra visuals instantiate for overlay isolation") + if active == null or control == null: + if active != null: active.free() + if control != null: control.free() + return + root.add_child(active) + root.add_child(control) + active.call("on_spawned") + control.call("on_spawned") + var active_skeleton := active.get("_skeleton") as Skeleton3D + var control_skeleton := control.get("_skeleton") as Skeleton3D + check(active_skeleton != null and control_skeleton != null, "paired visuals expose their skeletons") + if active_skeleton == null or control_skeleton == null: + active.free() + control.free() + return + var selected_bones: Array[int] = [] + var unselected_bones: Array[int] = [] + for bone_name in ["neck_lo_0", "neck_hi_0", "head_0", "jaw_0"]: + selected_bones.append(active_skeleton.find_bone(bone_name)) + for bone_name in ["neck_lo_1", "neck_hi_1", "head_1", "jaw_1"]: + unselected_bones.append(active_skeleton.find_bone(bone_name)) + check(not selected_bones.has(-1) and not unselected_bones.has(-1), "overlay bone groups exist for selected and unselected lanes") + if selected_bones.has(-1) or unselected_bones.has(-1): + active.free() + control.free() + return + active.call("_process", 0.2) + control.call("_process", 0.2) + active.call("play_telegraph", 0b001, &"default", 0.8) + active.call("_process", 0.16) + control.call("_process", 0.16) + var selected_changed := false + for bone_index in selected_bones: + if not active_skeleton.get_bone_pose(bone_index).is_equal_approx(control_skeleton.get_bone_pose(bone_index)): + selected_changed = true + check(selected_changed, "selected lane telegraph overlays its own branch bones") + var other_lane_unchanged := true + for bone_index in unselected_bones: + if not active_skeleton.get_bone_pose(bone_index).is_equal_approx(control_skeleton.get_bone_pose(bone_index)): + other_lane_unchanged = false + check(other_lane_unchanged, "unselected lane remains at its current idle pose") + active.call("_process", 0.8) + control.call("_process", 0.8) + var returned_to_idle := true + for bone_index in selected_bones: + if not active_skeleton.get_bone_pose(bone_index).is_equal_approx(control_skeleton.get_bone_pose(bone_index)): + returned_to_idle = false + check(returned_to_idle, "selected branch restores idle pose without stale overlay transforms") + active.free() + control.free() + + +func check_crown_lane_targeting() -> void: + var left_target_rotation := check_crown_pair(0b011, -1.0, 2, "left+centre") + var right_target_rotation := check_crown_pair(0b110, 1.0, 0, "centre+right") + check(not left_target_rotation.is_equal_approx(right_target_rotation), "crown orientation differs for opposite force-push pairs") + + +func check_crown_pair(lane_mask: int, expected_x: float, unselected_lane: int, label: String) -> Quaternion: + var packed := load(VISUAL_SCENE_PATH) as PackedScene + if packed == null: + check(false, label + " crown probe loads visual scene") + return Quaternion.IDENTITY + var active := packed.instantiate() as Node3D + var control := packed.instantiate() as Node3D + check(active != null and control != null, label + " crown probe instantiates paired visuals") + if active == null or control == null: + if active != null: active.free() + if control != null: control.free() + return Quaternion.IDENTITY + root.add_child(active) + root.add_child(control) + active.call("on_spawned") + control.call("on_spawned") + active.call("set_phase", 2) + control.call("set_phase", 2) + active.call("_process", 1.3) + control.call("_process", 1.3) + var active_skeleton := active.get("_skeleton") as Skeleton3D + var control_skeleton := control.get("_skeleton") as Skeleton3D + check(active_skeleton != null and control_skeleton != null, label + " paired visuals expose skeletons") + if active_skeleton == null or control_skeleton == null: + active.free() + control.free() + return Quaternion.IDENTITY + active.call("play_telegraph", lane_mask, &"force_push", 0.8) + active.call("_process", 0.2) + control.call("_process", 0.2) + var crown_target := float(active.get("_crown_target_x")) + check(is_equal_approx(crown_target, expected_x), "%s force_push targets crown x=%.0f (actual %.3f)" % [label, expected_x, crown_target]) + var fourth_index := active_skeleton.find_bone("head_3") + check(fourth_index >= 0, "%s crown head bone exists" % label) + var crown_rotation := active_skeleton.get_bone_pose_rotation(fourth_index) if fourth_index >= 0 else Quaternion.IDENTITY + if fourth_index >= 0: + active_skeleton.force_update_all_bone_transforms() + var crown_transform := active_skeleton.global_transform * active_skeleton.get_bone_global_pose(fourth_index) + var crown_forward := crown_transform.basis.z.normalized() + var toward_pair := Vector3(expected_x - crown_transform.origin.x, 0.0, 1.0).normalized() + check(crown_forward.dot(toward_pair) > 0.0, + "%s crown global transform faces its force-push pair (forward %s, target %s)" % [label, str(crown_forward), str(toward_pair)]) + var branch_names := ["branch_%d", "neck_lo_%d", "neck_hi_%d", "head_%d", "jaw_%d"] + var unselected_lane_matches_idle := true + for bone_pattern in branch_names: + var bone_index := active_skeleton.find_bone(bone_pattern % unselected_lane) + if bone_index < 0 or not active_skeleton.get_bone_pose(bone_index).is_equal_approx(control_skeleton.get_bone_pose(bone_index)): + unselected_lane_matches_idle = false + check(unselected_lane_matches_idle, "%s leaves unselected main lane %d at idle pose" % [label, unselected_lane]) + active.free() + control.free() + return crown_rotation + + +func snapshot_fallback_intro_pose(fallback: Node3D, art: Node3D) -> Array[Transform3D]: + var result: Array[Transform3D] = [fallback.transform, art.transform] + var heads: Array = art.get("heads") + for head: Node3D in heads: + result.append(head.transform) + var jaw := head.get_node_or_null("Jaw") as Node3D + if jaw != null: + result.append(jaw.transform) + var batches: Array = art.get("batches") + for batch: Dictionary in batches: + var node := batch.get("node") as MultiMeshInstance3D + if node == null or node.multimesh == null: + continue + for instance in node.multimesh.instance_count: + result.append(node.multimesh.get_instance_transform(instance)) + return result + + +func fallback_intro_pose_matches(fallback: Node3D, art: Node3D, snapshot: Array[Transform3D]) -> bool: + var current := snapshot_fallback_intro_pose(fallback, art) + if current.size() != snapshot.size(): + return false + for index in snapshot.size(): + if not current[index].is_equal_approx(snapshot[index]): + return false + return true + + +func check_missing_model_fallback() -> void: + var packed := load(VISUAL_SCENE_PATH) as PackedScene + check(packed != null, "fallback probe loads the production Hydra visual scene") + if packed == null: + return + var fallback := packed.instantiate() as Node3D + check(fallback != null, "fallback probe instantiates the production Hydra visual") + if fallback == null: + return + check(fallback.get("model_path") == GLB_PATH, "visual model_path defaults to the Hydra GLB") + fallback.set("model_path", "res://__hydra_missing_model_fallback_probe__.glb") + fallback.set("sway_amplitude", 0.12) + fallback.set("sway_period", 1.0) + root.add_child(fallback) + fallback.call("on_spawned") + var art: Node3D = fallback.get("_art") as Node3D + check(art != null, "missing GLB mounts procedural BossArt fallback") + if art == null: + fallback.free() + return + check_cinematic_focus_override(fallback, "missing-GLB fallback") + var child_count := fallback.get_child_count() + fallback.call("on_spawned") + check(fallback.get_child_count() == child_count, "fallback respawn reuses its existing children") + fallback.call("play_intro", 0.1) + fallback.call("_process", 0.2) + check(StringName(art.get("state")) == &"idle" and StringName(fallback.get("_state")) == &"idle", + "standalone fallback intro returns to idle") + check(absf(fallback.position.x) > 0.001, "fallback resumes sway after standalone intro") + fallback.call("set_phase", 2) + fallback.call("_process", 0.1) + check(int(fallback.get("_phase")) == 2 and int(art.get("phase")) == 2, "fallback phase two reaction reaches BossArt") + for mode in [&"intro_full", &"intro_short"]: + fallback.call("on_spawned") + fallback.call("set_time_factor", 0.0) + var seconds := cinematic_default_seconds(mode) + var held_progress := float(BossVisualBase.INTRO_HOLD_UNTIL[mode]) + fallback.call("play_cinematic", mode, seconds) + var pose_at := float(BossVisualBase.INTRO_POSE_AT[mode]) + fallback.call("set_cinematic_progress", mode, pose_at) + var intro_snapshot := snapshot_fallback_intro_pose(fallback, art) + fallback.call("_process", 0.5) + fallback.call("set_cinematic_progress", mode, held_progress) + check(fallback_intro_pose_matches(fallback, art, intro_snapshot), + "fallback %s intro holds head and MultiMesh transforms from INTRO_POSE_AT %.2f through %.2f" % [mode, pose_at, held_progress]) + fallback.call("play_cinematic", &"defeat_full", 2.0) + fallback.call("set_time_factor", 0.0) + fallback.call("set_cinematic_progress", &"defeat_full", 0.5) + check(is_equal_approx(float(fallback.get("_cinematic_age")), 1.0) and StringName(art.get("state")) == &"defeated", + "fallback defeat cinematic follows supplied progress") + fallback.call("set_cinematic_progress", &"defeat_full", 1.0) + check(bool(fallback.get("_terminal")) and StringName(art.get("state")) == &"defeated", + "fallback remains defeated at terminal cinematic progress") + fallback.call("on_spawned") + check(fallback.get_child_count() == child_count and StringName(art.get("state")) == &"idle", + "fallback respawn resets defeat without creating duplicate children") + fallback.free() + + +func read_dissolve_amount(model: Node3D) -> float: + var materials: Array = model.get("materials") if model != null else [] + for material: Variant in materials: + if material is ShaderMaterial: + return float(material.get_shader_parameter(&"dissolve")) + return -1.0 + + +func capture_dissolve_pixels(viewport: SubViewport, label: String) -> int: + for frame in 3: + await process_frame + await RenderingServer.frame_post_draw + var pixels := viewport.get_texture().get_image() + if pixels == null: + return 0 + pixels.convert(Image.FORMAT_RGBA8) + for argument in OS.get_cmdline_user_args(): + if argument.begins_with("--capture-dir="): + var directory := argument.trim_prefix("--capture-dir=") + DirAccess.make_dir_recursive_absolute(directory) + pixels.save_png(directory.path_join(label + ".png")) + var bytes := pixels.get_data() + var count := 0 + for index in pixels.get_width() * pixels.get_height(): + if bytes[index * 4 + 3] > 127: + count += 1 + return count + + +func check_defeat_remnant_render() -> void: + var args := OS.get_cmdline_user_args() + if DisplayServer.get_name() == "headless": + check(not args.has("--require-gpu"), "--require-gpu requires a graphical renderer") + print("BOSS MERGE CONFLICT HYDRA GPU: SKIP (headless; rerun graphically with -- --require-gpu)") + return + var packed := load(VISUAL_SCENE_PATH) as PackedScene + check(packed != null, "GPU defeat fixture loads Hydra visual scene") + if packed == null: + return + var visual := packed.instantiate() as Node3D + check(visual != null, "GPU defeat fixture instantiates Hydra visual") + if visual == null: + return + visual.position = Vector3.ZERO + visual.rotation = Vector3.ZERO + visual.scale = Vector3.ONE + root.add_child(visual) + visual.call("on_spawned") + var adapter := visual.get("_model") as Node3D + check(adapter != null, "GPU fixture mounts imported Hydra BossModel") + if adapter == null: + visual.free() + return + var tag := visual.get("_tag") as Node3D + if tag != null: + tag.visible = false + visual.call("set_phase", 2) + visual.call("_process", 1.3) + var skeleton := visual.get("_skeleton") as Skeleton3D + var crown_branch := skeleton.find_bone("branch_3") if skeleton != null else -1 + check(crown_branch >= 0 and skeleton.get_bone_pose_scale(crown_branch).length() > 0.1, + "GPU defeat fixture reveals the fourth head before measuring all four heads") + visual.call("set_time_factor", 0.0) + visual.call("play_cinematic", &"defeat_full", 4.0) + visual.set_process(false) + var viewport := SubViewport.new() + viewport.size = Vector2i(384, 384) + viewport.own_world_3d = true + viewport.transparent_bg = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + visual.reparent(viewport) + visual.transform = Transform3D.IDENTITY + var camera := Camera3D.new() + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + camera.size = 7.0 + camera.position = Vector3(0.0, 2.0, 12.0) + viewport.add_child(camera) + camera.look_at(Vector3(0.0, 2.0, 0.0), Vector3.UP) + camera.current = true + var checks_for_progress := [0.25, 0.60, 0.75, 0.90, 0.95] + var coverage_by_progress: Dictionary = {} + var coverage_bands := { + 0.60: Vector2(0.78, 0.99), + 0.75: Vector2(0.50, 0.82), + 0.90: Vector2(0.15, 0.55), + 0.95: Vector2(0.04, 0.28), + } + for progress: float in checks_for_progress: + visual.call("set_cinematic_progress", &"defeat_full", progress) + var expected_dissolve := clampf((progress - 0.55) / 0.45, 0.0, 1.0) + var driven_dissolve := read_dissolve_amount(adapter) + check(is_equal_approx(driven_dissolve, expected_dissolve), + "cinematic progress %.2f drives linear dissolve %.5f (actual %.5f)" % [progress, expected_dissolve, driven_dissolve]) + adapter.call("set_dissolve", 0.0) + var intact_pixels := await capture_dissolve_pixels(viewport, "progress-%.2f-pose-dissolve-0" % progress) + adapter.call("set_dissolve", driven_dissolve) + var remaining_pixels := await capture_dissolve_pixels(viewport, "progress-%.2f-cinematic" % progress) + var coverage := float(remaining_pixels) / maxi(intact_pixels, 1) + coverage_by_progress[progress] = coverage + check(is_equal_approx(read_dissolve_amount(adapter), driven_dissolve), + "dissolve override remains %.5f after awaiting the progress %.2f capture" % [driven_dissolve, progress]) + check(intact_pixels > 1000, "GPU defeat pose at %.2f has measurable intact pixels (%d)" % [progress, intact_pixels]) + if coverage_bands.has(progress): + var band: Vector2 = coverage_bands[progress] + check(coverage >= band.x and coverage <= band.y, + "cinematic progress %.2f same-pose coverage %.4f stays in calibrated %.2f..%.2f band" % [progress, coverage, band.x, band.y]) + print("HYDRA DEFEAT GPU: progress=%.2f dissolve=%.5f remaining=%d intact=%d coverage=%.4f" % [ + progress, driven_dissolve, remaining_pixels, intact_pixels, coverage + ]) + var ordered_progress := [0.60, 0.75, 0.90, 0.95] + var previous_coverage := 1.0 + for progress: float in ordered_progress: + var coverage := float(coverage_by_progress.get(progress, 1.0)) + check(coverage < previous_coverage, + "calibrated dissolve coverage decreases monotonically through progress %.2f (%.4f < %.4f)" % [progress, coverage, previous_coverage]) + previous_coverage = coverage + visual.call("set_cinematic_progress", &"defeat_full", 1.0) + var terminal_dissolve := read_dissolve_amount(adapter) + adapter.call("set_dissolve", 0.0) + var terminal_intact_pixels := await capture_dissolve_pixels(viewport, "progress-1.00-pose-dissolve-0") + adapter.call("set_dissolve", terminal_dissolve) + var terminal_remaining_pixels := await capture_dissolve_pixels(viewport, "progress-1.00-terminal") + check(bool(visual.get("_terminal")), "GPU cinematic reaches terminal defeat at progress 1.0") + check(is_equal_approx(read_dissolve_amount(adapter), terminal_dissolve), + "restoring terminal shader dissolve remains stable after capture") + check(terminal_remaining_pixels == 0, "cinematic progress 1.0 discards every terminal pixel") + check(terminal_intact_pixels > 1000, "terminal defeat pose has measurable dissolve-zero pixels (%d)" % terminal_intact_pixels) + # Report direct shader coverage near the final defeat pose, where the collapsing model + # still has projected area. Terminal progress itself may collapse the silhouette to zero. + visual.call("set_cinematic_progress", &"defeat_full", 0.90) + var direct_pose_pixels := await capture_dissolve_pose_baseline(viewport, adapter) + adapter.call("set_dissolve", 0.9) + var direct_shader_pixels := await capture_dissolve_pixels(viewport, "progress-0.90-direct-shader-dissolve-0.90") + var direct_shader_coverage := float(direct_shader_pixels) / maxi(direct_pose_pixels, 1) + print("HYDRA DEFEAT GPU: progress=0.90 direct_shader_0.90 remaining=%d intact=%d coverage=%.4f samples=%s terminal_intact=%d" % [ + direct_shader_pixels, direct_pose_pixels, direct_shader_coverage, str(coverage_by_progress), terminal_intact_pixels + ]) + visual.free() + viewport.free() + + +func capture_dissolve_pose_baseline(viewport: SubViewport, adapter: Node3D) -> int: + adapter.call("set_dissolve", 0.0) + return await capture_dissolve_pixels(viewport, "progress-1.00-pose-dissolve-0") + + +func finish() -> void: + call_deferred("finish_after_cleanup") + + +func finish_after_cleanup() -> void: + print("BOSS MERGE CONFLICT HYDRA CHECK: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/assets/boss_merge_conflict_hydra_check.gd.uid b/hoppys-adventure-arcade/tests/assets/boss_merge_conflict_hydra_check.gd.uid new file mode 100644 index 00000000..6c76a00b --- /dev/null +++ b/hoppys-adventure-arcade/tests/assets/boss_merge_conflict_hydra_check.gd.uid @@ -0,0 +1 @@ +uid://bp8q22et5o2yd diff --git a/hoppys-adventure-arcade/tests/boss_pose_bounds.gd b/hoppys-adventure-arcade/tests/boss_pose_bounds.gd new file mode 100644 index 00000000..1953872a --- /dev/null +++ b/hoppys-adventure-arcade/tests/boss_pose_bounds.gd @@ -0,0 +1,55 @@ +extends RefCounted +## CPU skinning for the dummy renderer. Deduplicate mesh vertices by their skin weights so +## animation-envelope checks inspect actual deformed points without repeated UV seam vertices. +static var _points: Dictionary = {} + +static func mesh_bounds(mesh: MeshInstance3D) -> AABB: + if mesh.skin == null: return mesh.global_transform * mesh.get_aabb() + var skeleton := mesh.get_node_or_null(mesh.skeleton) as Skeleton3D + if skeleton == null: return mesh.global_transform * mesh.get_aabb() + skeleton.force_update_all_bone_transforms() + if not _points.has(mesh.mesh): + var groups := {} + for surface in mesh.mesh.get_surface_count(): + var arrays := mesh.mesh.surface_get_arrays(surface) + var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] + var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES] + var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS] + var stride := bones.size() / vertices.size() + for i in vertices.size(): + var ids := PackedInt32Array() + var gains := PackedFloat32Array() + for j in stride: + if weights[i * stride + j] <= 0.0: continue + ids.append(bones[i * stride + j]) + gains.append(weights[i * stride + j]) + var key := var_to_str([ids, gains]) + if not groups.has(key): groups[key] = {"ids": ids, "gains": gains, "points": {}} + groups[key].points[vertices[i]] = true + var compact := [] + for group: Dictionary in groups.values(): + group.points = PackedVector3Array(group.points.keys()) + compact.append(group) + _points[mesh.mesh] = compact + var transforms: Array[Transform3D] = [] + for bind in mesh.skin.get_bind_count(): + var bone := mesh.skin.get_bind_bone(bind) + if bone < 0: bone = skeleton.find_bone(mesh.skin.get_bind_name(bind)) + assert(bone >= 0) + transforms.append(skeleton.global_transform * skeleton.get_bone_global_pose(bone) * mesh.skin.get_bind_pose(bind)) + var bounds := AABB() + var first := true + for group: Dictionary in _points[mesh.mesh]: + var pose := Transform3D(Basis(Vector3.ZERO, Vector3.ZERO, Vector3.ZERO), Vector3.ZERO) + for i in group.ids.size(): + var part: Transform3D = transforms[group.ids[i]] + var gain: float = group.gains[i] + pose.origin += part.origin * gain + pose.basis.x += part.basis.x * gain + pose.basis.y += part.basis.y * gain + pose.basis.z += part.basis.z * gain + for point: Vector3 in group.points: + var world := pose * point + bounds = AABB(world, Vector3.ZERO) if first else bounds.expand(world) + first = false + return bounds diff --git a/hoppys-adventure-arcade/tests/boss_pose_bounds.gd.uid b/hoppys-adventure-arcade/tests/boss_pose_bounds.gd.uid new file mode 100644 index 00000000..caf31b20 --- /dev/null +++ b/hoppys-adventure-arcade/tests/boss_pose_bounds.gd.uid @@ -0,0 +1 @@ +uid://s1hskj4b4ce6 diff --git a/hoppys-adventure-arcade/tests/runner_boss_cinematic.gd b/hoppys-adventure-arcade/tests/runner_boss_cinematic.gd new file mode 100644 index 00000000..c8f6ab5d --- /dev/null +++ b/hoppys-adventure-arcade/tests/runner_boss_cinematic.gd @@ -0,0 +1,2946 @@ +extends SceneTree +## Boss cinematics (contract 2.6 §11.14, plus the §11.4 signals, §11.5 visual feed and focus, and +## §11.13 bosses_met / bosses_defeated_kinds). Hand-driven 1/60 ticks, like tests/runner_logic.gd. +## Run with --path hoppys-adventure-arcade --script res://tests/runner_boss_cinematic.gd. +## +## Only scripts that never reach runner_game.gd are preloaded: under --script this file is compiled +## before the autoloads exist, and runner_game.gd needs AppRouter at compile time. + +const RunConfig = preload("res://scenes/runner/data/run_config.gd") +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") +const CONFIG_PATH := "res://scenes/runner/data/run_config.tres" +const ARENA_SCENE := "res://scenes/runner/boss/boss_arena.tscn" +const GAME_SCENE := "res://scenes/runner/runner_game.tscn" +const UI_SCENE := "res://scenes/runner/ui/boss_cinematic.tscn" +const TICK := 1.0 / 60.0 +## Contract §11.14 defaults. Stamped onto every duplicated config a length check reads, so a .tres +## tweak cannot move the numbers this file asserts. +const INTRO_FULL := 5.0 +const INTRO_SHORT := 2.5 +const DEFEAT_FULL := 3.6 +const DEFEAT_SHORT := 2.0 +const SKIP_AFTER := 0.5 +const SKIP_TAIL := 0.3 +const SPEED_SCALE := 0.25 +const HITSTOP := 0.12 +const SLOWMO := 0.8 +const SLOWMO_FACTOR := 0.4 +const OUTRO := 1.0 +const HIT_STOP_FACTOR := 0.05 +const EASE_SECONDS := 0.35 +const KILL_BASE := 600 +const KILL_PER_STAGE := 200 +const TOOL_BONUS := 20 +const MODES: Array[StringName] = [&"intro_full", &"intro_short", &"defeat_full", &"defeat_short"] + +## Bare facade. _ready is a no-op so autostart never opens a run (the real _ready would). @onready +## still resolves when the node enters the tree, so _tree_game builds the scene paths first. +const GAME_SOURCE := "extends \"res://scenes/runner/runner_game.gd\"\nfunc _ready() -> void:\n\tpass\n" +const HAZARD_SOURCE := "extends Node3D\nvar kind: StringName = &\"pr_backlog_low\"\nvar blocking := true\nfunc is_blocking() -> bool:\n\treturn blocking\nfunc clear() -> void:\n\tblocking = false\n" +const SPAWNER_SOURCE := "extends Node\nvar spawn_calls: Array = []\nfunc set_spawning(on: bool) -> void:\n\tspawn_calls.append(on)\nfunc begin_clear_road(_seconds: float) -> void:\n\tpass\nfunc clear_hazards_in_range() -> int:\n\treturn 0\n" +const RIG_SOURCE := "extends Node3D\nvar turns: Array = []\nfunc set_turn(progress: float, direction: int) -> void:\n\tturns.append([progress, direction])\nfunc shake(_strength: float, _duration := 0.35) -> void:\n\tpass\n" +## The slice of the player the hold touches: controls_enabled plus the setter refresh_player_controls +## calls (contract §11.14). CharacterBody3D because runner_game.player is typed as one. +const CONTROLS_SOURCE := "extends CharacterBody3D\nvar controls_enabled := true\nfunc set_controls_enabled(enabled: bool) -> void:\n\tcontrols_enabled = enabled\n" +## Contract §11.5 visual feed. Extends the real base, so a hook the backend calls under a new name +## still resolves; only the cinematic ones are recorded. +const RECORDER_SOURCE := ( + "extends \"res://scenes/runner/boss/boss_visual_base.gd\"\n" + + "var calls: Array = []\n" + + "func _note(method: StringName, args: Array) -> void:\n" + + "\tcalls.append([method, args.duplicate()])\n" + + "func play_cinematic(mode: StringName, seconds: float) -> void:\n" + + "\t_note(&\"play_cinematic\", [mode, seconds])\n" + + "func set_cinematic_progress(mode: StringName, progress: float) -> void:\n" + + "\t_note(&\"set_cinematic_progress\", [mode, progress])\n" + + "func set_time_factor(factor: float) -> void:\n" + + "\t_note(&\"set_time_factor\", [factor])\n" + + "func play_defeat() -> void:\n" + + "\t_note(&\"play_defeat\", [])\n" + + "func play_retreat() -> void:\n" + + "\t_note(&\"play_retreat\", [])\n" + + "func set_health(hp: int, max_health: int) -> void:\n" + + "\t_note(&\"set_health\", [hp, max_health])\n" +) +## Overrides the default focus with a fixed local point (contract §11.5). +const FOCUS_SOURCE := "extends \"res://scenes/runner/boss/boss_visual_base.gd\"\nfunc cinematic_focus() -> Vector3:\n\treturn Vector3(1.5, 2.5, -0.5)\n" +## Records every set_boss_cinematic, including the optional focus argument (contract §11.14). +const CAMERA_FEED_SOURCE := "extends Node3D\nvar calls: Array = []\nfunc set_boss_cinematic(progress: float, mode: StringName, boss_position: Vector3, focus_position: Variant = null) -> void:\n\tcalls.append([progress, mode, boss_position, focus_position])\nfunc set_turn(_progress: float, _direction: int) -> void:\n\tpass\nfunc shake(_strength: float, _duration := 0.35) -> void:\n\tpass\n" + +var failures := 0 +var checks := 0 +var base_config: RunConfig +var saved_scale := 1.0 +var game_script: GDScript +var runner_game_script: GDScript +var hazard_script: GDScript +var spawner_script: GDScript +var rig_script: GDScript +var controls_script: GDScript +var recorder_script: GDScript +var focus_script: GDScript +var camera_feed_script: GDScript +var visual_base_script: GDScript +var hit_manager_script: GDScript +var score_manager_script: GDScript +var effect_manager_script: GDScript +var speed_controller_script: GDScript +var stage_manager_script: GDScript +var autopilot_script: GDScript +var encounter_script: GDScript +var camera_script: GDScript +var junk: Array[Node] = [] +var temp_paths: Array[String] = [] +var live_scene: Node = null +## One throwaway board for every facade. Never user://leaderboard.cfg. +var scratch_leaderboard := "user://runner_boss_cinematic_%d.cfg" % (randi() % 1000000) + +func _initialize() -> void: + call_deferred("run") + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error("RUNNER BOSS CINEMATIC: " + message) + +func note(message: String) -> void: + print("RUNNER BOSS CINEMATIC: " + message) + +func run() -> void: + saved_scale = Engine.time_scale + temp_paths.append(scratch_leaderboard) + note("start") + base_config = (load(CONFIG_PATH) as RunConfig).duplicate() + hit_manager_script = load("res://scenes/runner/systems/hit_manager.gd") as GDScript + score_manager_script = load("res://scenes/runner/systems/score_manager.gd") as GDScript + effect_manager_script = load("res://scenes/runner/systems/effect_manager.gd") as GDScript + speed_controller_script = load("res://scenes/runner/systems/speed_controller.gd") as GDScript + stage_manager_script = load("res://scenes/runner/systems/stage_manager.gd") as GDScript + autopilot_script = load("res://scenes/runner/systems/autopilot.gd") as GDScript + encounter_script = load("res://scenes/runner/boss/boss_encounter.gd") as GDScript + camera_script = load("res://scenes/runner/systems/camera_rig.gd") as GDScript + runner_game_script = load("res://scenes/runner/runner_game.gd") as GDScript + game_script = _compile(GAME_SOURCE) + hazard_script = _compile(HAZARD_SOURCE) + spawner_script = _compile(SPAWNER_SOURCE) + rig_script = _compile(RIG_SOURCE) + controls_script = _compile(CONTROLS_SOURCE) + recorder_script = _compile(RECORDER_SOURCE) + focus_script = _compile(FOCUS_SOURCE) + camera_feed_script = _compile(CAMERA_FEED_SOURCE) + visual_base_script = load("res://scenes/runner/boss/boss_visual_base.gd") as GDScript + if game_script == null or hazard_script == null or spawner_script == null or rig_script == null \ + or controls_script == null or recorder_script == null or focus_script == null \ + or camera_feed_script == null or visual_base_script == null or runner_game_script == null \ + or hit_manager_script == null or score_manager_script == null or effect_manager_script == null \ + or speed_controller_script == null or stage_manager_script == null or autopilot_script == null \ + or encounter_script == null or camera_script == null or base_config == null: + check(false, "the test harness scripts failed to compile") + await _report() + return + _test_curves() + _test_full_and_short() + _test_checkpoint() + _test_skip() + _test_pause() + _test_defeat_and_retreat() + _test_hold_and_abort() + _test_visual_feed() + _test_unskippable_defeats() + _test_intro_timing_and_focus() + _test_retreat_outro_feed() + _test_zero_tail_skip() + _test_set_health_feed() + _test_zero_length() + _test_kill_mid_intro_order() + await _test_real_input() + await _test_real_defeat_input() + await _test_restart_during_intro() + await _report() + +# --- 8. Pure curves (contract §11.14 boss_shot, time_factor_profile) -------------------------- + +func _test_curves() -> void: + var endpoint_bad := "" + var range_bad := "" + var hold_at := {} + for mode in MODES: + var saw_one := false + var at := -1.0 + for i in 101: + var p := i * 0.01 + var shot: Dictionary = camera_script.boss_shot(p, mode) + var blend := float(shot["blend"]) + if (is_equal_approx(p, 0.0) or is_equal_approx(p, 1.0)) and blend != 0.0: + endpoint_bad = "%s blend %.6f at p %.2f" % [mode, blend, p] + if blend < 0.0 or blend > 1.0: + range_bad = "%s blend %.6f at p %.2f" % [mode, blend, p] + break + if blend == 1.0 and not saw_one: + saw_one = true + at = p + hold_at[mode] = at + if not saw_one and range_bad == "": + range_bad = "%s never reached blend 1" % mode + if endpoint_bad != "" or range_bad != "": + break + check(endpoint_bad == "", "8: boss_shot blend is exactly 0 at p = 0 and p = 1 (%s)" % endpoint_bad) + check(range_bad == "", "8: boss_shot blend stays in [0, 1] and hits 1 mid-shot (%s)" % range_bad) + var empty_bad := "" + for mode in [&"", &"not_a_cinematic"]: + for i in 101: + var p := i * 0.01 + var blend := float(camera_script.boss_shot(p, mode)["blend"]) + if blend != 0.0: + empty_bad = "%s blend %.6f at p %.2f" % [mode, blend, p] + break + if empty_bad != "": + break + # Clamped outside [0, 1] too: the curve answers the endpoints before the ramps. + for p in [-0.5, 1.5]: + var blend := float(camera_script.boss_shot(p, mode)["blend"]) + if blend != 0.0: + empty_bad = "%s blend %.6f at p %.2f" % [mode, blend, p] + check(empty_bad == "", "8: boss_shot blend is exactly 0 for an empty or unknown mode (%s)" % empty_bad) + var profile_bad := _profile_problems() + check(profile_bad == "", "8: time_factor_profile (%s)" % profile_bad) + note("8: curves hold-at %s, profile %s" % [hold_at, "ok" if profile_bad == "" else profile_bad]) + +## Hit-stop 0.05 on [0, hitstop), then slowmo_factor smoothstepped to 1, then exactly 1. +## Negative time is 1, not the hit-stop. The 0.05 is the contract constant, not a value read back +## from the same function under test. +func _profile_problems() -> String: + if _profile(-1.0) != 1.0 or _profile(-0.001) != 1.0: + return "negative t is %.4f / %.4f, expected 1" % [_profile(-1.0), _profile(-0.001)] + if _profile(0.0) != HIT_STOP_FACTOR: + return "t = 0 is %.4f, expected %.2f" % [_profile(0.0), HIT_STOP_FACTOR] + var stop_bad := "" + for i in 12: + var t := i * 0.01 + if t >= HITSTOP: + break + if _profile(t) != HIT_STOP_FACTOR: + stop_bad = "t %.2f is %.4f" % [t, _profile(t)] + break + if stop_bad != "": + return stop_bad + # t == hitstop is the first slow-mo sample, not still the hit-stop (the comparison is strict <). + var at_stop := _profile(HITSTOP) + if not is_equal_approx(at_stop, SLOWMO_FACTOR): + return "t = hitstop %.2f is %.4f, expected slowmo_factor %.2f" % [HITSTOP, at_stop, SLOWMO_FACTOR] + var prev := at_stop + for i in 80: + var t := HITSTOP + (i + 1) * (SLOWMO / 80.0) + var value := _profile(t) + if value + 0.000001 < prev: + return "fell from %.4f to %.4f at t %.4f" % [prev, value, t] + prev = value + if _profile(HITSTOP + SLOWMO) != 1.0: + return "t = hitstop + slowmo is %.4f, expected 1" % _profile(HITSTOP + SLOWMO) + if _profile(HITSTOP + SLOWMO + 0.01) != 1.0 or _profile(DEFEAT_FULL) != 1.0: + return "after the slow-mo, %.4f / %.4f, expected 1" % [_profile(HITSTOP + SLOWMO + 0.01), _profile(DEFEAT_FULL)] + # Independent of time_factor_profile: 0.4 + 0.6 * smoothstep over the slow-mo span. + var interior: Array[float] = [0.16, 0.24, 0.32, 0.40, 0.48, 0.52, 0.60, 0.68, 0.76, 0.84, 0.90] + for sample in interior: + var got: float = _profile(sample) + var want: float = _slowmo_factor_at(sample) + if absf(got - want) > 0.0001: + return "t %.4f is %.6f, formula %.6f" % [sample, got, want] + var mid: float = _profile(0.52) + if absf(mid - 0.7) > 0.0001: + return "t = 0.52 is %.6f, expected 0.7" % mid + var late: float = _profile(0.9) + if late >= 1.0: + return "t = 0.9 is %.6f, expected below 1" % late + return "" + +## 0.4 + 0.6 * smoothstep(0, 1, (t - 0.12) / 0.8), for t inside the slow-mo ramp. +func _slowmo_factor_at(t: float) -> float: + return SLOWMO_FACTOR + (1.0 - SLOWMO_FACTOR) * smoothstep(0.0, 1.0, (t - HITSTOP) / SLOWMO) + +func _profile(elapsed: float) -> float: + return float(encounter_script.time_factor_profile(elapsed, HITSTOP, SLOWMO, SLOWMO_FACTOR)) + +# --- 1. Full on stage 1, short on stage 4 (contract §11.14) ------------------------------------ + +func _test_full_and_short() -> void: + var rig := _stage_rig(_overrides({"boss_timeout_seconds": 2.5})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + stage.begin() + var expected_mode: Array[StringName] = [&"intro_full", &"intro_full", &"intro_full", &"intro_short"] + var expected_seconds := [INTRO_FULL, INTRO_FULL, INTRO_FULL, INTRO_SHORT] + var expected_kind: Array[StringName] = [ + &"flaky_test_swarm", &"merge_conflict_hydra", &"legacy_monolith", &"flaky_test_swarm" + ] + var summary := [] + for index in 4: + var tag := "1: stage %d" % (index + 1) + if index == 0: + if not _require_boss(rig, tag, 8.0): + break + elif not _advance_to_next_boss(rig, tag): + break + var events: Dictionary = rig["events"] + var intro: Array = events["boss_intro"] + var cinematic: Array = events["boss_cinematic"] + var opened: Array = intro.back() if not intro.is_empty() else [] + var shot: Array = cinematic.back() if not cinematic.is_empty() else [] + var mode: StringName = shot[1] if shot.size() >= 3 else &"" + var intro_seconds := float(opened[2]) if opened.size() >= 3 else -1.0 + var shot_seconds := float(shot[2]) if shot.size() >= 3 else -1.0 + var kind: StringName = opened[0] if opened.size() >= 1 else &"" + check(_same(events["order"].slice(maxi(events["order"].size() - 4, 0)), + [&"intro", &"cinematic", &"spawned", &"health"]), + "%s: signal order %s, expected intro, cinematic, spawned, health" % [tag, events["order"]]) + check(kind == expected_kind[index] and mode == expected_mode[index], + "%s: opened %s / %s, expected %s / %s (cycle %d)" + % [tag, kind, mode, expected_kind[index], expected_mode[index], BossDefs.cycle(index + 1)]) + check(is_equal_approx(intro_seconds, expected_seconds[index]) + and is_equal_approx(shot_seconds, intro_seconds), + "%s: boss_intro %.4f s and boss_cinematic %.4f s, expected both %.4f" + % [tag, intro_seconds, shot_seconds, expected_seconds[index]]) + var mirror: Dictionary = _last_mirror(events, "mirror_open") + check(StringName(mirror.get("mode", &"")) == mode and is_equal_approx(float(mirror.get("progress", -1.0)), 0.0) + and is_equal_approx(float(mirror.get("factor", -1.0)), 1.0) and bool(mirror.get("hold", false)), + "%s: mirror at boss_cinematic is mode %s progress %.4f factor %.4f hold %s" + % [tag, mirror.get("mode", &""), float(mirror.get("progress", -1.0)), float(mirror.get("factor", -1.0)), mirror.get("hold", false)]) + var window := _play_intro(rig, tag) + var ended_delta := int(window["ended_delta"]) + check(ended_delta == 1, "%s: boss_cinematic_ended fired %d time(s) for the intro, expected 1" % [tag, ended_delta]) + check(bool(window["clean"]), "%s: a threat appeared before the intro ended (%s)" % [tag, window["threat"]]) + var after := _wait_for_threat(rig, tag, 3.0) + # Stage 1 and stage 4 are the lengths the contract calls out; both must open the attack gate + # only once the window is over, and the first tool / telegraph follows that (not the 5 s mark + # on a short intro). + if index == 0 or index == 3: + check(bool(after["appeared"]) and int(after["post_ticks"]) > 0, + "%s: no telegraph or tool within %.1f s after the intro (post ticks %d, phase %s)" + % [tag, 3.0, int(after["post_ticks"]), stage.phase]) + var ended_after_intro := int(window["ended_at"]) + if not _finish_retreat(rig, tag): + break + var extra_ended: int = int(events["ended"].size()) - ended_after_intro + check(extra_ended == 0, + "%s: retreat added %d boss_cinematic_ended, expected none (intro already ended once)" % [tag, extra_ended]) + summary.append("%s %s %.3fs window %.3fs threat +%.3fs" % [ + kind, mode, shot_seconds, int(window["ticks"]) * TICK, int(after["post_ticks"]) * TICK]) + if index == 3: + var window_seconds: float = int(window["ticks"]) * TICK + check(absf(window_seconds - INTRO_SHORT) <= TICK + 0.0001, + "1: stage 4 intro_short ran %.4f s (%d ticks), expected %.3f s within one tick" + % [window_seconds, int(window["ticks"]), INTRO_SHORT]) + note("1: %s" % [", ".join(summary)]) + _free_rig(rig) + +# --- 2. Short intro after a checkpoint retry (contract §11.13 bosses_met, §11.14) -------------- + +func _test_checkpoint() -> void: + runner_game_script.set(&"pending_boss_checkpoint", {}) + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + rig["stage"].begin() + if not _require_boss(rig, "2: first meeting", 8.0): + _free_rig(rig) + return + var game: Node = rig["game"] + var events: Dictionary = rig["events"] + var first: Array = events["boss_cinematic"].back() + var first_mode: StringName = first[1] if first.size() >= 2 else &"" + check(first_mode == &"intro_full", "2: the meeting the checkpoint is taken on plays %s, expected intro_full" % first_mode) + var checkpoint: Dictionary = (game.boss_checkpoint as Dictionary).duplicate(true) + var met: Array = checkpoint.get("bosses_met", []) + check(_has_kind(met, &"flaky_test_swarm") and int(checkpoint.get("stage_index", 0)) == 1, + "2: boss-entry checkpoint bosses_met %s stage %s, expected flaky_test_swarm on stage 1" + % [met, checkpoint.get("stage_index", "")]) + _free_rig(rig) + _assert_retry(checkpoint, "2: begin_at_boss", false) + _assert_retry(checkpoint, "2: start_run", true) + runner_game_script.set(&"pending_boss_checkpoint", {}) + note("2: checkpoint bosses_met %s, both retries asked for intro_short on stage 1" % [met]) + +func _assert_retry(checkpoint: Dictionary, tag: String, via_start_run: bool) -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + if via_start_run: + runner_game_script.set(&"pending_boss_checkpoint", checkpoint.duplicate(true)) + game.start_run(0) + var pending: Variant = runner_game_script.get(&"pending_boss_checkpoint") + check(pending is Dictionary and (pending as Dictionary).is_empty(), + "%s: pending_boss_checkpoint was not consumed (%s)" % [tag, pending]) + else: + stage.begin_at_boss(checkpoint.duplicate(true)) + if not _require_boss(rig, tag, 8.0): + _free_rig(rig) + return + var events: Dictionary = rig["events"] + var shot: Array = events["boss_cinematic"].back() + var intro: Array = events["boss_intro"].back() + var mode: StringName = shot[1] if shot.size() >= 2 else &"" + var seconds := float(shot[2]) if shot.size() >= 3 else -1.0 + var intro_seconds := float(intro[2]) if intro.size() >= 3 else -1.0 + var want := minf(INTRO_SHORT, INTRO_FULL) + check(mode == &"intro_short" and is_equal_approx(seconds, want) and is_equal_approx(intro_seconds, seconds), + "%s: stage %d plays %s for %.4f s (boss_intro %.4f), expected intro_short %.4f s" + % [tag, int(stage.stage_index), mode, seconds, intro_seconds, want]) + check(int(stage.stage_index) == 1, "%s: retry opened on stage %d" % [tag, int(stage.stage_index)]) + var fresh: Array = (game.boss_checkpoint as Dictionary).get("bosses_met", []) + check(_has_kind(fresh, &"flaky_test_swarm"), "%s: the fresh checkpoint dropped bosses_met (%s)" % [tag, fresh]) + _free_rig(rig) + +# --- 3. Skip (contract §11.14 skip_after / skip_tail) ------------------------------------------ + +func _test_skip() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + rig["stage"].begin() + if not _require_boss(rig, "3: skip", 8.0): + _free_rig(rig) + return + check(not arena.can_skip_cinematic() and not game.skip_boss_cinematic(), + "3: skip at progress %.4f (%.4f s) was accepted" % [arena.cinematic_progress(), _cinematic_elapsed(arena)]) + var early := _tick_until(rig, func() -> bool: + return _cinematic_elapsed(arena) + TICK >= SKIP_AFTER, 2.0) + check(not arena.can_skip_cinematic() and not bool(game.skip_boss_cinematic()), + "3: skip became legal at %.4f s, before boss_cinematic_skip_after %.2f (ticks %d)" + % [_cinematic_elapsed(arena), SKIP_AFTER, early]) + _tick_rig(rig, 1) + var opened_at := _cinematic_elapsed(arena) + check(arena.can_skip_cinematic() and opened_at + 0.0001 >= SKIP_AFTER and opened_at < SKIP_AFTER + TICK * 2.0, + "3: can_skip went true at %.4f s after %d sub-threshold ticks (skip_after %.2f)" + % [opened_at, early, SKIP_AFTER]) + # The facade also refuses while paused, even though the encounter's own can_skip does not look + # at game.paused (contract §11.4: running and unpaused). + game.pause() + var encounter_would := bool(arena.can_skip_cinematic()) + var paused_skip := bool(game.skip_boss_cinematic()) + check(not paused_skip, "3: skip_boss_cinematic during pause returned true (encounter can_skip %s, elapsed %.4f s)" + % [encounter_would, _cinematic_elapsed(arena)]) + _resume(game) + var skipped_before: int = int(events["skipped"].size()) + var ended_before: int = int(events["ended"].size()) + var did_skip := bool(game.skip_boss_cinematic()) + var seconds := float(arena.cinematic_seconds) + var jumped := (seconds - SKIP_TAIL) / seconds + check(did_skip, "3: skip_boss_cinematic returned false at %.4f s" % _cinematic_elapsed(arena)) + check(is_equal_approx(arena.cinematic_progress(), jumped) and is_equal_approx(game.boss_cinematic_progress, jumped), + "3: progress %.6f (mirror %.6f), expected (%.3f - %.3f) / %.3f = %.6f" + % [arena.cinematic_progress(), game.boss_cinematic_progress, seconds, SKIP_TAIL, seconds, jumped]) + check(events["skipped"].size() == skipped_before + 1 and StringName(events["skipped"].back()) == &"intro_full", + "3: boss_cinematic_skipped %s, expected one intro_full" % [events["skipped"]]) + var mirror: Dictionary = _last_mirror(events, "mirror_skip") + check(not bool(mirror.get("hold", true)) and is_equal_approx(float(mirror.get("progress", -1.0)), jumped), + "3: on the skipped signal the mirror read progress %.4f hold %s" + % [float(mirror.get("progress", -1.0)), mirror.get("hold", "")]) + var again := bool(game.skip_boss_cinematic()) + check(not again and events["skipped"].size() == skipped_before + 1, + "3: a second skip returned %s and skipped is now %s" % [again, events["skipped"]]) + var threat_during := "" + var tail_ticks := 0 + var guard := _ticks(SKIP_TAIL + 1.0) + var threat_base := _threat_now(rig) + while events["ended"].size() == ended_before and tail_ticks < guard: + _tick_rig(rig, 1) + tail_ticks += 1 + var text := _threat_delta(rig, threat_base) + if text != "": + threat_during = text + break + var tail_elapsed := tail_ticks * TICK + check(events["ended"].size() == ended_before + 1, + "3: boss_cinematic_ended %d time(s) after the skip, expected 1" % (events["ended"].size() - ended_before)) + check(absf(tail_elapsed - SKIP_TAIL) <= TICK + 0.0001, + "3: the tail ran %.4f s (%d ticks), expected %.2f s within one tick" % [tail_elapsed, tail_ticks, SKIP_TAIL]) + check(threat_during == "", "3: a threat appeared during the tail (%s)" % threat_during) + var after := _wait_for_threat(rig, "3: after tail", 3.0) + var from_start := (early + 1 + tail_ticks + int(after["post_ticks"])) * TICK + check(bool(after["appeared"]) and from_start + 0.0001 < INTRO_FULL and int(after["post_ticks"]) > 0, + "3: first threat at %.4f s from the fight opening (%.4f s after the tail), expected after the tail and before the full %.1f s intro" + % [from_start, int(after["post_ticks"]) * TICK, INTRO_FULL]) + note("3: skip opened at %.4f s, jumped to %.4f, tail %.4f s, first threat at %.4f s" % [opened_at, jumped, tail_elapsed, from_start]) + _free_rig(rig) + _test_skip_inside_tail() + +## Once the playhead is inside the last skip_tail seconds, skip is refused even though nobody skipped. +func _test_skip_inside_tail() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var game: Node = rig["game"] + rig["stage"].begin() + if not _require_boss(rig, "3: tail gate", 8.0): + _free_rig(rig) + return + var seconds := float(arena.cinematic_seconds) + var gate := seconds - SKIP_TAIL + var before := _tick_until(rig, func() -> bool: + return _cinematic_elapsed(arena) + TICK >= gate, seconds + 1.0) + var still := _cinematic_elapsed(arena) + check(arena.can_skip_cinematic() and still < gate, + "3: can_skip was %s at %.4f s, one tick before the tail gate %.4f" % [arena.can_skip_cinematic(), still, gate]) + _tick_rig(rig, 1) + var inside := _cinematic_elapsed(arena) + check(not arena.can_skip_cinematic() and not game.skip_boss_cinematic() and inside + 0.0001 >= gate, + "3: inside the tail (%.4f s, gate %.4f, %d ticks in) skip was still accepted" % [inside, gate, before + 1]) + note("3: unskipped tail gate refuses skip at %.4f s (gate %.4f)" % [inside, gate]) + _free_rig(rig) + +# --- 4. Pause freezes the cinematic (contract §11.14) ------------------------------------------ + +func _test_pause() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + # Bound before begin(), so the overlay hears boss_cinematic instead of attaching after it. + var ui := _mount_cinematic_ui(game) + stage.begin() + if not _require_boss(rig, "4: pause", 8.0): + _resume(game) + _free_cinematic_ui(ui) + _free_rig(rig) + return + if ui != null: + _check_overlay_advances(ui, rig) + var progress: float = arena.cinematic_progress() + var mirror := float(game.boss_cinematic_progress) + game.pause() + var pause_ticks := 60 + var frozen_before: Array = _ui_snapshot(ui) if ui != null else [] + if ui != null and ui.has_method("_process"): + ui._process(1.0) + _tick_rig(rig, pause_ticks) + if ui != null and ui.has_method("_process"): + ui._process(1.0) + var frozen_after: Array = _ui_snapshot(ui) if ui != null else [] + check(game.paused and pause_ticks > 0 and arena.cinematic_progress() == progress and float(game.boss_cinematic_progress) == mirror, + "4: %d paused ticks moved progress %.6f -> %.6f (mirror %.6f -> %.6f)" + % [pause_ticks, progress, arena.cinematic_progress(), mirror, float(game.boss_cinematic_progress)]) + if ui != null: + check(str(frozen_before) == str(frozen_after), + "4: the boss cinematic overlay changed across _process(1.0) and %d paused ticks\n before %s\n after %s" + % [pause_ticks, frozen_before, frozen_after]) + _resume(game) + _tick_rig(rig, 60) + var advanced: float = arena.cinematic_progress() + check(advanced > progress + 0.15 and is_equal_approx(float(game.boss_cinematic_progress), advanced), + "4: after resume, progress %.4f (mirror %.4f), from %.4f across 60 ticks of a %.1f s intro" + % [advanced, float(game.boss_cinematic_progress), progress, float(arena.cinematic_seconds)]) + note("4: paused progress stayed %.6f, resumed to %.4f" % [progress, advanced]) + _resume(game) + _free_cinematic_ui(ui) + _free_rig(rig) + +## The overlay is in the tree before the fight, so one unpaused tick that crosses the title +## (progress 0.40) has to change what it shows. +func _mount_cinematic_ui(game: Node) -> Node: + if not ResourceLoader.exists(UI_SCENE): + print("SKIP: %s is not in the project, so pause did not check the overlay's visible state" % UI_SCENE) + return null + var packed := load(UI_SCENE) as PackedScene + if packed == null: + check(false, "4: %s exists but did not load as a PackedScene" % UI_SCENE) + return null + var ui := packed.instantiate() + root.add_child(ui) + if ui.has_method("bind"): + ui.bind(game) + return ui + +func _free_cinematic_ui(ui: Node) -> void: + if ui != null and is_instance_valid(ui): + ui.free() + +func _check_overlay_advances(ui: Node, rig: Dictionary) -> void: + var arena: Node = rig["arena"] + var seconds: float = float(arena.cinematic_seconds) + var step: float = TICK / maxf(seconds, TICK) + var ticks := 0 + var guard := _ticks(seconds + 1.0) + while float(arena.cinematic_progress()) + step < 0.40 and ticks < guard: + _tick_rig(rig, 1) + ticks += 1 + if ui.has_method("_process"): + ui._process(TICK) + var title: CanvasItem = ui.get("title_group") as CanvasItem + var before_p: float = float(arena.cinematic_progress()) + var before_visible := false if title == null else bool(title.visible) + var before := _ui_snapshot(ui) + _tick_rig(rig, 1) + if ui.has_method("_process"): + ui._process(TICK) + var after_p: float = float(arena.cinematic_progress()) + var after_visible := false if title == null else bool(title.visible) + var after := _ui_snapshot(ui) + check(before_p < 0.40 and after_p >= 0.40 and not before_visible and after_visible and str(before) != str(after), + "4: one unpaused tick %.4f -> %.4f left the title %s -> %s (overlay %s)" + % [before_p, after_p, before_visible, after_visible, "changed" if str(before) != str(after) else "unchanged"]) + +# --- 5. Defeat hold, and 9. Engine.time_scale (contract §11.14) -------------------------------- + +func _test_defeat_and_retreat() -> void: + var rig := _stage_rig(_overrides({ + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + "boss_kill_bonus_base": KILL_BASE, + "boss_kill_bonus_per_stage": KILL_PER_STAGE, + "tool_bonus": TOOL_BONUS, + })) + if rig.is_empty(): + return + var stage: Node = rig["stage"] + stage.begin() + var summary := [] + for index in 4: + var tag := "5: stage %d" % (index + 1) + if index == 0: + if not _require_boss(rig, tag, 8.0): + break + elif not _advance_to_next_boss(rig, tag): + break + if not _skip_current_intro(rig, tag): + break + var measured := _kill_and_time(rig, tag, index == 0 or index == 3) + summary.append(measured) + if stage.phase != &"stage_clear" and stage.phase != &"run": + if not _drive_to_phase(rig, &"stage_clear", 2.0) and stage.phase != &"run": + check(false, "%s: left the defeat in phase %s" % [tag, stage.phase]) + break + note("5: %s" % [", ".join(summary)]) + _free_rig(rig) + _test_retreat() + +func _kill_and_time(rig: Dictionary, tag: String, full_check: bool) -> String: + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var events: Dictionary = rig["events"] + var score: Node = rig["score"] + var kind := StringName(arena.kind) + var first_defeat := true + # Stage 4 repeats flaky. Stages 2 and 3 are the other two kinds, each still on its first defeat. + if kind == &"flaky_test_swarm" and int(stage.stage_index) > 1: + first_defeat = false + var want_mode: StringName = &"defeat_full" if first_defeat else &"defeat_short" + var want_seconds := DEFEAT_FULL if first_defeat else minf(DEFEAT_SHORT, DEFEAT_FULL) + var kill_bonus := KILL_BASE + KILL_PER_STAGE * (int(stage.stage_index) - 1) + check(int(arena.hp) == 1, "%s: HP is %d before the one tool, expected 1 (hp_scale %.4f, cycle %d)" + % [tag, int(arena.hp), float(game.config.boss_hp_scale), BossDefs.cycle(int(stage.stage_index))]) + if int(arena.hp) != 1: + return "%s hp %d" % [tag, int(arena.hp)] + # Intro factor is 1 (contract §11.14). Defeat is the only cinematic that leaves 1. + check(is_equal_approx(float(arena.time_factor()), 1.0), + "%s: time_factor during / just after the intro is %.4f, expected 1" % [tag, float(arena.time_factor())]) + var bonus_before := int(score.bonus_total) + var awarded_before: int = int(events["bonus_awarded"].size()) + var defeated_before: int = int(events["boss_defeated"].size()) + var cinematic_before: int = int(events["boss_cinematic"].size()) + var order_before: int = int(events["order"].size()) + var sentinel := saved_scale + 0.7 + if is_equal_approx(sentinel, 0.0): + sentinel = 1.7 + Engine.time_scale = sentinel + arena.apply_tool(&"unit_test") + var shot: Array = events["boss_cinematic"].back() if events["boss_cinematic"].size() > cinematic_before else [] + var mode: StringName = shot[1] if shot.size() >= 2 else &"" + var seconds := float(shot[2]) if shot.size() >= 3 else -1.0 + var defeated: Array = events["boss_defeated"].back() if events["boss_defeated"].size() > defeated_before else [] + var defeated_bonus := int(defeated[1]) if defeated.size() >= 2 else -1 + var added: Array = events["bonus_awarded"].slice(awarded_before) + var suffix: Array = events["order"].slice(order_before) + check(_has_subsequence(suffix, [&"defeated", &"cinematic"]), + "%s: order after the kill %s, expected defeated then cinematic on that call" % [tag, suffix]) + check(mode == want_mode and is_equal_approx(seconds, want_seconds), + "%s: boss_cinematic %s %.4f s, expected %s %.4f s" % [tag, mode, seconds, want_mode, want_seconds]) + check(defeated_bonus == kill_bonus, + "%s: boss_defeated bonus %d, expected %d + %d * %d = %d" + % [tag, defeated_bonus, KILL_BASE, KILL_PER_STAGE, int(stage.stage_index) - 1, kill_bonus]) + check(_bonus_has(added, kill_bonus, &"boss_kill") and _bonus_has(added, TOOL_BONUS, &"tool"), + "%s: bonus_awarded added %s, expected tool %d and boss_kill %d" % [tag, added, TOOL_BONUS, kill_bonus]) + check(int(score.bonus_total) == bonus_before + TOOL_BONUS + kill_bonus, + "%s: bonus_total %d -> %d, expected +%d tool and +%d kill" + % [tag, bonus_before, int(score.bonus_total), TOOL_BONUS, kill_bonus]) + check(stage.phase == &"boss", "%s: phase became %s on the kill tick, before the defeat cinematic" % [tag, stage.phase]) + check(is_equal_approx(float(arena.time_factor()), HIT_STOP_FACTOR) + and is_equal_approx(float(game.boss_time_factor), HIT_STOP_FACTOR), + "%s: defeat opened at time_factor %.4f mirror %.4f, expected %.2f" + % [tag, float(arena.time_factor()), float(game.boss_time_factor), HIT_STOP_FACTOR]) + var factor_bad := "" + var ticks := 0 + var guard := _ticks(want_seconds + 2.0) + var ended_before: int = int(events["ended"].size()) + var scale_changed := false + while stage.phase == &"boss" and ticks < guard: + _tick_rig(rig, 1) + ticks += 1 + if not is_equal_approx(Engine.time_scale, sentinel): + scale_changed = true + if factor_bad != "": + continue + var live := float(arena.time_factor()) + var mirror_factor := float(game.boss_time_factor) + if StringName(arena.cinematic_mode) == want_mode: + var elapsed := float(arena.cinematic_progress()) * seconds + var expected := _profile(elapsed) + if not is_equal_approx(live, expected) or not is_equal_approx(mirror_factor, live): + factor_bad = "tick %d elapsed %.4f live %.4f profile %.4f mirror %.4f" % [ticks, elapsed, live, expected, mirror_factor] + elif StringName(arena.cinematic_mode) == &"": + if not is_equal_approx(live, 1.0) or not is_equal_approx(mirror_factor, 1.0): + factor_bad = "end tick live %.4f mirror %.4f" % [live, mirror_factor] + else: + factor_bad = "mode %s" % arena.cinematic_mode + var held := ticks * TICK + if full_check: + check(stage.phase == &"stage_clear", "%s: phase %s after %.4f s, expected stage_clear" % [tag, stage.phase, held]) + check(absf(held - want_seconds) <= TICK + 0.0001, + "%s: stage_clear at %.4f s (%d ticks), defeat cinematic %.4f s (within one tick)" % [tag, held, ticks, want_seconds]) + check(events["ended"].size() == ended_before + 1, + "%s: boss_cinematic_ended fired %d time(s) for the defeat, expected 1" % [tag, events["ended"].size() - ended_before]) + check(factor_bad == "", "8: live time_factor left the profile on %s (%s)" % [tag, factor_bad]) + check(not scale_changed and is_equal_approx(Engine.time_scale, sentinel), + "9: Engine.time_scale is %.4f after the defeat cinematic, started at %.4f" % [Engine.time_scale, sentinel]) + Engine.time_scale = saved_scale + return "%s %s %.3fs clear@%.3fs bonus %d" % [kind, mode, seconds, held, defeated_bonus] + +func _test_retreat() -> void: + var rig := _stage_rig(_overrides({"boss_timeout_seconds": 0.05})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var events: Dictionary = rig["events"] + rig["stage"].begin() + if not _require_boss(rig, "5: retreat", 8.0): + _free_rig(rig) + return + if not _skip_current_intro(rig, "5: retreat intro"): + _free_rig(rig) + return + var cinematic_at: int = int(events["boss_cinematic"].size()) + var ended_at: int = int(events["ended"].size()) + var retreated_at: int = int(events["boss_retreated"].size()) + var ticks := 0 + var guard := _ticks(2.0) + while events["boss_retreated"].size() == retreated_at and ticks < guard and stage.phase == &"boss": + _tick_rig(rig, 1) + ticks += 1 + check(events["boss_retreated"].size() == retreated_at + 1, + "5: retreat did not fire (phase %s, fight_timer tick-in %d)" % [stage.phase, ticks]) + var hold_ticks := 0 + var hold_guard := _ticks(OUTRO + 2.0) + var mode_during := &"" + while stage.phase == &"boss" and hold_ticks < hold_guard: + if StringName(arena.cinematic_mode) != &"": + mode_during = StringName(arena.cinematic_mode) + if bool(stage.cinematic_hold): + mode_during = &"hold" + _tick_rig(rig, 1) + hold_ticks += 1 + var held := hold_ticks * TICK + check(stage.phase == &"stage_clear", "5: retreat ended in phase %s" % stage.phase) + check(absf(held - float(game.config.boss_outro_seconds)) <= TICK + 0.0001 + and is_equal_approx(float(game.config.boss_outro_seconds), OUTRO), + "5: retreat held %.4f s (%d ticks), expected boss_outro_seconds %.3f within one tick" + % [held, hold_ticks, float(game.config.boss_outro_seconds)]) + check(events["boss_cinematic"].size() == cinematic_at and events["ended"].size() == ended_at and mode_during == &"", + "5: retreat grew a cinematic (shots %d -> %d, ended %d -> %d, saw %s)" + % [cinematic_at, events["boss_cinematic"].size(), ended_at, events["ended"].size(), mode_during]) + note("5: retreat held %.4f s with no cinematic" % held) + _free_rig(rig) + +# --- 6 and 7. Hold, controls, speed, abort, catch (contract §11.14) ---------------------------- + +func _test_hold_and_abort() -> void: + _test_hold_skip() + _test_hold_end() + _test_abort() + _test_catch() + +func _test_hold_skip() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + var speed: Node = rig["speed"] + var player := _attach_controls(game) + stage.begin() + if not _require_boss(rig, "6: hold", 8.0): + _free_rig(rig) + return + var full_speed := float(game.speed) + var clock := float(stage.run_clock) + check(bool(stage.cinematic_hold) and not bool(player.controls_enabled), + "6: at the intro, hold %s controls %s" % [stage.cinematic_hold, player.controls_enabled]) + _tick_rig(rig, 8) + check(float(speed.cinematic_scale) > 0.55 and float(speed.cinematic_scale) < 0.95, + "6: cinematic_scale %.4f after 8 ticks, expected mid-ease from 1 toward %.2f" % [float(speed.cinematic_scale), SPEED_SCALE]) + _tick_rig(rig, _ticks(EASE_SECONDS) - 8) + check(absf(float(speed.cinematic_scale) - SPEED_SCALE) < 0.02, + "6: cinematic_scale %.4f after %.2f s, expected %.2f" % [float(speed.cinematic_scale), EASE_SECONDS, SPEED_SCALE]) + check(float(game.speed) < full_speed * 0.5 and absf(float(game.speed) - full_speed * SPEED_SCALE) < maxf(full_speed * 0.05, 0.05), + "6: corridor speed %.4f, from %.4f, expected about %.4f while the intro holds" + % [float(game.speed), full_speed, full_speed * SPEED_SCALE]) + check(is_equal_approx(float(stage.run_clock), clock) and is_equal_approx(float(game.run_clock), clock), + "6: run_clock %.4f / %.4f, expected it to stay %.4f through the intro" % [float(stage.run_clock), float(game.run_clock), clock]) + game.set_autopilot(true) + game.set_autopilot(false) + check(not bool(player.controls_enabled) and not bool(game.autopilot.enabled), + "6: set_autopilot(false) during the hold left controls %s (bot %s)" + % [player.controls_enabled, game.autopilot.enabled]) + var ready := _tick_until(rig, func() -> bool: return bool(arena_can_skip(rig)), 2.0) + if not arena_can_skip(rig): + _tick_rig(rig, 1) + check(bool(game.skip_boss_cinematic()), "6: could not skip once the ease and the skip gate had both passed (ticks %d)" % ready) + check(not bool(stage.cinematic_hold) and bool(player.controls_enabled), + "6: after the skip, hold %s controls %s (the tail must give controls back)" % [stage.cinematic_hold, player.controls_enabled]) + _tick_rig(rig, _ticks(EASE_SECONDS)) + check(absf(float(speed.cinematic_scale) - 1.0) < 0.02, + "6: cinematic_scale %.4f after the skip's %.2f s ease, expected 1" % [float(speed.cinematic_scale), EASE_SECONDS]) + check(absf(float(game.speed) - full_speed) < maxf(full_speed * 0.05, 0.05), + "6: corridor speed %.4f after the skip, expected back near %.4f" % [float(game.speed), full_speed]) + note("6: hold scale %.4f speed %.3f -> %.3f, skip restored scale %.4f speed %.3f, run_clock %.4f" + % [SPEED_SCALE, full_speed, full_speed * SPEED_SCALE, float(speed.cinematic_scale), float(game.speed), clock]) + _free_rig(rig) + +func _test_hold_end() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + var speed: Node = rig["speed"] + var events: Dictionary = rig["events"] + var player := _attach_controls(game) + stage.begin() + if not _require_boss(rig, "6: end", 8.0): + _free_rig(rig) + return + var full_speed := float(game.speed) + var ended_before: int = int(events["ended"].size()) + var window := _play_intro(rig, "6: end") + check(int(window["ended_delta"]) == 1 and events["ended"].size() == ended_before + 1, + "6: playing the intro out fired ended %d time(s)" % int(window["ended_delta"])) + check(not bool(stage.cinematic_hold) and bool(player.controls_enabled), + "6: at intro end, hold %s controls %s" % [stage.cinematic_hold, player.controls_enabled]) + var mirror: Dictionary = _last_mirror(events, "mirror_end") + check(StringName(mirror.get("mode", &"x")) == &"" and is_equal_approx(float(mirror.get("progress", -1.0)), 0.0) + and is_equal_approx(float(mirror.get("factor", -1.0)), 1.0), + "6: mirror at ended is mode %s progress %.4f factor %.4f" + % [mirror.get("mode", &""), float(mirror.get("progress", -1.0)), float(mirror.get("factor", -1.0))]) + _tick_rig(rig, _ticks(EASE_SECONDS)) + check(absf(float(speed.cinematic_scale) - 1.0) < 0.02 and absf(float(game.speed) - full_speed) < maxf(full_speed * 0.05, 0.05), + "6: after the intro ended, scale %.4f speed %.4f, expected scale 1 and speed near %.4f" + % [float(speed.cinematic_scale), float(game.speed), full_speed]) + note("6: natural end restored scale %.4f speed %.3f (from %.3f)" % [float(speed.cinematic_scale), float(game.speed), full_speed]) + _free_rig(rig) + +func _test_abort() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var speed: Node = rig["speed"] + var events: Dictionary = rig["events"] + var player := _attach_controls(game) + stage.begin() + if not _require_boss(rig, "7: abort", 8.0): + _free_rig(rig) + return + var full_speed := float(game.speed) + _tick_rig(rig, 10) + var ended_before: int = int(events["ended"].size()) + arena.abort() + check(events["ended"].size() == ended_before + 1, + "7: abort fired boss_cinematic_ended %d time(s), expected 1" % (events["ended"].size() - ended_before)) + check(not bool(stage.cinematic_hold) and bool(player.controls_enabled), + "7: after abort, hold %s controls %s" % [stage.cinematic_hold, player.controls_enabled]) + check(StringName(game.boss_cinematic_mode) == &"" and is_equal_approx(float(game.boss_cinematic_progress), 0.0) + and is_equal_approx(float(game.boss_time_factor), 1.0), + "7: mirrors after abort are mode %s progress %.4f factor %.4f, expected empty / 0 / 1" + % [game.boss_cinematic_mode, float(game.boss_cinematic_progress), float(game.boss_time_factor)]) + _tick_rig(rig, _ticks(EASE_SECONDS)) + check(absf(float(speed.cinematic_scale) - 1.0) < 0.02 and absf(float(game.speed) - full_speed) < maxf(full_speed * 0.05, 0.05), + "7: after abort, scale %.4f speed %.4f, expected scale 1 and speed near %.4f" + % [float(speed.cinematic_scale), float(game.speed), full_speed]) + note("7: abort ended once, mirrors clear, scale %.4f speed %.3f" % [float(speed.cinematic_scale), float(game.speed)]) + _free_rig(rig) + +func _test_catch() -> void: + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + var speed: Node = rig["speed"] + var hits: Node = rig["hits"] + var events: Dictionary = rig["events"] + var player := _attach_controls(game) + stage.begin() + if not _require_boss(rig, "7: catch", 8.0): + _free_rig(rig) + return + _tick_rig(rig, _ticks(EASE_SECONDS)) + var ended_before: int = int(events["ended"].size()) + var scale_before := float(speed.cinematic_scale) + hits.report_hit(_hazard()) + hits.iframe_remaining = 0.0 + hits.report_hit(_hazard()) + check(game.state == &"caught" and stage.phase == &"idle", + "7: catch left state %s phase %s" % [game.state, stage.phase]) + check(events["ended"].size() == ended_before + 1, + "7: catch fired boss_cinematic_ended %d time(s), expected 1" % (events["ended"].size() - ended_before)) + check(not bool(player.controls_enabled), + "6: catch left controls enabled (%s); they have to stay off" % player.controls_enabled) + check(StringName(game.boss_cinematic_mode) == &"" and is_equal_approx(float(game.boss_cinematic_progress), 0.0) + and is_equal_approx(float(game.boss_time_factor), 1.0), + "7: mirrors after the catch are mode %s progress %.4f factor %.4f" + % [game.boss_cinematic_mode, float(game.boss_cinematic_progress), float(game.boss_time_factor)]) + _tick_rig(rig, _ticks(EASE_SECONDS) + 2) + var target := float(speed._cinematic_target) if &"_cinematic_target" in speed else -1.0 + check(absf(float(speed.cinematic_scale) - 1.0) < 0.02, + "6: after the catch, cinematic_scale %.4f (target %.4f, was %.4f at the hit) did not ease back to 1 within %.2f s" + % [float(speed.cinematic_scale), target, scale_before, EASE_SECONDS]) + note("6/7: catch controls %s scale %.4f -> %.4f (target %.4f) phase %s" % [ + player.controls_enabled, scale_before, float(speed.cinematic_scale), target, stage.phase]) + _free_rig(rig) + +# --- 10. Visual feed (contract §11.5, §11.14) -------------------------------------------------- + +func _test_visual_feed() -> void: + _test_feed_intro_skip() + _test_feed_abort() + _test_feed_defeat() + +## start() mounts the graybox and plays the intro before it returns, so the recorder swapped in +## afterwards sees the intro from the next tick on. The play_cinematic -> progress 0 opening is +## asserted on the defeat, which begins after the swap. +func _test_feed_intro_skip() -> void: + var rig := _boss_rig(_overrides({})) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var game: Node = rig["game"] + var events: Dictionary = rig["events"] + arena.start(1) + var rec := _mount_recorder(arena) + if rec == null: + check(false, "10: the intro recorder did not mount") + _free_rig(rig) + return + rec.calls.clear() + var bad := "" + var prev := -1.0 + var ticks := 0 + var guard := _ticks(SKIP_AFTER + 1.0) + while not arena.can_skip_cinematic() and StringName(arena.cinematic_mode) != &"" and ticks < guard and bad == "": + bad = _feed_tick(arena, rec, &"intro_full", prev, false) + prev = _last_progress(rec) + ticks += 1 + check(bad == "" and ticks > 0, "10: intro feed before the skip gate (%s, %d ticks)" % [bad, ticks]) + var before: int = int(rec.calls.size()) + var did := bool(arena.skip_cinematic()) + var jumped := (float(arena.cinematic_seconds) - SKIP_TAIL) / float(arena.cinematic_seconds) + var jump_progress := _last_progress_call(rec, before) + check(did and jump_progress.size() == 2 and StringName(jump_progress[0]) == &"intro_full" + and is_equal_approx(float(jump_progress[1]), jumped) + and is_equal_approx(float(arena.cinematic_progress()), jumped), + "10: skip feed %s, arena progress %.6f, expected intro_full %.6f" % [jump_progress, float(arena.cinematic_progress()), jumped]) + var size_after_skip: int = int(rec.calls.size()) + check(not arena.skip_cinematic() and rec.calls.size() == size_after_skip, + "10: a second skip added feed calls (%d -> %d)" % [size_after_skip, rec.calls.size()]) + prev = float(jump_progress[1]) if jump_progress.size() == 2 else prev + var ended_before: int = int(events["ended"].size()) + var snap := {"calls": [], "mode": &""} + var on_ended := func(mode: StringName) -> void: + snap["calls"] = rec.calls.duplicate(true) + snap["mode"] = mode + game.boss_cinematic_ended.connect(on_ended) + bad = "" + var tail := 0 + while StringName(arena.cinematic_mode) != &"" and tail < _ticks(SKIP_TAIL + 1.0) and bad == "": + var mode_before := StringName(arena.cinematic_mode) + bad = _feed_tick(arena, rec, mode_before, prev, false) + if StringName(arena.cinematic_mode) == &"": + bad = "" + break + prev = _last_progress(rec) + tail += 1 + if game.boss_cinematic_ended.is_connected(on_ended): + game.boss_cinematic_ended.disconnect(on_ended) + var end_progress := _last_named(snap["calls"], &"set_cinematic_progress") + var end_factor := _last_named(snap["calls"], &"set_time_factor") + check(events["ended"].size() == ended_before + 1 and StringName(snap["mode"]) == &"intro_full", + "10: intro ended %d time(s) as %s" % [events["ended"].size() - ended_before, snap["mode"]]) + check(end_progress.size() == 2 and StringName(end_progress[0]) == &"intro_full" and is_equal_approx(float(end_progress[1]), 1.0) + and end_factor.size() == 1 and is_equal_approx(float(end_factor[0]), 1.0), + "10: final intro feed before ended was progress %s factor %s, expected (intro_full, 1) then 1" + % [end_progress, end_factor]) + check(bad == "", "10: intro feed after the skip (%s)" % bad) + note("10: intro feed %d ticks to the skip, jump %.4f, tail %d ticks, final %s" % [ticks, jumped, tail, end_progress]) + _free_rig(rig) + +func _test_feed_abort() -> void: + var rig := _boss_rig(_overrides({})) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var game: Node = rig["game"] + var events: Dictionary = rig["events"] + arena.start(1) + var rec := _mount_recorder(arena) + if rec == null: + check(false, "10: the abort recorder did not mount") + _free_rig(rig) + return + rec.calls.clear() + for _i in 8: + arena.tick(TICK) + var ended_before: int = int(events["ended"].size()) + var snap := {"calls": [], "mode": &""} + game.boss_cinematic_ended.connect(func(mode: StringName) -> void: + snap["calls"] = rec.calls.duplicate(true) + snap["mode"] = mode + , CONNECT_ONE_SHOT) + arena.abort() + var end_progress := _last_named(snap["calls"], &"set_cinematic_progress") + var end_factor := _last_named(snap["calls"], &"set_time_factor") + check(events["ended"].size() == ended_before + 1 and StringName(snap["mode"]) == &"intro_full", + "10: abort emitted ended %d time(s) as %s" % [events["ended"].size() - ended_before, snap["mode"]]) + check(end_progress.size() == 2 and StringName(end_progress[0]) == &"intro_full" and is_equal_approx(float(end_progress[1]), 1.0) + and end_factor.size() == 1 and is_equal_approx(float(end_factor[0]), 1.0), + "10: abort's final feed before ended was progress %s factor %s, expected (intro_full, 1) then 1" + % [end_progress, end_factor]) + check(rec.calls.size() == (snap["calls"] as Array).size(), + "10: abort added %d feed call(s) after boss_cinematic_ended" % (rec.calls.size() - (snap["calls"] as Array).size())) + note("10: abort final feed %s factor %s" % [end_progress, end_factor]) + _free_rig(rig) + +func _test_feed_defeat() -> void: + var rig := _boss_rig(_overrides({"boss_hp_scale": 0.001})) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var game: Node = rig["game"] + var events: Dictionary = rig["events"] + arena.start(1) + var rec := _mount_recorder(arena) + if rec == null: + check(false, "10: the defeat recorder did not mount") + _free_rig(rig) + return + if not _skip_arena_intro(arena): + check(false, "10: the defeat rig's intro never ended (mode %s)" % arena.cinematic_mode) + _free_rig(rig) + return + var ended_before: int = int(events["ended"].size()) + rec.calls.clear() + arena.apply_tool(&"unit_test") + var opening: Array = rec.calls.duplicate(true) + var names := _call_names(opening) + check(_same(names.slice(0, 5), [&"set_health", &"play_defeat", &"play_cinematic", &"set_cinematic_progress", &"set_time_factor"]), + "10: defeat opened with %s, expected set_health, play_defeat, play_cinematic, set_cinematic_progress, set_time_factor" % [names]) + var health_args: Array = opening[0][1] if not opening.is_empty() else [] + var played: Array = opening[2][1] if opening.size() >= 3 else [] + var progressed: Array = opening[3][1] if opening.size() >= 4 else [] + var factored: Array = opening[4][1] if opening.size() >= 5 else [] + var seconds := float(arena.cinematic_seconds) + check(health_args.size() == 2 and int(health_args[0]) == 0 and int(health_args[1]) == int(arena.max_hp) + and played.size() == 2 and StringName(played[0]) == &"defeat_full" and is_equal_approx(float(played[1]), DEFEAT_FULL) + and progressed.size() == 2 and StringName(progressed[0]) == &"defeat_full" and is_equal_approx(float(progressed[1]), 0.0) + and factored.size() == 1 and is_equal_approx(float(factored[0]), HIT_STOP_FACTOR), + "10: defeat opening args health %s play %s progress %s factor %s, expected set_health(0, %d), defeat_full %.1f, progress 0, factor %.2f" + % [health_args, played, progressed, factored, int(arena.max_hp), DEFEAT_FULL, HIT_STOP_FACTOR]) + var snap := {"calls": [], "mode": &""} + game.boss_cinematic_ended.connect(func(mode: StringName) -> void: + snap["calls"] = rec.calls.duplicate(true) + snap["mode"] = mode + , CONNECT_ONE_SHOT) + var bad := "" + var prev := 0.0 + var ticks := 0 + var guard := _ticks(DEFEAT_FULL + 1.0) + while StringName(arena.cinematic_mode) == &"defeat_full" and ticks < guard and bad == "": + bad = _feed_tick(arena, rec, &"defeat_full", prev, true, seconds) + if StringName(arena.cinematic_mode) == &"": + bad = "" + break + prev = _last_progress(rec) + ticks += 1 + var end_progress := _last_named(snap["calls"], &"set_cinematic_progress") + var end_factor := _last_named(snap["calls"], &"set_time_factor") + check(bad == "", "10: defeat feed (%s)" % bad) + check(StringName(snap["mode"]) == &"defeat_full" + and end_progress.size() == 2 and is_equal_approx(float(end_progress[1]), 1.0) + and end_factor.size() == 1 and is_equal_approx(float(end_factor[0]), 1.0) + and is_equal_approx(float(end_factor[0]), _profile(seconds)), + "10: defeat final feed before ended was %s / %s (profile at %.3f s is %.4f), ended mode %s" + % [end_progress, end_factor, seconds, _profile(seconds), snap["mode"]]) + check(events["ended"].size() == ended_before + 1 and StringName(snap["mode"]) == &"defeat_full", + "10: defeat emitted boss_cinematic_ended %d time(s) as %s, expected 1 defeat_full" + % [events["ended"].size() - ended_before, snap["mode"]]) + note("10: defeat opening %s, %d feed ticks, final %s factor %s" % [names.slice(0, 5), ticks, end_progress, end_factor]) + _free_rig(rig) + +## One non-ending tick: exactly one progress call matching cinematic_progress(), then one factor. +## `defeat` compares the factor to the profile; an intro's factor is exactly 1. +func _feed_tick(arena: Node, rec: Node, mode: StringName, prev: float, defeat: bool, seconds := 0.0) -> String: + var before: int = int(rec.calls.size()) + arena.tick(TICK) + if StringName(arena.cinematic_mode) == &"": + return "" + var added: Array = rec.calls.slice(before) + if added.size() != 2: + return "tick added %s, expected progress then factor" % [added] + var progress_call: Array = added[0] + var factor_call: Array = added[1] + if StringName(progress_call[0]) != &"set_cinematic_progress" or StringName(factor_call[0]) != &"set_time_factor": + return "tick added %s / %s" % [progress_call[0], factor_call[0]] + var args: Array = progress_call[1] + var progress: float = float(args[1]) + if StringName(args[0]) != mode or not is_equal_approx(progress, float(arena.cinematic_progress())): + return "progress %s, arena %.6f mode %s" % [args, float(arena.cinematic_progress()), arena.cinematic_mode] + if progress + 0.000001 < prev: + return "progress fell %.6f -> %.6f" % [prev, progress] + var factor := float((factor_call[1] as Array)[0]) + var expected := _profile(progress * seconds) if defeat else 1.0 + if not is_equal_approx(factor, expected) or not is_equal_approx(factor, float(arena.time_factor())): + return "factor %.4f, profile %.4f, live %.4f at progress %.4f" % [factor, expected, float(arena.time_factor()), progress] + return "" + +# --- 3. A real jump press skips and does not jump ---------------------------------------------- + +func _test_real_input() -> void: + _release_jump() + paused = false + Engine.time_scale = saved_scale + var packed := load(GAME_SCENE) as PackedScene + if packed == null: + check(false, "3: runner_game.tscn failed to load") + return + var game := packed.instantiate() as Node3D + game.autostart = false + game.config = _configured(_overrides({})) + # Before add_child: the packed scene already carries the script, so _ready would otherwise write + # the real user://leaderboard.cfg the moment the run starts. + game.leaderboard_path = scratch_leaderboard + live_scene = game + root.add_child(game) + await process_frame + if bool(game.paused): + game.resume() + var jumps := {"n": 0} + var skipped := {"n": 0} + game.player_action.connect(func(action: StringName) -> void: + if action == &"jump": + jumps["n"] += 1) + game.boss_cinematic_skipped.connect(func(_mode: StringName) -> void: + skipped["n"] += 1) + game.start_run(11) + var reached := await _wait_until(func() -> bool: + return game.phase == &"boss" and StringName(game.boss_cinematic_mode) != &"", 20.0) + if bool(game.paused): + game.resume() + check(reached, "3: the real scene never opened a cinematic (phase %s, mode %s)" % [game.phase, game.boss_cinematic_mode]) + if not reached: + _release_jump() + _free_live_scene() + return + var player: Node = game.player + var y0 := float(player.global_position.y) + var early_progress := float(game.boss_cinematic_progress) + _press_jump(game) + for _i in 8: + await physics_frame + var early_after := float(game.boss_cinematic_progress) + check(int(skipped["n"]) == 0 and early_after < SKIP_AFTER, + "3: the jump press before %.2f s skipped (skipped %d, progress %.4f -> %.4f)" + % [SKIP_AFTER, int(skipped["n"]), early_progress, early_after]) + check(int(jumps["n"]) == 0 and not bool(player.is_airborne) and float(player.global_position.y) <= y0 + 0.05 + and float(player.velocity.y) < 1.0, + "3: the early jump press left the floor (airborne %s, y %.3f -> %.3f, vy %.3f, player_action jump %d)" + % [player.is_airborne, y0, float(player.global_position.y), float(player.velocity.y), int(jumps["n"])]) + var skippable := await _wait_until(func() -> bool: + return game.boss_encounter != null and bool(game.boss_encounter.can_skip_cinematic()), 5.0) + check(skippable, "3: the real cinematic never reached the skip gate (progress %.4f, paused %s)" + % [float(game.boss_cinematic_progress), game.paused]) + if not skippable: + _release_jump() + _free_live_scene() + return + if bool(game.paused): + game.resume() + var y1 := float(player.global_position.y) + _press_jump(game) + for _i in 8: + await physics_frame + check(int(skipped["n"]) == 1 and float(game.boss_cinematic_progress) + 0.0001 >= (INTRO_FULL - SKIP_TAIL) / INTRO_FULL - 0.02, + "3: the jump press after the skip gate did not skip (skipped %d, progress %.4f)" + % [int(skipped["n"]), float(game.boss_cinematic_progress)]) + check(int(jumps["n"]) == 0 and not bool(player.is_airborne) and float(player.global_position.y) <= y1 + 0.05 + and float(player.velocity.y) < 1.0, + "3: the skipping press still jumped (airborne %s, y %.3f -> %.3f, vy %.3f, player_action jump %d)" + % [player.is_airborne, y1, float(player.global_position.y), float(player.velocity.y), int(jumps["n"])]) + var skipped_at := int(skipped["n"]) + _press_jump(game) + await physics_frame + check(int(skipped["n"]) == skipped_at and not bool(game.skip_boss_cinematic()), + "3: a second real press skipped again (%d -> %d)" % [skipped_at, int(skipped["n"])]) + note("3: real jump ignored at progress %.4f, skipped at progress %.4f, airborne %s, jump signals %d" + % [early_after, float(game.boss_cinematic_progress), player.is_airborne, int(jumps["n"])]) + _release_jump() + _free_live_scene() + +# --- 11. Defeats are unskippable, and 12. bosses_defeated_kinds (contract §11.14, §11.13) ------- + +## One run: the stage-1 kill is defeat_full and cannot be skipped, then a stage-index jump reaches +## stage 4 still carrying only that defeat. Its _enter_boss snapshot is the retry source. The same +## kind's defeat_short is walked the same way. Intros stay skippable (case 3). +func _test_unskippable_defeats() -> void: + runner_game_script.set(&"pending_boss_checkpoint", {}) + var rig := _stage_rig(_overrides({ + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + })) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + stage.begin() + if not _require_boss(rig, "11: defeat_full", 8.0): + _free_rig(rig) + return + var stage1: Dictionary = (game.boss_checkpoint as Dictionary).duplicate(true) + var beaten_early: Array = stage1.get("bosses_defeated_kinds", [""]) + check(beaten_early.is_empty() and _has_kind(stage1.get("bosses_met", []), &"flaky_test_swarm"), + "12: stage-1 boss-entry checkpoint bosses_defeated_kinds %s bosses_met %s, expected none beaten and flaky met" + % [beaten_early, stage1.get("bosses_met", [])]) + if not _skip_current_intro(rig, "11: intro"): + _free_rig(rig) + return + if not _walk_unskippable_defeat(rig, "11: defeat_full", &"defeat_full", DEFEAT_FULL): + _free_rig(rig) + return + # The clear that is already open would advance 1 -> 2. Park the index so that increment lands + # on stage 4 (same kind, cycle 1) without beating the bosses in between. The checkpoint itself + # still comes from _enter_boss. + stage.stage_index = 3 + if not _advance_to_next_boss(rig, "12: stage 4"): + _free_rig(rig) + return + var stage4: Dictionary = (game.boss_checkpoint as Dictionary).duplicate(true) + var beaten: Array = stage4.get("bosses_defeated_kinds", []) + var intro: Array = (rig["events"] as Dictionary)["boss_cinematic"].back() + var intro_mode: StringName = intro[1] if intro.size() >= 2 else &"" + check(int(stage.stage_index) == 4 and int(stage4.get("stage_index", 0)) == 4, + "12: stage-4 entry is stage %s (checkpoint stage %s)" % [stage.stage_index, stage4.get("stage_index", "")]) + check(beaten.size() == 1 and StringName(beaten[0]) == &"flaky_test_swarm", + "12: stage-4 boss-entry bosses_defeated_kinds %s, expected [&\"flaky_test_swarm\"]" % [beaten]) + check(intro_mode == &"intro_short", + "12: stage 4 opened %s, expected intro_short (flaky was already met)" % intro_mode) + if not _skip_current_intro(rig, "11: stage 4 intro"): + _free_rig(rig) + return + if not _walk_unskippable_defeat(rig, "11: defeat_short", &"defeat_short", minf(DEFEAT_SHORT, DEFEAT_FULL)): + _free_rig(rig) + return + _free_rig(rig) + _retry_defeat(stage4, "12: stage 4 begin_at_boss", false, &"defeat_short", minf(DEFEAT_SHORT, DEFEAT_FULL)) + _retry_defeat(stage4, "12: stage 4 start_run", true, &"defeat_short", minf(DEFEAT_SHORT, DEFEAT_FULL)) + _retry_defeat(stage1, "12: stage 1 retry", false, &"defeat_full", DEFEAT_FULL) + runner_game_script.set(&"pending_boss_checkpoint", {}) + note("12: stage 4 checkpoint bosses_defeated_kinds %s retries as defeat_short; stage 1 retry stays defeat_full" % [beaten]) + +## Tick the whole defeat. Skip stays false on every frame, including past skip_after and before the +## last skip_tail, and stage_clear still waits the full length (contract §11.14 Skip). +func _walk_unskippable_defeat(rig: Dictionary, tag: String, want_mode: StringName, want_seconds: float) -> bool: + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var events: Dictionary = rig["events"] + check(int(arena.hp) == 1, "%s: HP is %d before the kill, expected 1" % [tag, int(arena.hp)]) + if int(arena.hp) != 1: + return false + var skipped_before: int = int(events["skipped"].size()) + var ended_before: int = int(events["ended"].size()) + var cinematic_before: int = int(events["boss_cinematic"].size()) + arena.apply_tool(&"unit_test") + var shot: Array = events["boss_cinematic"].back() if events["boss_cinematic"].size() > cinematic_before else [] + var mode: StringName = shot[1] if shot.size() >= 2 else &"" + var seconds := float(shot[2]) if shot.size() >= 3 else -1.0 + check(mode == want_mode and is_equal_approx(seconds, want_seconds), + "%s: opened %s for %.4f s, expected %s %.4f s" % [tag, mode, seconds, want_mode, want_seconds]) + check(stage.phase == &"boss", "%s: phase became %s on the kill tick" % [tag, stage.phase]) + var bad := "" + var saw_mid := false + var max_elapsed := 0.0 + var mid_progress := -1.0 + var mid_ticks := -1 + var ticks := 0 + var guard := _ticks(want_seconds + 2.0) + var opening := _refuse_defeat_skip(game, arena) + if opening != "": + bad = "at 0 s " + opening + while stage.phase == &"boss" and ticks < guard: + var elapsed := _cinematic_elapsed(arena) + max_elapsed = maxf(max_elapsed, elapsed) + if StringName(arena.cinematic_mode) == want_mode: + if elapsed > SKIP_AFTER + TICK and elapsed + TICK < want_seconds - SKIP_TAIL: + saw_mid = true + if mid_ticks < 0: + mid_progress = float(arena.cinematic_progress()) + mid_ticks = ticks + var refused := _refuse_defeat_skip(game, arena) + if bad == "" and refused != "": + bad = "at %.4f s (tick %d) %s" % [elapsed, ticks, refused] + var progress := float(arena.cinematic_progress()) + var expected := ticks * TICK / want_seconds + if bad == "" and absf(progress - expected) > 3.0 * TICK / want_seconds: + bad = "progress %.4f after %d ticks, expected %.4f (elapsed %.4f)" % [progress, ticks, expected, elapsed] + _tick_rig(rig, 1) + ticks += 1 + var held := ticks * TICK + check(bad == "", "%s: %s" % [tag, bad]) + check(saw_mid, "%s: never sampled past %.2f s and before the last %.2f s (max elapsed %.4f s)" + % [tag, SKIP_AFTER, SKIP_TAIL, max_elapsed]) + if mid_ticks >= 0: + var mid_expected := mid_ticks * TICK / want_seconds + check(absf(mid_progress - mid_expected) <= 3.0 * TICK / want_seconds and mid_progress < (want_seconds - SKIP_TAIL) / want_seconds, + "%s: mid-defeat progress %.4f at tick %d, expected %.4f and still before the tail" + % [tag, mid_progress, mid_ticks, mid_expected]) + check(stage.phase == &"stage_clear", "%s: phase %s after %.4f s, expected stage_clear" % [tag, stage.phase, held]) + check(absf(held - want_seconds) <= TICK + 0.0001, + "%s: stage_clear at %.4f s (%d ticks), expected the full %.4f s within one tick" % [tag, held, ticks, want_seconds]) + check(events["skipped"].size() == skipped_before, + "%s: boss_cinematic_skipped fired %d time(s) during the defeat" % [tag, events["skipped"].size() - skipped_before]) + check(events["ended"].size() == ended_before + 1, + "%s: boss_cinematic_ended fired %d time(s), expected 1" % [tag, events["ended"].size() - ended_before]) + note("%s: %s %.3fs unskippable, clear@%.3fs, mid progress %.4f" % [tag, mode, seconds, held, mid_progress]) + return stage.phase == &"stage_clear" and bad == "" + +func _refuse_defeat_skip(game: Node, arena: Node) -> String: + var could := bool(arena.can_skip_cinematic()) + var encounter := bool(arena.skip_cinematic()) + var facade := bool(game.skip_boss_cinematic()) + if could or encounter or facade: + return "can_skip %s, skip_cinematic %s, skip_boss_cinematic %s, progress %.4f mode %s" \ + % [could, encounter, facade, float(arena.cinematic_progress()), arena.cinematic_mode] + return "" + +## A checkpoint retry plays intro_short, then the defeat length the beaten-kind set asks for. +func _retry_defeat(checkpoint: Dictionary, tag: String, via_start_run: bool, want_mode: StringName, want_seconds: float) -> void: + var rig := _stage_rig(_overrides({ + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + })) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + var events: Dictionary = rig["events"] + if via_start_run: + runner_game_script.set(&"pending_boss_checkpoint", checkpoint.duplicate(true)) + game.start_run(0) + var pending: Variant = runner_game_script.get(&"pending_boss_checkpoint") + check(pending is Dictionary and (pending as Dictionary).is_empty(), + "%s: pending_boss_checkpoint was not consumed (%s)" % [tag, pending]) + else: + stage.begin_at_boss(checkpoint.duplicate(true)) + if not _require_boss(rig, tag, 8.0): + _free_rig(rig) + return + var shot: Array = events["boss_cinematic"].back() + var intro: Array = events["boss_intro"].back() + var mode: StringName = shot[1] if shot.size() >= 2 else &"" + var seconds := float(shot[2]) if shot.size() >= 3 else -1.0 + var intro_seconds := float(intro[2]) if intro.size() >= 3 else -1.0 + var want_intro := minf(INTRO_SHORT, INTRO_FULL) + var want_stage := int(checkpoint.get("stage_index", -1)) + check(mode == &"intro_short" and is_equal_approx(seconds, want_intro) and is_equal_approx(intro_seconds, seconds), + "%s: retry plays %s for %.4f s (boss_intro %.4f), expected intro_short %.4f s" + % [tag, mode, seconds, intro_seconds, want_intro]) + check(int(stage.stage_index) == want_stage, "%s: retry opened on stage %d, checkpoint says %d" % [tag, int(stage.stage_index), want_stage]) + if not _skip_current_intro(rig, tag): + _free_rig(rig) + return + var cinematic_before: int = int(events["boss_cinematic"].size()) + var skipped_before: int = int(events["skipped"].size()) + check(int(rig["arena"].hp) == 1, "%s: HP is %d before the retry kill, expected 1" % [tag, int(rig["arena"].hp)]) + rig["arena"].apply_tool(&"unit_test") + var defeat: Array = events["boss_cinematic"].back() if events["boss_cinematic"].size() > cinematic_before else [] + var defeat_mode: StringName = defeat[1] if defeat.size() >= 2 else &"" + var defeat_seconds := float(defeat[2]) if defeat.size() >= 3 else -1.0 + check(defeat_mode == want_mode and is_equal_approx(defeat_seconds, want_seconds), + "%s: kill plays %s for %.4f s, expected %s %.4f s" % [tag, defeat_mode, defeat_seconds, want_mode, want_seconds]) + var held_ticks := 0 + var guard := _ticks(want_seconds + 2.0) + while stage.phase == &"boss" and held_ticks < guard: + if bool(rig["arena"].can_skip_cinematic()) or bool(game.skip_boss_cinematic()): + break + _tick_rig(rig, 1) + held_ticks += 1 + var held := held_ticks * TICK + check(stage.phase == &"stage_clear" and absf(held - want_seconds) <= TICK + 0.0001, + "%s: stage_clear at %.4f s in phase %s, expected %.4f s" % [tag, held, stage.phase, want_seconds]) + check(events["skipped"].size() == skipped_before, + "%s: the retry defeat emitted boss_cinematic_skipped (%d)" % [tag, events["skipped"].size() - skipped_before]) + note("%s: intro_short then %s %.3fs, clear@%.3fs" % [tag, defeat_mode, defeat_seconds, held]) + _free_rig(rig) + +## A real jump held through the middle of a defeat must not skip it (contract §11.14 Skip). +func _test_real_defeat_input() -> void: + _release_jump() + paused = false + Engine.time_scale = saved_scale + var packed := load(GAME_SCENE) as PackedScene + if packed == null: + check(false, "11: runner_game.tscn failed to load") + return + var game := packed.instantiate() as Node3D + game.autostart = false + game.config = _configured(_overrides({ + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + })) + game.leaderboard_path = scratch_leaderboard + live_scene = game + root.add_child(game) + await process_frame + if bool(game.paused): + game.resume() + var jumps := {"n": 0} + var skipped := {"n": 0} + game.player_action.connect(func(action: StringName) -> void: + if action == &"jump": + jumps["n"] += 1) + game.boss_cinematic_skipped.connect(func(_mode: StringName) -> void: + skipped["n"] += 1) + game.start_run(11) + var reached := await _wait_until(func() -> bool: + return game.phase == &"boss" and StringName(game.boss_cinematic_mode) != &"", 20.0) + if bool(game.paused): + game.resume() + if not reached or game.boss_encounter == null: + check(false, "11: the real scene never opened a boss cinematic (phase %s, mode %s)" + % [game.phase, game.boss_cinematic_mode]) + _release_jump() + _free_live_scene() + return + var arena: Node = game.boss_encounter + check(int(arena.hp) == 1, "11: real boss HP is %d at the intro, expected 1" % int(arena.hp)) + if int(arena.hp) != 1: + _release_jump() + _free_live_scene() + return + arena.apply_tool(&"unit_test") + check(StringName(game.boss_cinematic_mode) == &"defeat_full" and is_equal_approx(float(arena.cinematic_seconds), DEFEAT_FULL), + "11: the kill opened %s for %.4f s, expected defeat_full %.4f s" + % [game.boss_cinematic_mode, float(arena.cinematic_seconds), DEFEAT_FULL]) + var in_window := await _wait_until(func() -> bool: + if StringName(game.boss_cinematic_mode) != &"defeat_full": + return false + var elapsed: float = float(game.boss_cinematic_progress) * float(arena.cinematic_seconds) + return elapsed > SKIP_AFTER + 0.1 and elapsed < DEFEAT_FULL - SKIP_TAIL + , 8.0) + if bool(game.paused): + game.resume() + var elapsed_at := float(game.boss_cinematic_progress) * float(arena.cinematic_seconds) + check(in_window, "11: the real defeat never sat past %.2f s (elapsed %.4f, mode %s)" + % [SKIP_AFTER, elapsed_at, game.boss_cinematic_mode]) + if not in_window: + _release_jump() + _free_live_scene() + return + var player: Node = game.player + var y0 := float(player.global_position.y) + var progress_at := float(game.boss_cinematic_progress) + _press_jump(game) + for _i in 8: + await physics_frame + var progress_after := float(game.boss_cinematic_progress) + check(int(skipped["n"]) == 0 and StringName(game.boss_cinematic_mode) == &"defeat_full" + and progress_after > progress_at and progress_after < 0.5 + and not bool(arena.can_skip_cinematic()) and not bool(game.skip_boss_cinematic()), + "11: the mid-defeat jump skipped (skipped %d, mode %s, progress %.4f -> %.4f, can_skip %s)" + % [int(skipped["n"]), game.boss_cinematic_mode, progress_at, progress_after, arena.can_skip_cinematic()]) + check(int(jumps["n"]) == 0 and not bool(player.is_airborne) and float(player.global_position.y) <= y0 + 0.05 + and float(player.velocity.y) < 1.0, + "11: the defeat press left the floor (airborne %s, y %.3f -> %.3f, vy %.3f, player_action jump %d)" + % [player.is_airborne, y0, float(player.global_position.y), float(player.velocity.y), int(jumps["n"])]) + note("11: real jump at defeat elapsed %.3f s left progress %.4f -> %.4f, skipped %d, airborne %s" + % [elapsed_at, progress_at, progress_after, int(skipped["n"]), player.is_airborne]) + _release_jump() + _free_live_scene() + +## start_run during a live intro closes it with one ended, then the new run's controls and +## corridor scale are back (contract §11.14). +func _test_restart_during_intro() -> void: + _release_jump() + paused = false + Engine.time_scale = saved_scale + var packed := load(GAME_SCENE) as PackedScene + if packed == null: + check(false, "21: runner_game.tscn failed to load") + return + var game := packed.instantiate() as Node3D + game.autostart = false + game.config = _configured(_overrides({})) + game.leaderboard_path = scratch_leaderboard + live_scene = game + root.add_child(game) + await process_frame + if bool(game.paused): + game.resume() + game.start_run(11) + var reached := await _wait_until(func() -> bool: + return game.phase == &"boss" and (StringName(game.boss_cinematic_mode) == &"intro_full" or StringName(game.boss_cinematic_mode) == &"intro_short"), 20.0) + if bool(game.paused): + game.resume() + if not reached: + check(false, "21: the real scene never opened an intro (phase %s, mode %s)" % [game.phase, game.boss_cinematic_mode]) + _release_jump() + _free_live_scene() + return + var dipped := await _wait_until(func() -> bool: + return StringName(game.boss_cinematic_mode) == &"" or float(game.speed_controller.cinematic_scale) < 0.5, 2.0) + if bool(game.paused): + game.resume() + var mode_before: StringName = StringName(game.boss_cinematic_mode) + var scale_before: float = float(game.speed_controller.cinematic_scale) + check(dipped and (mode_before == &"intro_full" or mode_before == &"intro_short") and scale_before < 0.5, + "21: the intro scale never left 1 (mode %s, scale %.4f)" % [mode_before, scale_before]) + if mode_before != &"intro_full" and mode_before != &"intro_short": + _release_jump() + _free_live_scene() + return + var ended: Array = [] + var on_ended := func(mode: StringName) -> void: + ended.append(mode) + game.boss_cinematic_ended.connect(on_ended) + game.start_run(11) + if game.boss_cinematic_ended.is_connected(on_ended): + game.boss_cinematic_ended.disconnect(on_ended) + if bool(game.paused): + game.resume() + paused = false + var stage: Node = game.stage_manager + var player: Node = game.player + var ended_mode: StringName = &"" + if not ended.is_empty(): + ended_mode = ended[0] + check(ended.size() == 1 and ended_mode == mode_before, + "21: start_run emitted boss_cinematic_ended %s, expected one %s" % [ended, mode_before]) + var hold_after := false + var controls_after := false + if stage != null: + hold_after = bool(stage.cinematic_hold) + if player != null: + controls_after = bool(player.controls_enabled) + check(stage != null and player != null and not hold_after and controls_after, + "21: after restart hold %s controls %s" % [hold_after, controls_after]) + check(StringName(game.boss_cinematic_mode) == &"" and game.state == &"running" and game.phase == &"run", + "21: the new run is state %s phase %s mode %s" % [game.state, game.phase, game.boss_cinematic_mode]) + var scale_now: float = float(game.speed_controller.cinematic_scale) + for _i in _ticks(EASE_SECONDS): + await physics_frame + var scale_after: float = float(game.speed_controller.cinematic_scale) + check(absf(scale_after - 1.0) < 0.02, + "21: cinematic_scale %.4f after %.2f s, from %.4f at the restart, expected 1 within 0.02" + % [scale_after, EASE_SECONDS, scale_now]) + note("21: restart closed %s, scale %.4f -> %.4f, phase %s" % [mode_before, scale_now, scale_after, game.phase]) + _release_jump() + _free_live_scene() + +# --- 13. Intro timing, 14. focus default, 15. camera focus (contract §11.5, §11.14) ------------- + +## INTRO_POSE_AT / INTRO_HOLD_UNTIL, the shot keys those land on, and a frozen hold between them. +func _test_intro_timing_and_focus() -> void: + _test_intro_timing() + _test_focus_bounds() + _test_focus_cache_and_override() + _test_graybox_focus() + _test_camera_focus_feed() + +func _test_intro_timing() -> void: + var base_map: Dictionary = visual_base_script.get_script_constant_map() + var cam_map: Dictionary = camera_script.get_script_constant_map() + var pose: Dictionary = base_map.get("INTRO_POSE_AT", {}) + var hold: Dictionary = base_map.get("INTRO_HOLD_UNTIL", {}) + var shots: Dictionary = cam_map.get("BOSS_SHOTS", {}) + var expected_pose: Dictionary = {&"intro_full": 0.40, &"intro_short": 0.28} + var expected_hold: Dictionary = {&"intro_full": 0.66, &"intro_short": 0.88} + var const_bad := "" + if pose.size() != 2 or hold.size() != 2: + const_bad = "sizes pose %d hold %d, maps have pose %s hold %s shots %s" % [ + pose.size(), hold.size(), base_map.has("INTRO_POSE_AT"), base_map.has("INTRO_HOLD_UNTIL"), cam_map.has("BOSS_SHOTS")] + else: + for mode in expected_pose: + var got_pose: float = float(pose.get(mode, -1)) + var got_hold: float = float(hold.get(mode, -1)) + if not is_equal_approx(got_pose, float(expected_pose[mode])) or not is_equal_approx(got_hold, float(expected_hold[mode])): + const_bad = "%s pose %s hold %s" % [mode, pose.get(mode, ""), hold.get(mode, "")] + break + check(const_bad == "", + "13: INTRO_POSE_AT %s INTRO_HOLD_UNTIL %s, expected intro_full 0.40/0.66 and intro_short 0.28/0.88 (%s)" + % [pose, hold, const_bad]) + var deltas := "" + for mode in [&"intro_full", &"intro_short"]: + var pose_at: float = float(pose.get(mode, -1)) + var hold_at: float = float(hold.get(mode, -1)) + var table: Dictionary = shots.get(mode, {}) + var ret: float = float(table.get("ret", -1)) + # intro_short's hold runs right up to its return (§11.14), so the hold may end on `ret`. + check(pose_at < hold_at and hold_at <= ret, + "13: %s pose %.4f hold %.4f ret %.4f, expected pose < hold <= ret" % [mode, pose_at, hold_at, ret]) + var keys: Array = table.get("keys", []) + var has_pose := false + var has_hold := false + var listed := "" + for key in keys: + var row: Array = key + if row.is_empty(): + continue + var p: float = float(row[0]) + listed += " %.4f" % p + if is_equal_approx(p, pose_at): + has_pose = true + if is_equal_approx(p, hold_at): + has_hold = true + check(has_pose and has_hold, + "13: %s shot keys [%s ], expected one at pose %.4f and one at hold %.4f" % [mode, listed, pose_at, hold_at]) + if not has_pose or not has_hold or pose_at >= hold_at: + check(false, "13: %s hold not sampled (pose %.4f hold %.4f)" % [mode, pose_at, hold_at]) + continue + var anchor: Dictionary = camera_script.boss_shot(pose_at, mode) + var anchor_fov: float = float(anchor.get("fov", -999.0)) + var anchor_dist: float = float(anchor.get("distance", -999.0)) + var worst_fov := 0.0 + var worst_dist := 0.0 + var at_fov := pose_at + var at_dist := pose_at + for i in 21: + var p: float = pose_at + (hold_at - pose_at) * float(i) / 20.0 + var shot: Dictionary = camera_script.boss_shot(p, mode) + var dfov: float = absf(float(shot.get("fov", 0.0)) - anchor_fov) + var ddist: float = absf(float(shot.get("distance", 0.0)) - anchor_dist) + if dfov > worst_fov: + worst_fov = dfov + at_fov = p + if ddist > worst_dist: + worst_dist = ddist + at_dist = p + check(worst_fov <= 3.0 and worst_dist <= 1.0, + "13: %s between %.4f and %.4f fov moved %.3f° at p %.4f (anchor %.2f) and distance %.3f m at p %.4f (anchor %.2f); limits are ±3° and ±1 m" + % [mode, pose_at, hold_at, worst_fov, at_fov, anchor_fov, worst_dist, at_dist, anchor_dist]) + deltas += "%s fov Δ%.3f° dist Δ%.3f m; " % [mode, worst_fov, worst_dist] + note("13: pose %s hold %s; %s" % [pose, hold, deltas]) + +## Two visible boxes, one of them under a scaled pivot, plus a hidden box that must not count. +func _test_focus_bounds() -> void: + var probe := BoxMesh.new() + probe.size = Vector3(2, 2, 2) + var probe_aabb: AABB = probe.get_aabb() + check(probe_aabb.position.is_equal_approx(Vector3(-1, -1, -1)) and probe_aabb.size.is_equal_approx(Vector3(2, 2, 2)), + "14: BoxMesh (2, 2, 2) aabb pos %s size %s, expected pos (-1, -1, -1) size (2, 2, 2)" + % [probe_aabb.position, probe_aabb.size]) + var host := Node3D.new() + root.add_child(host) + junk.append(host) + host.add_child(_focus_box(Vector3(2, 2, 2), Vector3(4, 0, 0))) + var pivot := Node3D.new() + pivot.position = Vector3(0, 10, 0) + pivot.scale = Vector3(2, 1, 1) + host.add_child(pivot) + pivot.add_child(_focus_box(Vector3(1, 1, 1), Vector3(0, 1, 0))) + host.add_child(_focus_box(Vector3(10, 10, 10), Vector3(50, 0, 0), false)) + var hand := AABB(Vector3(-1, -1, -1), Vector3(6, 12.5, 2)) + var want_focus: Vector3 = _upper_third(hand) + var got: AABB = visual_base_script.call(&"rest_bounds", host) + var focus: Vector3 = visual_base_script.call(&"rest_focus", host) + check(got.position.is_equal_approx(hand.position) and got.size.is_equal_approx(hand.size), + "14: rest_bounds pos %s size %s, expected pos %s size %s with the hidden mesh ignored" + % [got.position, got.size, hand.position, hand.size]) + check(focus.is_equal_approx(want_focus), + "14: rest_focus %s, expected the upper-third centre %s" % [focus, want_focus]) + var hidden_root := Node3D.new() + root.add_child(hidden_root) + junk.append(hidden_root) + hidden_root.add_child(_focus_box(Vector3(2, 2, 2), Vector3.ZERO, false)) + hidden_root.add_child(_focus_box(Vector3(2, 2, 2), Vector3(4, 0, 0), false)) + var hidden_hand := AABB(Vector3(-1, -1, -1), Vector3(6, 2, 2)) + var hidden_got: AABB = visual_base_script.call(&"rest_bounds", hidden_root) + var hidden_focus: Vector3 = visual_base_script.call(&"rest_focus", hidden_root) + check(hidden_got.position.is_equal_approx(hidden_hand.position) and hidden_got.size.is_equal_approx(hidden_hand.size), + "14: hidden-only rest_bounds pos %s size %s, expected the fallback merge pos %s size %s" + % [hidden_got.position, hidden_got.size, hidden_hand.position, hidden_hand.size]) + check(hidden_focus.is_equal_approx(_upper_third(hidden_hand)), + "14: hidden-only rest_focus %s, expected %s" % [hidden_focus, _upper_third(hidden_hand)]) + var empty := Node3D.new() + root.add_child(empty) + junk.append(empty) + var empty_bounds: AABB = visual_base_script.call(&"rest_bounds", empty) + var empty_focus: Vector3 = visual_base_script.call(&"rest_focus", empty) + check(not empty_bounds.has_volume() and empty_focus.is_equal_approx(Vector3.ZERO), + "14: no-mesh rest_focus %s bounds pos %s size %s, expected the origin" + % [empty_focus, empty_bounds.position, empty_bounds.size]) + +## The base caches cinematic_focus() on first read. An override is what the encounter stores. +func _test_focus_cache_and_override() -> void: + var cached := Node3D.new() + cached.set_script(recorder_script) + root.add_child(cached) + junk.append(cached) + var box := _focus_box(Vector3(2, 2, 2)) + cached.add_child(box) + var before: Vector3 = visual_base_script.call(&"rest_focus", cached) + var first: Vector3 = cached.call(&"cinematic_focus") + box.position = Vector3(0, 20, 0) + box.scale = Vector3(3, 3, 3) + var second: Vector3 = cached.call(&"cinematic_focus") + var after: Vector3 = visual_base_script.call(&"rest_focus", cached) + check(first.is_equal_approx(before) and second.is_equal_approx(first) and not second.is_equal_approx(after), + "14: cinematic_focus %s then %s, rest_focus before %s after the move %s" + % [first, second, before, after]) + var rig := _boss_rig(_overrides({})) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var visual := Node3D.new() + visual.set_script(focus_script) + visual.position = Vector3(0.25, -0.5, 0.1) + visual.scale = Vector3(2, 3, 1) + var boss_root: Node3D = arena.get("boss_root") + if boss_root == null: + check(false, "14: the encounter has no boss mount for the focus override") + _free_rig(rig) + return + boss_root.add_child(visual) + arena.set("visual", visual) + arena.call(&"_read_cinematic_focus") + var fixed := Vector3(1.5, 2.5, -0.5) + var want: Vector3 = visual.transform * fixed + var got: Vector3 = arena.get("cinematic_focus") + check(got.is_equal_approx(want) and not got.is_equal_approx(fixed) and not got.is_equal_approx(visual.position), + "14: arena.cinematic_focus %s, expected visual.transform * %s = %s (visual position %s)" + % [got, fixed, want, visual.position]) + note("14: cached focus %s stayed put after the mesh moved to %s; override stored %s" % [first, after, got]) + _free_rig(rig) + +## Shipped visual scenes exist, so start() does not mount the graybox. Install the encounter's own +## graybox and read the focus through the same function _mount_boss uses, then finish the fight. +func _test_graybox_focus() -> void: + var rig := _boss_rig(_overrides({ + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + })) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + arena.start(1) + var gray := _mount_graybox(arena) + if gray == null: + check(false, "14: the graybox did not mount") + _free_rig(rig) + return + var local_bounds: AABB = visual_base_script.call(&"rest_bounds", gray) + var mount_bounds: AABB = gray.transform * local_bounds + var y0: float = mount_bounds.position.y + var y1: float = mount_bounds.position.y + mount_bounds.size.y + var rest: Vector3 = visual_base_script.call(&"rest_focus", gray) + var focus: Vector3 = arena.get("cinematic_focus") + var carried: Vector3 = gray.transform * rest + check(is_equal_approx(y0, -1.0) and is_equal_approx(y1, 1.4), + "14: graybox mount-space y spans %.4f .. %.4f, expected -1.0 .. 1.4 (bounds pos %s size %s)" + % [y0, y1, mount_bounds.position, mount_bounds.size]) + check(focus.is_equal_approx(carried) and focus.is_equal_approx(Vector3(0, 1, 0)), + "14: graybox cinematic_focus %s, expected the upper-third centre %s (about (0, 1, 0))" + % [focus, carried]) + if not _skip_arena_intro(arena): + check(false, "14: the graybox fight's intro never ended (mode %s)" % arena.cinematic_mode) + _free_rig(rig) + return + check(int(arena.hp) == 1, "14: HP is %d before the kill, expected 1" % int(arena.hp)) + if int(arena.hp) != 1: + _free_rig(rig) + return + var during: Vector3 = arena.get("cinematic_focus") + check(during.is_equal_approx(focus), + "14: cinematic_focus moved from %s to %s before the kill" % [focus, during]) + arena.apply_tool(&"unit_test") + var ticks := 0 + var guard := _ticks(DEFEAT_FULL + 1.0) + while not bool(arena.finished) and ticks < guard: + if not (arena.get("cinematic_focus") as Vector3).is_equal_approx(focus): + break + arena.tick(TICK) + ticks += 1 + var after: Vector3 = arena.get("cinematic_focus") + check(bool(arena.finished) and after.is_equal_approx(Vector3.ZERO), + "14: finished %s after %d ticks with cinematic_focus %s, expected zero once the fight ends" + % [arena.finished, ticks, after]) + note("14: graybox y %.3f..%.3f focus %s, cleared to %s (finished %s)" % [y0, y1, focus, after, arena.finished]) + _free_rig(rig) + +## Every boss tick hands the stage the mount position and the global cinematic focus. +func _test_camera_focus_feed() -> void: + var bias_bad := "" + for mode in MODES: + var shot: Dictionary = camera_script.boss_shot(0.5, mode) + if not shot.has("look_bias") or shot.has("look_y"): + bias_bad = "%s keys %s" % [mode, shot.keys()] + break + check(bias_bad == "", "15: boss_shot exposes look_bias and not look_y (%s)" % bias_bad) + var rig := _stage_rig(_overrides({})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var stage: Node = rig["stage"] + var feed := Node3D.new() + feed.set_script(camera_feed_script) + root.add_child(feed) + junk.append(feed) + game.camera_rig = feed + stage.begin() + if not _require_boss(rig, "15: camera", 8.0): + _free_rig(rig) + return + _tick_rig(rig, 8) + var arena: Node = rig["arena"] + var boss: Node3D = arena.get("boss") + if boss == null or not boss.is_inside_tree(): + check(false, "15: the boss mount is missing during the intro") + _free_rig(rig) + return + var mount_focus: Vector3 = arena.get("cinematic_focus") + var want_pos: Vector3 = boss.global_position + var want_focus: Vector3 = boss.to_global(mount_focus) + check(not mount_focus.is_equal_approx(Vector3.ZERO) and not want_focus.is_equal_approx(want_pos), + "15: cinematic_focus %s (global %s) is not distinct from the mount %s" % [mount_focus, want_focus, want_pos]) + var calls: Array = feed.get("calls") + var matched := 0 + var bad := "" + for entry in calls: + var row: Array = entry + if row.size() < 4 or not (row[2] is Vector3) or not (row[3] is Vector3): + bad = "call %s is not a 4-arg boss tick" % [row] + break + if StringName(row[1]) != &"intro_full": + bad = "mode %s at progress %s, expected intro_full" % [row[1], row[0]] + break + var got_pos: Vector3 = row[2] + var got_focus: Vector3 = row[3] + if not got_pos.is_equal_approx(want_pos) or not got_focus.is_equal_approx(want_focus): + bad = "passed boss %s focus %s, expected boss %s focus %s (mount focus %s)" % [ + got_pos, got_focus, want_pos, want_focus, mount_focus] + break + matched += 1 + check(bad == "" and matched >= 9, + "15: %d boss tick(s) passed focus_position == boss.to_global(cinematic_focus) (%s)" % [matched, bad]) + note("15: %d intro_full ticks, mount focus %s, global focus %s, look_bias on %d modes" % [ + matched, mount_focus, want_focus, MODES.size()]) + _free_rig(rig) + +# --- 16. A retreat outro feeds the visual nothing (contract §11.14) ---------------------------- + +## Intro length 0 so no cinematic opens. The timeout still holds boss_outro_seconds, and the same +## rig's defeat keeps the per-tick feed. +func _test_retreat_outro_feed() -> void: + var rig := _boss_rig(_overrides({ + "boss_intro_seconds": 0.0, + "boss_intro_short_seconds": 0.0, + "boss_timeout_seconds": 0.05, + "boss_hp_scale": 0.001, + })) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + var cinematic_at: int = int(events["boss_cinematic"].size()) + var ended_at: int = int(events["ended"].size()) + arena.start(1) + check(events["boss_cinematic"].size() == cinematic_at and events["ended"].size() == ended_at + and StringName(arena.cinematic_mode) == &"", + "16: an intro of 0 s opened %s (shots %d -> %d, ended %d -> %d)" + % [arena.cinematic_mode, cinematic_at, events["boss_cinematic"].size(), ended_at, events["ended"].size()]) + var rec := _mount_recorder(arena) + if rec == null: + check(false, "16: the retreat recorder did not mount") + _free_rig(rig) + return + rec.calls.clear() + var mark := -1 + var ticks := 0 + var guard := _ticks(2.0) + while mark < 0 and ticks < guard and not bool(arena.finished): + var before: int = int(rec.calls.size()) + var cinematic_before: int = int(events["boss_cinematic"].size()) + var ended_before: int = int(events["ended"].size()) + arena.tick(TICK) + ticks += 1 + if int(events["boss_retreated"].size()) > 0: + mark = before + cinematic_at = cinematic_before + ended_at = ended_before + check(mark >= 0, "16: the timeout did not retreat (finished %s after %d ticks)" % [arena.finished, ticks]) + if mark < 0: + _free_rig(rig) + return + var hold_ticks := 0 + var hold_guard := _ticks(OUTRO + 2.0) + while not bool(arena.finished) and hold_ticks < hold_guard: + arena.tick(TICK) + hold_ticks += 1 + var held := hold_ticks * TICK + var calls: Array = rec.calls + var progress := _count_calls(calls, &"set_cinematic_progress", mark) + var factor := _count_calls(calls, &"set_time_factor", mark) + var retreats := _count_calls(calls, &"play_retreat", 0) + check(progress == 0 and factor == 0, + "16: the retreat outro fed set_cinematic_progress %d time(s) and set_time_factor %d time(s)" + % [progress, factor]) + check(events["boss_cinematic"].size() == cinematic_at and events["ended"].size() == ended_at, + "16: the retreat emitted cinematic %d -> %d and ended %d -> %d" + % [cinematic_at, events["boss_cinematic"].size(), ended_at, events["ended"].size()]) + check(retreats == 1, "16: play_retreat ran %d time(s), expected 1" % retreats) + check(bool(arena.finished) and absf(held - OUTRO) <= TICK + 0.0001, + "16: finished %s after %.4f s (%d ticks), expected boss_outro_seconds %.3f within one tick" + % [arena.finished, held, hold_ticks, OUTRO]) + note("16: retreat outro %.4f s, play_retreat %d, progress %d, factor %d" % [held, retreats, progress, factor]) + _test_retreat_rig_defeat(rig) + _free_rig(rig) + +## The defeat on the rig that just retreated still pushes progress and the time factor every tick. +func _test_retreat_rig_defeat(rig: Dictionary) -> void: + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + arena.start(1) + var rec := _mount_recorder(arena) + if rec == null: + check(false, "16: the defeat recorder did not mount") + return + check(int(arena.hp) == 1, "16: HP is %d before the defeat, expected 1" % int(arena.hp)) + if int(arena.hp) != 1: + return + var cinematic_at: int = int(events["boss_cinematic"].size()) + rec.calls.clear() + arena.apply_tool(&"unit_test") + var names := _call_names(rec.calls) + var seconds: float = float(arena.cinematic_seconds) + check(_same(names.slice(0, 5), [&"set_health", &"play_defeat", &"play_cinematic", &"set_cinematic_progress", &"set_time_factor"]) + and StringName(arena.cinematic_mode) == &"defeat_full" and is_equal_approx(seconds, DEFEAT_FULL), + "16: defeat opened with %s mode %s for %.4f s, expected set_health then the defeat_full feed" + % [names, arena.cinematic_mode, seconds]) + var bad := "" + var prev := 0.0 + var feed_ticks := 0 + while feed_ticks < 8 and bad == "" and StringName(arena.cinematic_mode) == &"defeat_full": + bad = _feed_tick(arena, rec, &"defeat_full", prev, true, seconds) + if StringName(arena.cinematic_mode) == &"": + bad = "defeat ended on sample tick %d" % feed_ticks + break + prev = _last_progress(rec) + feed_ticks += 1 + check(bad == "" and feed_ticks == 8 and events["boss_cinematic"].size() == cinematic_at + 1, + "16: defeat feed over %d tick(s) (%s); cinematic signals %d -> %d" + % [feed_ticks, bad, cinematic_at, events["boss_cinematic"].size()]) + note("16: defeat_full feed held for %d ticks after the retreat" % feed_ticks) + +func _count_calls(calls: Array, method: StringName, from_index: int) -> int: + var n := 0 + for i in range(maxi(from_index, 0), calls.size()): + var entry: Array = calls[i] + if not entry.is_empty() and StringName(entry[0]) == method: + n += 1 + return n + +# --- 17. A zero skip tail closes the intro in the same call (contract §11.14) ------------------ + +func _test_zero_tail_skip() -> void: + var rig := _stage_rig(_overrides({"boss_cinematic_skip_tail": 0.0})) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var events: Dictionary = rig["events"] + var player := _attach_controls(game) + stage.begin() + if not _require_boss(rig, "17: zero tail", 8.0): + _free_rig(rig) + return + check(bool(stage.cinematic_hold) and not bool(player.controls_enabled) and StringName(arena.cinematic_mode) == &"intro_full", + "17: at the intro, hold %s controls %s mode %s" % [stage.cinematic_hold, player.controls_enabled, arena.cinematic_mode]) + _tick_until(rig, func() -> bool: return bool(arena.can_skip_cinematic()), 2.0) + if not bool(arena.can_skip_cinematic()): + _tick_rig(rig, 1) + check(bool(arena.can_skip_cinematic()), + "17: can_skip stayed false at %.4f s with a zero tail" % _cinematic_elapsed(arena)) + if not bool(arena.can_skip_cinematic()): + _free_rig(rig) + return + var order_at: int = int(events["order"].size()) + var ended_before: int = int(events["ended"].size()) + var skipped_before: int = int(events["skipped"].size()) + var did_skip := bool(arena.skip_cinematic()) + var tail: Array = events["order"].slice(order_at) + check(did_skip and _same(tail, [&"skipped", &"ended"]), + "17: skip_cinematic returned %s and the same call recorded %s, expected skipped then ended" % [did_skip, tail]) + check(events["skipped"].size() == skipped_before + 1 and StringName(events["skipped"].back()) == &"intro_full" + and events["ended"].size() == ended_before + 1 and StringName(events["ended"].back()) == &"intro_full", + "17: skipped %s ended %s, expected one intro_full of each" % [events["skipped"], events["ended"]]) + check(StringName(arena.cinematic_mode) == &"" and is_equal_approx(float(arena.cinematic_progress()), 0.0) + and not bool(arena.can_skip_cinematic()), + "17: after the skip, mode %s progress %.4f can_skip %s" % [arena.cinematic_mode, float(arena.cinematic_progress()), arena.can_skip_cinematic()]) + var mirror: Dictionary = _last_mirror(events, "mirror_end") + check(StringName(game.boss_cinematic_mode) == &"" and is_equal_approx(float(game.boss_cinematic_progress), 0.0) + and is_equal_approx(float(game.boss_time_factor), 1.0) + and StringName(mirror.get("mode", &"missing")) == &"" and not bool(mirror.get("hold", true)) + and not bool(stage.cinematic_hold) and bool(player.controls_enabled), + "17: mirrors after the call mode %s progress %.4f factor %.4f hold %s (snapshot %s), controls %s" + % [game.boss_cinematic_mode, float(game.boss_cinematic_progress), float(game.boss_time_factor), + stage.cinematic_hold, mirror, player.controls_enabled]) + var ended_at: int = int(events["ended"].size()) + var after := _wait_for_threat(rig, "17: zero tail", 3.0) + check(bool(after["appeared"]) and StringName(arena.cinematic_mode) == &"" and events["ended"].size() == ended_at + and stage.phase == &"boss", + "17: after the skip the fight did not start (threat %s, mode %s, ended %d -> %d, phase %s)" + % [after["appeared"], arena.cinematic_mode, ended_at, events["ended"].size(), stage.phase]) + note("17: zero tail closed intro_full in the skip call, threat +%.3f s" % (int(after["post_ticks"]) * TICK)) + _free_rig(rig) + +# --- 18. set_health follows every boss_health_changed (contract §11.5) ------------------------- + +func _test_set_health_feed() -> void: + var rig := _boss_rig(_overrides({ + "boss_hp_scale": 1.0, + "boss_timeout_seconds": 55.0, + })) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + var holder := {"rec": null} + var on_open := func(_hp: int, _max_hp: int) -> void: + if holder["rec"] == null: + holder["rec"] = _mount_recorder(arena) + game.boss_health_changed.connect(on_open) + # Stage 2 is the hydra: phase 2 accepts only unit_test and lint_rule, so a review comment resists. + # Stage 1's swarm accepts every tool in every phase. + arena.start(2) + if game.boss_health_changed.is_connected(on_open): + game.boss_health_changed.disconnect(on_open) + var rec: Node = holder["rec"] + if rec == null: + check(false, "18: the health signal did not mount the recorder") + _free_rig(rig) + return + var health_rows: Array = events["boss_health_changed"] + var open_args := _last_named(rec.calls, &"set_health") + check(_count_calls(rec.calls, &"set_health", 0) == 1 and health_rows.size() == 1 + and open_args.size() == 2 and int(open_args[0]) == int(health_rows[0][0]) and int(open_args[1]) == int(health_rows[0][1]) + and int(open_args[0]) == int(arena.hp) and int(open_args[0]) == int(arena.max_hp) and int(arena.hp) > 1, + "18: opening set_health %s, signal %s, live hp %d / %d" + % [open_args, health_rows, int(arena.hp), int(arena.max_hp)]) + var hit := _health_step(arena, rec, events, &"unit_test") + check(_health_matches(hit) and int(hit["hp"]) < int(hit["hp_before"]) and int(hit["added"]) == 1, + "18: unit_test set_health %s signal %s, hp %s -> %s, %d new call(s)" + % [hit["sets"], hit["health"], hit["hp_before"], hit["hp"], int(hit["added"])]) + var climbed := 0 + while int(arena.phase) < 2 and int(arena.hp) > 20 and climbed < 12: + var mid := _health_step(arena, rec, events, &"unit_test") + check(_health_matches(mid) and int(mid["hp"]) < int(mid["hp_before"]), + "18: climb set_health %s signal %s, hp %s -> %s phase %s" + % [mid["sets"], mid["health"], mid["hp_before"], mid["hp"], arena.phase]) + climbed += 1 + check(int(arena.phase) == 2 and int(arena.hp) > 0, + "18: phase is %d at hp %d / %d, expected phase 2 with HP left" % [int(arena.phase), int(arena.hp), int(arena.max_hp)]) + if int(arena.phase) != 2 or int(arena.hp) <= 0: + _free_rig(rig) + return + var info: Dictionary = arena.get_info() + var accepted: Array = info.get("accepted_tools", []) + var resist_tool: StringName = &"" + for kind in [&"review_comment", &"lint_rule", &"auto_fix_patch", &"unit_test"]: + if not accepted.has(kind): + resist_tool = kind + break + check(resist_tool != &"", "18: phase %d accepted %s, so nothing resists" % [int(arena.phase), accepted]) + if resist_tool == &"": + _free_rig(rig) + return + var hp_at: int = int(arena.hp) + var resist := _health_step(arena, rec, events, resist_tool) + check(_health_matches(resist) and int(resist["hp"]) == hp_at and int(resist["added"]) == 1, + "18: %s set_health %s signal %s, hp %d -> %s, %d new call(s) (phase %d accepts %s)" + % [resist_tool, resist["sets"], resist["health"], hp_at, resist["hp"], int(resist["added"]), int(arena.phase), accepted]) + var kill_tool: StringName = &"unit_test" if accepted.has(&"unit_test") else accepted[0] + var kill := {"hp": int(arena.hp)} + var guard := 0 + while int(arena.hp) > 0 and guard < 30: + kill = _health_step(arena, rec, events, kill_tool) + guard += 1 + if not _health_matches(kill): + break + check(_health_matches(kill) and int(kill["hp"]) == 0 and int(kill["max_hp"]) == int(arena.max_hp), + "18: kill set_health %s signal %s, hp %s" % [kill["sets"], kill["health"], kill["hp"]]) + note("18: set_health tracked full %d, a hit, %s resisted at %d, and the kill" % [int(arena.max_hp), resist_tool, hp_at]) + _free_rig(rig) + +func _health_step(arena: Node, rec: Node, events: Dictionary, tool: StringName) -> Dictionary: + var call_at: int = int(rec.calls.size()) + var health_at: int = int(events["boss_health_changed"].size()) + var hp_before: int = int(arena.hp) + arena.apply_tool(tool) + var added: Array = rec.calls.slice(call_at) + var health_all: Array = events["boss_health_changed"] + var health: Array = health_all.slice(health_at) + var sets: Array = [] + for entry in added: + var row: Array = entry + if not row.is_empty() and StringName(row[0]) == &"set_health": + sets.append(row[1]) + return { + "hp_before": hp_before, + "hp": int(arena.hp), + "max_hp": int(arena.max_hp), + "health": health, + "sets": sets, + "added": added.size(), + } + +func _health_matches(step: Dictionary) -> bool: + var sets: Array = step["sets"] + var health: Array = step["health"] + if sets.size() != 1 or health.size() != 1: + return false + var args: Array = sets[0] + var signal_row: Array = health[0] + if args.size() != 2 or signal_row.size() < 2: + return false + return int(args[0]) == int(signal_row[0]) and int(args[1]) == int(signal_row[1]) \ + and int(args[0]) == int(step["hp"]) and int(args[1]) == int(step["max_hp"]) + +# --- 19. A zero-length intro and defeat are not cinematics (contract §11.14) ------------------- + +func _test_zero_length() -> void: + var overrides := _overrides({ + "boss_intro_seconds": 0.0, + "boss_intro_short_seconds": 0.0, + "boss_defeat_seconds": 0.0, + "boss_defeat_short_seconds": 0.0, + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + }) + _test_zero_length_boss(overrides) + _test_zero_length_stage(overrides) + +func _test_zero_length_boss(overrides: Dictionary) -> void: + var rig := _boss_rig(overrides) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + var cinematic_at: int = int(events["boss_cinematic"].size()) + var ended_at: int = int(events["ended"].size()) + arena.start(1) + check(events["boss_cinematic"].size() == cinematic_at and events["ended"].size() == ended_at + and StringName(arena.cinematic_mode) == &"", + "19: an intro of 0 s opened %s (shots %d -> %d, ended %d -> %d)" + % [arena.cinematic_mode, cinematic_at, events["boss_cinematic"].size(), ended_at, events["ended"].size()]) + check(int(arena.hp) == 1, "19: HP is %d before the zero defeat, expected 1" % int(arena.hp)) + if int(arena.hp) != 1: + _free_rig(rig) + return + arena.apply_tool(&"unit_test") + check(bool(arena.finished) and StringName(arena.cinematic_mode) == &"" + and events["boss_cinematic"].size() == cinematic_at and events["ended"].size() == ended_at, + "19: defeat of 0 s finished %s inside apply_tool, mode %s, shots %d -> %d, ended %d -> %d" + % [arena.finished, arena.cinematic_mode, cinematic_at, events["boss_cinematic"].size(), ended_at, events["ended"].size()]) + note("19: boss rig finished %s with no cinematic" % arena.finished) + _free_rig(rig) + +func _test_zero_length_stage(overrides: Dictionary) -> void: + var rig := _stage_rig(overrides) + if rig.is_empty(): + return + var game: Node = rig["game"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var events: Dictionary = rig["events"] + var player := _attach_controls(game) + stage.begin() + if not _require_boss(rig, "19: zero length", 8.0): + _free_rig(rig) + return + check(StringName(arena.cinematic_mode) == &"" and not bool(stage.cinematic_hold) and bool(player.controls_enabled) + and events["boss_cinematic"].is_empty() and events["ended"].is_empty(), + "19: boss entry with a 0 s intro is mode %s hold %s controls %s shots %d ended %d" + % [arena.cinematic_mode, stage.cinematic_hold, player.controls_enabled, events["boss_cinematic"].size(), events["ended"].size()]) + check(int(arena.hp) == 1, "19: stage HP is %d, expected 1" % int(arena.hp)) + if int(arena.hp) != 1: + _free_rig(rig) + return + arena.apply_tool(&"unit_test") + check(bool(arena.finished) and not bool(stage.cinematic_hold) and StringName(arena.cinematic_mode) == &"" + and events["boss_cinematic"].is_empty() and events["ended"].is_empty(), + "19: the 0 s defeat finished %s with hold %s mode %s shots %d ended %d" + % [arena.finished, stage.cinematic_hold, arena.cinematic_mode, events["boss_cinematic"].size(), events["ended"].size()]) + _tick_rig(rig, 1) + check(stage.phase != &"boss", + "19: one tick after the zero defeat left phase %s" % stage.phase) + note("19: stage hold stayed false, phase %s one tick later" % stage.phase) + _free_rig(rig) + +# --- 20. A kill during the intro ends it before the defeat cinematic --------------------------- + +func _test_kill_mid_intro_order() -> void: + var rig := _boss_rig(_overrides({ + "boss_hp_scale": 0.001, + "boss_timeout_seconds": 55.0, + })) + if rig.is_empty(): + return + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + arena.start(1) + check(StringName(arena.cinematic_mode) == &"intro_full", + "20: the kill's intro is %s, expected intro_full" % arena.cinematic_mode) + if StringName(arena.cinematic_mode) != &"intro_full": + _free_rig(rig) + return + var order_at: int = int(events["order"].size()) + var ended_before: int = int(events["ended"].size()) + var cinematic_before: int = int(events["boss_cinematic"].size()) + arena.apply_tool(&"unit_test") + var tail: Array = events["order"].slice(order_at) + var ended_i := -1 + var cinematic_i := -1 + for i in tail.size(): + if StringName(tail[i]) == &"ended" and ended_i < 0: + ended_i = i + if StringName(tail[i]) == &"cinematic": + cinematic_i = i + var shot: Array = [] + if not events["boss_cinematic"].is_empty(): + shot = events["boss_cinematic"].back() + var shot_mode: StringName = &"" + if shot.size() >= 2: + shot_mode = shot[1] + var ended_mode: StringName = &"" + if not events["ended"].is_empty(): + ended_mode = events["ended"].back() + check(ended_i >= 0 and cinematic_i > ended_i + and events["ended"].size() == ended_before + 1 and ended_mode == &"intro_full" + and events["boss_cinematic"].size() == cinematic_before + 1 and shot_mode == &"defeat_full", + "20: order %s (ended at %d, cinematic at %d), ended %s x%d, next shot %s" + % [tail, ended_i, cinematic_i, ended_mode, events["ended"].size() - ended_before, shot_mode]) + note("20: ended(intro_full) then boss_cinematic(defeat_full)") + _free_rig(rig) + +func _focus_box(size: Vector3, at := Vector3.ZERO, shown := true) -> MeshInstance3D: + var mesh := MeshInstance3D.new() + var box := BoxMesh.new() + box.size = size + mesh.mesh = box + mesh.position = at + mesh.visible = shown + return mesh + +func _upper_third(bounds: AABB) -> Vector3: + return bounds.position + Vector3(bounds.size.x * 0.5, bounds.size.y * 5.0 / 6.0, bounds.size.z * 0.5) + +func _mount_graybox(arena: Node) -> Node3D: + var old: Node = arena.get("visual") + if is_instance_valid(old): + var parent := old.get_parent() + if parent != null: + parent.remove_child(old) + old.free() + arena.set("visual", null) + arena.set("_graybox", null) + var boss_root: Node3D = arena.get("boss_root") + if boss_root == null: + return null + var gray: Node3D = arena.call(&"_build_graybox") + if gray == null: + return null + boss_root.add_child(gray) + arena.set("visual", gray) + arena.set("_graybox", gray) + arena.call(&"_read_cinematic_focus") + return gray + +# --- harness ------------------------------------------------------------------------------------ + +func _overrides(extra: Dictionary) -> Dictionary: + var merged := { + "boss_intro_seconds": INTRO_FULL, + "boss_intro_short_seconds": INTRO_SHORT, + "boss_defeat_seconds": DEFEAT_FULL, + "boss_defeat_short_seconds": DEFEAT_SHORT, + "boss_cinematic_skip_after": SKIP_AFTER, + "boss_cinematic_skip_tail": SKIP_TAIL, + "boss_cinematic_speed_scale": SPEED_SCALE, + "boss_hitstop_seconds": HITSTOP, + "boss_slowmo_seconds": SLOWMO, + "boss_slowmo_factor": SLOWMO_FACTOR, + "boss_outro_seconds": OUTRO, + "boss_kill_bonus_base": KILL_BASE, + "boss_kill_bonus_per_stage": KILL_PER_STAGE, + "tool_bonus": TOOL_BONUS, + "stage_base_distance": 8.0, + "stage_distance_step": 0.0, + "stage_drain_distance": 4.0, + "stage_drain_timeout": 0.15, + "turn_seconds": 0.15, + "stage_clear_seconds": 0.2, + } + for key in extra: + merged[key] = extra[key] + return merged + +func _configured(overrides: Dictionary) -> RunConfig: + var config := base_config.duplicate() as RunConfig + for key in overrides: + config.set(key, overrides[key]) + return config + +func _compile(source: String) -> GDScript: + var script := GDScript.new() + script.source_code = source + if script.reload() != OK: + return null + return script + +## Scripted before add_child so leaderboard_path exists in time for _ready. _ready itself is a no-op +## on this compiled subclass, so autostart cannot open a run. @onready still runs on enter-tree, and +## the stand-in children below are what those lookups resolve. +func _tree_game() -> Node3D: + var game := Node3D.new() + _prime_facade(game) + game.set_script(game_script) + game.autostart = false + game.leaderboard_path = scratch_leaderboard + game.config = base_config.duplicate() + root.add_child(game) + game.set_physics_process(false) + junk.append(game) + return game + +## Names and types match runner_game.gd's @onready paths. The test replaces the system references +## afterwards; these nodes only keep enter-tree from reporting missing children. +func _prime_facade(game: Node) -> void: + var systems := Node.new() + systems.name = "Systems" + game.add_child(systems) + for child_name in ["SpeedController", "HitManager", "ScoreManager", "EffectManager", "Spawner", "StageManager", "Autopilot"]: + var child := Node.new() + child.name = child_name + systems.add_child(child) + var world := Node3D.new() + world.name = "World" + game.add_child(world) + var chunks := Node3D.new() + chunks.name = "Chunks" + world.add_child(chunks) + var turn := Node3D.new() + turn.name = "TurnRig" + world.add_child(turn) + for child_name in ["GrayboxEnvironment", "Environment"]: + var child := Node3D.new() + child.name = child_name + turn.add_child(child) + var player := CharacterBody3D.new() + player.name = "Player" + game.add_child(player) + var pursuer := Node3D.new() + pursuer.name = "Pursuer" + game.add_child(pursuer) + var camera := Node3D.new() + camera.name = "CameraRig" + game.add_child(camera) + for child_name in ["HUD", "Overlays", "DebugOverlay"]: + var layer := CanvasLayer.new() + layer.name = child_name + game.add_child(layer) + var audio := Node.new() + audio.name = "Audio" + game.add_child(audio) + +func _systems(game: Node3D) -> Dictionary: + var hits: Node = hit_manager_script.new() + var score: Node = score_manager_script.new() + var effects: Node = effect_manager_script.new() + var speed: Node = speed_controller_script.new() + var spawner := Node.new() + spawner.set_script(spawner_script) + for node in [hits, score, effects, speed, spawner]: + junk.append(node) + game.hit_manager = hits + game.score_manager = score + game.effect_manager = effects + game.speed_controller = speed + game.spawner = spawner + hits.setup(game) + score.setup(game) + effects.setup(game) + speed.setup(game) + speed.set_physics_process(false) + return {"hits": hits, "score": score, "effects": effects, "speed": speed, "spawner": spawner} + +func _stage_rig(overrides: Dictionary) -> Dictionary: + var game := _tree_game() + for key in overrides: + game.config.set(key, overrides[key]) + game.state = &"running" + game.speed = float(game.config.base_speed) + var systems := _systems(game) + var turn_rig := _fake_rig() + var camera := _fake_rig() + game.turn_rig = turn_rig + game.camera_rig = camera + var arena := _mount_arena(game) + if arena == null: + _free_rig({"game": game}) + return {} + var stage: Node = stage_manager_script.new() + root.add_child(stage) + stage.set_physics_process(false) + junk.append(stage) + game.stage_manager = stage + var events := _watch(game) + stage.setup(game) + # Connected after setup, so the stage machine — which binds first — has already written the + # mirrors by the time this frontend-ordered listener runs (contract §11.14). + _watch_mirrors(game, stage, events) + var rig := {"game": game, "stage": stage, "arena": arena, "events": events} + rig.merge(systems) + return rig + +func _boss_rig(overrides: Dictionary) -> Dictionary: + var game := _tree_game() + for key in overrides: + game.config.set(key, overrides[key]) + game.state = &"running" + var systems := _systems(game) + var events := _watch(game) + var arena := _mount_arena(game) + if arena == null: + _free_rig({"game": game}) + return {} + var rig := {"game": game, "arena": arena, "events": events} + rig.merge(systems) + return rig + +func _mount_arena(game: Node3D) -> Node3D: + var packed := load(ARENA_SCENE) as PackedScene + check(packed != null, "the boss arena scene failed to load") + if packed == null: + return null + var world := game.get_node_or_null(^"World") as Node3D + if world == null: + world = Node3D.new() + world.name = "World" + game.add_child(world) + var rig := world.get_node_or_null(^"TurnRig") as Node3D + if rig == null: + rig = Node3D.new() + rig.name = "TurnRig" + world.add_child(rig) + var arena := packed.instantiate() as Node3D + if arena == null: + check(false, "the boss arena root is not a Node3D") + return null + arena.name = "BossArena" + rig.add_child(arena) + arena.setup(game) + game.boss_encounter = arena + return arena + +func _fake_rig() -> Node3D: + var rig := Node3D.new() + rig.set_script(rig_script) + root.add_child(rig) + junk.append(rig) + return rig + +func _hazard() -> Node3D: + var hazard := Node3D.new() + hazard.set_script(hazard_script) + root.add_child(hazard) + junk.append(hazard) + return hazard + +func _attach_controls(game: Node) -> Node: + var player := game.get_node_or_null(^"Player") as CharacterBody3D + if player == null: + player = CharacterBody3D.new() + player.name = "Player" + game.add_child(player) + player.set_script(controls_script) + game.player = player + var bot: Node = autopilot_script.new() + game.add_child(bot) + bot.setup(game) + game.autopilot = bot + return player + +func _watch(game: Node) -> Dictionary: + var events := { + "order": [], + "boss_intro": [], + "boss_cinematic": [], + "boss_spawned": [], + "boss_health_changed": [], + "boss_attack_telegraph": [], + "boss_attack_planned": [], + "boss_defeated": [], + "boss_retreated": [], + "bonus_awarded": [], + "skipped": [], + "ended": [], + "mirror_open": [], + "mirror_skip": [], + "mirror_end": [], + } + game.boss_intro.connect(func(_kind: StringName, _name: String, _seconds: float) -> void: + events["boss_intro"].append([_kind, _name, _seconds]) + events["order"].append(&"intro")) + game.boss_cinematic.connect(func(kind: StringName, mode: StringName, seconds: float) -> void: + events["boss_cinematic"].append([kind, mode, seconds]) + events["order"].append(&"cinematic")) + game.boss_spawned.connect(func(_kind: StringName, _hp: int) -> void: + events["boss_spawned"].append([_kind, _hp]) + events["order"].append(&"spawned")) + game.boss_health_changed.connect(func(_hp: int, _max_hp: int) -> void: + events["boss_health_changed"].append([_hp, _max_hp]) + events["order"].append(&"health")) + game.boss_attack_telegraph.connect(func(mask: int, kind: StringName, seconds: float) -> void: + events["boss_attack_telegraph"].append([mask, kind, seconds])) + game.boss_attack_planned.connect(func(_attack: Dictionary) -> void: + events["boss_attack_planned"].append(true)) + game.boss_defeated.connect(func(kind: StringName, bonus: int) -> void: + events["boss_defeated"].append([kind, bonus]) + events["order"].append(&"defeated")) + game.boss_retreated.connect(func(kind: StringName, bonus: int) -> void: + events["boss_retreated"].append([kind, bonus]) + events["order"].append(&"retreated")) + game.bonus_awarded.connect(func(points: int, reason: StringName) -> void: + events["bonus_awarded"].append([points, reason])) + game.boss_cinematic_skipped.connect(func(mode: StringName) -> void: + events["skipped"].append(mode) + events["order"].append(&"skipped")) + game.boss_cinematic_ended.connect(func(mode: StringName) -> void: + events["ended"].append(mode) + events["order"].append(&"ended")) + return events + +func _watch_mirrors(game: Node, stage: Node, events: Dictionary) -> void: + game.boss_cinematic.connect(func(_kind: StringName, _mode: StringName, _seconds: float) -> void: + events["mirror_open"].append(_mirror(game, stage))) + game.boss_cinematic_skipped.connect(func(_mode: StringName) -> void: + events["mirror_skip"].append(_mirror(game, stage))) + game.boss_cinematic_ended.connect(func(_mode: StringName) -> void: + events["mirror_end"].append(_mirror(game, stage))) + +func _mirror(game: Node, stage: Node) -> Dictionary: + return { + "mode": game.boss_cinematic_mode, + "progress": game.boss_cinematic_progress, + "factor": game.boss_time_factor, + "hold": stage.cinematic_hold, + } + +func _last_mirror(events: Dictionary, key: String) -> Dictionary: + var rows: Array = events[key] + if rows.is_empty(): + return {} + return rows.back() + +func _tick_stage(nodes: Array, game: Node, count: int) -> void: + for _i in count: + if game.state == &"running" or game.state == &"caught": + game.elapsed += TICK + game.distance += float(game.speed) * TICK + for node in nodes: + node._physics_process(TICK) + +func _tick_rig(rig: Dictionary, count: int) -> void: + var nodes: Array = [] + if rig.has("speed"): + nodes.append(rig["speed"]) + if rig.has("stage"): + nodes.append(rig["stage"]) + _tick_stage(nodes, rig["game"], count) + +func _ticks(seconds: float) -> int: + return int(roundf(seconds / TICK)) + +func _drive_to_phase(rig: Dictionary, phase: StringName, seconds: float) -> bool: + var stage: Node = rig["stage"] + var guard := 0 + var limit := _ticks(seconds) + while stage.phase != phase and guard < limit: + _tick_rig(rig, 1) + guard += 1 + return stage.phase == phase + +func _require_boss(rig: Dictionary, tag: String, seconds: float) -> bool: + if _drive_to_phase(rig, &"boss", seconds): + return true + var stage: Node = rig["stage"] + check(false, "%s: did not reach boss (phase %s, distance %.2f / %.2f)" + % [tag, stage.phase, float(stage.stage_distance), float(stage.stage_distance_target)]) + return false + +## Tick until the fight in progress has been left and the next boss phase has opened. +func _advance_to_next_boss(rig: Dictionary, tag: String) -> bool: + var stage: Node = rig["stage"] + var left := false + var guard := 0 + var limit := _ticks(20.0) + while guard < limit: + if stage.phase != &"boss": + left = true + elif left: + return true + _tick_rig(rig, 1) + guard += 1 + check(false, "%s: never reached the next boss (phase %s)" % [tag, stage.phase]) + return false + +func _tick_until(rig: Dictionary, done: Callable, seconds: float) -> int: + var ticks := 0 + var limit := _ticks(seconds) + while ticks < limit and not bool(done.call()): + _tick_rig(rig, 1) + ticks += 1 + return ticks + +func _cinematic_elapsed(arena: Node) -> float: + if StringName(arena.cinematic_mode) == &"" or float(arena.cinematic_seconds) <= 0.0: + return 0.0 + return float(arena.cinematic_progress()) * float(arena.cinematic_seconds) + +func _play_intro(rig: Dictionary, _tag: String) -> Dictionary: + var events: Dictionary = rig["events"] + var ended_before: int = int(events["ended"].size()) + var threat_base := _threat_now(rig) + var ticks := 0 + var threat := "" + var guard := _ticks(INTRO_FULL + 2.0) + while events["ended"].size() == ended_before and ticks < guard: + _tick_rig(rig, 1) + ticks += 1 + # The tick that closes the intro returns before the attack clocks run, so a threat here is + # still inside the window (contract §11.14). Baselines ignore the previous stage's shots. + var text := _threat_delta(rig, threat_base) + if text != "": + threat = "%s at tick %d" % [text, ticks] + break + if events["ended"].size() != ended_before: + break + return { + "ticks": ticks, + "ended_delta": events["ended"].size() - ended_before, + "ended_at": events["ended"].size(), + "clean": threat == "", + "threat": threat, + } + +func _wait_for_threat(rig: Dictionary, _tag: String, seconds: float) -> Dictionary: + var events: Dictionary = rig["events"] + var arena: Node = rig["arena"] + var stage: Node = rig["stage"] + var base_tele: int = int(events["boss_attack_telegraph"].size()) + var base_plan: int = int(events["boss_attack_planned"].size()) + var ticks := 0 + var limit := _ticks(seconds) + var appeared := false + while ticks < limit and stage.phase == &"boss" and StringName(arena.cinematic_mode) == &"": + if events["boss_attack_telegraph"].size() > base_tele or events["boss_attack_planned"].size() > base_plan \ + or arena.tool_count() > 0 or arena.projectile_count() > 0: + appeared = true + break + _tick_rig(rig, 1) + ticks += 1 + return {"appeared": appeared, "post_ticks": ticks, "from_start": ticks * TICK} + +func _threat_now(rig: Dictionary) -> Dictionary: + var events: Dictionary = rig["events"] + var arena: Node = rig["arena"] + return { + "telegraph": events["boss_attack_telegraph"].size(), + "planned": events["boss_attack_planned"].size(), + "tools": arena.tool_count(), + "shots": arena.projectile_count(), + } + +func _threat_delta(rig: Dictionary, base: Dictionary) -> String: + var now := _threat_now(rig) + if int(now["telegraph"]) > int(base["telegraph"]): + return "telegraph +%d" % (int(now["telegraph"]) - int(base["telegraph"])) + if int(now["planned"]) > int(base["planned"]): + return "planned attack +%d" % (int(now["planned"]) - int(base["planned"])) + if int(now["tools"]) > int(base["tools"]): + return "tool_count %d" % int(now["tools"]) + if int(now["shots"]) > int(base["shots"]): + return "projectile_count %d" % int(now["shots"]) + return "" + +func _finish_retreat(rig: Dictionary, tag: String) -> bool: + var stage: Node = rig["stage"] + var guard := 0 + while stage.phase == &"boss" and guard < _ticks(8.0): + _tick_rig(rig, 1) + guard += 1 + if stage.phase == &"boss": + check(false, "%s: the timeout did not leave the boss (still %s after %d ticks)" % [tag, stage.phase, guard]) + return false + return true + +func _skip_current_intro(rig: Dictionary, tag: String) -> bool: + var arena: Node = rig["arena"] + var events: Dictionary = rig["events"] + var ended_before: int = int(events["ended"].size()) + if StringName(arena.cinematic_mode) == &"": + return true + var ticks := _tick_until(rig, func() -> bool: + return bool(arena.can_skip_cinematic()) or StringName(arena.cinematic_mode) == &"", INTRO_FULL + 1.0) + if StringName(arena.cinematic_mode) != &"" and bool(arena.can_skip_cinematic()): + rig["game"].skip_boss_cinematic() + var guard := 0 + while StringName(arena.cinematic_mode) != &"" and guard < _ticks(SKIP_TAIL + 1.0): + _tick_rig(rig, 1) + guard += 1 + if events["ended"].size() <= ended_before or StringName(arena.cinematic_mode) != &"": + check(false, "%s: the intro did not end (mode %s, ticks %d + %d)" % [tag, arena.cinematic_mode, ticks, guard]) + return false + return true + +func _skip_arena_intro(arena: Node) -> bool: + var ticks := 0 + while StringName(arena.cinematic_mode) != &"" and not arena.can_skip_cinematic() and ticks < _ticks(INTRO_FULL + 1.0): + arena.tick(TICK) + ticks += 1 + if StringName(arena.cinematic_mode) != &"" and arena.can_skip_cinematic(): + arena.skip_cinematic() + var guard := 0 + while StringName(arena.cinematic_mode) != &"" and guard < _ticks(SKIP_TAIL + 1.0): + arena.tick(TICK) + guard += 1 + return StringName(arena.cinematic_mode) == &"" + +func arena_can_skip(rig: Dictionary) -> bool: + return bool(rig["arena"].can_skip_cinematic()) + +func _mount_recorder(arena: Node) -> Node: + var old: Node = arena.get("visual") + if is_instance_valid(old): + var parent := old.get_parent() + if parent != null: + parent.remove_child(old) + old.free() + arena.set("visual", null) + arena.set("_graybox", null) + var boss_root: Node3D = arena.get("boss_root") + if boss_root == null: + return null + var rec := Node3D.new() + rec.set_script(recorder_script) + boss_root.add_child(rec) + arena.set("visual", rec) + return rec + +func _last_progress(rec: Node) -> float: + var args := _last_named(rec.calls, &"set_cinematic_progress") + if args.size() < 2: + return -1.0 + return float(args[1]) + +func _last_progress_call(rec: Node, from_index: int) -> Array: + var rows: Array = rec.calls.slice(from_index) + var found: Array = [] + for entry in rows: + if StringName(entry[0]) == &"set_cinematic_progress": + found = entry[1] + return found + +func _last_named(calls: Array, method: StringName) -> Array: + var found: Array = [] + for entry in calls: + if StringName(entry[0]) == method: + found = entry[1] + return found + +func _call_names(calls: Array) -> Array: + var names: Array = [] + for entry in calls: + names.append(entry[0]) + return names + +func _same(got: Array, expected: Array) -> bool: + if got.size() != expected.size(): + return false + for i in got.size(): + if StringName(got[i]) != StringName(expected[i]): + return false + return true + +func _has_subsequence(order: Array, needle: Array) -> bool: + var n := 0 + for item in order: + if n < needle.size() and StringName(item) == StringName(needle[n]): + n += 1 + return n == needle.size() + +func _has_kind(value: Variant, kind: StringName) -> bool: + if not value is Array: + return false + for entry in value: + if StringName(entry) == kind: + return true + return false + +func _bonus_has(rows: Array, points: int, reason: StringName) -> bool: + for row in rows: + if int(row[0]) == points and StringName(row[1]) == reason: + return true + return false + +func _ui_snapshot(node: Node) -> Array: + var rows: Array = [] + _ui_walk(node, rows) + return rows + +func _ui_walk(node: Node, rows: Array) -> void: + var text := "" + if node is Label: + text = (node as Label).text + elif node is RichTextLabel: + text = (node as RichTextLabel).get_parsed_text() + var visible := bool(node.get("visible")) if "visible" in node else true + var modulate: Variant = node.get("modulate") if "modulate" in node else Color(1, 1, 1, 1) + var position: Variant = node.get("position") if "position" in node else Vector2.ZERO + rows.append([String(node.name), visible, modulate, position, text]) + for child in node.get_children(): + _ui_walk(child, rows) + +func _press_jump(game: Node) -> void: + Input.action_press("jump") + var event := InputEventAction.new() + event.action = "jump" + event.pressed = true + event.strength = 1.0 + Input.parse_input_event(event) + +func _release_jump() -> void: + if not InputMap.has_action("jump"): + return + Input.action_release("jump") + var event := InputEventAction.new() + event.action = "jump" + event.pressed = false + Input.parse_input_event(event) + +func _silence_audio() -> void: + _stop_players(root) + var audio := root.get_node_or_null("AudioService") + if audio == null: + return + for key in ["music", "sfx"]: + var player := audio.get(key) as AudioStreamPlayer + if player == null: + continue + player.stop() + player.stream = null + +func _stop_players(node: Node) -> void: + if node is AudioStreamPlayer: + (node as AudioStreamPlayer).stop() + for child in node.get_children(): + _stop_players(child) + +func _resume(game: Node) -> void: + if is_instance_valid(game) and bool(game.paused): + game.resume() + paused = false + +## `seconds` is game time. The bound is that many physics frames, so a slow host cannot +## exhaust the wait before the simulation has had those ticks (Godot caps physics steps per frame). +func _wait_until(predicate: Callable, seconds: float) -> bool: + var frames := _ticks(seconds) + for _i in frames: + if bool(predicate.call()): + return true + await physics_frame + return bool(predicate.call()) + +func _free_live_scene() -> void: + if live_scene != null and is_instance_valid(live_scene): + var scene := live_scene + live_scene = null + if bool(scene.paused): + scene.resume() + scene.free() + paused = false + +func _free_rig(rig: Dictionary) -> void: + var game: Node = rig.get("game", null) + if game == null or not is_instance_valid(game): + return + if bool(game.get("paused")): + _resume(game) + junk.erase(game) + if game.get_parent() != null: + game.get_parent().remove_child(game) + game.free() + +func _report() -> void: + _release_jump() + Engine.time_scale = saved_scale + paused = false + if runner_game_script != null: + runner_game_script.set(&"pending_boss_checkpoint", {}) + # The audio autoload starts playback during every headless run, and the real scene swaps its + # music. Stop it and let one frame drop the playback objects before exit. + _silence_audio() + await process_frame + _free_live_scene() + for path in temp_paths: + for suffix in ["", ".tmp", ".bak"]: + if FileAccess.file_exists(path + suffix): + DirAccess.remove_absolute(path + suffix) + for node in junk: + if is_instance_valid(node): + node.free() + junk.clear() + _silence_audio() + var audio := root.get_node_or_null("AudioService") + if audio != null and audio.has_method("shutdown"): + audio.shutdown() + await process_frame + await create_timer(0.3).timeout + await process_frame + await process_frame + var status := "PASS" if failures == 0 else "FAIL" + print("RUNNER BOSS CINEMATIC: ", status, " (", failures, " failures / ", checks, " checks)") + quit(1 if failures > 0 else 0) diff --git a/hoppys-adventure-arcade/tests/runner_boss_cinematic.gd.uid b/hoppys-adventure-arcade/tests/runner_boss_cinematic.gd.uid new file mode 100644 index 00000000..edd17503 --- /dev/null +++ b/hoppys-adventure-arcade/tests/runner_boss_cinematic.gd.uid @@ -0,0 +1 @@ +uid://c8lmj43mu3xn1 diff --git a/hoppys-adventure-arcade/tests/runner_boss_presentation.gd b/hoppys-adventure-arcade/tests/runner_boss_presentation.gd new file mode 100644 index 00000000..1d8b733a --- /dev/null +++ b/hoppys-adventure-arcade/tests/runner_boss_presentation.gd @@ -0,0 +1,329 @@ +extends SceneTree +## Presentation-only checks for boss_cinematic.tscn and boss_copy.gd. +## Run with --path hoppys-adventure-arcade --script res://tests/runner_boss_presentation.gd. + +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") +const ToolDefs = preload("res://scenes/runner/powerups/tool_defs.gd") +const BossCopy = preload("res://scenes/runner/ui/boss_copy.gd") +const CinematicScene = preload("res://scenes/runner/ui/boss_cinematic.tscn") +const BossHud = preload("res://scenes/runner/ui/boss_hud.gd") +const PostFX = preload("res://scenes/runner/ui/post_fx.gd") +const RunConfig = preload("res://scenes/runner/data/run_config.gd") +const RunnerAudioScene = preload("res://scenes/runner/audio/runner_audio.tscn") +var scratch_board := "user://runner_boss_presentation_%d.cfg" % (randi() % 1000000) + +class Facade extends Node: + signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) + signal boss_cinematic_skipped(mode: StringName) + signal boss_cinematic_ended(mode: StringName) + signal boss_defeated(kind: StringName, bonus: int) + signal boss_spawned(kind: StringName, hp: int) + signal boss_health_changed(hp: int, max_hp: int) + signal boss_phase_changed(phase: int) + signal boss_retreated(kind: StringName, bonus: int) + signal tool_collected(kind: StringName, damage: int) + signal phase_changed(phase: StringName) + signal caught + signal run_started(seed: int) + signal stage_cleared(index: int, bonus: int) + var leaderboard_path := "" + var paused := false + var state: StringName = &"running" + var normalized_speed := 0.0 + var stage_index := 1 + var boss_cinematic_progress := 0.0 + var boss_time_factor := 1.0 + var config: Resource + var can_skip := false + var boss_info := { + "kind": &"flaky_test_swarm", "name": "Flaky Test Swarm", "hp": 100, + "max_hp": 100, "phase": 1, "phase_count": 3, "accepted_tools": [], + "pending_attacks": [] + } + + func get_boss_info() -> Dictionary: + return boss_info + + func can_skip_boss_cinematic() -> bool: + return can_skip and not paused + +class RecordingRunnerAudio extends "res://scenes/runner/audio/runner_audio.gd": + var stop_requests: Array[StringName] = [] + + func _stop_cue(kind: StringName) -> void: + stop_requests.append(kind) + super._stop_cue(kind) + +var failures := 0 +var checks := 0 + +func _initialize() -> void: + call_deferred("run") + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error("RUNNER BOSS PRESENTATION: " + message) + +func run() -> void: + _test_copy_contract() + var facade := Facade.new() + ## Set the scratch board path before adding the facade, matching runner_game fixture setup. + facade.leaderboard_path = scratch_board + root.add_child(facade) + var cinematic := CinematicScene.instantiate() + root.add_child(cinematic) + cinematic.bind(facade) + _test_full_timing_and_skip(cinematic, facade) + _test_full_card_dwell(cinematic, facade) + _test_short_mode(cinematic, facade) + _test_pause_freezes_presentation(cinematic, facade) + _test_resolved_bonus(cinematic, facade) + _test_zero_length_hp_plate(facade) + _test_post_fx_skip_tail(facade) + await _test_audio_defeat_fanfare_lifecycle() + var audio_service := root.get_node_or_null("AudioService") + if audio_service != null and audio_service.has_method("shutdown"): + audio_service.shutdown() + await process_frame + await create_timer(0.3).timeout + for child in root.get_children(): + if child != self: + child.free() + await process_frame + print("RUNNER BOSS PRESENTATION: %s (%d checks / %d failures)" % ["PASS" if failures == 0 else "FAIL", checks, failures]) + quit(0 if failures == 0 else 1) + +func _test_copy_contract() -> void: + for stage in range(1, 4): + var def: Dictionary = BossDefs.base_def(stage) + var rows: Array[Dictionary] = BossCopy.hint_rows(def) + check(rows.size() == BossDefs.PHASE_COUNT, "%s has one hint row per phase" % def.kind) + for phase in range(1, BossDefs.PHASE_COUNT + 1): + if phase > rows.size(): + continue + var row: Dictionary = rows[phase - 1] + var accepted: Array[StringName] = BossDefs.accepted_tools(def, phase) + var accepts_every_tool := accepted.size() == ToolDefs.kinds().size() + check(row.phase == phase, "%s hint row has phase %d" % [def.kind, phase]) + check(row.accepted_tools == accepted, "%s phase %d copy eligibility matches BossDefs.accepted_tools" % [def.kind, phase]) + check(row.all_tools == accepts_every_tool, "%s phase %d all-tools marker matches acceptance" % [def.kind, phase]) + var expected_tools: Array = Array(accepted) + check(row.tools == expected_tools, "%s phase %d glyph list matches accepted tools" % [def.kind, phase]) + check(BossCopy.epithet(&"flaky_test_swarm", 1).find("REGRESSED") == -1, "first lap epithet has no regression tag") + check(BossCopy.epithet(&"flaky_test_swarm", 2).contains("REGRESSED v2"), "second lap epithet has regression tag") + +func _test_full_timing_and_skip(cinematic: Node, facade: Facade) -> void: + facade.can_skip = false + facade.boss_cinematic.emit(&"flaky_test_swarm", &"intro_full", 5.0) + facade.boss_cinematic_progress = 0.399 + update_cinematic(cinematic) + check(not cinematic.title_group.visible, "full title stays hidden before its facade-progress threshold") + facade.boss_cinematic_progress = 0.40 + update_cinematic(cinematic) + check(cinematic.title_group.visible, "full title appears at its facade-progress threshold") + check(cinematic.threat.visible and cinematic.threat.visible_characters == 0, "threat typing starts with title timing") + facade.boss_cinematic_progress = 0.50 + update_cinematic(cinematic) + var typed_at_half: int = cinematic.threat.visible_characters + check(typed_at_half == 12, "threat typing advances at 40 characters per facade-clock second") + update_cinematic(cinematic) + check(cinematic.threat.visible_characters == typed_at_half, "fixed facade progress produces fixed title-card text") + facade.boss_cinematic_progress = 0.099 + update_cinematic(cinematic) + check(not cinematic.skip_prompt.visible, "full intro does not show skip before the 0.5-second backend guard") + facade.can_skip = true + facade.boss_cinematic_progress = 0.10 + update_cinematic(cinematic) + check(cinematic.skip_prompt.visible, "full intro shows skip when the facade says the 0.5-second guard is open") + facade.boss_cinematic_ended.emit(&"intro_full") + facade.can_skip = false + +func _test_short_mode(cinematic: Node, facade: Facade) -> void: + facade.can_skip = false + facade.boss_cinematic.emit(&"merge_conflict_hydra", &"intro_short", 2.5) + facade.boss_cinematic_progress = 0.199 + update_cinematic(cinematic) + check(not cinematic.skip_prompt.visible, "short skip prompt stays hidden before the 0.5-second facade guard") + facade.boss_cinematic_progress = 0.28 + update_cinematic(cinematic) + check(cinematic.title_group.visible, "short title appears from the facade clock") + check(not cinematic.chyron.visible and not cinematic.source_path.visible, "short intro omits chyron and source path") + check(not cinematic.threat.visible and not cinematic.hints.visible, "short intro omits threat and tool hints") + check(not cinematic.skip_prompt.visible and not cinematic.resolved.visible, "short intro presents its title and epithet without threat or resolved copy") + facade.can_skip = true + facade.boss_cinematic_progress = 0.28 + update_cinematic(cinematic) + check(cinematic.skip_prompt.visible, "short intro shows skip when the facade says the 0.5-second guard is open") + facade.boss_cinematic_skipped.emit(&"intro_short") + check(not cinematic.skip_prompt.visible, "short skip prompt hides after the facade reports a skip") + facade.can_skip = false + +func _test_full_card_dwell(cinematic: Node, facade: Facade) -> void: + const CARD_SECONDS := 5.0 + const SAMPLE_SECONDS := 0.005 + const THREAT_DWELL_REQUIRED := 1.2 + const HINTS_DWELL_REQUIRED := 1.0 + for stage in range(1, 4): + facade.stage_index = stage + facade.can_skip = false + var kind: StringName = BossDefs.base_def(stage).kind + facade.boss_cinematic.emit(kind, &"intro_full", CARD_SECONDS) + var threat_visible := false + var threat_dwell := 0.0 + var longest_threat_dwell := 0.0 + var hints_visible := false + var hints_dwell := 0.0 + var longest_hints_dwell := 0.0 + for sample in range(1, int(CARD_SECONDS / SAMPLE_SECONDS) + 1): + var elapsed := float(sample) * SAMPLE_SECONDS + facade.boss_cinematic_progress = elapsed / CARD_SECONDS + update_cinematic(cinematic) + var threat_fully_shown: bool = ( + cinematic.threat.visible + and cinematic.threat.visible_characters >= cinematic.threat.get_total_character_count() + and cinematic.threat.modulate.a >= 0.999 + ) + if threat_fully_shown: + threat_dwell = threat_dwell + SAMPLE_SECONDS if threat_visible else 0.0 + threat_visible = true + longest_threat_dwell = maxf(longest_threat_dwell, threat_dwell) + else: + threat_visible = false + threat_dwell = 0.0 + var hints_fully_shown: bool = cinematic.hints.visible and cinematic.hints.modulate.a >= 0.999 + if hints_fully_shown: + hints_dwell = hints_dwell + SAMPLE_SECONDS if hints_visible else 0.0 + hints_visible = true + longest_hints_dwell = maxf(longest_hints_dwell, hints_dwell) + else: + hints_visible = false + hints_dwell = 0.0 + check(longest_threat_dwell >= THREAT_DWELL_REQUIRED, + "stage %d threat copy is fully typed and visible at full alpha for at least 1.2 seconds" % stage) + check(longest_hints_dwell >= HINTS_DWELL_REQUIRED, + "stage %d tool hints are visible at full alpha for at least 1.0 second" % stage) + print("BOSS PRESENTATION DWELL: stage=%d threat=%.3fs hints=%.3fs" % [stage, longest_threat_dwell, longest_hints_dwell]) + facade.boss_cinematic_ended.emit(&"intro_full") + +func _test_pause_freezes_presentation(cinematic: Node, facade: Facade) -> void: + facade.boss_cinematic.emit(&"flaky_test_swarm", &"intro_full", 5.0) + facade.boss_cinematic_progress = 0.50 + update_cinematic(cinematic) + var chars_before_pause: int = cinematic.threat.visible_characters + facade.paused = true + facade.boss_cinematic_progress = 0.65 + update_cinematic(cinematic) + check(cinematic.threat.visible_characters == chars_before_pause, "paused facade progress does not advance cinematic copy") + facade.paused = false + update_cinematic(cinematic) + check(cinematic.threat.visible_characters > chars_before_pause, "presentation resumes from the updated facade progress") + facade.boss_cinematic_ended.emit(&"intro_full") + +func _test_resolved_bonus(cinematic: Node, facade: Facade) -> void: + facade.can_skip = true + facade.boss_defeated.emit(&"legacy_monolith", 245) + facade.boss_cinematic.emit(&"legacy_monolith", &"defeat_full", 3.6) + facade.boss_cinematic_progress = 0.599 + update_cinematic(cinematic) + check(not cinematic.resolved.visible, "resolved callout stays hidden before 60 percent") + check(not cinematic.skip_prompt.visible, "defeat cinematic never shows the skip prompt") + facade.boss_cinematic_progress = 0.60 + update_cinematic(cinematic) + check(cinematic.resolved.visible and cinematic.resolved_title.text == "THREAD RESOLVED", "resolved callout appears at 60 percent") + check(cinematic.bonus_label.text.contains("245"), "resolved callout displays the boss-defeated bonus") + facade.boss_cinematic_ended.emit(&"defeat_full") + facade.can_skip = false + +func _test_zero_length_hp_plate(facade: Facade) -> void: + var hud := BossHud.new() + root.add_child(hud) + hud.bind(facade) + ## Contract sends no cinematic signal for a zero-length intro. Spawn must reveal the plate itself. + facade.boss_spawned.emit(&"flaky_test_swarm", 100) + check(hud.visible and hud._fight_active, "zero-length intro with no cinematic signal shows the HP plate on spawn") + +func _test_post_fx_skip_tail(facade: Facade) -> void: + facade.config = RunConfig.new() + facade.config.set("boss_cinematic_skip_tail", 0.8) + facade.boss_time_factor = 1.0 + facade.boss_cinematic_progress = 0.0 + var post_fx := PostFX.new() + root.add_child(post_fx) + post_fx.bind(facade) + facade.boss_cinematic.emit(&"flaky_test_swarm", &"intro_full", 5.0) + facade.boss_cinematic_progress = 0.84 + facade.boss_cinematic_skipped.emit(&"intro_full") + check(is_equal_approx(float(post_fx.values.letterbox), 1.0), "post FX keeps letterbox fully open at the configured skip-tail start") + facade.boss_cinematic_progress = 0.92 + post_fx._process(0.0) + check(is_equal_approx(float(post_fx.values.letterbox), 0.5), "post FX letterbox eases halfway through the configured skip tail") + facade.boss_cinematic_progress = 1.0 + post_fx._process(0.0) + facade.boss_cinematic_ended.emit(&"intro_full") + post_fx._process(0.0) + check(is_equal_approx(float(post_fx.values.letterbox), 0.0), "post FX letterbox reaches zero at cinematic end") + var clock_before := post_fx.clock + facade.boss_time_factor = 0.4 + post_fx._process(1.0) + check(is_equal_approx(post_fx.clock - clock_before, 0.4), "post FX local clock applies the facade boss time factor") + post_fx.bind(null) + +func _test_audio_defeat_fanfare_lifecycle() -> void: + var facade := Facade.new() + facade.leaderboard_path = scratch_board + root.add_child(facade) + var audio := RunnerAudioScene.instantiate() + audio.set_script(RecordingRunnerAudio) + root.add_child(audio) + audio.bind(facade) + var stops: Array[StringName] = audio.get("stop_requests") + stops.clear() + var first_voice := _start_resolved_voice(audio, facade) + var playback: AudioStreamPlaybackPolyphonic = audio.get("_playback") + check(first_voice != AudioStreamPlaybackPolyphonic.INVALID_ID and playback.is_stream_playing(first_voice), + "defeat-short fanfare starts as a real polyphonic playback voice") + facade.boss_cinematic_progress = 1.0 + facade.boss_cinematic_ended.emit(&"defeat_short") + check(not stops.has(&"boss_resolved"), "cinematic end does not request stopping the resolved fanfare") + facade.stage_cleared.emit(1, 400) + check(not stops.has(&"boss_resolved"), "stage clear leaves the resolved fanfare stop request history unchanged") + check(playback.is_stream_playing(first_voice), "resolved fanfare remains a real active voice before catch cleanup") + facade.caught.emit() + await process_frame + check(stops.has(&"boss_resolved"), "catch cleanup requests stopping the resolved fanfare after cinematic end") + await create_timer(0.5).timeout + check(not playback.is_stream_playing(first_voice), "catch cleanup stops the resolved fanfare playback voice") + stops.clear() + var run_started_voice := _start_resolved_voice(audio, facade) + check(run_started_voice != AudioStreamPlaybackPolyphonic.INVALID_ID and playback.is_stream_playing(run_started_voice), + "run-start cleanup fixture starts a real resolved fanfare voice") + facade.run_started.emit(2) + await process_frame + check(stops.has(&"boss_resolved"), "new-run cleanup requests stopping the prior cinematic fanfare") + await create_timer(0.5).timeout + check(not playback.is_stream_playing(run_started_voice), "new-run cleanup stops the prior resolved playback voice") + stops.clear() + var teardown_voice := _start_resolved_voice(audio, facade) + var teardown_playback: AudioStreamPlaybackPolyphonic = audio.get("_playback") + check(teardown_voice != AudioStreamPlaybackPolyphonic.INVALID_ID and teardown_playback.is_stream_playing(teardown_voice), + "teardown fixture starts another real resolved fanfare voice") + audio.queue_free() + await process_frame + check(stops.has(&"boss_resolved"), "RunnerAudio teardown requests stopping active resolved fanfare") + await create_timer(0.5).timeout + check(not teardown_playback.is_stream_playing(teardown_voice), "RunnerAudio teardown stops active cinematic voices") + +func _start_resolved_voice(audio: Node, facade: Facade) -> int: + facade.boss_cinematic_progress = 0.0 + facade.boss_cinematic.emit(&"legacy_monolith", &"defeat_short", 2.0) + facade.boss_cinematic_progress = 0.60 + audio._process(0.0) + for voice: int in audio.get("_voices"): + if audio.get("_voice_kinds").get(voice) == &"boss_resolved": + return voice + return AudioStreamPlaybackPolyphonic.INVALID_ID + +func update_cinematic(cinematic: Node) -> void: + cinematic._process(0.0) diff --git a/hoppys-adventure-arcade/tests/runner_boss_presentation.gd.uid b/hoppys-adventure-arcade/tests/runner_boss_presentation.gd.uid new file mode 100644 index 00000000..fd9315a8 --- /dev/null +++ b/hoppys-adventure-arcade/tests/runner_boss_presentation.gd.uid @@ -0,0 +1 @@ +uid://c4d9apm3r7k2w diff --git a/hoppys-adventure-arcade/tests/runner_boss_validator.gd b/hoppys-adventure-arcade/tests/runner_boss_validator.gd index 5dc768c6..99c78b91 100644 --- a/hoppys-adventure-arcade/tests/runner_boss_validator.gd +++ b/hoppys-adventure-arcade/tests/runner_boss_validator.gd @@ -15,6 +15,10 @@ extends SceneTree ## B6 (contract 2.4) every signature event — a flaky commit, a scatter split, a rebase sweep, a legacy ## gap shift, a debris shadow and its landing, a branching volley — leaves, from every lane, a ## survivable answer that can still be started boss_signature_reaction_min (0.35 s) after it. +## B7 (contract 2.6 §11.14) the cinematic copy table (ui/boss_copy.gd, frontend-owned) names every +## boss in the roster, and only those, with a real epithet (REGRESSED vN from the second lap), threat +## line and source path. Each phase gets a hint row whose tools are exactly BossDefs.accepted_tools, +## so the "how to resolve" row can never contradict the fight. ## ## Since contract 2.4 the shots are not assumed to fly straight: every beat is resolved through ## boss/boss_attacks.gd (the same code boss_encounter.gd fires from), the bot hears the encounter's @@ -53,6 +57,11 @@ const STEP_GUARD := 4000 ## Board the facade points at. Nothing here finishes a run, so the file is never created; the ## override exists so no code path can ever reach the real board. const SCRATCH_BOARD := "user://boss_validator_leaderboard.cfg" +## B7: the frontend copy table. It is loaded at run time, so a missing file is a failed check rather +## than a compile error that would stop the whole validator. +const COPY_PATH := "res://scenes/runner/ui/boss_copy.gd" +## A kind no roster will ever hold, whose answers are boss_copy's own fallbacks. +const NO_SUCH_BOSS: StringName = &"__no_such_boss__" ## The contract §7 kinematics runner_chunk_validator.gd simulates (gravity 28, jump 9.8 / 0.70 s ## airtime, roll 0.70 s, lane tween 0.13 s ease-out), as a CharacterBody3D the real autopilot can @@ -251,6 +260,7 @@ func run() -> void: _validate_fight(stage_index) _check_escalation_floors() _check_signature_coverage() + _check_copy() _report() ## Every phase of one stage's fight: the beat sweep, the pattern sweep and the tool guarantee. @@ -773,6 +783,95 @@ func _check_signature_coverage() -> void: tightest.append("%s %.3f s" % [name, float(event_min[name])]) print("RUNNER BOSS: B6 events measured %s; tightest windows: %s" % [event_counts, ", ".join(tightest)]) +# --- B7: the cinematic copy table agrees with the roster and the fight (contract 2.6 §11.14) --- + +## Every roster boss at every cycle, through boss_copy's own API: epithet / threat / source path are +## real (not the fallback an unknown kind gets), the epithet carries "REGRESSED vN" from the second +## lap on, and hint_rows() gives one row per phase whose tools are BossDefs.accepted_tools exactly +## and whose text names them (or says every tool lands). The copy table ships with contract 2.6, so a +## tree without it fails: renaming or dropping the file must not silently drop this check. +func _check_copy() -> void: + check(ResourceLoader.exists(COPY_PATH), "B7: %s is missing, so the cinematic copy cannot be checked" % COPY_PATH) + if not ResourceLoader.exists(COPY_PATH): + return + var copy := load(COPY_PATH) as GDScript + check(copy != null, "B7: %s did not load as a script" % COPY_PATH) + if copy == null: + return + var table: Dictionary = copy.get_script_constant_map().get("COPY", {}) + var roster := {} + for entry in BossDefs.SLOTS: + roster[StringName(entry["kind"])] = true + check(table.size() == roster.size(), "B7: the copy table has %d rows for a %d-boss roster" % [table.size(), roster.size()]) + for row_kind in table: + check(roster.has(StringName(row_kind)), "B7: the copy table carries %s, which no stage fights" % row_kind) + var fallback_epithet := String(copy.epithet(NO_SUCH_BOSS)) + var fallback_threat := String(copy.threat(NO_SUCH_BOSS)) + var fallback_path := String(copy.source_path(NO_SUCH_BOSS)) + var every_tool := ToolDefs.kinds().size() + var samples := PackedStringArray() + for slot in BossDefs.SLOTS.size(): + var kind: StringName = BossDefs.SLOTS[slot]["kind"] + check(table.has(kind), "B7: %s has no copy row" % kind) + var first_epithet := String(copy.epithet(kind, 1)) + check(not first_epithet.is_empty() and first_epithet != fallback_epithet, + "B7: %s epithet is the fallback %s" % [kind, first_epithet]) + var threat := String(copy.threat(kind)) + check(not threat.is_empty() and threat != fallback_threat, "B7: %s threat is the fallback %s" % [kind, threat]) + var path := String(copy.source_path(kind)) + check(path != fallback_path and path.contains(":"), "B7: %s source path is %s, expected a path:line" % [kind, path]) + for cycle in CYCLES: + var stage_index := cycle * BossDefs.SLOTS.size() + slot + 1 + var def := BossDefs.fight_def(stage_index, config) + var lap := cycle + 1 + var epithet := String(copy.epithet(kind, lap)) + var tag := "REGRESSED v%d" % lap + check(epithet.begins_with(first_epithet) and (epithet.contains(tag) == (lap >= 2)), + "B7: %s lap %d epithet is %s" % [kind, lap, epithet]) + _check_hint_rows(copy, def, kind, cycle, every_tool, samples) + print("RUNNER BOSS: B7 copy covers %d bosses x %d cycles; hints %s" % [roster.size(), CYCLES, "; ".join(samples)]) + +## One fight's hint rows against BossDefs: one per phase, in order, with the accepted tools and the +## all-tools flag the fight really has, and text that names those tools and no other. +func _check_hint_rows(copy: GDScript, def: Dictionary, kind: StringName, cycle: int, every_tool: int, + samples: PackedStringArray) -> void: + var rows: Array = copy.hint_rows(def) + var phases := int(def.get("phase_count", BossDefs.PHASE_COUNT)) + check(rows.size() == phases, "B7: %s c%d has %d hint rows for %d phases" % [kind, cycle, rows.size(), phases]) + for index in mini(rows.size(), phases): + var row: Dictionary = rows[index] + var phase := index + 1 + var accepted := _sorted_names(BossDefs.accepted_tools(def, phase)) + var tag := "%s c%d p%d" % [kind, cycle, phase] + check(int(row.get("phase", 0)) == phase, "B7: %s row is for phase %s" % [tag, row.get("phase", "")]) + check(_sorted_names(row.get("accepted_tools", [])) == accepted and _sorted_names(row.get("tools", [])) == accepted, + "B7: %s hint tools %s / %s, BossDefs accepts %s" + % [tag, row.get("accepted_tools", []), row.get("tools", []), accepted]) + var all_tools := accepted.size() == every_tool + check(bool(row.get("all_tools", not all_tools)) == all_tools, + "B7: %s all_tools is %s, but %d of %d tools land" % [tag, row.get("all_tools", ""), accepted.size(), every_tool]) + var text := String(row.get("text", "")) + check(not text.is_empty(), "B7: %s hint row has no text" % tag) + if all_tools: + check(text.contains("EVERY TOOL LANDS"), "B7: %s accepts every tool but its hint reads %s" % [tag, text]) + else: + for tool_kind in ToolDefs.kinds(): + var label := ToolDefs.label(tool_kind).to_upper() + var named := text.contains(label) + check(named == accepted.has(String(tool_kind)), + "B7: %s hint %s %s %s, BossDefs accepts %s" + % [tag, text, "names" if named else "omits", label, accepted]) + if cycle == 0: + samples.append("%s p%d \"%s\"" % [kind, phase, text]) + +func _sorted_names(tools: Variant) -> PackedStringArray: + var names := PackedStringArray() + if tools is Array: + for tool_kind in tools: + names.append(String(tool_kind)) + names.sort() + return names + # --- harness --- func _compile(source: String) -> GDScript: diff --git a/hoppys-adventure-arcade/tests/runner_logic.gd b/hoppys-adventure-arcade/tests/runner_logic.gd index 9bb42f3f..fa8036e3 100644 --- a/hoppys-adventure-arcade/tests/runner_logic.gd +++ b/hoppys-adventure-arcade/tests/runner_logic.gd @@ -839,8 +839,28 @@ func _test_config_sanity() -> void: and float(c.turn_swing_in_fraction) + float(c.turn_swing_out_fraction) < 1.0, "9: the swing ramps must leave a hold window, got %.2f + %.2f" % [float(c.turn_swing_in_fraction), float(c.turn_swing_out_fraction)]) - check(is_equal_approx(float(c.boss_intro_seconds), 3.0), - "9: the boss entrance runs %.2f s, expected 3.0" % float(c.boss_intro_seconds)) + # Contract 2.6 §11.14: a 5 s first-meeting intro, 2.5 s repeats, a 3.6 / 2.0 s defeat, and the + # 1 s outro left to the retreat alone. + check(is_equal_approx(float(c.boss_intro_seconds), 5.0) + and is_equal_approx(float(c.boss_intro_short_seconds), 2.5), + "9: the boss intro runs %.2f s full / %.2f s short, expected 5.0 / 2.5" + % [float(c.boss_intro_seconds), float(c.boss_intro_short_seconds)]) + check(is_equal_approx(float(c.boss_defeat_seconds), 3.6) + and is_equal_approx(float(c.boss_defeat_short_seconds), 2.0) + and is_equal_approx(float(c.boss_outro_seconds), 1.0), + "9: the defeat runs %.2f / %.2f s and the retreat outro %.2f s, expected 3.6 / 2.0 / 1.0" + % [float(c.boss_defeat_seconds), float(c.boss_defeat_short_seconds), float(c.boss_outro_seconds)]) + check(is_equal_approx(float(c.boss_cinematic_skip_after), 0.5) + and is_equal_approx(float(c.boss_cinematic_skip_tail), 0.3) + and is_equal_approx(float(c.boss_cinematic_speed_scale), 0.25), + "9: the skip opens at %.2f s with a %.2f s tail and the corridor slows to %.2fx, expected 0.5 / 0.3 / 0.25" + % [float(c.boss_cinematic_skip_after), float(c.boss_cinematic_skip_tail), + float(c.boss_cinematic_speed_scale)]) + check(is_equal_approx(float(c.boss_hitstop_seconds), 0.12) + and is_equal_approx(float(c.boss_slowmo_seconds), 0.8) + and is_equal_approx(float(c.boss_slowmo_factor), 0.4), + "9: the defeat hit-stop is %.2f s and the slow-mo %.2f s from %.2f, expected 0.12 / 0.8 / 0.4" + % [float(c.boss_hitstop_seconds), float(c.boss_slowmo_seconds), float(c.boss_slowmo_factor)]) # Contract §11.1/§11.10 v2.3: the stage banner covers the lanes, so the corridor stays clear # across it and still owes the player a reaction once it lifts. check(is_equal_approx(float(c.stage_banner_clear_seconds), 3.5) @@ -1729,7 +1749,8 @@ func _test_escalation() -> void: # --- 19 / 20. Boss HP, phases and tool damage (systems spec §10.3) --- func _test_boss_fight() -> void: - var rig := _boss_rig({"boss_intro_seconds": 0.0, "boss_outro_seconds": 0.2}) + # Contract 2.6 §11.14: a kill holds the fight for its defeat cinematic, shortened here to 0.2 s. + var rig := _boss_rig({"boss_intro_seconds": 0.0, "boss_defeat_seconds": 0.2, "boss_outro_seconds": 0.0}) var game: Node3D = rig["game"] var arena: Node3D = rig["arena"] var score: Node = rig["score"] @@ -1785,8 +1806,11 @@ func _test_boss_fight() -> void: check(_bonus_reasons(events) == [&"tool", &"tool", &"tool", &"tool", &"tool", &"boss_kill"], "19: the bonus reasons are five tools and a kill, got %s" % [_bonus_reasons(events)]) - # The outro holds the fight open so the visual gets its play_defeat time. + # The defeat cinematic holds the fight open so the visual gets its play_defeat time. check(not bool(arena.finished), "19: the fight is not finished until the outro is over") + check(StringName(arena.cinematic_mode) == &"defeat_full" and is_equal_approx(float(arena.cinematic_seconds), 0.2), + "19: the kill opens a %.2f s %s cinematic, expected a 0.20 s defeat_full" + % [float(arena.cinematic_seconds), arena.cinematic_mode]) arena.tick(0.1) check(not bool(arena.finished), "19: the outro is still running at 0.1 s") arena.tick(0.15) @@ -1800,7 +1824,7 @@ func _test_boss_fight() -> void: _free_rig(rig) # Resisted phases: the tool is still collected and still paid for, it just deals nothing. - var hydra_rig := _boss_rig({"boss_intro_seconds": 0.0, "boss_outro_seconds": 0.0}) + var hydra_rig := _boss_rig({"boss_intro_seconds": 0.0, "boss_defeat_seconds": 0.0, "boss_outro_seconds": 0.0}) var hydra: Node3D = hydra_rig["arena"] var hydra_events: Dictionary = hydra_rig["events"] var hydra_score: Node = hydra_rig["score"] @@ -1832,6 +1856,7 @@ func _test_boss_fight() -> void: func _test_boss_timeout() -> void: var rig := _boss_rig({ "boss_intro_seconds": 0.0, + "boss_defeat_seconds": 0.0, "boss_outro_seconds": 0.0, "boss_timeout_seconds": 1.0 }) @@ -1947,6 +1972,7 @@ func _test_caught_mid_boss() -> void: "stage_drain_timeout": 0.2, "turn_seconds": 0.2, "boss_intro_seconds": 0.0, + "boss_defeat_seconds": 0.0, "boss_outro_seconds": 0.0, "stage_clear_seconds": 0.5 }, true, true) @@ -2020,6 +2046,7 @@ func _checkpoint_overrides() -> Dictionary: "stage_drain_timeout": 0.2, "turn_seconds": 0.2, "boss_intro_seconds": 0.0, + "boss_defeat_seconds": 0.0, "boss_outro_seconds": 0.0, "stage_clear_seconds": 0.5 } @@ -2245,7 +2272,7 @@ func _test_boss_checkpoint() -> void: ## The fight seed is a function of the run seed and the stage alone (contract §11.13): stage 2 reached ## after a whole stage-1 fight draws the same lane and tool streams as a checkpoint retry into stage 2. func _test_boss_stage_seed() -> void: - var overrides := {"boss_intro_seconds": 0.0, "boss_outro_seconds": 0.0} + var overrides := {"boss_intro_seconds": 0.0, "boss_defeat_seconds": 0.0, "boss_outro_seconds": 0.0} var seed := 4242 var played := _boss_rig(overrides) var retried := _boss_rig(overrides) @@ -2480,8 +2507,12 @@ func _test_boss_intro() -> void: var arena: Node3D = rig["arena"] var events: Dictionary = rig["events"] arena.start(1) - check(events["boss_log"] == [&"intro", &"spawned"], - "28: the entrance card precedes the boss, got %s" % [events["boss_log"]]) + # Contract 2.6 §11.14: the intro cinematic opens between the card and the boss. + check(events["boss_log"] == [&"intro", &"cinematic", &"spawned"], + "28: the entrance card and its cinematic precede the boss, got %s" % [events["boss_log"]]) + check(events["boss_cinematic"].size() == 1 and StringName(events["boss_cinematic"][0][1]) == &"intro_full" + and is_equal_approx(float(events["boss_cinematic"][0][2]), intro_seconds), + "28: a first meeting plays the full %.1f s intro, got %s" % [intro_seconds, events["boss_cinematic"]]) check(events["boss_intro"].size() == 1, "28: boss_intro fires once per fight, got %d" % events["boss_intro"].size()) check(StringName(events["boss_intro"][0][0]) == &"flaky_test_swarm" @@ -2503,8 +2534,8 @@ func _test_boss_intro() -> void: arena.tick(TICK) check(not events["boss_attack_telegraph"].is_empty(), "28: the fight never opened fire after the %.1f s intro" % intro_seconds) - check(events["boss_log"] == [&"intro", &"spawned"], - "28: neither entrance signal repeats, got %s" % [events["boss_log"]]) + check(events["boss_log"] == [&"intro", &"cinematic", &"spawned", &"cinematic_ended"], + "28: no entrance signal repeats and the intro ends once, got %s" % [events["boss_log"]]) _free_rig(rig) # --- 30. Boss signature attacks: beat data, shot motion, live encounter (contract 2.4 §11.6) --- @@ -3609,8 +3640,10 @@ func _watch_stage(game: Node) -> Dictionary: ## proven rather than inferred from two independent counts (contract §11.4). "turn_log": [], "boss_intro": [], - ## Same idea for the fight: boss_intro must precede boss_spawned. + ## Same idea for the fight: boss_intro must precede boss_spawned, with the contract 2.6 + ## §11.14 cinematic between them. "boss_log": [], + "boss_cinematic": [], "boss_spawned": [], "boss_health_changed": [], "boss_phase_changed": [], @@ -3640,6 +3673,11 @@ func _watch_stage(game: Node) -> Dictionary: game.boss_spawned.connect(func(kind: StringName, hp: int) -> void: events["boss_spawned"].append([kind, hp]) events["boss_log"].append(&"spawned")) + game.boss_cinematic.connect(func(kind: StringName, mode: StringName, seconds: float) -> void: + events["boss_cinematic"].append([kind, mode, seconds]) + events["boss_log"].append(&"cinematic")) + game.boss_cinematic_ended.connect(func(_mode: StringName) -> void: + events["boss_log"].append(&"cinematic_ended")) game.boss_health_changed.connect(func(hp: int, max_hp: int) -> void: events["boss_health_changed"].append([hp, max_hp])) game.boss_phase_changed.connect(func(phase: int) -> void: diff --git a/hoppys-adventure-arcade/tests/runner_perf_probe.gd b/hoppys-adventure-arcade/tests/runner_perf_probe.gd index b5d41d20..a1f0d534 100644 --- a/hoppys-adventure-arcade/tests/runner_perf_probe.gd +++ b/hoppys-adventure-arcade/tests/runner_perf_probe.gd @@ -1,11 +1,17 @@ extends SceneTree ## Endless Runner frame-time probe. Rendered runs only (headless does not draw): ## Godot --always-on-top --path hoppys-adventure-arcade --script res://tests/runner_perf_probe.gd [-- seconds=60 seed=4242] +## Add -- boss_cinematic=true for the opt-in full intro and defeat capture across stages 1-3. ## Plays an autopilot run from the runner menu's Play route and attributes every slow frame to what ## happened on it (pickup, chunk spawn, turn, boss, stage). Writes a throwaway leaderboard only. const HITCH_MS := 25.0 +const BOSS_DRAW_BUDGET := 40 +const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") +const ToolDefs = preload("res://scenes/runner/powerups/tool_defs.gd") +const StageDefs = preload("res://scenes/runner/data/stage_defs.gd") var seconds := 60.0 var run_seed := 4242 +var boss_cinematic_probe := false var game: Node var events: Array[String] = [] var counts := {} @@ -17,6 +23,8 @@ func _initialize() -> void: var parts := arg.split("=") if parts.size() == 2 and parts[0] == "seconds": seconds = float(parts[1]) if parts.size() == 2 and parts[0] == "seed": run_seed = int(parts[1]) + if parts.size() == 2 and parts[0] == "boss_cinematic": + boss_cinematic_probe = parts[1].to_lower() in ["true", "1", "yes"] call_deferred("run") func _note(what: String) -> void: @@ -29,6 +37,9 @@ func run() -> void: quit(1) return root.size = Vector2i(1280, 720) + if boss_cinematic_probe: + await _run_boss_cinematic_probe() + return var load_start := Time.get_ticks_usec() game = (load("res://scenes/runner/runner_game.tscn") as PackedScene).instantiate() game.autostart = false @@ -81,3 +92,170 @@ func run() -> void: game.queue_free() await create_timer(0.3).timeout quit(0) + +## Explicit boss presentation mode. It keeps the real RunnerGame, BossEncounter, mounted visuals, +## FX, HUD and camera, but jumps directly to stages 1-3 so each cinematic can be measured promptly. +func _run_boss_cinematic_probe() -> void: + var load_start := Time.get_ticks_usec() + game = (load("res://scenes/runner/runner_game.tscn") as PackedScene).instantiate() + game.autostart = false + game.leaderboard_path = OS.get_temp_dir().path_join("runner_perf_probe_boss_cinematic_%d.cfg" % Time.get_ticks_msec()) + root.add_child(game) + await process_frame + print("RUNNER PERF BOSS CINEMATIC: scene ready in %.0f ms" % ((Time.get_ticks_usec() - load_start) / 1000.0)) + game.start_run(run_seed) + var manager: Node = game.stage_manager + var encounter: Node = game.boss_encounter + var failed := false + for stage in range(1, 4): + manager.stage_index = stage + manager.stage_name = StageDefs.boss_name(stage) + manager._publish() + manager._enter_boss() + var expected_kind: StringName = BossDefs.boss_kind(stage) + var kind: StringName = encounter.kind + var intro_mode: StringName = encounter.cinematic_mode + if kind != expected_kind or intro_mode != &"intro_full": + push_error("RUNNER PERF BOSS CINEMATIC: stage %d opened %s / %s, expected %s / intro_full" % [stage, kind, intro_mode, expected_kind]) + failed = true + var census := {"surfaces": 0, "multimesh_draws": 0, "multimesh_instances": 0, + "particle_draws": 0, "label_draws": 0, "geometry_peak": 0, "render_peak": 0, + "render_pair_samples": 0, "render_pair_invalid": 0, "boss_render_draws_peak": -1, + "boss_render_with_at_peak": -1, "boss_render_without_at_peak": -1} + var sampled_frames := 0 + var defeat_samples := 0 + var saw_defeat := false + var steps := 0 + while encounter.cinematic_mode in [&"intro_full", &"defeat_full"] and steps < 1200: + await _probe_frame(encounter, manager) + if encounter.cinematic_mode == &"defeat_full": saw_defeat = true + if saw_defeat: defeat_samples += 1 + _sample_render_and_geometry(census) + sampled_frames += 1 + steps += 1 + if encounter.cinematic_mode != &"": + push_error("RUNNER PERF BOSS CINEMATIC: stage %d intro did not end inside the frame guard" % stage) + failed = true + # Exercise actual accepted tool damage and the regular boss_defeated -> defeat cinematic path. + var hits := 0 + while int(encounter.hp) > 0 and hits < 32: + var accepted: Array[StringName] = BossDefs.accepted_tools(encounter.def, int(encounter.phase)) + var tool_kind: StringName = accepted[0] if not accepted.is_empty() else ToolDefs.kinds()[0] + encounter.apply_tool(tool_kind) + hits += 1 + if int(encounter.hp) > 0 or encounter.cinematic_mode != &"defeat_full": + push_error("RUNNER PERF BOSS CINEMATIC: stage %d did not reach a real defeat after %d accepted tools" % [stage, hits]) + failed = true + saw_defeat = encounter.cinematic_mode == &"defeat_full" + steps = 0 + while not bool(encounter.finished) and steps < 1200: + await _probe_frame(encounter, manager) + if encounter.cinematic_mode == &"defeat_full": saw_defeat = true + if saw_defeat: defeat_samples += 1 + _sample_render_and_geometry(census) + sampled_frames += 1 + steps += 1 + if not bool(encounter.finished): + push_error("RUNNER PERF BOSS CINEMATIC: stage %d defeat did not finish inside the frame guard" % stage) + failed = true + var boss_draws := int(census.geometry_peak) + var engine_draws := int(census.boss_render_draws_peak) + var passed := defeat_samples > 0 and boss_draws > 0 and boss_draws <= BOSS_DRAW_BUDGET \ + and int(census.render_pair_samples) > 0 and int(census.render_pair_invalid) == 0 \ + and engine_draws > 0 and engine_draws <= BOSS_DRAW_BUDGET + if not passed: + failed = true + var status := "PASS" if passed else "FAIL" + var report := "RUNNER PERF BOSS CINEMATIC: %s stage=%d kind=%s accepted_hits=%d frames=%d defeat_samples=%d render_calls_peak=%d engine_boss_draw_delta_peak=%d (with=%d without=%d pairs=%d invalid=%d) boss_fx_draws_peak=%d (surfaces=%d multimesh_draws=%d multimeshes=%d particle_draws=%d labels=%d)" % [ + status, stage, kind, hits, sampled_frames, defeat_samples, int(census.render_peak), engine_draws, + int(census.boss_render_with_at_peak), int(census.boss_render_without_at_peak), + int(census.render_pair_samples), int(census.render_pair_invalid), boss_draws, + int(census.surfaces), int(census.multimesh_draws), int(census.multimesh_instances), + int(census.particle_draws), int(census.label_draws)] + print(report) + game.queue_free() + await create_timer(0.3).timeout + print("RUNNER PERF BOSS CINEMATIC: %s (boss+FX budget <=%d per defeat; defeat samples required >0)" % ["FAIL" if failed else "PASS", BOSS_DRAW_BUDGET]) + quit(1 if failed else 0) + +func _probe_frame(encounter: Node, manager: Node) -> void: + if game.paused: game.resume() + await process_frame + # RunnerGame owns the regular physics tick. This keeps real pose/FX/audio updates and only + # explicitly refreshes the camera in case a host's render cadence misses a physics step. + if encounter.cinematic_mode != &"": + manager._apply_boss_camera() + var arena := game.get_node("World/TurnRig/BossArena") as Node + var fx := arena.get_node_or_null("BossFX") + if fx != null and fx.has_method("_process"): fx._process(0.0) + await RenderingServer.frame_post_draw + +func _sample_render_and_geometry(census: Dictionary) -> void: + var arena := game.get_node_or_null("World/TurnRig/BossArena") as Node3D + if arena == null: return + var current := {"surfaces": 0, "multimesh_draws": 0, "multimesh_instances": 0, + "particle_draws": 0, "label_draws": 0} + _count_boss_draws(arena, current) + var draws := _draw_total(current) + if draws > int(census.geometry_peak): + for key in current: census[key] = current[key] + census.geometry_peak = draws + var calls := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) + if calls > int(census.render_peak): census.render_peak = calls + _sample_boss_render_delta(arena, census) + +## Force three synchronous draws without advancing the scene tree, so the full-frame counter +## isolates the mounted BossArena contribution from the same rendered pose and simulation tick. +func _sample_boss_render_delta(arena: Node3D, census: Dictionary) -> void: + var originally_visible := arena.visible + if not originally_visible: + census.render_pair_invalid = int(census.render_pair_invalid) + 1 + return + arena.visible = true + RenderingServer.force_draw(false, 0.0) + var with_boss := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) + arena.visible = false + RenderingServer.force_draw(false, 0.0) + var without_boss := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) + arena.visible = originally_visible + RenderingServer.force_draw(false, 0.0) + var restored := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) + census.render_pair_samples = int(census.render_pair_samples) + 1 + var delta := with_boss - without_boss + if with_boss <= 0 or without_boss < 0 or delta < 0 or restored != with_boss: + census.render_pair_invalid = int(census.render_pair_invalid) + 1 + return + if delta > int(census.boss_render_draws_peak): + census.boss_render_draws_peak = delta + census.boss_render_with_at_peak = with_boss + census.boss_render_without_at_peak = without_boss + +func _count_boss_draws(node: Node, tally: Dictionary) -> void: + if (node is Node3D or node is CanvasItem) and not node.is_visible_in_tree(): return + if node is Label3D: + var label_3d := node as Label3D + if not label_3d.text.is_empty(): + tally.label_draws = int(tally.label_draws) + (2 if label_3d.outline_size > 0 else 1) + elif node is Label: + if not (node as Label).text.is_empty(): tally.label_draws = int(tally.label_draws) + 1 + elif node is MultiMeshInstance3D: + var instance := node as MultiMeshInstance3D + if instance.multimesh != null and instance.multimesh.mesh != null \ + and instance.multimesh.visible_instance_count != 0: + tally.multimesh_instances = int(tally.multimesh_instances) + 1 + tally.multimesh_draws = int(tally.multimesh_draws) + _surface_passes(instance, instance.multimesh.mesh.get_surface_count()) + elif node is GPUParticles3D: + var particles := node as GPUParticles3D + for pass_index in int(particles.get("draw_passes")): + var mesh := particles.get_draw_pass_mesh(pass_index) + if mesh != null: tally.particle_draws = int(tally.particle_draws) + _surface_passes(particles, mesh.get_surface_count()) + elif node is MeshInstance3D: + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh != null: tally.surfaces = int(tally.surfaces) + _surface_passes(mesh_instance, mesh_instance.mesh.get_surface_count()) + for child in node.get_children(): _count_boss_draws(child, tally) + +func _surface_passes(instance: GeometryInstance3D, surface_count: int) -> int: + return surface_count * (2 if instance.material_overlay != null else 1) + +func _draw_total(tally: Dictionary) -> int: + return int(tally.surfaces) + int(tally.multimesh_draws) + int(tally.particle_draws) + int(tally.label_draws) diff --git a/hoppys-adventure-arcade/tests/runner_scaffold_check.gd b/hoppys-adventure-arcade/tests/runner_scaffold_check.gd index 33660534..e151e022 100644 --- a/hoppys-adventure-arcade/tests/runner_scaffold_check.gd +++ b/hoppys-adventure-arcade/tests/runner_scaffold_check.gd @@ -48,14 +48,17 @@ const SIGNALS := [ "phase_changed", "stage_started", "stage_cleared", "turn_incoming", "turn_started", "turn_finished", "boss_intro", "boss_spawned", "boss_health_changed", "boss_phase_changed", "boss_attack_telegraph", "boss_projectile_fired", "boss_defeated", "boss_retreated", "tool_collected", - "pursuit_alert", "pursuer_distance_changed" + "pursuit_alert", "pursuer_distance_changed", + # Contract 2.6 §11.14. + "boss_cinematic", "boss_cinematic_skipped", "boss_cinematic_ended" ] -## Contract §4.3 and the §11.4 additions. +## Contract §4.3 and the §11.4 / §11.14 additions. const METHODS := [ "pause", "resume", "toggle_pause", "retry", "go_to_menu", "hit_stop", "submit_name", "get_leaderboard", "get_result", "get_active_effects", "start_run", "set_autopilot", - "get_stage_info", "get_boss_info", "track_z", "track_aabb", "on_tool_hit" + "get_stage_info", "get_boss_info", "track_z", "track_aabb", "on_tool_hit", "skip_boss_cinematic", + "can_skip_boss_cinematic" ] ## Contract §4.2 and the §11.4 additions. @@ -66,6 +69,7 @@ const PROPERTIES := [ "phase", "stage_index", "stage_name", "stage_distance", "stage_distance_target", "run_clock", "next_turn_direction", "boss", "boss_kind", "boss_hp", "boss_max_hp", "boss_phase", + "boss_cinematic_mode", "boss_cinematic_progress", "boss_time_factor", "stage_manager", "turn_rig", "boss_encounter" ] @@ -83,6 +87,7 @@ const OPTIONAL_HUD_MOUNTS := [ "res://scenes/runner/ui/boss_hud.tscn", "res://scenes/runner/ui/stage_banner.tscn", "res://scenes/runner/ui/stage_clear.tscn", + "res://scenes/runner/ui/boss_cinematic.tscn", "res://scenes/runner/ui/post_fx.tscn" ] diff --git a/hoppys-adventure-arcade/tests/runner_smoke.gd b/hoppys-adventure-arcade/tests/runner_smoke.gd index 0d5dbbe3..79cdf873 100644 --- a/hoppys-adventure-arcade/tests/runner_smoke.gd +++ b/hoppys-adventure-arcade/tests/runner_smoke.gd @@ -33,9 +33,10 @@ const STAGE_DISTANCE := 260.0 const BOSS_HP_SCALE := 0.4 const SMOKE_BOSS_HP := 40 ## Wall-clock ceilings for the two v2 phases. The stage seam is the long one: a 260 m run, the -## drain, the bend, the 3 s boss entrance, the fight and the card. Every wait below carries its own -## copy of this budget, and at TIME_SCALE 6 it buys 240 game seconds each — so the v2.1 entrance -## (boss_intro_seconds 1.2 -> 3.0, half a wall-clock second) needs no more room here. +## drain, the bend, the 5 s boss intro, the fight, the 3.6 s defeat and the card. Every wait below +## carries its own copy of this budget, and at TIME_SCALE 6 it buys 240 game seconds each — so the +## contract 2.6 cinematics (8.6 game seconds, under a wall-clock second and a half) need no more +## room here. const STAGE_WALL_TIMEOUT := 40.0 const BOSS_WALL_TIMEOUT := 20.0 @@ -187,6 +188,15 @@ func _stage_phase() -> void: check(game.boss_max_hp == SMOKE_BOSS_HP, "the smoke fight opened on %d hp, expected the scaled %d" % [game.boss_max_hp, SMOKE_BOSS_HP]) check(game.boss != null, "the fight has no boss node") + # Contract 2.6 §11.14: the run's first meeting plays the full intro, and it holds the player. + check(not events["boss_cinematic"].is_empty() and StringName(events["boss_cinematic"][0][0]) == &"intro_full" + and is_equal_approx(float(events["boss_cinematic"][0][1]), float(game.config.boss_intro_seconds)), + "stage 1 did not open on a %.1f s intro_full, got %s" + % [float(game.config.boss_intro_seconds), events["boss_cinematic"]]) + check(game.boss_cinematic_mode == &"intro_full" and bool(game.stage_manager.cinematic_hold) + and not bool(game.player.controls_enabled), + "the intro does not hold the player (mode %s, hold %s, controls %s)" + % [game.boss_cinematic_mode, game.stage_manager.cinematic_hold, game.player.controls_enabled]) check(await _wait_until(func() -> bool: return _damaging_tools() > 0, STAGE_WALL_TIMEOUT), "the greedy bot never landed a tool on the boss (hp %d/%d)" % [game.boss_hp, game.boss_max_hp]) @@ -215,6 +225,7 @@ func _stage_phase() -> void: # wait above can time out, and an empty array here would fault instead of failing the check. check(not events["stage_cleared"].is_empty() and int(events["stage_cleared"][0][1]) > 0, "the stage clear paid no bonus across %d stage_cleared event(s)" % events["stage_cleared"].size()) + _check_seam_cinematics() # The whole point of the fair bot: the shots are telegraphed and it answers every one of them. check(events["damage_taken"].is_empty(), @@ -803,6 +814,13 @@ func _watch() -> void: "boss_spawned": [], "boss_defeated": [], "boss_retreated": [], + ## Contract 2.6 §11.14: [mode, seconds, elapsed at the signal] per cinematic, and the elapsed of + ## each ending, so the seam can prove the defeat really held the stage clear. + "boss_cinematic": [], + "boss_cinematic_skipped": [], + "boss_cinematic_ended": [], + "boss_defeated_at": [], + "stage_cleared_at": [], "tool_collected": [], "caught": 0, "run_finished": 0, @@ -812,7 +830,8 @@ func _watch() -> void: events["stage_started"].append([index, name, speed]) _open_banner_window(index)) game.stage_cleared.connect(func(index: int, bonus: int) -> void: - events["stage_cleared"].append([index, bonus])) + events["stage_cleared"].append([index, bonus]) + events["stage_cleared_at"].append(float(game.elapsed))) game.turn_incoming.connect(func(direction: int, seconds: float) -> void: events["turn_incoming"].append([direction, seconds]) events["turn_log"].append(&"incoming")) @@ -824,7 +843,14 @@ func _watch() -> void: game.boss_spawned.connect(func(kind: StringName, hp: int) -> void: events["boss_spawned"].append([kind, hp])) game.boss_defeated.connect(func(kind: StringName, bonus: int) -> void: - events["boss_defeated"].append([kind, bonus])) + events["boss_defeated"].append([kind, bonus]) + events["boss_defeated_at"].append(float(game.elapsed))) + game.boss_cinematic.connect(func(_kind: StringName, mode: StringName, seconds: float) -> void: + events["boss_cinematic"].append([mode, seconds, float(game.elapsed)])) + game.boss_cinematic_skipped.connect(func(mode: StringName) -> void: + events["boss_cinematic_skipped"].append(mode)) + game.boss_cinematic_ended.connect(func(mode: StringName) -> void: + events["boss_cinematic_ended"].append([mode, float(game.elapsed)])) game.boss_retreated.connect(func(kind: StringName, bonus: int) -> void: events["boss_retreated"].append([kind, bonus])) game.tool_collected.connect(func(kind: StringName, damage: int) -> void: @@ -858,6 +884,45 @@ func _watch() -> void: game.leaderboard_saved.connect(func(_entries: Array, _rank: int) -> void: events["leaderboard_saved"] += 1) +## Contract 2.6 §11.14 across the stage-1 seam: the intro played its full length, the kill opened a +## defeat_full on the same tick, the stage clear waited that cinematic out, the bot never skipped, +## and the hold let go of the corridor speed. Times are game seconds off `elapsed`, so a slow frame +## cannot stretch them; two physics ticks of slack cover the tick boundary and the float countdown. +func _check_seam_cinematics() -> void: + var slack := 2.0 / 60.0 + 0.001 + var cinematics: Array = events["boss_cinematic"] + var ended: Array = events["boss_cinematic_ended"] + check(cinematics.size() >= 2 and ended.size() >= 2, + "the seam played %d cinematic(s) and ended %d, expected the intro and the defeat" + % [cinematics.size(), ended.size()]) + if cinematics.size() < 2 or ended.size() < 2: + return + var intro: Array = cinematics[0] + var intro_end: Array = ended[0] + check(StringName(intro_end[0]) == &"intro_full" + and absf(float(intro_end[1]) - float(intro[2]) - float(intro[1])) <= slack, + "the intro ran %.3f s, expected %.1f" % [float(intro_end[1]) - float(intro[2]), float(intro[1])]) + var defeat: Array = cinematics[1] + var defeat_seconds := float(game.config.boss_defeat_seconds) + check(StringName(defeat[0]) == &"defeat_full" and is_equal_approx(float(defeat[1]), defeat_seconds), + "the kill opened %s for %.2f s, expected defeat_full for %.2f" % [defeat[0], float(defeat[1]), defeat_seconds]) + var killed_at: Array = events["boss_defeated_at"] + var cleared_at: Array = events["stage_cleared_at"] + if killed_at.is_empty() or cleared_at.is_empty(): + return + check(is_equal_approx(float(defeat[2]), float(killed_at[0])), + "the defeat cinematic opened %.3f s after boss_defeated, expected the same tick" + % (float(defeat[2]) - float(killed_at[0]))) + var held := float(cleared_at[0]) - float(killed_at[0]) + check(held >= defeat_seconds - slack and held <= defeat_seconds + slack, + "the stage cleared %.3f s after the kill, expected the %.2f s defeat hold" % [held, defeat_seconds]) + check(events["boss_cinematic_skipped"].is_empty(), + "the autopilot skipped %s; nothing but a player press may skip" % [events["boss_cinematic_skipped"]]) + check(game.boss_cinematic_mode == &"" and not bool(game.stage_manager.cinematic_hold) + and is_equal_approx(float(game.speed_controller.cinematic_scale), 1.0), + "the hold outlived the fight (mode %s, hold %s, scale %.2f)" + % [game.boss_cinematic_mode, game.stage_manager.cinematic_hold, float(game.speed_controller.cinematic_scale)]) + ## Tools whose damage actually landed on the boss (a resisting phase reports 0). func _damaging_tools() -> int: var landed := 0 diff --git a/hoppys-adventure-arcade/tests/runner_visual_smoke.gd b/hoppys-adventure-arcade/tests/runner_visual_smoke.gd index 95f404ac..35bd5679 100644 --- a/hoppys-adventure-arcade/tests/runner_visual_smoke.gd +++ b/hoppys-adventure-arcade/tests/runner_visual_smoke.gd @@ -1,4 +1,5 @@ extends SceneTree +const BossBounds = preload("res://tests/boss_pose_bounds.gd") const TurnPreview = preload("res://scenes/runner/environment/dev/turn_preview.gd") const CorridorScript = preload("res://scenes/runner/environment/corridor.gd") const SignatureDefs = preload("res://scenes/runner/boss/boss_defs.gd") @@ -27,6 +28,14 @@ class LegacyPursuitGame extends Node: signal caught var paused := false +class CinematicAudioGame extends "res://scenes/runner/ui/dev/mock_runner_game.gd": + signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) + signal boss_cinematic_skipped(mode: StringName) + signal boss_cinematic_ended(mode: StringName) + var boss_cinematic_mode: StringName = &"" + var boss_cinematic_progress := 0.0 + var boss_time_factor := 1.0 + var failures := 0 var _logger := ErrorCapture.new() @@ -942,7 +951,7 @@ func check_runner_audio() -> void: const Mock = preload("res://scenes/runner/ui/dev/mock_runner_game.gd") var music := load("res://Assets/Backgrounds/cyberpunk-street-files/Assets/music/cyberpunk-street.ogg") as AudioStreamOggVorbis check(music != null and music.loop == true, "imported runner OGG loops before audio bind") - var mock := Mock.new() + var mock := CinematicAudioGame.new() mock.autoplay = false root.add_child(mock) var audio := RunnerAudio.instantiate() @@ -955,6 +964,7 @@ func check_runner_audio() -> void: peak[0] = maxi(peak[0], audio.active_voice_count()) check(audio.active_voice_count() <= 10, "audio hard ten voice cap") ) + mock.boss_intro.connect(func(kind, _name, seconds): mock.boss_cinematic.emit(kind, &"intro_full", seconds)) audio.bind(mock) audio.bind(mock) audio.set_process(false) @@ -965,7 +975,7 @@ func check_runner_audio() -> void: var service := root.get_node("AudioService") check(service.music.stream.resource_path == audio.MUSIC_PATH and service.music.stream.loop, "runner BGM loaded and looping") check(service.music.volume_db == -12.0, "runner BGM level") - check(Synth.RECIPES.size() == 32, "F10 has exactly 32 recipes") + check(Synth.RECIPES.size() == 37, "cinematic cues extend all 32 existing recipes") check(audio.HIGH_PRIORITY.has(&"alert_sting"), "alert sting is high priority") for kind: StringName in Synth.RECIPES: var stream := Synth.sound(kind) @@ -996,6 +1006,27 @@ func check_runner_audio() -> void: mock.advance_demo(0.1) audio._process(0.1) check(mock.state == &"finished", "audio demo completes") + # New contract 2.6 cues use the real progress feed, including a skip and seven bursts. + mock.state_changed.emit(&"running") + for mode in [&"intro_full", &"intro_short", &"defeat_full"]: + audio.one_shots.stop() + audio.one_shots.play() + audio._playback = audio.one_shots.get_stream_playback() + audio._voices.clear() + audio._voice_kinds.clear() + mock.boss_cinematic_progress = 0.0 + mock.boss_cinematic.emit(&"flaky_test_swarm", mode, 5.0) + for i in 20: + audio.one_shots.stop() + audio.one_shots.play() + audio._playback = audio.one_shots.get_stream_playback() + audio._voices.clear() + audio._voice_kinds.clear() + mock.boss_cinematic_progress = float(i) / 20 + audio._process(0.15) + await process_frame + if mode == &"intro_short": mock.boss_cinematic_skipped.emit(mode) + mock.boss_cinematic_ended.emit(mode) for kind: StringName in Synth.RECIPES: check(int(counts.get(kind, 0)) > 0, "demo played trigger: " + kind) check(counts.get(&"heartbeat", 0) == 1, "demo heartbeat starts exactly once across low progress ticks") @@ -1185,7 +1216,7 @@ func check_runner_audio() -> void: mock.paused_changed.emit(false) audio._process(2.0) check(is_equal_approx(service.music_volume_db, -12.0), "stage card releases hold") - print("RUNNER AUDIO: 32 recipes/triggers; demo heartbeat starts=1; peak voices=", peak[0], "; AudioService players=", service.get_child_count()) + print("RUNNER AUDIO: 37 recipes/triggers; demo heartbeat starts=1; peak voices=", peak[0], "; AudioService players=", service.get_child_count()) mock.free() await frames() check(is_equal_approx(service.music.pitch_scale, 1.0), "audio teardown resets pitch") @@ -1216,6 +1247,13 @@ func _force_loop_hit(game: Node) -> void: check(game.hit_manager.report_hit(hazard) == &"damaged", "full loop forced hazard applies damage") hazard.free() +func _remove_temporary_leaderboard(board_path: String) -> void: + for suffix in ["", ".tmp", ".bak"]: + var path: String = board_path + suffix + if FileAccess.file_exists(path): + check(DirAccess.remove_absolute(path) == OK, "temporary leaderboard cleanup: " + path) + check(not FileAccess.file_exists(path), "temporary leaderboard removed: " + path) + func check_runner_full_loop() -> void: var before := failures var router := root.get_node("AppRouter") @@ -1289,11 +1327,7 @@ func check_runner_full_loop() -> void: check(current_scene.name == &"MainMenu", "hub is MainMenu") check(audio.music.stream == audio.default_track and audio.music.playing, "hub restores default music") node_added.disconnect(_isolate_routed_game) - for suffix in ["", ".tmp", ".bak"]: - var path: String = loop_board_path + suffix - if FileAccess.file_exists(path): - check(DirAccess.remove_absolute(path) == OK, "temporary leaderboard cleanup") - check(not FileAccess.file_exists(loop_board_path), "temporary leaderboard removed") + _remove_temporary_leaderboard(loop_board_path) print("RUNNER FULL LOOP: ", "PASS" if before == failures else "FAIL", " (menu, play, hit, pursuit, %ss timer recovery, hit, hit, caught, finished, game over, Codex, retry, menu, hub, default music; isolated board removed)" % recovery_seconds) current_scene.free() await frames() @@ -1604,7 +1638,11 @@ func boss_geometry_bounds(visual: Node3D) -> AABB: var box: AABB = visual.global_transform.affine_inverse() * node.global_transform * pose * AABB(-Vector3.ONE * 0.5, Vector3.ONE) result = box if first else result.merge(box) first = false - else: + elif node is MeshInstance3D and node.skin != null: + var box: AABB = visual.global_transform.affine_inverse() * BossBounds.mesh_bounds(node) + result = box if first else result.merge(box) + first = false + elif not node is Label3D: var box: AABB = visual.global_transform.affine_inverse() * node.global_transform * node.get_aabb() result = box if first else result.merge(box) first = false @@ -1641,16 +1679,17 @@ func check_bosses() -> void: visual.sway_amplitude = 2.4 visual.sway_period = 3.2 visual.on_spawned() - check(visual._art.tag.text.replace("\n", " ") == visual.boss_name, "boss plate reads boss_name") - check(visual._art.tag.font_size >= 40 and visual._art.tag.position.y >= 3.15 and visual._art.tag.texture_filter == BaseMaterial3D.TEXTURE_FILTER_LINEAR, "gallery plate above body and linearly filtered") + check(visual._tag.text.replace("\n", " ") == visual.boss_name, "boss plate reads boss_name") + check(visual._tag.font_size >= 40 and visual._tag.position.y >= 3.15 and visual._tag.texture_filter == BaseMaterial3D.TEXTURE_FILTER_LINEAR, "gallery plate above body and linearly filtered") var count := visual.find_children("*", "", true, false).size() visual.on_spawned() check(count == visual.find_children("*", "", true, false).size(), "idempotent boss spawn " + kind) check(visual.process_mode == Node.PROCESS_MODE_PAUSABLE, "boss PAUSABLE " + kind) - visual.play_intro(3.0) + check(visual._model != null, "accepted GLB mounted " + kind) + visual.play_cinematic(&"intro_full", 3.0) for frame in 181: check_boss_envelope(visual, "%s intro frame %d" % [kind, frame], true) - if frame < 180: visual._process(1.0 / 60.0) + if frame < 180: visual.set_cinematic_progress(&"intro_full", float(frame + 1) / 180) visual.on_spawned() var boss_peak := 0 for phase in [1, 2, 3]: @@ -1700,12 +1739,14 @@ func check_bosses() -> void: for ending in [&"play_defeat", &"play_retreat"]: visual.on_spawned() visual.call(ending) + if ending == &"play_defeat": visual.play_cinematic(&"defeat_full", 3.6) for i in 20: - visual._process(0.045) + if ending == &"play_defeat": visual.set_cinematic_progress(&"defeat_full", float(i + 1) / 20) + else: visual._process(0.05) check_boss_envelope(visual, String(kind) + " " + String(ending)) boss_peak = maxi(boss_peak, hazard_draw_count(visual)) visual._process(0.1) - check(not visual._art.visible, "boss outro fits backend second") + check(not visual.is_visible_in_tree() or not visual._model.visible or float(visual._model.materials[0].get_shader_parameter("dissolve")) >= 0.999, "boss terminal hides or fully dissolves") check(boss_peak <= 40, "boss peak draw budget " + kind) peak = maxi(peak, boss_peak) print("BOSS DRAW CENSUS: ", kind, "=", boss_peak) @@ -1878,7 +1919,17 @@ func check_tools_and_projectiles() -> void: # Exercise the actual backend mount and signals without simulating collisions by hand. var game := (load("res://scenes/runner/runner_game.tscn") as PackedScene).instantiate() game.autostart = false + var board_path := "user://test_runner_boss_mount_%d.cfg" % OS.get_process_id() + game.leaderboard_path = board_path root.add_child(game) + # Keep teardown in the caller so a failed check or early return cannot bypass it. + _check_boss_mount_game(game) + game.free() + _remove_temporary_leaderboard(board_path) + await frames() + print("TOOLS AND PROJECTILES: ", "PASS" if failures == before else "FAIL") + +func _check_boss_mount_game(game: Node) -> void: var arena: Node = game.boss_encounter var boss_hud: Control for node in game.find_children("*", "Control", true, false): @@ -1888,24 +1939,21 @@ func check_tools_and_projectiles() -> void: for stage in [1, 2, 3]: arena.start(stage) check(arena.visual.get_script().resource_path.contains("boss/visuals/"), "backend mounts shipped boss visual") - check(boss_hud.intro_card.visible and not boss_hud.visible and boss_hud.intro_name.text == String(game.get_boss_info().name), "real facade intro precedes fight name plate") - check(arena.visual.intro_calls == 1 and arena.visual._art.state == &"intro", "real facade forwards entrance") - boss_hud._process(float(game.config.boss_intro_seconds)) - arena.visual._process(float(game.config.boss_intro_seconds)) + var cinematic: Node = game.get_node("HUD/BossCinematic") + check(cinematic.root.visible and not boss_hud.visible and cinematic.boss_name.text == String(game.get_boss_info().name).to_upper(), "real facade cinematic precedes HP plate") + check(arena.visual.intro_calls == 1 and arena.visual._state == &"intro", "encounter forwards one cinematic entrance") + arena.tick(float(game.config.boss_intro_seconds)) check(boss_hud.visible and boss_hud.title.text.contains(String(game.get_boss_info().name).to_upper()), "real fight name plate after intro") while arena.phase < 2: arena.apply_tool(&"unit_test") check(boss_hud.pips[1].filled, "real fight phase pip") var previous_hp: int = arena.hp - check(not arena.visual._art.tag.visible, "mounted boss HUD suppresses 3D name plate") + check(not arena.visual._tag.visible, "mounted boss HUD suppresses 3D name plate") if stage == 2: arena.apply_tool(&"review_comment") elif stage == 3: arena.apply_tool(&"unit_test") - if stage > 1: check(arena.hp == previous_hp and arena.visual._art.state == &"resist", "real phase resists wrong tool") + if stage > 1: check(arena.hp == previous_hp and arena.visual._model != null, "real phase resists wrong tool") arena.apply_tool(&"review_comment" if stage == 3 else &"unit_test") check(arena.hp < previous_hp, "real phase accepts correct tool") arena.abort() - game.free() - await frames() - print("TOOLS AND PROJECTILES: ", "PASS" if failures == before else "FAIL") func check_turn() -> void: var before := failures @@ -2141,71 +2189,57 @@ func check_turn_warning() -> void: func check_boss_intro() -> void: var before := failures - var mock := preload("res://scenes/runner/ui/dev/mock_runner_game.gd").new() - mock.autoplay = false - root.add_child(mock) - mock.start_run() - var audio := preload("res://scenes/runner/audio/runner_audio.tscn").instantiate() - mock.add_child(audio) - audio.bind(mock) - audio.set_process(false) - var overlay: Control = mock.boss_overlay - overlay.set_process(false) - var expected_taunts := { - &"flaky_test_swarm": "PASSED LOCALLY. FAILS ON CI.", - &"merge_conflict_hydra": "<<<<<<< HEAD \u2014 3 BRANCHES, SOON 4. ZERO AGREEMENT.", - &"legacy_monolith": "@deprecated SINCE 2011. STILL IN PRODUCTION.", - } - for kind: StringName in expected_taunts: - mock.boss_kind = kind - mock.mount_demo_boss() - var visual: Node3D = mock.boss.get_child(0) - visual.set_process(false) - mock.boss_hp = 140 - mock.boss_intro.emit(kind, "Test Boss", 3.0) - mock.boss_spawned.emit(kind, 140) - await frames() - check(overlay.intro_card.size.x <= 680.0 and overlay.intro_card.position.y >= 0.0 and overlay.intro_card.get_global_rect().end.y <= 130.0, "intro card stays in top band") - check(overlay.intro_name.get_theme_font_size("font_size") == 24, "intro name retains compact 24 unit layout") - check(overlay.intro_card.visible and overlay.intro_name.text == "Test Boss", "intro card shows facade name") - check(overlay.intro_taunt.text == expected_taunts[kind] and overlay.intro_taunt.visible_characters == 0, "correct typed taunt " + kind) - mock.stage_started.emit(2, &"Queued stage", 13.0) - check(not overlay.visible and not mock.stage_banner.visible, "intro suppresses HP and stage banner") - check(visual.intro_calls == 1 and visual._art.state == &"intro", "boss FX forwards optional entrance " + kind) - check(audio._boss_duck == -12.0, "intro heavier music duck survives spawned") - overlay._process(1.0) - visual._process(1.0) - if kind == &"flaky_test_swarm": - check(visual._art.batches[0].poses[0].origin.z < -1.0, "swarm tiles fly from vanishing point") - elif kind == &"merge_conflict_hydra": - check(visual._art.heads[0].position.y < 1.75 and visual._art.heads[2].scale.y < 1.0, "hydra rises and unfurls heads") - else: - check(visual._art.position.z < -10.0, "monolith approaches from far end") - check(overlay.intro_taunt.visible_characters == overlay.intro_taunt.text.length(), "taunt typed within one second") - mock.pause() - var age: float = overlay.intro_age - overlay._process(1.0) - audio._process(1.0) - check(overlay.intro_age == age and audio._intro_remaining == 3.0, "pause freezes intro card and duck") - visual.set_process(true) - paused = true - var visual_age: float = visual._art.age - for i in 4: await process_frame - check(visual._art.age == visual_age, "scene pause freezes boss entrance") - paused = false - visual.set_process(false) - mock.resume() - overlay._process(1.4) - check(overlay.intro_flash.color.a > 0.0 and overlay.intro_vignette.visible, "entrance impact uses screen flash and vignette") - overlay._process(0.6) - visual._process(2.0) - audio._process(3.0) - check(not overlay.intro_card.visible and overlay.visible, "HP enters only at end of intro") - check(visual._art.state == &"idle" and visual._art.position == Vector3.ZERO, "entrance restores idle exactly at duration") - check(audio._boss_duck == -4.0 and audio.active_voice_count() <= 10, "intro releases into boss duck with voice cap") - check_boss_envelope(visual, "post intro " + kind) - mock.boss_intro.emit(&"unknown", "Unknown Bug", 1.0) - check(overlay.intro_name.text == "Unknown Bug" and overlay.intro_taunt.text.is_empty(), "unknown boss name-only fallback") - mock.free() + var game: Node = load("res://scenes/runner/runner_game.tscn").instantiate() + game.autostart = false + var board_path := "user://test_runner_boss_intro_%d.cfg" % OS.get_process_id() + game.leaderboard_path = board_path + root.add_child(game) + # Keep teardown in the caller so a failed check or early return cannot bypass it. + _check_boss_intro_game(game) + game.free() + _remove_temporary_leaderboard(board_path) await frames() print("BOSS INTRO: ", "PASS" if before == failures else "FAIL") + +func _check_boss_intro_game(game: Node) -> void: + var copy := preload("res://scenes/runner/ui/boss_copy.gd") + game.start_run(721) + game.set_process(false) + game.set_physics_process(false) + var overlay: Node = game.get_node("HUD/BossCinematic") + var hud: Node = game.get_node("HUD/BossHud") + var audio: Node = game.get_node("Audio/RunnerAudio") + var arena: Node = game.boss_encounter + for stage in [1, 2, 3]: + arena.start(stage) + var kind: StringName = game.get_boss_info().kind + check(overlay.root.visible and not hud.visible, "cinematic replaces HP before spawn") + check(overlay.epithet.text == copy.epithet(kind).to_upper(), "single epithet source " + kind) + check(overlay.threat.text == copy.threat(kind), "single threat source " + kind) + check(arena.visual._model != null and arena.visual.intro_calls == 1, "GLB cinematic receives one start") + check(audio._boss_duck == -18.0, "cinematic duck survives spawn") + arena.tick(2.0) + game.stage_manager._publish() + overlay._process(0) + check(overlay.title_group.visible and overlay.title_group.scale.x > 1.7, "title slams during camera hold") + arena.tick(0.75) + game.stage_manager._publish() + overlay._process(0) + check(overlay.threat.visible_characters >= 22 and overlay.threat.visible_characters <= 23, "threat types at 40 characters per second") + game.pause() + var frozen: int = overlay.threat.visible_characters + var duck: float = audio._boss_duck + overlay._process(1) + audio._process(1) + check(overlay.threat.visible_characters == frozen and audio._boss_duck == duck, "pause freezes clock-driven UI and mix") + game.resume() + arena.tick(0.9) + game.stage_manager._publish() + overlay._process(0) + check(overlay.hints.visible and overlay.threat.visible, "hint row and complete threat coexist for the wide shot") + arena.tick(1.35) + game.stage_manager._publish() + check(not overlay.root.visible and hud.visible, "HP plate enters on cinematic end") + check(audio._boss_duck == -4.0, "intro releases into fight mix") + arena.abort() + check(copy.threat(&"unknown") == "Review required.", "unknown boss has safe fallback copy") diff --git a/hoppys-adventure-arcade/tests/runner_warmup_check.gd b/hoppys-adventure-arcade/tests/runner_warmup_check.gd index f3ae6e23..8dfc6d5e 100644 --- a/hoppys-adventure-arcade/tests/runner_warmup_check.gd +++ b/hoppys-adventure-arcade/tests/runner_warmup_check.gd @@ -88,6 +88,7 @@ func run() -> void: check(stage.get_child_count() == 2, "batch %s mounted into one group" % batch) stage.call("clear") check(stage.get_child_count() == 1, "batch %s cleared" % batch) + check_boss_scenes_retained(GpuWarmup, "after clearing all warm-up batches") var expected := {"pickup": 20, "burst": 2, "hazard": 42, "boss": 12, "boss_fx": 5, "boss_marks": 1, "boss_projectile": 8, "projectile_swatch": 13, "pursuer": 2, "hoppy": 8, "canvas": 1} for category in expected: @@ -111,10 +112,28 @@ func run() -> void: game.free() var Cleanup: Script = load("res://scenes/runner/environment/hifi/fixture_cleanup.gd") await Cleanup.call("release", self, [] as Array[Node]) + GpuWarmup.call("release") + check((GpuWarmup.get("retained") as Dictionary).is_empty(), "GPU warm-up release drops retained boss scenes and draw materials") if failures == 0: print("RUNNER WARMUP: PASS (%d checks)" % checks) else: print("RUNNER WARMUP: FAIL (%d failures / %d checks)" % [failures, checks]) quit(0 if failures == 0 else 2) +func check_boss_scenes_retained(GpuWarmup: Script, context: String) -> void: + var retained: Dictionary = GpuWarmup.get("retained") + var BossDefs: Script = load("res://scenes/runner/boss/boss_defs.gd") + for slot: Dictionary in BossDefs.get("SLOTS"): + var kind: StringName = slot.get("kind", &"") + var visual_path := "res://scenes/runner/boss/visuals/%s_visual.tscn" % String(kind) + var visual_scene := load(visual_path) as PackedScene + check(visual_scene != null, "%s loads boss visual scene: %s" % [context, visual_path]) + if visual_scene == null: + continue + var visual := visual_scene.instantiate() + var model_path: String = visual.get_script().get_script_constant_map().get("MODEL_PATH", "") + visual.free() + var model := load(model_path) as PackedScene if not model_path.is_empty() else null + check(model != null and retained.has(model), "%s retains imported boss model scene: %s" % [context, model_path]) + ## The scene at bind time (graybox detached, as run() does), with the live Hoppy dressed and the ## corridor posed mid-turn, plus every batch the stage mounts. Also the explicit C1/C2/C4 checks. func warm_census(game: Node, GpuWarmup: Script) -> Dictionary: diff --git a/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py b/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py new file mode 100644 index 00000000..03abf461 --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/boss_kit.py @@ -0,0 +1,431 @@ +"""Shared runner boss pipeline. Geometry and poses use Godot metres (x, y up, z). + +Recipes construct BossBuilder, add bones and weighted Parts, attach all sockets, +bake_actions(durations, pose_callback), then export(out_dir). See build_kit_selftest.py. +Part / xform / linear and procedural finishes reuse the props kit; GLB images and +ORM use glb_atlas. Seeded recipes are stable, but Blender's UV solver can vary bytes. +""" +import hashlib +import json +import os +from pathlib import Path +import random +import re +import shutil +import struct +import sys +import tempfile + +import bpy +from mathutils import Matrix, Vector + +HERE = Path(__file__).resolve().parent +DEFAULT_ASSET_DIR = HERE.parent.parent.parent / 'Assets' / 'runner' / 'bosses' +sys.path.insert(0, str(HERE.parent)) +sys.path.insert(0, str(HERE.parent / 'props')) +import props_kit as P +from props_kit import Part as Part, linear, xform, G2B, B2G +from glb_atlas import consolidate_atlases + +SEED = 73019 +RECIPE_REVISION = 2 +BUDGET = json.loads((HERE / 'bosses_budget.json').read_text()) +ACTIONS = tuple(BUDGET['actions']) +SOCKETS = tuple(BUDGET['sockets']) +FPS = BUDGET['animation_sample_rate'] +PALETTE = {name: linear(value) for name, value in { + 'navy': '101d2d', 'slate': '435464', 'cream': 'fff0d3', 'mint': '79e0c4', + 'matrix': '63f9b2', 'orange': 'f78949', 'danger': 'ff4d3d', 'lilac': 'a99bff'}.items()} + + +def log(message): + print('[boss-kit] ' + message, flush=True) + + +def require(condition, message): + if not condition: + raise RuntimeError('[boss-kit] ' + message) + + +def xyz(value): + return Vector((value[0], -value[2], value[1])) + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def read_glb(path): + raw = Path(path).read_bytes() + require(len(raw) >= 20, 'Empty GLB') + magic, version, total = struct.unpack_from(' 1e-5, 'Zero-length bone: ' + name) + bpy.ops.object.select_all(action='DESELECT') + self.rig.select_set(True) + bpy.context.view_layer.objects.active = self.rig + bpy.ops.object.mode_set(mode='EDIT') + b = self.rig.data.edit_bones.new(name) + b.head, b.tail = xyz(head), xyz(tail) + if parent: + require(parent in self.rig.data.edit_bones, 'Missing parent bone: ' + parent) + b.parent = self.rig.data.edit_bones[parent] + bpy.ops.object.mode_set(mode='OBJECT') + return name + + def add_part(self, part, bone): + """Rigid weight by default. Refine obj.vertex_groups before export for soft joints.""" + require(bone in self.rig.data.bones, 'Missing part bone: ' + bone) + obj = part.to_object(self.scene.collection, self.finishes) + obj.vertex_groups.new(name=bone).add(list(range(len(obj.data.vertices))), 1.0, 'REPLACE') + obj.parent = self.rig + mod = obj.modifiers.new('Boss skin', 'ARMATURE') + mod.object = self.rig + self.objects.append(obj) + return obj + + def socket(self, name, bone, position): + """Attach at a Godot-frame rest position, then follow the named animated bone.""" + require(name in SOCKETS and name not in self.sockets, 'Invalid/duplicate socket: ' + name) + require(bone in self.rig.data.bones, 'Missing socket bone: ' + bone) + obj = bpy.data.objects.new(name, None) + self.scene.collection.objects.link(obj) + obj.empty_display_type = 'PLAIN_AXES' + obj.empty_display_size = 0.12 + obj.parent, obj.parent_type, obj.parent_bone = self.rig, 'BONE', bone + bpy.context.view_layer.update() + obj.matrix_world = Matrix.Translation(xyz(position)) + self.sockets[name] = obj + return obj + + def pose(self, bone, location=(0, 0, 0), rotation=(0, 0, 0), scale=(1, 1, 1)): + """Rest-relative pose using Godot axes; rotation in radians, scale in Godot axes.""" + p = self.rig.pose.bones[bone] + rest = p.bone.matrix_local.to_3x3().to_4x4() + delta = G2B @ xform(location, rotation, scale) @ B2G + p.matrix_basis = rest.inverted() @ delta @ rest + + def bake_actions(self, durations, pose_callback): + """Call pose_callback(builder, action, normalized_time) at every 120 Hz key.""" + require(set(durations) == set(ACTIONS), 'All eight standard actions are required') + self.durations = dict(durations) + for name in ACTIONS: + frames = round(durations[name] * FPS) + require(frames > 0 and abs(frames / FPS - durations[name]) < 1e-6, + 'Action duration must land on a 120 Hz frame: ' + name) + action = bpy.data.actions.new(name) + self.rig.animation_data.action = action + for frame in range(frames + 1): + self.scene.frame_set(frame) + for p in self.rig.pose.bones: + p.rotation_mode = 'QUATERNION' + p.matrix_basis = Matrix.Identity(4) + pose_callback(self, name, frame / frames) + for p in self.rig.pose.bones: + p.keyframe_insert('location', frame=frame, group=p.name) + p.keyframe_insert('rotation_quaternion', frame=frame, group=p.name) + p.keyframe_insert('scale', frame=frame, group=p.name) + for layer in action.layers: + for strip in layer.strips: + for bag in strip.channelbags: + for curve in bag.fcurves: + for key in curve.keyframe_points: + key.interpolation = 'LINEAR' + require(len(action.slots) == 1, 'Expected one rig action slot: ' + name) + self.rig.animation_data.action = None + track = self.rig.animation_data.nla_tracks.new() + track.name = name + strip = track.strips.new(name, 0, action) + strip.action_slot = action.slots[0] + strip.action_frame_start, strip.action_frame_end = 0, frames + track.mute = True + action['loop'] = name == 'idle' + self.reset_pose() + + def reset_pose(self): + self.rig.animation_data.action = None + for p in self.rig.pose.bones: + p.matrix_basis = Matrix.Identity(4) + self.scene.frame_set(0) + bpy.context.view_layer.update() + + def _join(self): + require(bool(self.objects), 'Boss has no geometry') + bpy.ops.object.select_all(action='DESELECT') + for obj in self.objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = self.objects[0] + bpy.ops.object.join() + obj = bpy.context.view_layer.objects.active + obj.name = self.kind + '__Body' + obj.data.name = obj.name + self.objects = [obj] + return obj + + def _bake(self, obj, out_dir): + P.pack_uvs([obj]) + written, _images = P.bake_channels(self.scene, [obj], self.kind, self.resolution, out_dir, log) + mat = bpy.data.materials.new(self.kind + '_pbr') + nodes, links = mat.node_tree.nodes, mat.node_tree.links + p = nodes.get('Principled BSDF') + for channel, path in written.items(): + image = bpy.data.images.get(self.kind + '_' + channel) + if image is None: + image = bpy.data.images.load(str(path), check_existing=False) + # Godot prefixes extracted images with the GLB basename. Match it once, + # so importing reuses these maps instead of creating a second loose set. + image.name = 'surface_' + channel + target = out_dir / (self.kind + '_surface_' + channel + '.png') + path.replace(target) + written[channel] = target + image.filepath_raw = str(target) + image.colorspace_settings.name = 'sRGB' if channel in ('albedo', 'emission') else 'Non-Color' + image.pack() + tex = nodes.new('ShaderNodeTexImage') + tex.image = image + if channel == 'albedo': + links.new(tex.outputs['Color'], p.inputs['Base Color']) + elif channel == 'emission': + links.new(tex.outputs['Color'], p.inputs['Emission Color']) + p.inputs['Emission Strength'].default_value = 1.0 + elif channel == 'normal': + normal = nodes.new('ShaderNodeNormalMap') + links.new(tex.outputs['Color'], normal.inputs['Color']) + links.new(normal.outputs[0], p.inputs['Normal']) + else: + sep = nodes.new('ShaderNodeSeparateColor') + links.new(tex.outputs['Color'], sep.inputs[0]) + links.new(sep.outputs['Green'], p.inputs['Roughness']) + links.new(sep.outputs['Blue'], p.inputs['Metallic']) + obj.data.materials.clear() + obj.data.materials.append(mat) + for polygon in obj.data.polygons: + polygon.material_index = 0 + return written + + def export(self, out_dir, report_metadata=None): + """Validate a staged build before publishing //.*.""" + target = Path(out_dir).resolve() / self.kind + target.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.' + self.kind + '-', dir=target.parent) as temporary: + staged = Path(temporary) + # Keep Godot's UIDs and import settings when replacing an existing asset. + for existing in target.glob(self.kind + '*.import'): + shutil.copy2(existing, staged / existing.name) + report = self._export_staged(staged, report_metadata or {}) + target.mkdir(exist_ok=True) + # All baking, encoding, metadata and checks succeeded. Publish the report last. + for path in sorted(staged.iterdir(), key=lambda p: (p.suffix == '.json', p.name)): + os.replace(path, target / path.name) + log('EXPORT OK kind=%s tris=%d materials=%d bones=%d surfaces=%d actions=%d sockets=%d' % + (self.kind, report['triangles'], report['materials'], report['bones'], report['surfaces'], + len(ACTIONS), len(SOCKETS))) + return report + + def _export_staged(self, out_dir, report_metadata): + require(set(self.sockets) == set(SOCKETS), 'All ten sockets are required') + require(set(self.durations) == set(ACTIONS), 'Bake actions before exporting') + self.reset_pose() + obj = self._join() + textures = {} if self.quick else self._bake(obj, out_dir) + path = out_dir / (self.kind + '.glb') + bpy.ops.object.select_all(action='DESELECT') + for item in [self.rig, obj, *self.sockets.values()]: + item.select_set(True) + bpy.context.view_layer.objects.active = self.rig + result = bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True, + export_animations=True, export_animation_mode='ACTIONS', export_frame_range=False, + export_force_sampling=True, export_extras=True, export_yup=True, export_image_format='AUTO') + require('FINISHED' in result and path.is_file(), 'GLB export failed') + payload, chunks = read_glb(path) + # Blender names embedded images from their filepath, ignoring datablock names. + # Godot adds the model basename on extraction, so keep only the atlas suffix. + for image in payload.get('images', []): + prefix = self.kind + '_' + if image.get('name', '').startswith(prefix): + image['name'] = image['name'][len(prefix):] + binary = next(data for kind, data in chunks if kind == 0x004e4942) + images = consolidate_atlases(payload, binary) + encode_rest_positions(payload, chunks) + animations = [a.get('name') for a in payload.get('animations', [])] + require(len(animations) == len(ACTIONS) and set(animations) == set(ACTIONS), + 'Exported actions differ: ' + str(animations)) + require(len(payload.get('skins', [])) == 1, 'Expected one skin') + require(len(payload.get('meshes', [])) == 1, 'Expected one merged skinned mesh') + nodes = payload['nodes'] + for name in SOCKETS: + require(sum(node.get('name') == name for node in nodes) == 1, 'Missing/duplicate socket: ' + name) + primitives = [p for mesh in payload['meshes'] for p in mesh['primitives']] + triangles = 0 + for primitive in primitives: + require(primitive.get('mode', 4) == 4, 'Expected triangles') + require('JOINTS_0' in primitive['attributes'] and 'WEIGHTS_0' in primitive['attributes'], + 'Unskinned primitive') + count = payload['accessors'][primitive['indices']]['count'] + require(count % 3 == 0, 'Invalid index count') + triangles += count // 3 + census = dict(triangles=triangles, materials=len(payload.get('materials', [])), + bones=len(payload['skins'][0]['joints']), surfaces=len(primitives)) + limits = {key: cap for key, cap in BUDGET['limits'].items() + if not (self.quick and key == 'materials')} + for key, cap in limits.items(): + require(0 < census[key] <= cap, '%s budget exceeded: %s > %s' % (key, census[key], cap)) + require(self.quick or len(images) == 4, 'Baked GLB must carry albedo, ORM, normal, emission') + # Include every shared source that can change geometry, shading or the export census. + sources = [self.recipe_path, Path(__file__), HERE / 'bosses_budget.json', + HERE.parent / 'glb_atlas.py', HERE.parent / 'props' / 'props_kit.py'] + hashes = {str(p.relative_to(HERE.parent.parent.parent)): sha(p) for p in sources} + provenance = dict(seed=self.seed, recipe_revision=self.revision, kit_revision=RECIPE_REVISION, + source_hashes=hashes, kind=self.kind, + rest_position_encoding='TEXCOORD_1.xy,TEXCOORD_2.x;TEXCOORD_2.y=1') + require(not (set(provenance) | set(census)) & set(report_metadata), 'Reserved report metadata key') + provenance.update(report_metadata) + payload['asset']['extras'] = provenance + write_glb(path, payload, chunks) + report = dict(**provenance, **census, blender=bpy.app.version_string, + quick=self.quick, animation_sample_rate=FPS, actions=list(ACTIONS), + sockets=list(SOCKETS), bone_names=[b.name for b in self.rig.data.bones], + action_seconds=self.durations, budget=limits, image_report=images, + textures={k: {'file': p.name, 'sha256': sha(p)} for k, p in textures.items()}, + glb={'file': path.name, 'sha256': sha(path), 'bytes': path.stat().st_size}) + path.with_suffix('.build.json').write_text(json.dumps(report, indent=2, sort_keys=True) + '\n') + # Supply import settings before the editor's first scan. Existing imports keep their UID. + import_path = Path(str(path) + '.import') + if not import_path.exists(): + import_path.write_text('[remap]\n\nimporter="scene"\nimporter_version=1\ntype="PackedScene"\n\n[params]\n\n' + 'animation/import=true\nanimation/fps=120\nanimation/trimming=false\n' + 'animation/remove_immutable_tracks=true\nanimation/import_rest_as_RESET=false\n' + 'skins/use_named_skins=true\n_subresources={"nodes": {"PATH:AnimationPlayer": {"optimizer/enabled": false}}}\n') + # Float rest coordinates must not be quantized by Godot's mesh compression. + import_text = import_path.read_text() + setting = 'meshes/force_disable_compression=true' + if re.search(r'^meshes/force_disable_compression=.*$', import_text, flags=re.MULTILINE): + import_text = re.sub(r'^meshes/force_disable_compression=.*$', setting, import_text, flags=re.MULTILINE) + else: + import_text += setting + '\n' + import_path.write_text(import_text) + for texture_path in textures.values(): + texture_import = Path(str(texture_path) + '.import') + text = texture_import.read_text() if texture_import.exists() else ( + '[remap]\n\nimporter="texture"\ntype="CompressedTexture2D"\n\n[params]\n\n') + # Compatibility uses lossless atlases, including after the 3D-detection pass. + for key, value in {'compress/mode': '0', 'detect_3d/compress_to': '0', + 'mipmaps/generate': 'true'}.items(): + pattern = r'^' + re.escape(key) + r'=.*$' + if re.search(pattern, text, flags=re.MULTILINE): + text = re.sub(pattern, key + '=' + value, text, flags=re.MULTILINE) + else: + text += key + '=' + value + '\n' + texture_import.write_text(text) + return report diff --git a/hoppys-adventure-arcade/tools/blender/bosses/bosses_budget.json b/hoppys-adventure-arcade/tools/blender/bosses/bosses_budget.json new file mode 100644 index 00000000..b32874ec --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/bosses_budget.json @@ -0,0 +1,9 @@ +{ + "schema": 1, + "animation_sample_rate": 120, + "atlas_resolution": 1024, + "limits": {"triangles": 18000, "materials": 4, "bones": 40, "surfaces": 6}, + "kinds": ["kit_selftest", "flaky_test_swarm", "merge_conflict_hydra", "legacy_monolith"], + "actions": ["intro", "idle", "telegraph", "attack", "hurt", "phase_change", "defeat", "retreat"], + "sockets": ["fx_core", "fx_0", "fx_1", "fx_2", "fx_3", "fx_4", "fx_5", "muzzle_0", "muzzle_1", "muzzle_2"] +} diff --git a/hoppys-adventure-arcade/tools/blender/bosses/build_flaky_test_swarm.py b/hoppys-adventure-arcade/tools/blender/bosses/build_flaky_test_swarm.py new file mode 100644 index 00000000..4ca60828 --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/build_flaky_test_swarm.py @@ -0,0 +1,307 @@ +"""Flaky Test Swarm: a CI queen and six reversible test-result worker drones. + +Original geometry, four source finishes, one baked skin. Godot metres, facing +Z. +The plate glyphs are geometry so face flips never swap a material at runtime. +""" +import argparse +import math +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, xform + +RECIPE_REVISION = 5 +TAU = math.tau +CENTRE_Y = 1.02 +DRONES = [(2.16, 1.28, .27), (.90, 2.36, -.31), + (-1.30, 2.25, .33), (-2.16, .81, -.32), + (-1.35, -.35, .18), (1.30, -.12, -.18)] +DRONE_SCALES = [.95, 1.07, .93, 1.05, .92, 1.10] + + +def smooth(a, b, t): + u = min(1.0, max(0.0, (t - a) / (b - a))) + return u * u * (3 - 2 * u) + + +def stroke(part, finish, a, b, width, depth=0.025, matrix=None): + mid = tuple((a[i] + b[i]) / 2 for i in range(3)) + length = math.hypot(b[0] - a[0], b[1] - a[1]) + transform = xform(mid, (0, 0, math.atan2(b[1] - a[1], b[0] - a[0]))) + part.box(finish, (length + width * .45, width, depth), + transform if matrix is None else matrix @ transform, bevel=width * .16, segments=1) + + +def lettering(part, text, centre, height, finish): + # Hand-drawn industrial stroke letters, no external font dependency. + glyphs = { + 'R': [[(0, 0), (0, 1), (.6, 1), (.7, .7), (.55, .5), (0, .5)], [( .38, .5), (.75, 0)]], + 'E': [[(.7, 1), (0, 1), (0, 0), (.7, 0)], [(0, .5), (.55, .5)]], + 'T': [[(0, 1), (.8, 1)], [(.4, 1), (.4, 0)]], + 'Y': [[(0, 1), (.4, .5), (.8, 1)], [(.4, .5), (.4, 0)]], + 'C': [[(.75, .95), (.1, .95), (0, .8), (0, .2), (.1, .05), (.75, .05)]], + 'I': [[(0, 1), (.7, 1)], [(.35, 1), (.35, 0)], [(0, 0), (.7, 0)]], + } + start = centre[0] - (len(text) - .25) * height * .5 + for i, char in enumerate(text): + for path in glyphs[char]: + for a, b in zip(path, path[1:]): + stroke(part, finish, + (start + (i + a[0]) * height, centre[1] + (a[1] - .5) * height, centre[2]), + (start + (i + b[0]) * height, centre[1] + (b[1] - .5) * height, centre[2]), + height * .13, .018) + + +def build_core(kit): + kit.bone('core', (0, CENTRE_Y, 0), (0, CENTRE_Y + .5, 0), 'root') + kit.bone('lens', (0, 1.20, .43), (0, 1.45, .43), 'core') + body = Part('CI_queen_chassis') + outline = [(-.64, -.86), (.64, -.86), (1.01, -.35), (.91, .55), + (.50, .92), (-.50, .92), (-.91, .55), (-1.01, -.35)] + body.prism('navy', outline, .91, xform((0, CENTRE_Y, -.02)), bevel=.11, segments=3) + body.tube('green', [(x, y + CENTRE_Y, .445) for x, y in outline + [outline[0]]], .017, sides=6) + body.box('shell', (1.26, .20, 1.02), xform((0, .28, -.02)), bevel=.06) + # Queen crown: a pair of swept antennae, repeated by the workers. + for side in [-1, 1]: + body.tube('navy', [(side * .43, 1.82, 0), (side * .65, 2.17, -.02), + (side * .58, 2.43, -.02)], .045, sides=8) + body.ellipsoid('green', (.092, .11, .075), xform((side * .58, 2.43, -.02)), u=12, v=6) + # Forward hooked mandibles frame the label instead of reading as a mouth. + body.prism('shell', [(side * .68, .69), (side * .94, .47), + (side * .88, -.17), (side * .55, -.37), + (side * .37, -.22), (side * .63, -.15), + (side * .67, .22), (side * .56, .35)], .14, + xform((0, 0, .46)), bevel=.027, segments=3) + stroke(body, 'red', (side * .80, -.16, .54), (side * .56, -.28, .54), .038, .018) + for j in range(3): + body.box('green', (.09, .16, .025), xform((side * (.61 + .115 * j), .74, .455)), bevel=.012) + body.cylinder('shell', .60, .10, xform((0, 1.22, .445), (math.pi / 2, 0, 0)), segments=24, bevel=.025) + body.cylinder('navy', .515, .035, xform((0, 1.22, .508), (math.pi / 2, 0, 0)), segments=24) + body.ring('green', .465, .037, xform((0, 1.22, .54), (math.pi / 2, 0, 0)), segments=24, sides=6) + # A cream label plate and dark engraved-looking strokes read in the close-up. + body.box('shell', (1.05, .28, .065), xform((0, .47, .477)), bevel=.025) + lettering(body, 'RETRY', (0, .47, .52), .174, 'navy') + body.box('navy', (.44, .21, .05), xform((0, 2.21, .49)), bevel=.025) + lettering(body, 'CI', (0, 2.21, .526), .16, 'green') + # Hidden behind the paired crown covers until the runtime phase split opens. + crack = [(-.02, 2.035, .465), (.055, 1.95, .465), (-.045, 1.87, .465), (.025, 1.77, .465)] + for a, b in zip(crack, crack[1:]): stroke(body, 'red', a, b, .073, .023) + kit.add_part(body, 'core') + lens = Part('opening_status_lens') + lens.ellipsoid('green', (.41, .41, .09), xform((0, 1.22, .51)), u=24, v=12) + lens.prism('navy', [(-.115, -.25), (.115, -.25), (.115, .25), (-.115, .25)], .027, + xform((0, 1.22, .606)), bevel=.025, segments=3) + lens.prism('red', [(-.012, -.22), (.040, -.14), (.035, .18), (0, .24), (-.04, .14), (-.035, -.18)], .021, + xform((0, 1.22, .628)), bevel=.008) + kit.add_part(lens, 'lens') + for side, suffix in [(-1, 'L'), (1, 'R')]: + bone = 'carapace_' + suffix + kit.bone(bone, (side * .54, 1.24, 0), (side * .54, 1.7, 0), 'core') + armor = Part('split_carapace_' + suffix) + armor.prism('shell', [(side * .13, 1.84), (side * .49, 2.02), + (side * .93, 1.66), (side * 1.03, 1.03), + (side * .74, .79), (side * .61, 1.59)], .28, + xform((0, 0, .27)), bevel=.06, segments=3) + armor.prism('shell', [(side * .005, 1.49), (side * .61, 1.71), + (side * .55, 1.89), (side * .015, 1.66)], .15, + xform((0, 0, .59)), bevel=.024, segments=3) + armor.box('shell', (.245, .38, .11), xform((side * .1225, 1.90, .49)), bevel=.012, segments=3) + stroke(armor, 'green', (side * .04, 1.655, .68), (side * .54, 1.835, .68), .024, .016) + stroke(armor, 'navy', (side * .65, 1.44, .422), (side * .76, 1.13, .422), .023, .018) + # One crisp status stripe on the shoulder, dark inset behind it. + stroke(armor, 'navy', (side * .64, 1.78, .425), (side * .82, 1.56, .425), .14, .03) + stroke(armor, 'green', (side * .66, 1.77, .447), (side * .79, 1.60, .447), .04, .025) + kit.add_part(armor, bone) + + +def build_drone(kit, index, centre): + bone = 'drone_%d' % index + kit.bone(bone, centre, (centre[0], centre[1] + .3, centre[2]), 'root') + part = Part('two_faced_worker_%d' % index) + size = DRONE_SCALES[index] + m = xform(centre, scale=(size,) * 3) + shell = [(-.34, -.34), (.34, -.34), (.47, -.14), (.40, .27), + (.22, .39), (-.22, .39), (-.40, .27), (-.47, -.14)] + part.prism('shell', shell, .40, m, bevel=.055, segments=3) + for side in [-1, 1]: + stroke(part, 'navy', (side * .20, .335, .23), (side * .35, .265, .23), .023, .018, m) + part.cylinder('navy', .095, .38, m @ xform((0, -.495, .107), (2.8, 0, 0)), + radius_top=0, segments=10) + part.cylinder('red', .033, .12, m @ xform((0, -.630, .155), (2.8, 0, 0)), + radius_top=0, segments=8) + part.box('navy', (.87, .10, .44), m @ xform((0, -.18, 0)), bevel=.025) + for side in [-1, 1]: + part.tube('navy', [(side * .18, .34, 0), (side * .28, .58, -.025)], + .024, matrix=m, sides=6) + part.cylinder('navy', .05, .10, m @ xform((side * .28, .625, -.025)), + radius_top=0, segments=6) + for front in [1, -1]: + face = m @ xform((0, 0, front * .23), (0, 0 if front == 1 else math.pi, 0)) + part.box('navy', (.70, .54, .04), face, bevel=.035) + # Large raised luminous glyph, with a thin matching status strip. + if front == 1: + stroke(part, 'green', (-.23, .00, .035), (-.065, -.15, .035), .093, .035, face) + stroke(part, 'green', (-.065, -.15, .035), (.23, .18, .035), .093, .035, face) + else: + stroke(part, 'red', (-.19, -.17, .035), (.19, .17, .035), .097, .035, face) + stroke(part, 'red', (-.19, .17, .035), (.19, -.17, .035), .097, .035, face) + part.box('green' if front == 1 else 'red', (.28, .026, .02), face @ xform((0, -.23, .03)), bevel=.005) + # Two un-bevelled visor wedges cost 24 triangles per face. Replacing + # antenna bobbles with six-sided spikes pays for this scowling brow. + for side in [-1, 1]: + part.prism('navy', [(side * .34, .24), (side * .025, .17), + (side * .025, .23), (side * .34, .30)], .038, + face @ xform((0, 0, .059))) + kit.add_part(part, bone) + for side, label in [(-1, 'L'), (1, 'R')]: + wing_bone = 'wing_%d_%s' % (index, label) + pivot = (centre[0] + side * .34 * size, centre[1] + .13 * size, centre[2] - .06 * size) + kit.bone(wing_bone, pivot, (pivot[0] + side * .3, pivot[1], pivot[2]), bone) + wing = Part(wing_bone) + # Rotate around the shoulder: down in the screen plane, swept back in Z. + swept = (m @ xform((side * .34, .13, -.04), + (0, side * math.radians(35), -side * math.radians(25))) + @ xform((-side * .34, -.13, 0))) + wing.prism('shell', [(side * .31, .08), (side * .88, .43), + (side * .78, -.11), (side * .66, -.17), + (side * .70, .07), (side * .40, -.07)], .065, + swept, bevel=.012, segments=2) + wing.prism('navy', [(side * .45, .11), (side * .77, .33), + (side * .71, .07), (side * .47, -.005)], .014, + swept @ xform((0, 0, .041)), bevel=.006) + stroke(wing, 'red', (side * .37, .13, .047), (side * .82, .395, .047), .033, .02, swept) + kit.add_part(wing, wing_bone) + kit.socket('fx_%d' % index, bone, (centre[0], centre[1], centre[2] + .29)) + + +def pose(kit, action, t): + pulse = math.sin(math.pi * t) + if action == 'intro': + kit.pose('core', location=(0, -.26 * (1 - smooth(.1, .7, t)), -.45 * (1 - t)), + rotation=(0, -.30 * (1 - t), 0)) + opened = .02 + .98 * smooth(.58, .85, t) + kit.pose('lens', scale=(opened, opened, 1)) + elif action == 'idle': + kit.pose('core', location=(0, .04 * math.sin(TAU * t), 0), rotation=(0, .06 * math.sin(TAU * t), 0)) + elif action == 'telegraph': + kit.pose('core', rotation=(0, .05 * math.sin(t * TAU * 3) * pulse, 0)) + kit.pose('lens', scale=(1, 1 - .20 * pulse, 1)) + elif action == 'attack': + wind = smooth(0, .20, t) * (1 - smooth(.20, .36, t)) + strike = smooth(.22, .36, t) * (1 - smooth(.50, 1, t)) + kit.pose('core', location=(0, .15 * wind - .10 * strike, -.18 * strike), rotation=(.30 * strike, 0, 0)) + elif action == 'hurt': + kit.pose('core', location=(.12 * math.sin(t * TAU * 2) * pulse, 0, -.20 * pulse), rotation=(.17 * pulse, 0, 0)) + elif action == 'phase_change': + kit.pose('core', rotation=(0, .10 * math.sin(t * TAU * 2) * pulse, .05 * math.sin(TAU * t))) + for side, label in [(-1, 'L'), (1, 'R')]: + kit.pose('carapace_' + label, location=(side * .11 * pulse, 0, 0), rotation=(0, side * .15 * pulse, 0)) + elif action == 'defeat': + drop = smooth(.05, .55, t) + kit.pose('core', location=(0, -.34 * drop, -.30 * (1 - drop)), rotation=(-.25 + .41 * drop, 0, -.24 * drop)) + kit.pose('lens', scale=(1, max(.04, .6 * (1 - drop)), 1)) + for side, label in [(-1, 'L'), (1, 'R')]: + kit.pose('carapace_' + label, location=(side * .16 * drop, -.12 * drop, 0), rotation=(0, 0, -side * .2 * drop)) + elif action == 'retreat': + kit.pose('root', location=(0, 2.2 * t * t, -4 * t * t), scale=(1 - .7 * t,) * 3) + for i, (x, y, z) in enumerate(DRONES): + phase = i * TAU / 6 + location = (0, 0, 0) + yaw = math.pi if i % 3 == 2 else 0 + roll = 0 + if action == 'intro': + assembly = 1 - smooth(.02 + i * .035, .63 + i * .025, t) + # Own-side radial starts avoid crossing through the queen. The + # envelope clamp below keeps every wing tip inside the corridor. + location = (x * .50 * assembly, (y - CENTRE_Y) * .50 * assembly, -.80 * assembly) + yaw = yaw * (1 - assembly) + TAU * assembly + hold_face = math.pi if i in [0, 2, 3, 5] else 0 + hold_lock = smooth(.80, 1.0, t) + yaw = yaw * (1 - hold_lock) + hold_face * hold_lock + roll = -.32 * assembly + elif action == 'idle': + a = .10 * math.sin(TAU * t) + location = (x * (math.cos(a) - 1) - (y - CENTRE_Y) * math.sin(a), + x * math.sin(a) + (y - CENTRE_Y) * (math.cos(a) - 1) + .035 * math.sin(TAU * t + phase), + .12 * math.sin(TAU * t + phase)) + # Two eased half-turns per loop, with a stable face between flips. + f = (t + i / 6) % 1 + yaw += math.pi * (smooth(.12, .20, f) + smooth(.64, .72, f)) + roll = .045 * math.sin(TAU * t + phase) + elif action == 'telegraph': + lock = smooth(.70, .92, t) + yaw = (TAU * 3 * smooth(0, .8, t) + math.pi) * (1 - lock) + 7 * math.pi * lock + location = (-x * .10 * t, -(y - CENTRE_Y) * .10 * t, .035 * pulse) + elif action == 'attack': + wind = smooth(0, .20, t) * (1 - smooth(.20, .36, t)) + strike = smooth(.22, .36 + .012 * i, t) * (1 - smooth(.50, 1, t)) + location = (x * .20 * wind - math.copysign(.46 * strike, x), + .15 * wind - .32 * strike, .22 * strike) + yaw = math.pi + roll = -.18 * math.copysign(strike, x) + elif action == 'hurt': + location = (x * .13 * pulse, (y - CENTRE_Y) * .14 * pulse, -.18 * pulse) + roll = math.copysign(.45 * pulse, x) + elif action == 'phase_change': + location = (-x * .15 * pulse, -(y - CENTRE_Y) * .12 * pulse, .08 * pulse) + yaw += TAU * smooth(.15, .85, t) + elif action == 'defeat': + fall = smooth(.05 + i * .008, .55, t) + a = 1.6 * fall + heap = [(2.48, .26, .23), (1.49, .34, -.28), (.50, .40, -.50), + (-2.48, .26, -.25), (-1.49, .34, .24), (-.50, .40, -.50)] + # The hit-stop starts in a struck pose; the staggered spiral then + # settles into six distinct slots before the radial burn consumes it. + target_x = (x * math.cos(a) - (y - CENTRE_Y) * math.sin(a)) * (1 - fall) + heap[i][0] * fall + target_y = (CENTRE_Y + x * math.sin(a) + (y - CENTRE_Y) * math.cos(a)) * (1 - fall) + heap[i][1] * fall + target_x += x * .25 * (1 - fall) + target_y += (y - CENTRE_Y) * .14 * (1 - fall) + location = (target_x - x, target_y - y, (heap[i][2] - z) * fall - .12 * (1 - fall)) + yaw = math.pi + roll = math.copysign(.50 + .70 * fall, x) + # Mount Y is 2.6 m. The contract floor is 1.6 m, so reserve 2 cm + # above local -1.0 for interpolation, including the cinematic idle tail. + # Keep the old-front + 0.4 m limit through edge-on face flips too. + size = DRONE_SCALES[i] + ex = size * (.90 * abs(math.cos(roll)) + .64 * abs(math.sin(roll))) + ey = size * (.90 * abs(math.sin(roll)) + .70 * abs(math.cos(roll))) + # Swept wings project behind the body, so a red-facing half-turn puts + # their full rear reach in front. Bound both faces, not only the plates. + ez = size * (.90 * abs(math.sin(yaw)) + .64 * abs(math.cos(yaw))) + edge = 3.48 if action == 'intro' else 3.26 + location = (min(edge - ex, max(-edge + ex, x + location[0])) - x, + min(3.38 - ey, max(-.98 + ey, y + location[1])) - y, + min(location[2], .84 - z - ez)) + kit.pose('drone_%d' % i, location=location, rotation=(0, yaw, roll)) + wing_beat = .14 * math.sin(TAU * (8 * t) + phase) if action not in ['defeat', 'retreat'] else -.30 * t + for side, label in [(-1, 'L'), (1, 'R')]: + kit.pose('wing_%d_%s' % (i, label), rotation=(0, side * wing_beat, 0)) + + +def build(out_dir, quick=False): + kit = BossBuilder('flaky_test_swarm', __file__, revision=RECIPE_REVISION, quick=quick) + kit.material('shell', 'fff0d3', roughness=.43, metallic=.15, grunge=.12) + kit.material('navy', '142735', finish='rubber', roughness=.47, metallic=.24) + kit.material('green', '63f9b2', finish='plain', roughness=.26, metallic=.12, + emission='63f9b2', emission_energy=.75, grunge=0) + kit.material('red', 'ff4d3d', finish='plain', roughness=.30, metallic=.10, + emission='ff4d3d', emission_energy=.85, grunge=0) + kit.bone('root', (0, 0, 0), (0, .3, 0)) + build_core(kit) + for index, centre in enumerate(DRONES): + build_drone(kit, index, centre) + kit.socket('fx_core', 'core', (0, 1.22, .53)) + for i, x in enumerate([-.38, 0, .38]): + kit.socket('muzzle_%d' % i, 'core', (x, .17, .55)) + kit.bake_actions(dict(intro=2.4, idle=4.0, telegraph=.8, attack=.55, + hurt=.4, phase_change=1.2, defeat=2.0, retreat=1.0), pose) + return kit.export(out_dir) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out-dir', type=Path, default=DEFAULT_ASSET_DIR) + parser.add_argument('--quick', action='store_true') + args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) + build(args.out_dir, args.quick) diff --git a/hoppys-adventure-arcade/tools/blender/bosses/build_kit_selftest.py b/hoppys-adventure-arcade/tools/blender/bosses/build_kit_selftest.py new file mode 100644 index 00000000..819de35a --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/build_kit_selftest.py @@ -0,0 +1,75 @@ +"""Small mechanical test dummy proving skinning, actions, sockets and baked ORM. + +Blender --background --python-exit-code 1 --python tools/blender/bosses/build_kit_selftest.py -- + --out-dir Assets/runner/bosses [--quick] +This is a pipeline fixture, not a final boss design. +""" +import argparse +import math +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, SOCKETS, xform + +RECIPE_REVISION = 2 + + +def pose(kit, action, t): + pulse = math.sin(t * math.pi) + if action == 'idle': + kit.pose('core', location=(0, math.sin(t * math.tau) * 0.06, 0)) + kit.pose('arm', rotation=(0, 0, math.sin(t * math.tau) * 0.16)) + elif action == 'intro': + kit.pose('root', scale=(0.65 + 0.35 * t,) * 3, rotation=(0, (1 - t) * 0.8, 0)) + elif action == 'telegraph': + kit.pose('arm', rotation=(0, 0, -t * 0.8)) + elif action == 'attack': + kit.pose('arm', rotation=(0, 0, -0.8 + pulse * 1.5)) + elif action == 'hurt': + kit.pose('core', rotation=(pulse * 0.2, 0, pulse * 0.18)) + elif action == 'phase_change': + kit.pose('core', rotation=(0, t * math.tau, 0), scale=(1 + pulse * 0.15,) * 3) + elif action == 'defeat': + # Collapse into fx_core. A matching orthographic camera can undo only the + # projection change, proving the dissolve mask is invariant under skinning. + shrink = 1 - t * 0.75 + kit.pose('root', location=(0, 0.78 * (1 - shrink), 0.4 * (1 - shrink)), + scale=(shrink,) * 3) + elif action == 'retreat': + kit.pose('root', location=(0, t * 0.4, -t * 1.0), scale=(1 - t * 0.4,) * 3) + + +def build(out_dir, quick=False): + kit = BossBuilder('kit_selftest', __file__, revision=RECIPE_REVISION, quick=quick, atlas_resolution=256) + kit.material('shell', '435464', roughness=0.42, metallic=0.65) + kit.material('core', '79e0c4', finish='plain', roughness=0.3, metallic=0.1, + emission='79e0c4', emission_energy=0.8, grunge=0.0) + kit.material('trim', 'fff0d3', roughness=0.55, metallic=0.15) + kit.bone('root', (0, 0, 0), (0, 0.2, 0)) + kit.bone('core', (0, 0.2, 0), (0, 1.1, 0), 'root') + kit.bone('arm', (0.5, 0.7, 0), (1.1, 0.7, 0), 'core') + body = Part('dummy_core') + body.box('shell', (1.0, 1.15, 0.65), xform((0, 0.65, 0)), bevel=0.10) + body.ellipsoid('core', (0.29, 0.29, 0.1), xform((0, 0.78, 0.34)), u=16, v=8) + body.box('trim', (0.6, 0.08, 0.04), xform((0, 0.30, 0.34)), bevel=0.02) + kit.add_part(body, 'core') + arm = Part('dummy_arm') + arm.box('trim', (0.72, 0.18, 0.22), xform((0.87, 0.7, 0)), bevel=0.04) + arm.ellipsoid('core', (0.13, 0.13, 0.13), xform((1.24, 0.7, 0)), u=12, v=6) + kit.add_part(arm, 'arm') + for i, name in enumerate(SOCKETS): + position = (0, 0.78, 0.4) if name == 'fx_core' else ((i % 3 - 1) * 0.38, 0.28 + (i // 3) * 0.27, 0.4) + kit.socket(name, 'arm' if name.startswith('muzzle') else 'core', + (1.24, 0.7, (i - 8) * 0.08) if name.startswith('muzzle') else position) + kit.bake_actions(dict(intro=1.0, idle=2.0, telegraph=0.6, attack=0.5, hurt=0.3, + phase_change=1.0, defeat=1.5, retreat=1.0), pose) + return kit.export(out_dir) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out-dir', type=Path, default=DEFAULT_ASSET_DIR) + parser.add_argument('--quick', action='store_true') + args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) + build(args.out_dir, args.quick) diff --git a/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py b/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py new file mode 100644 index 00000000..4b081dc1 --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/build_legacy_monolith.py @@ -0,0 +1,341 @@ +"""Legacy Monolith: a load-bearing stack of obsolete server racks, cast as a golem. +Original geometry in Godot metres. Four finish classes bake to one skinned surface. +""" +import argparse +import math +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, xform + +RECIPE_REVISION = 3 +RACK_Y = (-0.47, 0.34, 1.32, 2.68) +STATUS_Y = (-0.14, 0.68, 2.105, 3.29) +STATUS_Z = (0.86, 0.84, 0.845, 0.78) +DURATIONS = dict(intro=2.4, idle=4.0, telegraph=0.8, attack=0.8, hurt=0.4, + phase_change=1.2, defeat=2.4, retreat=1.6) +# Original 5x7 stencil cells, extruded with the plate, no font or downloaded artwork. +GLYPHS = { + '@': ['01110','10001','10111','10101','10111','10000','01110'], + 'd': ['00001','00001','01101','10011','10001','10011','01101'], + 'e': ['00000','00000','01110','10001','11111','10000','01110'], + 'p': ['00000','00000','11110','10001','10001','11110','10000','10000','10000'], + 'r': ['00000','00000','10110','11001','10000','10000','10000'], + 'c': ['00000','00000','01111','10000','10000','10000','01111'], + 'a': ['00000','00000','01110','00001','01111','10001','01111'], + 't': ['00100','00100','11111','00100','00100','00101','00010'], + 's': ['00000','00000','01111','10000','01110','00001','11110'], + 'i': ['00100','00000','01100','00100','00100','00100','01110'], + 'n': ['00000','00000','11110','10001','10001','10001','10001'], + ' ': ['000','000','000','000','000','000','000'], + '2': ['01110','10001','00001','00110','01000','10000','11111'], + '0': ['01110','10011','10101','10101','11001','10001','01110'], + '1': ['00100','01100','00100','00100','00100','00100','01110'], +} + + +def text(part, words, center, cell, finish='dark'): + glyphs = [] + for char in words: + rows = GLYPHS[char] + columns = [i for i in range(len(rows[0])) if any(row[i] == '1' for row in rows)] + first, last = (min(columns), max(columns)) if columns else (0, 2) + glyphs.append([row[first:last + 1] for row in rows]) + width = (sum(len(rows[0]) + 1 for rows in glyphs) - 1) * cell + cursor = center[0] - width / 2 + for rows in glyphs: + for row, cells in enumerate(rows): + # Join contiguous pixels per row into one stencil stroke. + start = None + for col in range(len(cells) + 1): + on = col < len(cells) and cells[col] == '1' + if on and start is None: + start = col + if not on and start is not None: + count = col - start + part.box(finish, (count * cell * 0.94, cell * 0.89, 0.008), + xform((cursor + (start + count / 2) * cell, + center[1] + (3 - row) * cell, center[2]))) + start = None + cursor += (len(rows[0]) + 1) * cell + + +def box(p, finish, at, size, bevel=0.04, rot=(0, 0, 0)): + p.box(finish, size, xform(at, rot), bevel=bevel, segments=2) + + +def bolt(p, at, radius=0.045): + p.cylinder('dark', radius, 0.035, xform(at, (math.pi / 2, 0, 0)), segments=8) + + +def panel(kit, name, bone, center, width, height, depth): + p = Part(name) + box(p, 'slate', center, (width, height, depth), 0.075) + box(p, 'beige', (center[0], center[1], center[2] + depth / 2 - 0.035), + (width - 0.17, height - 0.16, 0.13), 0.035) + for sx in [-1, 1]: + for sy in [-1, 1]: + bolt(p, (center[0] + sx * (width / 2 - 0.14), center[1] + sy * (height / 2 - 0.13), center[2] + depth / 2 + 0.055)) + return p + + +def smooth(a, b, t): + x = max(0.0, min(1.0, (t - a) / (b - a))) + return x * x * (3 - 2 * x) + + +def anchor_base(kit, pitch, tower_y=0.0, thunk=0.0): + # Contract 11.5: the chassis can lean/slam while the lowest rack stays level. + pivot_y = RACK_Y[0] - 0.3 + y = pivot_y - tower_y - thunk + kit.pose('rack_0', location=(0, y * math.cos(pitch) - pivot_y, -y * math.sin(pitch)), + rotation=(-pitch, 0, 0)) + + +def pose(kit, action, t): + pulse = math.sin(math.pi * t) + if action == 'idle': + kit.pose('tower', location=(0, math.sin(t * math.tau) * 0.012, 0), + rotation=(0, math.sin(t * math.tau) * 0.008, 0)) + for side, sign in [('L', -1), ('R', 1)]: + kit.pose('cables_' + side, rotation=(0, 0, math.sin(t * math.tau + sign) * 0.035)) + kit.pose('fan_' + side, rotation=(0, 0, sign * t * math.tau * 2)) + elif action == 'intro': + approach = smooth(0, 0.45, t) + kit.pose('root', location=(0, 0, -4.0 * (1 - approach))) + stance = smooth(0.80, 1.0, t) + kit.pose('tower', rotation=(0.08 * stance, 0, 0)) + for side, sign in [('L', -1), ('R', 1)]: + kit.pose('arm_' + side, location=(0, 0.11 * stance, 0), + rotation=(-0.22 * stance, 0, sign * 0.21 * stance)) + kit.pose('hand_' + side, rotation=(-0.12 * stance, 0, 0)) + for i in range(4): + u = smooth(0.18 + i * 0.12, 0.29 + i * 0.12, t) + if i == 0: + anchor_base(kit, 0.08 * stance, thunk=0.02 * math.sin(math.pi * u)) + else: + kit.pose('rack_%d' % i, location=(0, -0.12 * math.sin(math.pi * u), 0), + rotation=(0, (1 - u) * (0.06 if i % 2 else -0.06), 0)) + kit.pose('led_%d' % i, scale=(max(u, 0.001),) * 3) + for side in ['L', 'R']: + light = max(smooth(0.64, 0.82, t), 0.001) + kit.pose('eye_' + side, scale=(light, light, 1)) + elif action == 'telegraph': + lift = smooth(0, 0.85, t) + kit.pose('tower', rotation=(-0.025 * lift, 0, 0)) + for side, sign in [('L', -1), ('R', 1)]: + kit.pose('arm_' + side, location=(-sign * 0.27 * lift, 0.16 * lift, 0), rotation=(-0.32 * lift, 0, sign * 0.30 * lift)) + kit.pose('hand_' + side, rotation=(-0.18 * lift, 0, 0)) + kit.pose('reactor', scale=(1 + 0.09 * lift,) * 3) + elif action == 'attack': + lift = 1 - smooth(0.06, 0.28, t) + # 0.30..0.42 holds impact for 66 ms at the runtime's 0.55 s quake duration. + impact = smooth(0.14, 0.30, t) * (1 - smooth(0.42, 1.0, t)) + kit.pose('tower', location=(0, -0.20 * impact, 0), rotation=(0.10 * impact, 0, 0)) + anchor_base(kit, 0.10 * impact, tower_y=-0.20 * impact) + for side, sign in [('L', -1), ('R', 1)]: + # Keep the slam below rest while every fist clears the 1.6 m lower bound. + kit.pose('arm_' + side, location=(-sign * 0.27 * lift, 0.16 * lift - 0.20 * impact, 0.30 * impact), + rotation=(-0.32 * lift + 0.10 * impact, 0, sign * (0.30 * lift - 0.08 * impact))) + kit.pose('hand_' + side, rotation=(-0.18 * lift, 0, 0)) + elif action == 'hurt': + kick = smooth(0, 0.25, t) * (1 - smooth(0.5, 1, t)) + kit.pose('rack_1', location=(kick * 0.35, 0, 0), rotation=(0, kick * 0.08, 0)) + kit.pose('rack_3', rotation=(-pulse * 0.025, 0, -pulse * 0.025)) + elif action == 'phase_change': + for side, sign in [('L', -1), ('R', 1)]: + kit.pose('shell_' + side, location=(sign * 0.20 * pulse, 0, 0.08 * pulse), rotation=(0, sign * 0.12 * pulse, 0)) + for i in range(3): + kit.pose('debris_%d' % i, location=((i - 1) * pulse * 0.12, pulse * 0.18, pulse * 0.06), + rotation=(pulse * 0.12, 0, (i - 1) * pulse * 0.10)) + elif action == 'defeat': + # Four independently rigged modules topple from top to bottom, never one scaled cube. + for i in range(4): + fall = smooth((3 - i) * 0.13, 0.62 + (3 - i) * 0.11, t) + if i == 0: + kit.pose('rack_0', location=(-fall * 0.18, 0, fall * 0.10), rotation=(0, -fall * 0.08, 0)) + else: + kit.pose('rack_%d' % i, location=((1 if i % 2 else -1) * fall * (0.5 if i == 2 else 0.26), + -fall * (1.2 if i == 3 else 0.10 + i * 0.08), fall * 0.10), + rotation=(fall * (0.9 if i == 3 else 0.12 + 0.10 * i), + (i - 1.5) * fall * 0.045, (1 if i % 2 else -1) * fall * 0.15)) + kit.pose('led_%d' % i, scale=(max(1 - smooth(0.12 + (3 - i) * 0.1, 0.42 + (3 - i) * 0.1, t), 0.001),) * 3) + for side, sign in [('L', -1), ('R', 1)]: + kit.pose('arm_' + side, rotation=(0.15 * t, 0, sign * -0.18 * t)) + kit.pose('eye_' + side, scale=(max(1 - smooth(0.20, 0.36, t), 0.001), + max(1 - smooth(0.03, 0.20, t), 0.001), 1)) + kit.pose('shell_' + side, location=(sign * t * 0.30, -t * 0.15, t * 0.08), rotation=(0, sign * t * 0.3, 0)) + for i in range(3): + kit.pose('debris_%d' % i, location=((i - 1) * t * 0.32, -t * 0.7, t * 0.25), rotation=(t * 0.5, 0, (i - 1) * t * 0.45)) + elif action == 'retreat': + kit.pose('root', location=(0, 0, -smooth(0.35, 1, t) * 0.35)) + for i in range(4): + lit = max(1 - smooth((3 - i) * 0.1, 0.35 + (3 - i) * 0.1, t), 0.001) + kit.pose('led_%d' % i, scale=(lit,) * 3) + for side in ['L', 'R']: + kit.pose('eye_' + side, scale=(max(1 - smooth(0, 0.35, t), 0.001), 0.04, 1)) + + +def build(out_dir, quick=False): + kit = BossBuilder('legacy_monolith', __file__, revision=RECIPE_REVISION, quick=quick) + kit.material('beige', 'c5b79c', finish='shell', roughness=0.48, metallic=0.45, + period=0.9, grunge=0.08, wear=0.30, bump=0.22) + kit.material('slate', '6a7ba2', roughness=0.38, metallic=0.60, period=0.8, grunge=0.08, wear=0.20) + kit.material('dark', '182632', finish='rubber', roughness=0.68, metallic=0.12, grunge=0.12, tread=32) + kit.material('glow', 'f78949', finish='plain', roughness=0.3, metallic=0.08, + emission='f78949', emission_energy=0.9, grunge=0.0, bump=0.0) + kit.bone('root', (0, 0, 0), (0, 0.15, 0)) + kit.bone('tower', (0, 0, 0), (0, 1, 0), 'root') + for i, y in enumerate(RACK_Y): + kit.bone('rack_%d' % i, (0, y - 0.3, 0), (0, y + 0.3, 0), 'tower') + kit.bone('led_%d' % i, (0, STATUS_Y[i], STATUS_Z[i]), + (0, STATUS_Y[i] + 0.1, STATUS_Z[i]), 'rack_%d' % i) + for side, sign in [('L', -1), ('R', 1)]: + kit.bone('arm_' + side, (sign * 1.58, 1.7, 0), (sign * 2.28, 0.80, 0.1), 'tower') + kit.bone('hand_' + side, (sign * 2.40, 0.7, 0.1), (sign * 2.40, 0.1, 0.1), 'arm_' + side) + kit.bone('shell_' + side, (sign * 0.75, 1.3, 0.60), (sign * 0.75, 1.8, 0.60), 'rack_2') + kit.bone('eye_' + side, (sign * 0.63, 2.76, 0.82), (sign * 0.63, 2.90, 0.82), 'rack_3') + kit.bone('fan_' + side, (sign * 1.02, 0.33, 0.80), (sign * 1.02, 0.33, 0.93), 'rack_1') + kit.bone('cables_' + side, (sign * 1.12, 3.02, -0.30), (sign * 1.50, 1.7, -0.45), 'rack_3') + kit.bone('reactor', (0, 1.34, 0.78), (0, 1.55, 0.78), 'rack_2') + for i in range(3): + kit.bone('wall_%d' % i, ((i - 1) * 1.05, -0.45, 0.70), ((i - 1) * 1.05, -0.2, 0.70), 'rack_0') + kit.bone('debris_%d' % i, ((i - 1) * 1.40, 2.02 + (0.35 if i == 1 else 0), -0.35), + ((i - 1) * 1.40, 2.2 + (0.35 if i == 1 else 0), -0.35), 'tower') + + base = Part('LoadBearingBase') + box(base, 'dark', (0, -0.50, -0.05), (3.22, 0.74, 1.60), 0.10) + box(base, 'slate', (0, -0.82, -0.06), (3.48, 0.16, 1.64), 0.04) + kit.add_part(base, 'rack_0') + for i in range(3): + p = panel(kit, 'WallShutter%d' % i, 'wall_%d' % i, ((i - 1) * 1.05, -0.44, 0.71), 0.96, 0.59, 0.22) + for j in range(4): + box(p, 'dark', ((i - 1) * 1.05, -0.61 + j * 0.11, 0.84), (0.57, 0.035, 0.015), 0) + kit.add_part(p, 'wall_%d' % i) + + utility = panel(kit, 'PunchCardModule', 'rack_1', (0, 0.34, 0), 3.20, 0.80, 1.54) + for y in [0.02, 0.30, 0.59]: + box(utility, 'dark', (0, y, 0.829), (3.02, 0.018, 0.015), 0) + box(utility, 'dark', (0, 0.32, 0.81), (1.14, 0.50, 0.08), 0.035) + box(utility, 'beige', (0, 0.32, 0.875), (0.98, 0.34, 0.025), 0.015) + for row in range(3): + for col in range(8): + if (col * 3 + row * 7) % 5 != 1: + box(utility, 'dark', (-0.4 + col * 0.112, 0.215 + row * 0.104, 0.893), (0.044, 0.056, 0.012), 0) + kit.add_part(utility, 'rack_1') + for side, sign in [('L', -1), ('R', 1)]: + fan = Part('CoolingFan' + side) + fan.ring('slate', 0.225, 0.035, xform((sign * 1.02, 0.33, 0.81), (math.pi / 2, 0, 0)), segments=20, sides=6) + fan.cylinder('dark', 0.19, 0.055, xform((sign * 1.02, 0.33, 0.81), (math.pi / 2, 0, 0)), segments=20) + for i in range(5): + a = i * math.tau / 5 + box(fan, 'beige', (sign * 1.02 + math.sin(a) * 0.095, 0.33 + math.cos(a) * 0.095, 0.85), + (0.075, 0.19, 0.03), 0.015, (0, 0, -a + 0.4)) + bolt(fan, (sign * 1.02, 0.33, 0.885), 0.06) + kit.add_part(fan, 'fan_' + side) + + chassis = Part('ReactorChassis') + box(chassis, 'dark', (0, 1.41, -0.05), (3.24, 1.27, 1.50), 0.09) + for sign in [-1, 1]: + box(chassis, 'slate', (sign * 1.50, 1.41, 0.64), (0.21, 1.12, 0.27), 0.04) + box(chassis, 'beige', (sign * 1.54, 1.78, 0.01), (0.30, 0.46, 1.52), 0.06) + box(chassis, 'slate', (0, 2.07, -0.05), (2.58, 0.27, 1.36), 0.055) + # Old machine identity is physically stencilled into its own nameplate. + box(chassis, 'beige', (0, 1.94, 0.79), (3.30, 0.36, 0.07), 0.035) + text(chassis, '@deprecated since 2011', (0, 1.94, 0.834), 0.025) + kit.add_part(chassis, 'rack_2') + for side, sign in [('L', -1), ('R', 1)]: + p = Part('CrackedChest' + side) + outline = [(0.18, 0.0), (0.37, 0.22), (0.14, 0.39), (0.34, 0.56), (0.16, 0.79), + (1.27, 0.79), (1.27, 0.0)] + outline = [(sign * x, y + 0.89) for x, y in outline] + p.prism('beige', outline, 0.22, xform((0, 0, 0.83)), bevel=0.025, segments=2) + for y in [1.02, 1.53]: + bolt(p, (sign * 1.12, y, 0.96)) + for y in [1.02, 1.29, 1.56]: + box(p, 'dark', (sign * 1.08, y, 0.958), (0.33, 0.04, 0.012), 0) + for j in range(2): + box(p, 'glow', (sign * (0.80 + j * 0.055), y, 0.960), (0.025, 0.025, 0.014), 0) + # Three distinct fractures, two descending, avoid a mirrored second face. + fractures = ([(-0.32, 1.48), (-0.52, 1.28), (-0.69, 1.36), (-0.78, 1.06), (-1.05, 0.97)],) if sign < 0 else ( + [(0.30, 1.28), (0.54, 1.20), (0.59, 1.04), (0.91, 0.95)], + [(0.36, 1.52), (0.60, 1.60), (0.77, 1.53), (1.06, 1.62)]) + for points in fractures: + p.tube('glow', [(x, y, 0.974) for x, y in points], 0.03, sides=5, radius_end=0.01) + kit.add_part(p, 'shell_' + side) + reactor = Part('OrangeReactor') + reactor.cylinder('glow', 0.40, 0.19, xform((0, 1.30, 0.82), (math.pi / 2, 0, 0)), segments=24) + reactor.ring('slate', 0.47, 0.075, xform((0, 1.30, 0.87), (math.pi / 2, 0, 0)), segments=24, sides=6) + for sign in [-1, 1]: + box(reactor, 'dark', (sign * 0.115, 1.30, 0.94), (0.05, 0.58, 0.03), 0.01) + kit.add_part(reactor, 'reactor') + + head = panel(kit, 'CRTHead', 'rack_3', (0, 2.68, -0.06), 2.90, 1.22, 1.53) + box(head, 'slate', (0, 3.25, -0.06), (3.04, 0.20, 1.60), 0.06) + for side, sign in [('L', -1), ('R', 1)]: + box(head, 'dark', (sign * 0.64, 2.79, 0.775), (1.01, 0.62, 0.12), 0.09) + # Heavy slanted brow rims turn two obsolete CRT terminals into a stern golem face. + box(head, 'beige', (sign * 0.63, 3.08, 0.85), (1.07, 0.17, 0.24), 0.04, (0, 0, sign * 0.24)) + eyes = Part('CRTPhosphor' + side) + box(eyes, 'glow', (sign * 0.64, 2.78, 0.851), (0.70, 0.27, 0.035), 0.055) + for j in range(3): + box(eyes, 'dark', (sign * 0.64, 2.69 + j * 0.08, 0.874), (0.65, 0.014, 0.008), 0) + kit.add_part(eyes, 'eye_' + side) + box(head, 'dark', (0, 2.35, 0.78), (1.32, 0.22, 0.10), 0.03) + for i in range(7): + box(head, 'beige', (-0.54 + i * 0.18, 2.35, 0.845), (0.06, 0.15, 0.03), 0.01) + kit.add_part(head, 'rack_3') + + for side, sign in [('L', -1), ('R', 1)]: + shoulder = Part('CastShoulder' + side) + box(shoulder, 'slate', (sign * 1.98, 1.62, -0.01), (0.95, 0.74, 1.42), 0.14, (0, 0, sign * 0.10)) + box(shoulder, 'beige', (sign * 2.07, 1.66, 0.58), (0.78, 0.57, 0.28), 0.07) + shoulder.cylinder('dark', 0.27, 0.66, xform((sign * 2.11, 1.10, 0.0), (0, 0, math.pi / 2)), segments=16) + kit.add_part(shoulder, 'arm_' + side) + hand = Part('SlabFist' + side) + box(hand, 'beige', (sign * 2.46, 0.48, 0.04), (1.12, 1.30, 1.45), 0.13, (0, 0, -sign * 0.05)) + box(hand, 'slate', (sign * 2.48, -0.12, 0.12), (1.16, 0.26, 1.50), 0.065) + for i in range(3): + box(hand, 'slate', (sign * 2.46, 0.12 + i * 0.33, 0.80), (0.87, 0.08, 0.17), 0.025) + for x in [-0.36, 0.36]: + bolt(hand, (sign * 2.46 + x, 0.94, 0.79), 0.055) + kit.add_part(hand, 'hand_' + side) + cables = Part('CableDreadlocks' + side) + for i in range(4): + x = sign * (0.86 + i * 0.18) + cables.tube('dark' if i % 2 else 'slate', + [(x, 3.17, -0.30), (x + sign * 0.23, 3.32 + 0.04 * i, -0.44), + (x + sign * 0.44, 2.73, -0.72), (x + sign * 0.42, 2.01, -0.77), + (x + sign * 0.28, 1.22 + i * 0.12, -0.65)], 0.065, sides=7) + kit.add_part(cables, 'cables_' + side) + for i in range(4): + led = Part('RackStatus%d' % i) + box(led, 'glow', (0, STATUS_Y[i], STATUS_Z[i]), (2.4, 0.05, 0.02), 0.008) + for j in range(3): + box(led, 'glow', (1.21, RACK_Y[i] - 0.16 + j * 0.12, 0.80), (0.065, 0.042, 0.035), 0.008) + kit.add_part(led, 'led_%d' % i) + for i in range(3): + debris = Part('LooseSlab%d' % i) + x, y = (i - 1) * 1.40, 2.02 + (0.35 if i == 1 else 0) + box(debris, 'slate', (x, y, -0.45), (0.82, 0.32, 0.86), 0.045, (0.12, (i - 1) * 0.12, (i - 1) * -0.08)) + kit.add_part(debris, 'debris_%d' % i) + + kit.socket('fx_core', 'reactor', (0, 1.30, 1.0)) + for i in range(6): + kit.socket('fx_%d' % i, 'rack_%d' % min(i // 2, 3), ((-1 if i % 2 else 1) * 1.05, RACK_Y[min(i // 2, 3)], 0.85)) + kit.socket('muzzle_0', 'reactor', (0, 1.3, 1.05)) + kit.socket('muzzle_1', 'hand_L', (-2.46, 0.22, 0.92)) + kit.socket('muzzle_2', 'hand_R', (2.46, 0.22, 0.92)) + triangles = sum(part.data.polygons.__len__() for part in kit.objects) + print('[legacy-monolith] parts=%d polygons=%d bones=%d' % (len(kit.objects), triangles, len(kit.rig.data.bones)), flush=True) + kit.bake_actions(DURATIONS, pose) + return kit.export(out_dir) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out-dir', type=Path, default=DEFAULT_ASSET_DIR) + parser.add_argument('--quick', action='store_true') + args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) + build(args.out_dir, args.quick) diff --git a/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py b/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py new file mode 100644 index 00000000..deb2702e --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/build_merge_conflict_hydra.py @@ -0,0 +1,254 @@ +"""Braided branch serpents. Coordinates are Godot metres, facing +Z.""" +import argparse +import bpy +import hashlib +import math +from pathlib import Path +import sys +from mathutils import Vector +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from boss_kit import DEFAULT_ASSET_DIR, BossBuilder, Part, xform, B2G + +RECIPE_REVISION = 6 +HEADS = [(-2,2.02,.06),(0,1.72,.04),(2,2.02,.06),(0,3.02,-.40)] +ROOTS = [(-.95,.17,-.22),(0,.35,-.25),(.95,.17,-.22),(0,.12,-.56)] +HEAD_PITCH = math.radians(15) +DURATIONS = dict(intro=2.4,idle=3.2,telegraph=.8,attack=.5,hurt=.35,phase_change=1.2,defeat=1.8,retreat=1.0) + + +def smooth(a,b,t): + q=max(0,min(1,(t-a)/(b-a))) + return q*q*(3-2*q) + + +def neck_path(i): + x,y,z=HEADS[i] + if i in (0,2): + sign=-1 if i==0 else 1 + controls=[ROOTS[i],(sign*1.65,.65,-.15),(sign*2.28,1.15,-.34), + (sign*2.14,y+.10,z-.60),(.95*x,y+.30,z-.64),(x,y+.05,z-.34)] + elif i==1: + controls=[ROOTS[i],(-.36,.83,-.25),(.30,1.35,-.38), + (-.18,y+.12,z-.60),(0,y+.30,z-.64),(x,y+.05,z-.34)] + else: + controls=[ROOTS[i],(-.22,.92,-.66),(.24,1.77,-.75), + (-.15,2.70,-.88),(0,y+.30,z-.59),(x,y+.05,z-.32)] + # A forward hanging skull emerges from a cobra arc above and behind it. + controls=list(map(Vector,controls)) + result=[] + for k in range(31): + u=k/30*(len(controls)-1) + seg=min(int(u),len(controls)-2); t=u-seg + a=controls[max(seg-1,0)]; b=controls[seg] + c=controls[seg+1]; d=controls[min(seg+2,len(controls)-1)] + result.append(tuple(.5*((2*b)+(-a+c)*t+(2*a-5*b+4*c-d)*t*t+(-a+3*b-3*c+d)*t*t*t))) + return result + + +def marker_part(i,at): + # Monospace font contours make these literal merge-conflict tokens, not icon arrows. + # Fonts are converted to original mesh contours; the font file is not distributed. + font_path=Path(bpy.utils.system_resource('DATAFILES')) / 'fonts/DejaVuSansMono.woff2' + curve=bpy.data.curves.new('conflict_marker','FONT') + if not font_path.is_file(): raise RuntimeError('Blender bundled DejaVu Sans Mono is missing: '+str(font_path)) + curve.font=bpy.data.fonts.load(str(font_path)) + curve.body=['<<<<<<<','=======','>>>>>>>','--force'][i] + if i==1: curve.space_character=.75 + curve.align_x='CENTER'; curve.align_y='CENTER' + curve.size=1; curve.extrude=.035; curve.offset=.006; curve.resolution_u=1 + obj=bpy.data.objects.new('marker_type',curve) + bpy.context.scene.collection.objects.link(obj) + bpy.ops.object.select_all(action='DESELECT') + obj.select_set(True); bpy.context.view_layer.objects.active=obj + bpy.ops.object.convert(target='MESH') + obj=bpy.context.object + part=Part(f'literal_marker_{i}') + part.bm.from_mesh(obj.data) + extent=max(v.co.x for v in part.bm.verts)-min(v.co.x for v in part.bm.verts) + low=min(v.co.y for v in part.bm.verts); high=max(v.co.y for v in part.bm.verts) + # Independent height normalization keeps the seven equals signs as paired bars. + for v in part.bm.verts: + v.co=Vector(at)+Vector((v.co.x*.94/extent,(v.co.y-(low+high)/2)*.22/(high-low),v.co.z*.35)) + for face in part.bm.faces: face.material_index=part.slot('lilac') + bpy.data.objects.remove(obj,do_unlink=True) + return part + + +def weight_neck(obj,i,points): + obj.vertex_groups.clear() + groups=[obj.vertex_groups.new(name=f'{n}_{i}') for n in ('neck_lo','neck_hi','head')] + samples=list(map(Vector,points)) + for v in obj.data.vertices: + g=B2G@v.co + t=min(range(len(samples)),key=lambda k:(g-samples[k]).length_squared)/(len(samples)-1) + lo=1-smooth(.18,.55,t) + head=smooth(.70,.98,t) + for group,w in zip(groups,(lo,1-lo-head,head)): + if w>.00001: group.add([v.index],w,'REPLACE') + + +def pose(kit,action,t): + pulse=math.sin(math.pi*t) + fourth=smooth(.20,.78,t) if action=='phase_change' else .001 + kit.pose('branch_3',scale=(max(.001,fourth),)*3) + if action=='idle': kit.pose('knot',rotation=(0,.022*math.sin(t*math.tau),0)) + elif action=='intro': kit.pose('knot',scale=(.88+.12*smooth(0,.7,t),)*3) + elif action=='phase_change': kit.pose('knot',rotation=(.035*math.sin(t*math.tau*3)*pulse,0,.045*math.sin(t*math.tau*4)*pulse)) + elif action=='defeat': + q=smooth(.12,1,t) + kit.pose('root',location=(0,-.12*q,0),scale=(1-.12*q,)*3) + kit.pose('knot',rotation=(0,.18*q,0)) + elif action=='retreat': + q=smooth(0,1,t) + kit.pose('root',location=(0,-.16*q,-.15*q),scale=(1-.65*q,)*3) + for i,(x,y,z) in enumerate(HEADS): + side=-1 if x<0 else 1 + ph=t*math.tau+i*1.7 + lo,hi,head,jaw=(f'{n}_{i}' for n in ('neck_lo','neck_hi','head','jaw')) + if action=='idle': + kit.pose(lo,rotation=(.022*math.sin(ph),0,.065*math.sin(ph))) + kit.pose(hi,rotation=(-.02*math.sin(ph),.025*math.cos(ph),-.12*math.sin(ph))) + kit.pose(head,rotation=(.025*math.sin(ph),.08*math.sin(ph),0)) + kit.pose(jaw,rotation=(.025+.018*math.sin(ph),0,0)) + elif action=='intro': + rise=smooth(.04+i*.15,.40+i*.15,t) + roar=smooth(.57,.82,t) + kit.pose(lo,rotation=(0,0,side*.58*(1-rise)),scale=(1,.30+.70*rise,1)) + kit.pose(hi,rotation=(.22*(1-rise),0,-side*.65*(1-rise))) + kit.pose(head,rotation=(.20*roar,(-1 if i==0 else 1 if i==2 else 0)*math.radians(8)*roar,0)) + kit.pose(jaw,rotation=(.70*roar,0,0)) + elif action=='telegraph': + q=smooth(0,.85,t) + kit.pose(hi,rotation=(-.10*q,0,0)) + kit.pose(head,location=(0,(.06 if i==3 else .30)*q,-.40*q),rotation=(-.45*q,0,0)) + kit.pose(jaw,rotation=(.75*q,0,0)) + elif action=='attack': + s=math.sin(math.pi*smooth(0,.50,t)); r=math.sin(math.pi*smooth(.42,1,t)) + kit.pose(hi,rotation=(.10*s-.08*r,0,0)) + kit.pose(head,location=(0,-.35*s,.30*s-.14*r),rotation=(.50*s-.10*r,0,0)) + kit.pose(jaw,rotation=(.56*(1-smooth(.08,.38,t))+.12*r,0,0)) + elif action=='hurt': + kit.pose(head,location=(0,.05*pulse,-.30*pulse),rotation=(-.45*pulse,side*.25*pulse,0)) + kit.pose(hi,rotation=(-.05*pulse,0,side*.06*pulse)) + kit.pose(jaw,rotation=(.25*pulse,0,0)) + elif action=='phase_change': + kit.pose(lo,rotation=(0,0,.06*math.sin(t*math.tau*3+i)*pulse)) + kit.pose(head,rotation=(-.12*pulse,0,0)) + kit.pose(jaw,rotation=(.48*pulse,0,0)) + elif action=='defeat': + q=smooth(.34+i*.025,1,t) + cross=smooth(.04+i*.025,.54,t) + # Cross the branches first, then compress the interleaved heap. + direction=-1 if i in (0,1) else 1 + kit.pose(lo,rotation=(.08*q,0,direction*.16*cross),scale=(1,1-.60*q,1)) + kit.pose(hi,location=(-x*.65*cross,.08*cross,(.10 if i%2 else -.10)*cross),rotation=(.12*q,0,direction*.90*cross)) + kit.pose(head,location=(-x*.20*cross,-.16*q,0),rotation=(.25*q,0,direction*.16*cross)) + kit.pose(jaw,rotation=(.28*(1-q),0,0)) + elif action=='retreat': + q=smooth(i*.035,1,t) + kit.pose(lo,scale=(1,1-.83*q,1),rotation=(0,side*.25*q,0)) + kit.pose(hi,rotation=(.25*q,0,side*.35*q)) + kit.pose(head,rotation=(.38*q,0,0)) + + +def build(out_dir,quick=False): + kit=BossBuilder('merge_conflict_hydra',__file__,revision=RECIPE_REVISION,quick=quick) + kit.material('plum','673d78',roughness=.38,metallic=.15,grunge=.04) + kit.material('magenta','d46ad4',roughness=.36,metallic=.15,grunge=.03) + kit.material('dark','182336',roughness=.44,metallic=.5,grunge=.05) + kit.material('lilac','c2b8ff',finish='plain',roughness=.28,metallic=.12,emission='a99bff',emission_energy=.65,grunge=0) + kit.material('commit','a99bff',finish='plain',roughness=.4,metallic=.08,emission='a99bff',emission_energy=.20,grunge=0) + kit.material('throat','702d49',finish='plain',roughness=.6,metallic=0,emission='ff7185',emission_energy=.3,grunge=0) + kit.bone('root',(0,0,0),(0,.2,0)) + kit.bone('knot',(0,.05,-.3),(0,.4,-.3),'root') + knot=Part('braided_merge_knot') + # One continuous (2,3) trefoil with alternating over/under crossings. + angles=[k*math.tau/72 for k in range(73)] + points=[(.45*(2+math.cos(3*a))*math.cos(2*a), + .08+.42*math.sin(3*a),-.10+.18*(2+math.cos(3*a))*math.sin(2*a)) for a in angles] + knot.tube('plum',points,.285,sides=10) + knot.tube('magenta',[(a,b,c+.24) for a,b,c in points],.055,sides=6) + knot.cylinder('magenta',.32,.12,xform((0,.16,.40),(math.pi/2,0,0)),segments=12,bevel=.025) + knot.ring('commit',.23,.032,xform((0,.16,.49),(math.pi/2,0,0)),segments=16,sides=4) + kit.add_part(knot,'knot') + for i,(x,y,z) in enumerate(HEADS): + points=neck_path(i); mid=points[15] + crown=1.05 if i==3 else 1.0 + # Geometry and jaw hinge share a rest pitch; animations add to that pose. + head_xform=xform((x,y,z),(HEAD_PITCH-(.12 if i==3 else 0),0,0),(crown,)*3) + def hp(p): return head_xform @ Vector(p) + def mount(part,bone): + for v in part.bm.verts: v.co=hp(v.co) + return kit.add_part(part,bone) + gate,lo,hi,head,jaw=(f'{n}_{i}' for n in ('branch','neck_lo','neck_hi','head','jaw')) + for name,origin,parent in [(gate,ROOTS[i],'knot'),(lo,ROOTS[i],gate),(hi,mid,lo),(head,(x,y,z),hi),(jaw,hp((0,-.17,-.10)),head)]: + kit.bone(name,origin,(origin[0],origin[1]+.2,origin[2]),parent) + neck=Part(f'branch_tube_{i}') + neck.tube('plum',points,.29,sides=12,radius_end=.20) + neck.tube('magenta',[(a,b,c+.255) for a,b,c in points],.06,sides=6,radius_end=.045) + weight_neck(kit.add_part(neck,lo),i,points) + for k in (4,9,14,19,24): + p=Vector(points[k])+Vector((0,0,.255-k*.001)) + node=Part(f'commit_{i}_{k}') + node.cylinder('commit',.12,.048,xform(p,(math.pi/2,0,0)),segments=10) + node.cylinder('dark',.044,.018,xform(p+Vector((0,0,.03)),(math.pi/2,0,0)),segments=8) + # The nodes use the same smooth weights as their underlying branch. + weight_neck(kit.add_part(node,lo),i,points) + skull=Part(f'serpent_head_{i}') + skull.prism('plum',[(-.48,-.16),(-.55,.04),(-.46,.30),(-.28,.41),(.28,.41),(.46,.30),(.55,.04),(.48,-.16)],.57,bevel=.055,segments=3) + snout=Part(f'wedge_snout_{i}') + snout.prism('magenta',[(-.36,-.12),(-.43,.04),(-.26,.21),(.26,.21),(.43,.04),(.36,-.12)],.86,xform((0,-.01,.52)),bevel=.045,segments=2) + for v in snout.bm.verts: + taper=max(0,min(1,(v.co.z-.09)/.86)) + v.co.x*=1-.50*taper + v.co.y-=.12*taper + mount(snout,head) + for sign in [-1,1]: + # Pupil-free slits under inward-descending brows keep the open roar hostile. + eye=xform((sign*.265,.235,.61),(-.22,sign*.13,sign*math.radians(25))) + skull.prism('dark',[(-.15,-.048),(-.14,.063),(.14,.063),(.15,-.048)],.045,eye,bevel=.012) + skull.prism('lilac',[(-.13,-.0225),(-.12,.0225),(.12,.0225),(.13,-.0225)],.025,eye@xform((0,0,.03)),bevel=.006) + skull.box('plum',(.289 if i==3 else .34,.08,.10),eye@xform((0,.054,.025)),bevel=.018,segments=2) + skull.tube('plum',[(sign*.44,.24,-.12),(sign*.60,.46,-.31),(sign*.59,.57,-.44)],.10,sides=8,radius_end=.012) + skull.box('dark',(.022,.014,.10),xform((sign*.125,.096,.835),(.12,sign*math.radians(30),0)),bevel=.005,segments=1) + for k in range(3): + skull.cylinder('lilac',.040,.18,xform((sign*(.34-k*.05),-.21-k*.035,.31+k*.225),(math.pi+.18,0,0)),segments=6,radius_top=.004) + # A dark seam separates the crown armour from the facial shell. + skull.box('dark',(.88,.024,.59),xform((0,.27,-.015)),bevel=.007,segments=1) + plates=[ [(-.64,.02),(-.40,-.15),(.53,-.15),(.53,.19),(-.40,.19)], + [(-.52,-.14),(.52,-.14),(.58,.04),(.45,.25),(-.45,.25),(-.58,.04)], + [(-.53,-.15),(.40,-.15),(.64,.02),(.40,.19),(-.53,.19)], + [(-.53,-.14),(.53,-.14),(.54,.12),(.24,.15),(0,.23),(-.24,.15),(-.54,.12)] ] + skull.prism('plum',plates[i],.13,xform((0,.52 if i==3 else .53,.03)),bevel=.025,segments=2) + skull.box('dark',(1.0,.30,.025),xform((0,.54 if i==3 else .55,.11)),bevel=.024) + skull.box('lilac',(.94,.025,.018),xform((0,.72 if i==3 else .715,.12)),bevel=.006,segments=1) + mount(marker_part(i,(0,.54 if i==3 else .55,.135)),head) + mount(skull,head) + lower=Part(f'articulated_jaw_{i}') + lower.prism('plum',[(-.40,-.04),(-.34,-.14),(.34,-.14),(.40,-.04),(.38,.055),(-.38,.055)],.91,xform((0,-.235,.46)),bevel=.038,segments=3) + for v in lower.bm.verts: + taper=max(0,min(1,(v.co.z-.04)/.91)) + v.co.x*=1-.35*taper + v.co.y-=.10*taper + lower.box('throat',(.50,.03,.70),xform((0,-.185,.45)),bevel=.012) + for sign in [-1,1]: lower.cylinder('lilac',.042,.22,xform((sign*.245,-.115,.73)),segments=6,radius_top=.007) + mount(lower,jaw) + if i<3: + kit.socket(f'muzzle_{i}',jaw,hp((0,-.20,.94))) + kit.socket(f'fx_{i*2}',lo,points[7]); kit.socket(f'fx_{i*2+1}',hi,points[21]) + kit.socket('fx_core','knot',(0,.16,.49)) + kit.bake_actions(DURATIONS,pose) + font_path=Path(bpy.utils.system_resource('DATAFILES')) / 'fonts/DejaVuSansMono.woff2' + glyph_font={'name':'DejaVu Sans Mono','source':'Blender DATAFILES/fonts/DejaVuSansMono.woff2', + 'license':'Bitstream Vera and DejaVu Fonts License', + 'sha256':hashlib.sha256(font_path.read_bytes()).hexdigest(), + 'usage':'Converted glyph outlines only; font file is not redistributed.'} + return kit.export(out_dir, report_metadata={'glyph_font': glyph_font}) + + +if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out-dir',type=Path,default=DEFAULT_ASSET_DIR) + parser.add_argument('--quick',action='store_true') + args=parser.parse_args(sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else []) + build(args.out_dir,args.quick) diff --git a/hoppys-adventure-arcade/tools/blender/bosses/rebuild_bosses.py b/hoppys-adventure-arcade/tools/blender/bosses/rebuild_bosses.py new file mode 100644 index 00000000..c550ee66 --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/rebuild_bosses.py @@ -0,0 +1,39 @@ +"""Blender entrypoint for the boss recipes, including the shared-kit fixture. + +Blender --background --python-exit-code 1 --python tools/blender/bosses/rebuild_bosses.py -- + --kind kit_selftest|flaky_test_swarm|merge_conflict_hydra|legacy_monolith|all + --out-dir Assets/runner/bosses [--quick] +Each recipe exports build(out_dir, quick=False). all requires every registered recipe. +""" +import argparse +import importlib.util +import json +from pathlib import Path +import sys + +HERE = Path(__file__).resolve().parent +KINDS = json.loads((HERE / 'bosses_budget.json').read_text())['kinds'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--kind', choices=KINDS + ['all'], required=True) + parser.add_argument('--out-dir', type=Path, default=HERE.parent.parent.parent / 'Assets' / 'runner' / 'bosses') + parser.add_argument('--quick', action='store_true') + args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) + kinds = KINDS if args.kind == 'all' else [args.kind] + paths = [HERE / ('build_' + kind + '.py') for kind in kinds] + missing = [str(path) for path in paths if not path.is_file()] + if missing: + raise RuntimeError('Boss recipes are not installed yet: ' + ', '.join(missing)) + sys.path.insert(0, str(HERE)) + for kind, path in zip(kinds, paths): + spec = importlib.util.spec_from_file_location('boss_recipe_' + kind, path) + recipe = importlib.util.module_from_spec(spec) + spec.loader.exec_module(recipe) + recipe.build(args.out_dir, quick=args.quick) + print('BOSSES BUILD: PASS (' + ', '.join(kinds) + ')', flush=True) + + +if __name__ == '__main__': + main() diff --git a/hoppys-adventure-arcade/tools/blender/bosses/render_bosses.py b/hoppys-adventure-arcade/tools/blender/bosses/render_bosses.py new file mode 100644 index 00000000..8e82113d --- /dev/null +++ b/hoppys-adventure-arcade/tools/blender/bosses/render_bosses.py @@ -0,0 +1,172 @@ +"""Eevee turntable and contact sheets from the exported GLB, all eight actions. + +Blender --background --python-exit-code 1 --python tools/blender/bosses/render_bosses.py -- + --kind kit_selftest --asset-dir Assets/runner/bosses --out-dir /tmp/boss-renders + [--size 512 --views 8 --frames 6] +Each PNG sheet reads left to right in time/angle; captures.json records exact poses. +""" +import argparse +import json +import math +from pathlib import Path +import sys + +import bpy +import numpy as np +from mathutils import Matrix, Vector + +HERE = Path(__file__).resolve().parent +BUDGET = json.loads((HERE / 'bosses_budget.json').read_text()) + + +def require(ok, message): + if not ok: + raise RuntimeError('[boss-render] ' + message) + + +def bind(rig, name): + matches = [] + for track in rig.animation_data.nla_tracks: + track.mute = True + for strip in track.strips: + if track.name == name and strip.action: + matches.append((strip.action, strip.action_slot)) + require(len(matches) == 1, 'Expected one action binding: ' + name) + rig.animation_data.action, rig.animation_data.action_slot = matches[0] + return matches[0][0] + + +def bounds(meshes): + points = [] + deps = bpy.context.evaluated_depsgraph_get() + for obj in meshes: + evaluated = obj.evaluated_get(deps) + mesh = evaluated.to_mesh() + try: + points.extend(evaluated.matrix_world @ v.co for v in mesh.vertices) + finally: + evaluated.to_mesh_clear() + return points + + +def sheet(files, path, size): + # Assemble rendered PNGs using Blender's image API; no Pillow dependency on the host. + pixels = np.zeros((size, size * len(files), 4), dtype=np.float32) + for index, file in enumerate(files): + image = bpy.data.images.load(str(file), check_existing=False) + tile = np.empty(size * size * 4, dtype=np.float32) + image.pixels.foreach_get(tile) + pixels[:, index * size:(index + 1) * size, :] = tile.reshape(size, size, 4) + bpy.data.images.remove(image) + image = bpy.data.images.new(path.stem, width=size * len(files), height=size, alpha=True) + image.pixels.foreach_set(pixels.ravel()) + image.filepath_raw, image.file_format = str(path), 'PNG' + image.save() + bpy.data.images.remove(image) + + +def render(kind, args): + glb = args.asset_dir.resolve() / kind / (kind + '.glb') + require(glb.is_file(), 'Missing GLB: ' + str(glb)) + out = args.out_dir.resolve() / kind + out.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.render.fps = BUDGET['animation_sample_rate'] + bpy.ops.import_scene.gltf(filepath=str(glb)) + rigs = [o for o in bpy.data.objects if o.type == 'ARMATURE'] + require(len(rigs) == 1, 'Expected one rig') + rig = rigs[0] + meshes = [o for o in bpy.data.objects if o.type == 'MESH'] + for track in rig.animation_data.nla_tracks: + track.mute = True + rig.animation_data.action = None + for bone in rig.pose.bones: + bone.matrix_basis = Matrix.Identity(4) + scene.frame_set(0) + rest = bounds(meshes) + for engine in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'): + try: + scene.render.engine = engine + break + except TypeError: + continue + else: + raise RuntimeError('Eevee is unavailable') + scene.render.resolution_x = scene.render.resolution_y = args.size + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = 'PNG' + scene.world = bpy.data.worlds.new('Boss navy studio') + bg = scene.world.node_tree.nodes.get('Background') + bg.inputs[0].default_value, bg.inputs[1].default_value = (0.02, 0.03, 0.06, 1), 0.45 + lo = Vector(tuple(min(p[i] for p in rest) for i in range(3))) + hi = Vector(tuple(max(p[i] for p in rest) for i in range(3))) + centre = (lo + hi) * 0.5 + span = max(hi - lo) + for name, offset, energy, color in [('key', (1.5, -2, 2), 500, (1, 0.98, 0.92)), + ('fill', (-2, -1, 1), 180, (0.6, 0.9, 0.85)), ('rim', (0, 2, 2), 350, (0.5, 0.95, 0.8))]: + data = bpy.data.lights.new(name, 'AREA') + data.energy, data.color, data.size = energy * span * span, color, span * 2 + light = bpy.data.objects.new(name, data) + scene.collection.objects.link(light) + light.location = centre + Vector(offset) * span + light.rotation_euler = (centre - light.location).to_track_quat('-Z', 'Y').to_euler() + data = bpy.data.cameras.new('Boss camera') + data.type = 'ORTHO' + camera = bpy.data.objects.new('Boss camera', data) + scene.collection.objects.link(camera) + scene.camera = camera + + def camera_at(points, angle): + low = Vector(tuple(min(p[i] for p in points) for i in range(3))) + high = Vector(tuple(max(p[i] for p in points) for i in range(3))) + target = (low + high) * 0.5 + diameter = (high - low).length + data.ortho_scale = max(diameter * 1.15, 0.5) + camera.location = target + Vector((math.sin(angle), -math.cos(angle), 0.35)) * diameter * 3 + camera.rotation_euler = (target - camera.location).to_track_quat('-Z', 'Y').to_euler() + + def capture(name): + path = out / (name + '.png') + scene.render.filepath = str(path) + bpy.ops.render.render(write_still=True) + require(path.is_file(), 'Missing PNG: ' + str(path)) + return path + + views = [] + for i in range(args.views): + camera_at(rest, math.tau * i / args.views) + views.append(capture('turntable_%02d' % i)) + sheet(views, out / 'turntable.png', args.size) + manifest = {'kind': kind, 'fps': BUDGET['animation_sample_rate'], 'turntable_degrees': [360 * i / args.views for i in range(args.views)], 'actions': {}} + for name in BUDGET['actions']: + action = bind(rig, name) + first, last = action.frame_range + frames = [first + (last - first) * i / (args.frames - 1) for i in range(args.frames)] + points = [] + for frame in frames: + scene.frame_set(math.floor(frame), subframe=frame % 1) + points.extend(bounds(meshes)) + camera_at(points, 0.35) + files = [] + for i, frame in enumerate(frames): + scene.frame_set(math.floor(frame), subframe=frame % 1) + files.append(capture(name + '_%02d' % i)) + sheet(files, out / (name + '.png'), args.size) + manifest['actions'][name] = {'frames': frames, 'seconds': [(f - first) / BUDGET['animation_sample_rate'] for f in frames]} + (out / 'captures.json').write_text(json.dumps(manifest, indent=2) + '\n') + print('BOSS RENDER: PASS ' + str(out), flush=True) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--kind', choices=BUDGET['kinds'] + ['all'], required=True) + parser.add_argument('--asset-dir', type=Path, default=HERE.parent.parent.parent / 'Assets' / 'runner' / 'bosses') + parser.add_argument('--out-dir', type=Path, required=True) + parser.add_argument('--size', type=int, default=512) + parser.add_argument('--views', type=int, default=8) + parser.add_argument('--frames', type=int, default=6) + args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) + require(args.size >= 64 and 1 <= args.views <= 32 and 2 <= args.frames <= 24, 'Invalid render dimensions') + for kind in BUDGET['kinds'] if args.kind == 'all' else [args.kind]: + render(kind, args)