diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7f8c583..8f7f10c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,9 @@ jobs: run: | suites=( code_city_assets + code_city_celebration code_city_logic + code_city_mini_boss code_city_motion code_city_smoke controller_service @@ -81,8 +83,11 @@ jobs: runner_boss_validator runner_chunk_validator runner_logic + runner_run_intro + runner_run_intro_ui runner_scaffold_check runner_smoke + runner_synth_threads runner_visual_smoke runner_warmup_check ) @@ -107,6 +112,12 @@ jobs: exit 1 fi + # Quits from the hub, fights, the Code City celebration, the runner intro and mid-transition + # with their audio playing. Godot prints its exit-leak lines only after the script is gone, + # so the wrapper runs tests/exit_leak.gd once per scenario, fails on them and times the exit. + - name: Exit leaks + run: python3 tools/checks/exit_leak.py --godot ~/godot/godot + lint: name: lint runs-on: ubuntu-24.04 diff --git a/ASSETS_LICENSES.md b/ASSETS_LICENSES.md index 30a402e8..f025c1d7 100644 --- a/ASSETS_LICENSES.md +++ b/ASSETS_LICENSES.md @@ -27,7 +27,8 @@ Status key: ✅ verified · ⚠️ needs sign-off · ❌ **blocks public release | HellDrake (extended) | `Audio/Beat'em Up/helldrake(extended).ogg` | Centurion_of_war | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([OpenGameArt](https://opengameart.org/content/helldrake), byte-identical). Credit: "HellDrake" by Centurion_of_war. | Code City Brawl: Security | ✅ | | Angel Eyes (Vision II) | `Audio/Beat'em Up/kim_lightyear_-_angel_eyes_vision_ii.mp3` | Kim Lightyear (OGA: KLY) | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) or [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) ([OpenGameArt](https://opengameart.org/content/angel-eyes-vision-ii), byte-identical). Credit: "Angel Eyes (Vision II)" by Kim Lightyear. Preserve ShareAlike terms for adaptations. | Code City Brawl: Git HQ | ⚠️ source verified; license-policy sign-off and version selection | | The Final | `Audio/Beat'em Up/Kim Lightyear - The Final.mp3` | Kim Lightyear (OGA: KLY) | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) ([OpenGameArt](https://opengameart.org/content/the-final), byte-identical). Credit: "The Final" by Kim Lightyear. | Code City Brawl: boss | ⚠️ source verified; license-policy sign-off | -| Boss Fight 2 | `Audio/Beat'em Up/Boss Fight 2.mp3` | Luis Zuno (ansimuz) | CC0 ([OpenGameArt](https://opengameart.org/content/boss-fight-2), byte-identical to `Boss Fight 2 Files/Boss Fight 2.mp3` inside `boss_fight_2_files.zip`). Attribution optional. | Not referenced; bundled in Code City Brawl folder | ✅ | +| MERGE SUCCESSFUL jingle and victory loop | `Audio/Beat'em Up/code_city_victory_jingle.wav`, `code_city_victory_loop.wav` | Original project compositions and square-wave synthesis; no third-party samples | CC0 1.0. The project dedicates these compositions and recordings to the public domain under the [CC0 1.0 Universal dedication](https://creativecommons.org/publicdomain/zero/1.0/). Deterministic source: `tools/build_code_city_victory_music.py`; build and playback notes: `docs/soundtrack.md`. | Code City Brawl: celebration, ending pages and results | ✅ | +| Boss Fight 2 | `Audio/Beat'em Up/Boss Fight 2.mp3` | Luis Zuno (ansimuz) | CC0 ([OpenGameArt](https://opengameart.org/content/boss-fight-2), byte-identical to `Boss Fight 2 Files/Boss Fight 2.mp3` inside `boss_fight_2_files.zip`). Attribution optional. | Code City Brawl: Stack Overflow and Zero-Day district-boss fights (`Catalog.MINI_BOSS_MUSIC`) | ✅ | | Hero Immortal | `Audio/Beat'em Up/Hero vs Villian/Hero Immortal.mp3` | Trevor Lentz | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) ([OpenGameArt](https://opengameart.org/content/hero-immortal), byte-identical). Credit: "Hero Immortal" by Trevor Lentz. Preserve ShareAlike terms for adaptations. | Heroes vs. Villains: Generation Floor | ⚠️ source verified; license-policy sign-off | | 8-bit - Slay The Evil | `Audio/Beat'em Up/Hero vs Villian/8-bit_slay_the_evil.ogg` | HydroGene | CC0 ([OpenGameArt](https://opengameart.org/content/8-bit-slay-the-evil), byte-identical). Attribution optional. | Heroes vs. Villains: AI SLOP | ✅ | | Frivolous Hero | `Audio/Beat'em Up/Hero vs Villian/frivolous_hero.ogg` | Alexandr Zhelanov | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([OpenGameArt](https://opengameart.org/content/frivolous-hero), byte-identical). Credit: "Frivolous Hero" by Alexandr Zhelanov; retain the supplied [creator link](https://www.youtube.com/channel/UCxmng6_DMIayDwkiWGVzVRQ?view_as=subscriber) and contact totalnomus@gmail.com. | Heroes vs. Villains: Review Gauntlet | ✅ | @@ -37,7 +38,7 @@ Status key: ✅ verified · ⚠️ needs sign-off · ❌ **blocks public release ### Audio verification — 2026-10-09 -Checked all 12 supplied OpenGameArt pages and compared the corresponding downloads with the local files using SHA-256; all 12 are byte-identical. Also located and checksum-matched `tipa16.mp3` to **moon chime** (CC0), resolving its previous unknown-source blocker. `Boss Fight 2.mp3` was missing from this table and is now listed. Runtime references place **Valor of Heroes** in Orbital Ops, not Code City Brawl; **Boss Fight 2** is bundled but has no runtime reference. +Checked all 12 supplied OpenGameArt pages and compared the corresponding downloads with the local files using SHA-256; all 12 are byte-identical. Also located and checksum-matched `tipa16.mp3` to **moon chime** (CC0), resolving its previous unknown-source blocker. `Boss Fight 2.mp3` was missing from this table and is now listed. Runtime references place **Valor of Heroes** in Orbital Ops, not Code City Brawl; **Boss Fight 2** now plays during Code City Brawl's Stack Overflow and Zero-Day district-boss fights via `Catalog.MINI_BOSS_MUSIC`. The audio inventory is covered by the individual tracks and pack rows above, plus the eight project-original Heroes vs. Villains cues below. No additional unidentified audio files were found. Existing pack verifications are retained; this pass does not re-establish their earlier byte-match claims. @@ -54,7 +55,7 @@ These assets were made for this project, either by hand, by procedure, or with i | Asset | Path | Notes | Status | |---|---|---|---| | Hoppy 2D sprite pack | `Character/Hoppy/2D/hoppy-sprite-pack/` | Generated from internal comic references | ⚠️ brand sign-off | -| Hoppy brawler combat supplement | `code-city-brawl-player/` | Image generation; exact prompt in `PROMPT.md`, reproducible atlas packing in `tools/build_hoppy_brawler.py` | ⚠️ brand sign-off and project asset terms | +| Hoppy brawler combat supplement | `code-city-brawl-player/` | Image generation; exact combat and victory prompts in `PROMPT.md`, untouched masters `source.png` and `victory-source.png`, reproducible atlas packing in `tools/build_hoppy_brawler.py` | ⚠️ brand sign-off and project asset terms | | Hoppy armed supplement | `hoppy-armed/` | Built-in image generation; exact prompts in each mode's `PROMPTS.md` / `refinements.json`, untouched masters in `sources/`, deterministic packing in `tools/build_hoppy_armed.py`. See its [`README.md`](hoppys-adventure-arcade/Assets/hoppy-armed/README.md). | ⚠️ brand sign-off and project asset terms | | Beat'em-up story illustrations | `beatemup-story/` | Built-in image generation; exact prompts and regeneration history in `refinements.json`, untouched masters in `sources/`, deterministic packing in `tools/build_story_scenes.py`. | ⚠️ brand sign-off and project asset terms | | Hoppy 3D mascot | `runner/hoppy/` | Built by `tools/blender/build_hoppy.py` | ⚠️ brand sign-off | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66164492..41f3a684 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ godot --headless --path hoppys-adventure-arcade --script res://tests/runner_logi godot --headless --path hoppys-adventure-arcade --script res://tests/platformer_smoke.gd ``` -CI runs every headless suite listed in `.github/workflows/ci.yml`. Add a focused regression test when you change behavior. Some suites capture screenshots when run without `--headless`; include those in the PR for visual changes. On macOS, pass `--always-on-top` for graphical runs so the capture doesn't stall when the window is covered. +CI runs every headless suite listed in `.github/workflows/ci.yml`. It also runs `python3 tools/checks/exit_leak.py --godot `, which quits from screens with audio playing and fails on Godot's "leaked at exit" lines. Add a focused regression test when you change behavior. Some suites capture screenshots when run without `--headless`; include those in the PR for visual changes. On macOS, pass `--always-on-top` for graphical runs so the capture doesn't stall when the window is covered. Per-mode notes and test details live in [`docs/`](docs/). diff --git a/docs/code-city-brawl.md b/docs/code-city-brawl.md index 0f7d29ea..1ba537de 100644 --- a/docs/code-city-brawl.md +++ b/docs/code-city-brawl.md @@ -14,33 +14,48 @@ Launch Godot 4.7.1 and choose **Beat'em Up → Play Code City Brawl** from the a | Pick up nearby weapon | E or K | Y / Triangle | | Pause / resume | Escape | Start | -W/Up changes the ground lane. Jump is a separate height above that lane. Attacks have a forward reach and depth tolerance, so standing in another lane avoids melee. Jump above ground attacks and projectiles, or sidestep their telegraphs. Late attack input buffers one next combo step; a gap of more than 0.48 seconds after recovery resets the chain. +W/Up changes the ground lane. Jump is a separate height above that lane. Attacks have a forward reach and depth tolerance, so standing in another lane avoids melee. Jump above ground attacks and projectiles, or sidestep their telegraphs. Late attack input buffers one next combo step; a gap of more than 0.48 seconds after recovery resets the chain. Hoppy keeps his facing through a swing, so its hit area never flips mid-attack. Pressing the other way during or right after a swing turns him for the next one, buffered combo steps included. A quick tap mid-swing is remembered until the swing ends. -Hoppy has three HP. Coffee restores one HP. Each stage begins with fresh health and equipment. Losing the last HP knocks Hoppy out. Play freezes under a white flash, he is thrown back away from the hit and topples over, lands with a dust puff and a bounce, and lies there with dizzy stars and a "K.O." callout while the music ducks. About 2.2 seconds after the hit, the game-over menu offers a retry from that stage's first encounter, restoring the score saved on entry. Pause freezes combat, effects and music, including during the introduction and stage transitions; losing window focus also pauses. Return-to-menu is available from pause, intros and results. +Hoppy has three HP. Coffee restores one HP. Each stage begins with fresh health and equipment. Losing the last HP plays a knockout cinematic, the same in both brawler modes: +- A slow-motion throw under a camera push and letterbox bars, with the HUD, minimap and control hints hidden. +- Hoppy lying in the defeat pose with orbiting stars. +- A REVIEW FAILED card. -There are three gated encounters per district. Clear every living combatant, including split soldiers and reviving zombies, then walk right. The ninth encounter is the boss. No confirm press is needed between stages: +The sequence lasts about 3.5 seconds and restores the camera and HUD when it ends or is cancelled. The game-over menu then offers a retry from that stage's first encounter, restoring the score saved on entry. Pause freezes combat, effects and music, including during the introduction and stage transitions; losing window focus also pauses. Return-to-menu is available from pause, intros and results. + +There are three gated encounters per district. Clear every living combatant, including split soldiers and reviving zombies, then walk right. In the first two districts, clearing the third encounter's wave calls in a [district boss](#district-bosses) instead of opening the gate. The ninth encounter is the Grand Merge Conflict. No confirm press is needed between stages: - **Introduction.** Three painted story scenes open the run: Code City under siege, the review pipeline breached, and Hoppy clocking in on a rooftop over the districts. - Each scene slowly pans and zooms under letterbox bars while its caption types out, then dips to black. - Each turns by itself after 4.5 seconds while a bar in the lower letterbox drains. - In the lower bar, **Next →** turns a scene sooner, **Skip intro** goes straight to the first district, and **Return to Beat'em Up menu** leaves. -- **Stage exit and entry.** Walk through the third encounter's open gate and Hoppy keeps running out on his own under a DISTRICT SECURED banner. The screen dips to black while the next district loads, then he runs in under its title banner. The sequence takes about 2–3 seconds; the title banner stays up for 2.8 seconds and fades over the start of combat, and messages raised in that time (such as the encounter counter) wait until it clears. The first room's enemies wait 0.8 seconds before their first telegraph. Retry uses the same run-in. +- **District boss down.** When a district boss falls, play stops and its adds and threats clear. Hoppy hops once in his victory pose under a DISTRICT BOSS DOWN banner (+500 points) for 1.4 seconds. +- **Stage exit and entry.** After the victory hop, Hoppy runs off to the right on his own under a DISTRICT SECURED banner. The screen dips to black while the next district loads, then he runs in under its title banner. The sequence takes about 2–3 seconds; the title banner stays up for 2.8 seconds and fades over the start of combat, and messages raised in that time (such as the encounter counter) wait until it clears. The first room's enemies wait 0.8 seconds before their first telegraph. Retry uses the same run-in. - **Boss music** dips, swaps tracks at the bottom and comes back up over 0.6 seconds instead of cutting hard. -- **Ending.** The Force Merge finisher leads into a painted victory epilogue, and then the victory screen completes the run. +- **Celebration.** After the Force Merge finisher, a 5.2-second celebration plays: + - Hoppy's three-frame victory cheer, confetti, a camera push and a MERGE SUCCESSFUL banner, with the HUD, minimap and control hints hidden until it ends. + - The boss track ducks, and an original 3.5-second square-wave jingle plays once. + - Then a 16.552-second F-major victory loop starts and continues through the ending pages and results. + - Pause freezes the choreography and the jingle. Rebuilding the stage or leaving cancels the celebration. +- **Ending.** The celebration leads into a painted victory epilogue, and then the victory screen completes the run. - The epilogue is three story scenes, played like the introduction: the Grand Merge Conflict resolving, Code City coming back online, and Hoppy shipping it on a rooftop at sunrise. - Each scene holds for 5 seconds. **Next →** turns a scene sooner and **Skip** goes straight to the victory screen. Pause freezes the epilogue and resumes it on the same scene. - The victory screen counts the final score up from zero over 1.8 seconds and shows the time taken, then **Return to Beat'em Up menu** takes focus. - The victory screen and game-over menu ignore confirm, click and touch input for their first 0.5 seconds, so a jump or attack press carried over from combat can't select an option. After that, the first press lands the score count at once. - **Minimap.** A minimap sits in the top-right corner, under the HUD bar and clear of the boss bar. - - Its top row shows the whole run: all nine encounters as tiles, grouped by district. Cleared encounters are filled, the current one pulses, and the boss encounter is marked. - - Below that is the current encounter, with Hoppy, the enemies and the boss as live dots over the depth band, and the gate (red while closed, dashed once open). + - Its top row shows the whole run: all nine encounters as tiles, grouped by district. Cleared encounters are filled, the current one pulses, and the final boss encounter is marked. + - Below that is the current encounter, with Hoppy, the enemies and the boss (a district boss too) as live dots over the depth band, and the gate (red while closed, dashed once open). - It hides during the story scenes, the victory screen, game over and while a stage title banner is up. ![Hoppy running into the Security Sector under its title banner](previews/code-city/stage_banner.png) | Opening story scene | Knockout on the last HP | |---|---| -| ![The first story scene of the introduction](previews/code-city/intro.png) | ![Hoppy knocked out, with dizzy stars and the K.O. callout](previews/code-city/knockout.png) | +| ![The first story scene of the introduction](previews/code-city/intro.png) | ![Hoppy knocked out under letterbox bars and the REVIEW FAILED card](previews/code-city/knockout.png) | + +| District boss down | Running off to the next district | Celebration | +|---|---|---| +| ![Hoppy cheering under the DISTRICT BOSS DOWN banner as Stack Overflow fades](previews/code-city/district_boss_down.png) | ![Hoppy running off to the right under the DISTRICT SECURED banner](previews/code-city/district_boss_runoff.png) | ![Hoppy cheering by the merge button under confetti and the MERGE SUCCESSFUL banner](previews/code-city/celebration.png) | | Minimap | Victory epilogue | Victory screen | |---|---|---| @@ -69,12 +84,32 @@ CR Bot supplies an Approval Shield, the Linting Fairy supplies speed, and Senior | Firewall Shield | 3 | Absorbs three hits while retaining the unarmed combo | | Refactor Hammer | 3 | Slow, four-damage single-target strike | -Each weapon has a baked sheet of Hoppy holding it, so grip, pixel scale and layering match the body in every frame. With a weapon equipped, idle, side running, pickup and hurt play the sheet's `hold_idle`, `run`, `equip` and `hurt` clips. The three combo steps play `step1`–`step3`: thrusts and alternating sweeps for the stick, staff and branch; a crack, an extended lash and a recoil for the whip; overhead and side swings for the hammer. Each step shows a windup, contact or follow-through frame chosen from the attack clock. Contact occurs at 44% of the swing, inside the contact frame. The shield stays in guard during the unarmed combo and shows its block-recoil frame on blocks. Hits burst on the enemy; swing and miss effects start at the weapon tip (the sheet's contact-frame anchor), mirrored with facing, and a trail follows the contact frame. Poses with no armed frame (walking toward or away from the camera, and jumping) draw a pixel-matched carry icon at Hoppy's paw. It draws behind the body in the back view and on jumps, where the paw is at his chin, and the shield also does so walking toward the camera, so no carry covers his face. Facing locks through each swing, and damage cancels the motion. Reach, timing, durability and damage are unchanged. A weapon without a sheet would fall back to the older hand-animated weapon cel. +Each weapon has a baked sheet of Hoppy holding it, so grip, pixel scale and layering match the body in every frame. With a weapon equipped, idle, side running, pickup and hurt play the sheet's `hold_idle`, `run`, `equip` and `hurt` clips. Walking toward or away from the camera plays its `walk_down` or `walk_up` clip when the sheet has one. The three combo steps play `step1`–`step3`: thrusts and alternating sweeps for the stick, staff and branch; a crack, an extended lash and a recoil for the whip; overhead and side swings for the hammer. Each step shows a windup, contact or follow-through frame chosen from the attack clock. Contact occurs at 44% of the swing, inside the contact frame. The shield stays in guard during the unarmed combo and shows its block-recoil frame on blocks. Hits burst on the enemy; swing and miss effects start at the weapon tip (the sheet's contact-frame anchor), mirrored with facing, and a trail follows the contact frame. Poses with no armed frame (jumping, and walking toward or away from the camera on a sheet without those clips) draw a pixel-matched carry icon at Hoppy's paw. It draws behind the body in the back view and on jumps, where the paw is at his chin, and the shield also does so walking toward the camera, so no carry covers his face. Facing holds through each swing, and damage cancels the motion. Reach, timing, durability and damage are unchanged. A weapon without a sheet would fall back to the older hand-animated weapon cel. ![All six armed weapons at the contact frame](previews/code-city-motion/weapons_44.png) Weapons require the pickup action; consumables collect on contact. A successful offensive swing consumes one durability charge even if it hits multiple targets. Misses cost no charge. Breaking a weapon returns Hoppy to the unarmed combo. The hammer selects the first valid enemy in encounter order, then props if no enemy was hit; it is not a nearest-target lock-on system. +## District bosses + +The first two districts end on a district boss instead of a gate. It is called in when the third encounter's wave is cleared, drops in on the right of the room and takes the boss bar (DISTRICT BOSS · name). The music dips into the district-boss fight track, and the district's stage hazards pause for the fight. The bar and its label clear as soon as the boss falls. + +| District | Boss | HP | Adds below half HP | +|---|---|---:|---| +| CLI/IDE District | STACK OVERFLOW, a tottering tower of call-stack frames | 14 | Two CLI Gremlins | +| Security Sector | ZERO-DAY, a cloaked exploit knight | 18 | Two Dependency Bots | + +- **No openings to wait for.** Every hit lands, and every fourth hit makes the boss reel for 0.6 seconds, dropping the move it was winding up. +- **Slam.** The boss winds up while a ground warning marks Hoppy's spot, leaps onto it and lands as the warning fires. A shot then runs along the lane in each direction. Jump the shots or change lanes. +- **Dash.** The boss lines up on Hoppy's lane behind a row of warning markers and charges across the room. +- **Enrage.** Below half HP its wind-ups are 20% shorter, and it calls in two adds, once. + +Each boss has its own 16-frame sheet in the Code City pack (`stack_overflow`, `zero_day`), drawn at the enemies' scale so it stands about 1.6 times as tall as a Linter Enforcer. A boss whose sheet is missing from the pack manifest draws a stand-in at about twice that enemy's size instead: the Linter Enforcer for Stack Overflow and the Force Push Bandit for Zero-Day. + +| Stack Overflow winding up a slam | Zero-Day winding up a slam | +|---|---| +| ![Stack Overflow, a tower of call-stack frames, telegraphing a slam onto Hoppy's spot](previews/code-city/mini_boss_1.png) | ![Zero-Day, a cloaked exploit knight, telegraphing a slam onto Hoppy's spot](previews/code-city/mini_boss_2.png) | + ## The Grand Merge Conflict The boss has 24 HP and exposes its core during recovery. Land the third unarmed combo hit or an offensive weapon strike during that opening. While exposed, the core adds 70 pixels of forward reach and 44 pixels of depth tolerance to the attack: an unarmed finisher reaches 180 pixels forward with ±90 pixels of lane tolerance (previously 110 / ±46). This forgiveness applies only to the boss during recovery; facing, finisher requirements and recovery timing are unchanged. @@ -83,7 +118,7 @@ The boss has 24 HP and exposes its core during recovery. Land the third unarmed 2. **Split Screen (50% HP):** flanks to the other side between opposing projectile volleys, with a cyan/red screen divide. 3. **Merge Chaos (25% HP):** faster flanking, committed jumping slams, projectile storms with a safe lane, bounded reinforcements and mild screen distortion. -A leap commits to the warned destination; it does not follow Hoppy after takeoff. The ground warning includes the 0.65-second flight. Landings carry impact effects and camera feedback. Recovery keeps the boss still for 2.4 seconds in phase 1 and 2.1 seconds thereafter. Phase 3 holds the staggered `hurt` pose with a breathing animation, a pulsing core ring, and a persistent FINISHER cue. The ring drains from the same recovery timer used for vulnerability, freezes on pause, and clears on recovery exit or defeat. `core_exposure.gd` draws this cue over the existing art; no damage timing or hit area changes are tied to the visual. Phase transitions clear old threats and cancel travel. The final hit starts the automatic Force Merge jump/slam, resolves the core and awards 1,000 points. The victory epilogue and victory screen follow, and the run counts as complete once. +A leap commits to the warned destination; it does not follow Hoppy after takeoff. The ground warning includes the 0.65-second flight. Landings carry impact effects and camera feedback. Recovery keeps the boss still for 2.4 seconds in phase 1 and 2.1 seconds thereafter. Phase 3 holds the staggered `hurt` pose with a breathing animation, a pulsing core ring, and a persistent FINISHER cue. The ring drains from the same recovery timer used for vulnerability, freezes on pause, and clears on recovery exit or defeat. `core_exposure.gd` draws this cue over the existing art; no damage timing or hit area changes are tied to the visual. Phase transitions clear old threats and cancel travel. The final hit starts the automatic Force Merge jump/slam, resolves the core and awards 1,000 points. The celebration, victory epilogue and victory screen follow, and the run counts as complete once. ## Audio and assets @@ -92,17 +127,19 @@ A leap commits to the warned destination; it does not follow Hoppy after takeoff | CLI/IDE and mode menu | `Zodik - Future Travel.ogg` | | Security | `helldrake(extended).ogg` | | Git HQ | `kim_lightyear_-_angel_eyes_vision_ii.mp3` | +| District bosses | `Boss Fight 2.mp3` | | Boss | `Kim Lightyear - The Final.mp3` | Music uses independently duplicated looping streams on the Music bus. Ten reusable positional voices play punch, damage, pickup, jump and fanfare sounds from Juhani Junkala's Essential Retro collection on the SFX bus. Shared imported audio resources are not mutated. -The existing [Code City Brawl pack](code-city-brawl-assets.md) supplies environments, enemies, boss, items, weapons and effects. `Assets/code-city-brawl-player/` adds 16 Hoppy combat/depth-movement frames, plus the source image, exact generation prompt and atlas manifest. Side-running and death reuse the Platformer Hoppy resource. Repack the combat source with `tools/build_hoppy_brawler.py` (Pillow required). `Assets/hoppy-armed/code_city/` adds the six armed sheets (16 frames in 192×128 cells, foot pivot at 72,118), the carry icons, the untouched generated masters, the exact prompts and a manifest of clips and anchors. Rebuild it with `uvx --with pillow python tools/build_hoppy_armed.py --mode code_city`; the [pack README](../hoppys-adventure-arcade/Assets/hoppy-armed/README.md) describes the layout. `Assets/beatemup-story/code_city/` holds the three generated story scenes and the three victory-epilogue scenes at 1600×900, with their untouched masters, exact prompts and regeneration history. Rebuild them with `uvx --with pillow python tools/build_story_scenes.py`. Licensing and provenance status are recorded in [ASSETS_LICENSES.md](../ASSETS_LICENSES.md). +The existing [Code City Brawl pack](code-city-brawl-assets.md) supplies environments, enemies, boss, items, weapons and effects. `Assets/code-city-brawl-player/` adds 16 Hoppy combat/depth-movement frames, plus the source image, exact generation prompt and atlas manifest. Side-running and death reuse the Platformer Hoppy resource. Repack the combat source with `tools/build_hoppy_brawler.py` (Pillow required). `Assets/hoppy-armed/code_city/` adds six armed sheets with 30 populated 192×128 cells each (26 active, plus four unused legacy run cells), a six-frame side run and four-frame up/down walks, with the pivot at (72,118). Body registration keeps the torso aligned across movement, idle, equip and hurt while the legs cycle. The pack also includes carry icons, untouched generated masters, exact prompts and a manifest of clips, body landmarks and anchors. Rebuild it with `uvx --with pillow python tools/build_hoppy_armed.py --mode code_city`; the [pack README](../hoppys-adventure-arcade/Assets/hoppy-armed/README.md) describes the layout. `Assets/beatemup-story/code_city/` holds the three generated story scenes and the three victory-epilogue scenes at 1600×900, with their untouched masters, exact prompts and regeneration history. Rebuild them with `uvx --with pillow python tools/build_story_scenes.py`. Licensing and provenance status are recorded in [ASSETS_LICENSES.md](../ASSETS_LICENSES.md). ## Implementation and verification -Runtime scripts are in `modes/beatem_up/`: `catalog.gd` owns immutable tuning (including the `FLOW` timings) and music mappings; `player.gd`, `enemy.gd` and `boss.gd` own instance state; `player.gd` picks the armed or unarmed frames for each pose; `weapon_rig.gd` draws the carry icon, the contact trail and the fallback weapon cel from the player attack clock; `game.gd` owns encounters, UI, items, audio and cleanup. `stage_flow.gd`, shared with Heroes vs. Villains, runs the stage exit and entry sequence, the black veil, the banner and the music envelope from the game's physics tick rather than tweens, so it freezes on pause and headless tests can step it. Four more shared scripts work the same way: +Runtime scripts are in `modes/beatem_up/`: `catalog.gd` owns immutable tuning (including the `FLOW` timings and the `MINI_BOSSES` table) and music mappings; `player.gd`, `enemy.gd`, `boss.gd` and `mini_boss.gd` own instance state (`mini_boss.gd` is the district boss, sharing `game.gd`'s boss slot and `boss.gd`'s duck-typed interface); `player.gd` picks the armed or unarmed frames for each pose; `weapon_rig.gd` draws the carry icon, the contact trail and the fallback weapon cel from the player attack clock; `game.gd` owns encounters, UI, items, audio and cleanup. `stage_flow.gd`, shared with Heroes vs. Villains, runs the stage exit and entry sequence, the black veil, the banner and the music envelope from the game's physics tick rather than tweens, so it freezes on pause and headless tests can step it. Four more shared scripts work the same way: - `story_scene.gd` draws a story scene (pan and zoom, letterbox, typed caption, dip) from the intro clock. It plays the catalog's `STORY` pages for the opening and its `ENDING` pages for the victory epilogue. - `knockout.gd` plays the last-HP knockout before `game_over()`, with the catalog's `KNOCKOUT` tuning. +- `celebration.gd` (Code City only) plays the celebration between the Force Merge and the ending pages. - `victory_screen.gd` counts up the final score on the results screen. Its copy is the catalog's `VICTORY`. - `minimap.gd` draws the top-right minimap from a per-tick snapshot of the room. @@ -112,6 +149,8 @@ Runtime scripts are in `modes/beatem_up/`: `catalog.gd` owns immutable tuning (i godot --headless --path hoppys-adventure-arcade --import godot --headless --path hoppys-adventure-arcade --script res://tests/code_city_assets.gd godot --headless --path hoppys-adventure-arcade --script res://tests/code_city_logic.gd +godot --headless --path hoppys-adventure-arcade --script res://tests/code_city_mini_boss.gd +godot --headless --path hoppys-adventure-arcade --script res://tests/code_city_celebration.gd godot --headless --path hoppys-adventure-arcade --script res://tests/code_city_motion.gd godot --headless --path hoppys-adventure-arcade --script res://tests/code_city_smoke.gd godot --headless --path hoppys-adventure-arcade --script res://tests/stage_flow.gd @@ -123,15 +162,29 @@ godot --headless --path hoppys-adventure-arcade --script res://tests/beatemup_kn godot --path hoppys-adventure-arcade --always-on-top --script res://tests/code_city_smoke.gd godot --path hoppys-adventure-arcade --always-on-top --script res://tests/code_city_motion.gd godot --path hoppys-adventure-arcade --always-on-top --script res://tests/beatemup_knockout.gd +godot --path hoppys-adventure-arcade --always-on-top --script res://tests/capture_code_city_bosses.gd ``` -The logic suite covers combo buffering, attack deduplication and depth, weapon durability, armor, shields/statuses, hammer target count, soldier splits, zombie revival, gate ownership, pickup rules, hazard warnings, jumping, pause, boss thresholds, Force Merge, retry cleanup and all four music mappings. The smoke suite injects keyboard/gamepad events, traverses all nine encounters with no confirm input between stages, verifies bounded pools and one completion, and returns to the menu. `stage_flow.gd` checks the director's phase order and durations, a single stage rebuild on black, pause during every phase, the music envelope and boss-music dip, retry, cancellation on game over and return-to-menu, and the 0.5-second menu guard. `hoppy_armed_assets.gd` requires all eleven armed sheets in both modes and checks their frames, clips, pivots, anchors, body height and carry icons. `beatemup_story.gd` checks, for both modes, that each story scene's caption types and its pan moves on the clock, the dips and bars, Next / Skip / the one-press card skip, the pause freeze, and the hands-free hand-off into the first stage. `beatemup_knockout.gd` covers the knockout in both modes: +The logic suite covers combo buffering, turning between swings and on buffered steps, attack deduplication and depth, weapon durability, armor, shields/statuses, hammer target count, soldier splits, zombie revival, gate ownership, pickup rules, hazard warnings, jumping, pause, boss thresholds, Force Merge, retry cleanup and all four music mappings. The smoke suite injects keyboard/gamepad events, traverses all nine encounters with no confirm input between stages, beats both district bosses, verifies bounded pools and one completion, and returns to the menu. `code_city_mini_boss.gd` checks that districts 1 and 2 call in their district boss and district 3 keeps the Grand Merge Conflict; the boss's stagger, enrage and adds; and the hands-free defeat: victory hop, run-off and next district. It kills both district bosses and the Grand Merge Conflict through real swings, armed and unarmed, and checks that the victory pose holds with the weapon hidden and no i-frame flicker. It also checks: +- the district-boss fight track and the district track's return; +- the boss bar's colour, and that it clears after a district boss falls; +- pause during the hop and during the celebration; +- that a retry clears the boss. `stage_flow.gd` checks the director's phase order and durations, a single stage rebuild on black, pause during every phase, the music envelope and boss-music dip, retry, cancellation on game over and return-to-menu, and the 0.5-second menu guard. `hoppy_armed_assets.gd` requires all eleven armed sheets in both modes and checks their frames, clips, pivots, anchors, body height and carry icons. `beatemup_story.gd` checks, for both modes, that each story scene's caption types and its pan moves on the clock, the dips and bars, Next / Skip / the one-press card skip, the pause freeze, and the hands-free hand-off into the first stage. `beatemup_knockout.gd` covers the knockout in both modes: - Hoppy is knocked out by direct and live hits (bullets, ground warnings, enemy and boss contact). -- Phase timing: the menu appears only at about 2.2 seconds. +- Phase timing: the slow-motion throw, the camera push and letterbox bars, the card, and the menu after about 3.5 seconds (never more than 4). +- The HUD, minimap and control hints are hidden through the cinematic and restored after it, and the camera is restored on completion or cancellation. - The pause freeze, and that a double knockout is ignored. - Retry, and the menu's input guard. -Rendered runs save previews in `docs/previews/code-city/`. `--always-on-top` keeps macOS from skipping frames while the window is covered, which otherwise stalls the captures. +`code_city_celebration.gd` checks the celebration's assets and plays it on a stub owner: +- the victory clip and the click-free jingle and loop; +- `done` called once at 5.2 seconds; +- the boss-track duck, then the jingle; +- the switch to the looping victory track, which keeps playing afterwards; +- the camera push and its restore; +- cancellation at each stage. + +Rendered runs save previews in `docs/previews/code-city/`. `capture_code_city_bosses.gd` steps the game into each district boss and the finale and writes the boss, hop, run-off and celebration stills. The rendered knockout run saves its frames under `/tmp/s3-captures/`; `knockout.png` is its REVIEW FAILED card. `--always-on-top` keeps macOS from skipping frames while the window is covered, which otherwise stalls the captures. The traversal bot uses invulnerability to establish reachability; it does **not** validate difficulty balance. Physical controllers, exported builds and mobile/web targets have not been tested. This v1 uses three finite panoramic rooms per district with repeated scenery and curated enemy waves; prop bodies do not obstruct movement. Boss attacks use ground-plane hit geometry, not pixel-perfect sprite collision. These are explicit starting points for the next gameplay-tuning pass. @@ -150,3 +203,14 @@ Phase 3 exposure polish: logic and motion regression tests passed, including bot ### Hands-free stage flow and armed sheets — October 8, 2026 Godot 4.7.1: the project imported headless and all 26 headless suites listed in CI passed, including `stage_flow` and `hoppy_armed_assets`. The rendered smoke run completed all nine encounters with no confirm input between stages. The rendered motion run's `weapons_25/44/70` captures were compared against the armed sheet cells for all six weapons, and the stage fade and banner captures were inspected. Ruff and `git diff --check` passed. As before, the traversal bot checks reachability, not difficulty. + +### Brawl polish: district bosses, celebration and knockout cinematic — October 10, 2026 + +Godot 4.7.1, on the integrated brawl branch with the armed-locomotion and mini-boss sheets and the celebration and knockout work merged in. These suites passed headless: +- the Code City suites: `code_city_logic`, `code_city_mini_boss` (on the real boss sheets), `code_city_celebration`, `code_city_motion`, `code_city_assets` and `code_city_smoke`; +- the shared suites: `stage_flow`, `beatemup_story`, `beatemup_knockout`, `beatemup_minimap` and `hoppy_armed_assets`; +- the Heroes vs. Villains suites: logic, smoke, motion and assets. + +The rendered smoke run completed all nine encounters through both district bosses and the celebration. It now waits for a graphical start to settle before changing scenes; without that wait the scene change failed. The rendered knockout run passed in both modes. + +A validator review then found that a boss's last hit, landed by a real swing, lost Hoppy's victory pose. The rest of that player tick re-posed him and re-showed his weapon. The tests had defeated bosses by calling their damage method directly, so they missed it. The player now stops its tick once a swing ends play, and `code_city_mini_boss` kills both district bosses and the Grand Merge Conflict through real swings. The `capture_code_city_bosses.gd` stills were first saved at 1280×752 because the maximized window opened 32 px taller than 720. The script now sizes the window to 1280×720 before capturing. The re-shot stills, including the swing-driven DISTRICT BOSS DOWN hop, were inspected at that size. diff --git a/docs/endless-runner-contract.md b/docs/endless-runner-contract.md index 2e9fc421..7beff317 100644 --- a/docs/endless-runner-contract.md +++ b/docs/endless-runner-contract.md @@ -1,6 +1,6 @@ # Endless Runner — Backend/Frontend Contract -Version 2.6 · 2026-10-09 · GDScript · v2 (stages, turns, bosses) is specified in §11 and overrides §2–§8 where they conflict +Version 2.7 · 2026-10-10 · GDScript · v2 (stages, 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 @@ -154,7 +154,7 @@ frontend may use it for deterministic cosmetics). |---|---| | `pause()` / `resume()` / `toggle_pause()` | Only while `running`. `resume()` re-syncs held input so nothing fires on unpause | | `retry()` | Unpauses and routes to `runner_game` (fresh session) | -| `retry_boss()` | v2.5 (§11.13): unpauses and routes to `runner_game`, which opens on the turn into the boss the run died at. Falls back to `retry()` when `boss_checkpoint` is empty | +| `retry_boss()` | v2.5 (§11.13): unpauses and routes to `runner_game`, which opens in front of the boss the run died at. Falls back to `retry()` when `boss_checkpoint` is empty | | `go_to_menu()` | Unpauses and routes to `runner` | | `hit_stop(seconds: float)` | Brief `Engine.time_scale` dip owned by the backend (max 0.12 s) | | `submit_name(name: String) -> bool` | Sanitises (trim, strip control chars, ≤ 16, blank → `Hoppy`) and renames this run's leaderboard entry. Returns `true` if persisted. Never adds a second entry | @@ -336,36 +336,38 @@ mouse-hover failures on a clean `main`; the docs validated it on 4.7.1. New runn ## 10. Change log +- 2.7 (2026-10-10): §11 — the automatic turn is gone and the corridor stays straight. Phases are `run → boss → stage_clear`: the drain at the end of `run` hands straight to `boss` on the tick it ends. Removed: the three turn signals (announcement, start, finish); property `next_turn_direction`; result field `turns_taken`; checkpoint keys `turns_taken` and `next_turn_direction`; the whole RunConfig Turn group (bend length, warning lead, graybox yaw, camera yaw and bank, swing fractions); the corridor's turn hook and the camera swing. `World/TurnRig` keeps its name as a static mount. §11.15 — the run intro: a new `intro` phase opens every run from stage 1 (full 6.0 s on a fresh run from the menu, short 2.2 s on `retry()`); a boss-checkpoint retry has none, and the boss's own short intro is its lead-in. The intro is not run time: `elapsed`, `distance` and the score's base rate start at the hand-over to `run`. New facade signals `run_intro(mode, seconds)`, `run_intro_beat(index, label)`, `run_intro_skipped()`, `run_intro_ended()`; read-only `run_intro_mode`, `run_intro_progress`; methods `can_skip_run_intro()`, `skip_run_intro()`; static `pending_intro_mode`; RunConfig group "Run intro"; `camera_rig.set_run_intro(progress, mode, focus)` reading the frontend's `data/run_intro_shots.gd`; optional HUD mount `ui/run_intro.tscn`. The player is held through the cinematic hold until the GO beat, and a jump / `ui_accept` skips after 0.3 s and before GO. The StageManager's unused `stage_rng` / `STAGE_SEED_MIX` are gone (the encounter's `fight_rng` draws every boss roll). - 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`. - 2.2 (2026-09-21): §11.3/§11.8 — `ui/post_fx.tscn` sanctioned as an optional HUD mount (CanvasLayer 9, PAUSABLE, idempotent `has_signal`-guarded `bind(game)`): one full-screen post pass for grade, grain, vignette, aberration, letterbox, speed streaks and Nitro lift, with the HUD keeping the pursuit vignette and corner bars unless the frontend hands them over via `post_fx.set_settings()`. -- 2.1 (2026-09-21): §11.4 — `turn_incoming(direction, seconds)` announces the corner `turn_warning_seconds` (3.0) ahead of the bend on the direction `turn_started` then repeats, with `next_turn_direction` on the facade; `boss_intro(kind, name, seconds)` opens the fight immediately before `boss_spawned`. §11.9/§11.10 — the camera swing becomes a ramp–hold–return envelope (`turn_swing_in_fraction` / `turn_swing_out_fraction` 0.3) and is retuned to `turn_camera_yaw_degrees` 55 / `turn_camera_bank_degrees` 12 / `turn_seconds` 2.4; `boss_intro_seconds` 1.2 → 3.0. -- 2.0 (2026-09-19): §11 v2 addendum — run phases (run → turn → boss → stage_clear), `StageManager`, `TurnRig`, boss encounters with telegraphed projectiles and auto-fire tool pickups, faster speed ramp (`time_to_max_speed` 100 s + per-stage bumps), pursuit alert signals, new facade signals/properties/result fields, boss visual hooks, projectile hitbox table, tool table, optional UI mounts (`pursuit_alert`, `boss_hud`, `stage_banner`, `stage_clear`), corridor `set_turn`, leaderboard `SCHEMA_VERSION` 2 with in-place migration of v1 boards. +- 2.1 (2026-09-21): §11.4 — a turn announcement signal warns of the corner 3.0 s ahead of the bend, in the direction the bend then takes, with `next_turn_direction` on the facade; `boss_intro(kind, name, seconds)` opens the fight immediately before `boss_spawned`. §11.9/§11.10 — the camera swing becomes a ramp–hold–return envelope (0.3 swing fractions), retuned to 55° yaw / 12° bank over a 2.4 s bend; `boss_intro_seconds` 1.2 → 3.0. (Turns removed in 2.7.) +- 2.0 (2026-09-19): §11 v2 addendum — run phases (run → turn → boss → stage_clear), `StageManager`, `TurnRig`, boss encounters with telegraphed projectiles and auto-fire tool pickups, faster speed ramp (`time_to_max_speed` 100 s + per-stage bumps), pursuit alert signals, new facade signals/properties/result fields, boss visual hooks, projectile hitbox table, tool table, optional UI mounts (`pursuit_alert`, `boss_hud`, `stage_banner`, `stage_clear`), a corridor turn hook, leaderboard `SCHEMA_VERSION` 2 with in-place migration of v1 boards. - 1.0 (2026-09-19): initial contract reconciled from the backend, procgen/tuning and frontend design passes. - 1.3 (2026-09-19): §4.3 — `result.leaderboard_error:int` added; a failed leaderboard write keeps `rank` 0 / `qualifies` false and emits no `leaderboard_saved`. A board whose `meta/version` is newer than the supported schema is one such failure: it reads as empty and `submit`/`rename` refuse to overwrite it, returning `ERR_FILE_UNRECOGNIZED`. - 1.2 (2026-09-19): §5.2 — ground-projection quads exempt from the 110 % rule on y; per-hazard draw-call budget stated. - 1.1 (2026-09-19): engine version policy (4.7.1 pin, 4.7.0 validation); AppRouter rule scoped to in-game overlays; routes wording clarified; leaderboard writes are atomic through a `.tmp` and a temporary `.bak` that is removed once the new board is in place, so a successful write leaves neither behind (a `.bak` survives only a crash mid-write, and `load_entries` falls back to it); Nitro cap arithmetic and viewport-scale restore semantics clarified; return-type rule generalised; mount resources validated as `PackedScene`; timed laser cycle spelled out. -## 11. v2 addendum — stages, turns, boss fights (contract 2.0) +## 11. v2 addendum — stages and boss fights (contract 2.0) Everything in §1–§10 stays in force unless a rule below replaces it. The user decisions behind v2: **endless -with escalating stages** (never a finite campaign), **automatic cinematic turns** (no turn input, no new way to -lose), **tools auto-fire on pickup** (no fire button). Backend detail lives in `docs/endless-runner-systems.md` §10. +with escalating stages** (never a finite campaign) and **tools auto-fire on pickup** (no fire button). v2.0–2.6 +also had automatic cinematic turns between a stage's run and its boss; v2.7 removed them, and the corridor stays +straight. Backend detail lives in `docs/endless-runner-systems.md` §10. ### 11.1 Run phases `state` is unchanged (`ready → running → caught → finished`). A new orthogonal read-only property -`phase: StringName ∈ {idle, run, turn, boss, stage_clear}` is owned by `Systems/StageManager` and emitted through +`phase: StringName ∈ {idle, intro, run, boss, stage_clear}` is owned by `Systems/StageManager` and emitted through `phase_changed(phase)`. `caught` aborts any phase to `idle`. | Phase | Enters | Ends | Spawner | `run_clock` | |---|---|---|---|---| -| `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`), then the defeat cinematic (§11.14); or `boss_timeout_seconds` (`boss_retreated`), then `boss_outro_seconds` | idle | frozen | +| `intro` | v2.7 (§11.15): `start_run()` when a run intro is due (never on a checkpoint retry) | its length (`run_intro_full_seconds` / `run_intro_short_seconds`), or the skip tail; then `run` | idle | frozen | +| `run` | `start_run()` (after the intro) 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 | +| `boss` | run drained (on the same tick) | 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 @@ -400,7 +402,7 @@ with `stage_difficulty_step 0.25`; chunk tiers, weights and the §8 hazard table ```text World ├─ Floor, Chunks unchanged, never rotated -└─ TurnRig (Node3D, systems/turn_rig.gd) +└─ TurnRig (Node3D, systems/turn_rig.gd) v2.7: a static mount; the name is kept, nothing rotates it ├─ GrayboxEnvironment moved from World/ ├─ Environment moved from World/; mount environment/corridor.tscn └─ BossArena (boss/boss_arena.tscn, boss/boss_encounter.gd) @@ -413,12 +415,13 @@ Systems 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/run_intro.tscn (v2.7, §11.15), ui/post_fx.tscn (CanvasLayer 9, under the panels) ``` -Gameplay never rotates: chunks, hazards, projectiles, tools and the player stay in the unrotated frame, so every +Gameplay never rotates: chunks, hazards, projectiles, tools and the player stay in the World frame, so every z-test (`despawn_z`, `blast_range_z`, dodge line, autopilot) is unchanged. `RunnerGame.track_z(node)` / -`track_aabb(node)` are the rig-local accessors backend code uses (identity when the rig is unrotated). +`track_aabb(node)` are the rig-local accessors backend code uses (the identity, since the rig never rotates). ### 11.4 Facade additions (extends §4) @@ -429,8 +432,6 @@ Signals (additive; §4.1 unchanged): | `phase_changed(phase: StringName)` | StageManager | on change only | | `stage_started(index: int, name: StringName, speed: float)` | StageManager | `name` is the stage's boss display name, e.g. `&"Flaky Test Swarm"`; `speed` = base speed for the stage | | `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_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 | @@ -443,16 +444,20 @@ Signals (additive; §4.1 unchanged): | `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; `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 | +| `run_intro(mode: StringName, seconds: float)` | StageManager | v2.7 (§11.15): the run intro opens; `mode` is `&"full"` or `&"short"`; `phase` is already `intro` and the mirrors are set | +| `run_intro_beat(index: int, label: String)` | StageManager | v2.7: each beat as the intro's clock reaches it, in index order; full `title`, `3`, `2`, `1`, `GO!`, short `READY`, `GO!`; the last beat (GO) hands the player back before it is sent | +| `run_intro_skipped()` | StageManager | v2.7: the player skipped; the intro jumps to its last `run_intro_skip_tail` seconds, the beats jumped over are never sent and the GO beat follows at once | +| `run_intro_ended()` | StageManager | v2.7: exactly once per `run_intro`: played out, after a skip's tail, or at once on an abort (a catch, a new run) | | `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 | Read-only properties: `phase`, `stage_index: int`, `stage_name: StringName`, `stage_distance: float`, -`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`, +`stage_distance_target: float`, `run_clock: float`, `boss: Node3D` (null outside a fight), `boss_kind`, `boss_hp`, `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 +`boss_cinematic_progress: float` (0..1), `boss_time_factor: float` (1 outside a defeat); v2.7 (§11.15) +`run_intro_mode: StringName` (`&""` outside an intro) and `run_intro_progress: float` (0..1). Methods: v2.7 +`skip_run_intro() -> bool` and `can_skip_run_intro() -> bool`; 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` @@ -460,7 +465,7 @@ a skip prompt; §11.14), `get_stage_info() -> Dictionary` phase count, 3 today, and 0 in the empty block; each `pending_attacks` row is `{lane_mask, kind, remaining}` plus, v2.4, `{impact_mask, signature, committed, commit_remaining, attack}`, where `lane_mask` switches from the candidates to the committed lanes at the commit). `result` gains `stages_cleared`, -`stage_reached`, `bosses_defeated`, `bosses_escaped`, `tools_used`, `boss_damage_dealt`, `turns_taken` (all int); +`stage_reached`, `bosses_defeated`, `bosses_escaped`, `tools_used`, `boss_damage_dealt` (all int; v2.7 dropped `turns_taken`); v2.5 adds `checkpoint_retry: bool` (§11.13). Projectiles count in `hazards_dodged` (it is a stat, not a score input). `camera_rig.shake(0.35)` fires on every damaging tool hit (backend-owned, as in §4.2). @@ -577,9 +582,10 @@ than under `HUD`, and it is the sanctioned home for 3D fight effects, so no pane |---|---|---| | `ui/pursuit_alert.tscn` | Star Fox-style incoming-transmission panel with radar/pulse; states the catch rule | `pursuit_started`, `pursuit_progress`, `pursuit_alert`, `pursuit_recovered`, `damage_blocked`, `caught` | | `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_banner.tscn` | `STAGE n / ` banner, raised on `stage_started` unless a boss is on screen. 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`, `boss_intro`, `boss_spawned`, `phase_changed` | | `ui/stage_clear.tscn` | stage-clear card (time, tools used, bonus) | `stage_cleared`, `boss_defeated`/`boss_retreated`, `get_stage_info()` | | `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/run_intro.tscn` | v2.7 (§11.15): the run intro overlay (title card, countdown, GO burst, skip prompt). Driven **only** off `run_intro_progress` and the `run_intro_*` signals, no clock of its own; clears on `run_intro_ended`. Hoppy's victory pose under the title is the backend's (the `title` beat calls the player visual's `celebrate()`) | `run_intro`, `run_intro_beat`, `run_intro_skipped`, `run_intro_ended`, `run_intro_mode` / `run_intro_progress`, `can_skip_run_intro()`, `skip_run_intro()` | | `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()` | @@ -604,19 +610,12 @@ them for legibility (font ≥ 18 units, outline ≥ 6, panel-backed; see the v2 ### 11.9 Corridor (extends §5.5) -Optional methods, all `has_method`-guarded by the backend: `set_turn(progress: float, direction: int)` (called every -physics tick during `turn`, `progress` 0→1; the frontend owns the visual bend and must make p = 1 visually identical -to p = 0), `set_phase(phase: StringName)`, `set_boss_arena(active: bool)`. When the mounted corridor lacks `set_turn`, -the backend `TurnRig` rotates **itself** by `turn_yaw_degrees × smoothstep(progress)` (fallback only), so whatever -environment is mounted under it turns with it — the frontend corridor when present, otherwise the graybox -environment. Mounting a corridor still frees `GrayboxEnvironment` (§3); nothing is retained for the fallback. The camera swing -(`turn_camera_yaw_degrees` 55, `turn_camera_bank_degrees` 12, returning to zero at p = 1) is backend-owned in -`camera_rig.gd` and always applies. Its envelope is **ramp–hold–return**, not a wave: the yaw and the bank -smoothstep up to the peak over the first `turn_swing_in_fraction` (0.3) of the bend, **hold** there through the -middle, and smoothstep back to zero over the last `turn_swing_out_fraction` (0.3). The profile is exactly 0 at -p = 0 and p = 1 for any fractions, so the camera is never left displaced. Gameplay is unaffected: the treadmill -frame stays unrotated, and the frontend corridor is expected to match the 55° with its own yaw so the bend and -the camera agree. +Optional methods, all `has_method`-guarded by the backend: `set_phase(phase: StringName)` and +`set_boss_arena(active: bool)`. The StageManager reaches the mounted corridor through `TurnRig.corridor`. Mounting a +corridor still frees `GrayboxEnvironment` (§3). v2.7: the corridor is straight and never rotates; the v2.0–2.6 +corridor turn hook, the TurnRig's fallback yaw and the camera's turn swing are gone. The starship +corridor closes **both** ends at every scroll offset: the far bulkhead past the spawn horizon, and an entry +bulkhead behind Hoppy (z ≈ +24) that the run intro's reverse shots (§11.15) land on and gameplay never sees. ### 11.10 RunConfig additions (single tuning source: the `@export` defaults in `run_config.gd`; `run_config.tres` stays an empty resource that may override them and is never mutated at runtime — this supersedes the §7 heading's wording) @@ -630,8 +629,10 @@ Stages: stage_base_distance 900.0, stage_distance_step 250.0, stage_distance_ the player, because the stage banner is covering the lanes (§11.1) stage_banner_reaction_seconds 1.0 seconds of travel the nearest blocking hazard is still away the moment 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 +Run intro: run_intro_enabled true (tests turn it off on a duplicated config), run_intro_full_seconds 6.0, + run_intro_short_seconds 2.2, run_intro_skip_after 0.3, run_intro_skip_tail 0.4, + run_intro_full_beats ["title", "3", "2", "1", "GO!"] at run_intro_full_beat_times [1.0, 3.0, 3.8, 4.6, 5.4], + run_intro_short_beats ["READY", "GO!"] at run_intro_short_beat_times [1.0, 1.6] (v2.7, §11.15) 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 @@ -673,10 +674,10 @@ measured from the commit for a flaky beat, and **B6** proves that every commit, and landing, and every branching volley, leaves from each lane a survivable answer (final lane plus at most one jump or roll against the impact geometry) whose latest start is ≥ `boss_signature_reaction_min` after the event. `runner_smoke.gd` -proves stage 1 → turn → boss 1 → stage clear → stage 2 with zero projectile hits under a shortened config, and a +proves stage 1 → boss 1 → stage clear → stage 2 with zero projectile hits under a shortened config, and a catch mid-fight ending the run once. `runner_visual_smoke.gd` gains `HUD LEGIBILITY`, `PURSUIT ALERT`, -`BOSS VISUALS`, `TOOLS AND PROJECTILES`, `TURN` sections and a 30-recipe audio census (voices ≤ 10); the full loop -must cross a stage seam, a turn, a boss and a stage-clear card. +`BOSS VISUALS` and `TOOLS AND PROJECTILES` sections and an audio census (voices ≤ 10); the full loop must cross a +stage seam, a boss and a stage-clear card. ### 11.13 Boss checkpoint retry (contract 2.5) @@ -684,13 +685,13 @@ A catch during the `boss` phase no longer has to throw the run away. - **Snapshot.** `StageManager._enter_boss()` writes `game.boss_checkpoint` just before `boss_encounter.start()`: `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 + hazards_dodged, hazards_blasted_total, stages_cleared, bosses_defeated, bosses_escaped, tools_used, + boss_damage_dealt`, plus the systems' Game Over 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 + exactly while a fight is live, or after a catch that landed in one — never after a death in `run` or `stage_clear`. - **Game Over.** `ui/game_over.tscn` shows **Retry Boss** only while `boss_checkpoint` is non-empty, and it takes the default focus in place of Retry (name entry still comes first on a qualifying run). **Retry** always restarts @@ -706,13 +707,13 @@ 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), hands `bosses_met` to the encounter's + tallies, 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 - checkpoint, so a boss can be retried any number of times. The encounter re-seeds `fight_rng` on every + difficulty floor, keeps spawning off and the seam hazard-free, emits `stage_started` once, then enters the boss + through the normal `_enter_boss()`, which takes a fresh checkpoint, so a boss can be retried any number of times. + There is no run intro on this path (v2.7, §11.15): the boss's `intro_short` cinematic is the lead-in. (v2.0–2.6 + replayed the stage's turn here.) The encounter re-seeds `fight_rng` on every `start(index)` from the run seed, its salt and the stage index alone (`(seed ^ RNG_SALT) + index * RNG_STAGE_STEP`), so a stage-N fight draws the same sequence whether it is reached by a normal run or by a retry. @@ -720,7 +721,7 @@ A catch during the `boss` phase no longer has to throw the run away. fresh `run_id` (`--boss-`) whose score continues from the checkpoint. Its result carries `checkpoint_retry: true` (false on every other run). - **Verification.** `runner_logic.gd` section 24b (checkpoint on boss entry and kept by the catch, repeated - `retry_boss()` reloads into turn → boss with stage/score/`run_clock` intact and full HP, plain Retry from zero, no + `retry_boss()` reloads straight into the boss (no run intro) 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. @@ -784,13 +785,13 @@ on the player and the skip. The frontend draws the cinematic from that clock. `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 + focus_position)`. `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, + and for an unknown or empty mode, so the camera can never be left displaced. 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 @@ -808,3 +809,55 @@ on the player and the skip. The frontend draws the cinematic from that clock. 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. + +### 11.15 Run intro (contract 2.7) + +Every run now opens on a short in-engine cinematic. The backend owns the clock, the camera, the hold on the player, +the beats and the skip; the frontend draws the title card and countdown from that clock. + +- **When.** `RunnerGame.pending_intro_mode` (static, like `pending_boss_checkpoint`) picks the intro the next + `start_run()` opens with and is consumed there (back to `&"full"`). A fresh run from the menu gets **full** + (`run_intro_full_seconds` 6.0); `retry()` sets **short** (`run_intro_short_seconds` 2.2), and `go_to_menu()` sets + full again; `&""` opens the next run with no intro (capture tools use it). `start_run()` hands the mode to + `StageManager.begin(intro_mode)`, which defaults to `&""` (no intro) when called directly, and + `run_intro_enabled` false skips every intro. A **boss-checkpoint retry never plays a run intro**, whatever the + pending mode: `begin_at_boss(checkpoint)` goes straight to `stage_started` and the boss, whose `intro_short` + cinematic is the lead-in, so the player is never handed control at GO only to lose it to the boss intro. +- **Phase.** `intro` comes before stage 1's `run`; stage 1's `stage_started` goes out when the intro ends, as the + run opens. The spawner places nothing in it. It is not run time: `run_clock`, the facade's `elapsed` and + `distance` and the ScoreManager's base-rate accrual all stay at zero through it and start at the hand-over, so the + HUD clock reads 00:00 and the score 0 at GO. +- **Beats.** `run_intro_beat(index, label)` goes out as the intro's clock reaches each time in + `run_intro_*_beat_times` (full `title` 1.0, `3` 3.0, `2` 3.8, `1` 4.6, `GO!` 5.4; short `READY` 1.0, `GO!` 1.6), + always in index order. The `title` beat is the title card's cue and plays Hoppy's `victory` clip through + `runner_player.gd` → `visual.celebrate()`. +- **Hold.** From `run_intro` until the **last beat (GO)** the StageManager holds `cinematic_hold`: manual controls + are off (autopilot included, through `refresh_player_controls()`) and the corridor eases to + `boss_cinematic_speed_scale`. GO hands both back before it is sent. The camera and the mirrors keep following + the clock to the end. +- **Skip.** Jump or `ui_accept` skips once `run_intro_skip_after` (0.3 s) has played, until the GO beat has gone + out or the last `run_intro_skip_tail` (0.4 s) begins, whichever is first, while running and unpaused + (`can_skip_run_intro()`); the press is consumed and never jumps. From GO on, controls are live and a jump is a + jump. A skip jumps the clock to the tail, gives control back at once, sends `run_intro_skipped()`, then the + GO beat if it had not gone out yet; the beats it jumped over are never sent. The tail still plays, so the camera + lands. The autopilot never skips. +- **Camera.** Every intro tick the StageManager calls `camera_rig.set_run_intro(progress, mode, focus)` with Hoppy's + global position as `focus`, and `(0, &"", Vector3.ZERO)` at every exit. The pure static + `camera_rig.run_intro_shot(p, mode)` returns `{blend, distance, height, orbit, fov, look_bias}` from the + frontend's `res://scenes/runner/data/run_intro_shots.gd` `const SHOTS := {&"full": {cut, ret, keys}, &"short": + {...}}` (rows `[p, distance, height, orbit, fov, look_bias]`, smoothstepped like `BOSS_SHOTS`), read once through + `ResourceLoader.exists` + `load()` and never preloaded, with a built-in fallback per missing mode. The lens sits + `distance` m behind `focus` along +Z, rotated `orbit`° about it and `height` m up, aimed at `focus` + `look_bias` + m; the rest pose is `[8.0, 4.0, 0.0, 60.0, 2.5]`. `cut` 0 is a hard cut on frame one (the rig resets the + camera's physics interpolation on it); `blend` ramps out over `[ret, 1]` and is exactly 0 at p = 1. Keep `ret` + at or below the skip landing (0.933 full, 0.818 short) so a skip never pops the camera. +- **HUD.** `runner_hud.gd` hides its widgets and GET READY while `phase` is `intro`, and opens GET READY with the + run when the intro hands over to `run`. `ui/run_intro.tscn` is an optional HUD mount (§11.8). +- **Abort.** A catch or a new run closes a live intro with `run_intro_ended()` and releases the hold. +- **Verification.** `tests/runner_run_intro.gd` covers: full on a fresh run and short after `retry()`, no run + intro on a checkpoint retry (`stage_started`, then the boss), the beats and their order and times, controls held + until GO and back on it, the skip window (refused before 0.3 s, from GO and in the tail; no jump on a skip press; + a jump on GO is a jump), `elapsed`, `distance` and score at zero at the hand-over, the spawner's placement gates + through the intro and the clear road after it, the GO beat after a skip, pause + freezing the clock, `run_intro_ended` exactly once on every exit path, `run_intro_shot` blend at both ends and the + rest pose, and `run_intro_enabled` off going straight to `run`. diff --git a/docs/endless-runner-systems.md b/docs/endless-runner-systems.md index b8203b33..8c20d0ac 100644 --- a/docs/endless-runner-systems.md +++ b/docs/endless-runner-systems.md @@ -62,7 +62,7 @@ default. Equivalently: `prev.exit_clear + gap + next.entry_clear ≥ v_eff × ga `speed = lerp(base_speed + b, max_speed + b, smoothstep(0, 1, clamp(run_clock / time_to_max_speed, 0, 1)))` with `time_to_max_speed 100` (v1 used `elapsed / 165`), the per-stage bonus `b = min(stage_speed_step × (stage_index − 1), stage_speed_bonus_max)`, then `+ tail_speed_per_minute` per 60 s of run_clock past the ramp, capped at -`hard_cap_speed + b`. `run_clock` counts RUN-phase seconds only and is frozen during `turn`, `boss` and +`hard_cap_speed + b`. `run_clock` counts RUN-phase seconds only and is frozen during `boss` and `stage_clear`, so a fight never advances the ramp or the chunk tier; the speed reached holds through those phases. Difficulty stays 1.0 in the tail. Nitro multiplies the current speed by `nitro_speed_multiplier` (capped at `nitro_speed_cap` 38) and does not advance `run_clock`. @@ -492,11 +492,11 @@ active → roll, off → nothing. Jump trigger `front_z ≥ −(v × 0.22 + 0.4) - The `AppRouter` veil tween must run while paused → `AppRouter.process_mode = ALWAYS` and unpause before switching scenes. -## 10. v2 systems — stages, turns, boss fights (systems spec 2.0) +## 10. v2 systems — stages and boss fights (systems spec 2.0; straight corridor since contract 2.7) Authoritative tuning and facade live in the contract §11; this section is the implementation spec. Style rules unchanged. Two clocks: `elapsed` (total unpaused seconds) and `run_clock` (RUN-phase seconds; drives speed and -difficulty; frozen in `turn`, `boss`, `stage_clear`). +difficulty; frozen in `boss` and `stage_clear`). `runner_game.gd` `FRONTEND_MOUNTS` gains one v2 entry beyond the HUD panels: `World/TurnRig/BossArena` → `boss/boss_fx.tscn`, the optional 3D fight effects (telegraphs, tracers, damage numbers). It follows the same rules @@ -506,58 +506,62 @@ must freeze with the pause menu. Giving the effects a home inside the arena is w `World`. The `HUD` list also carries one more optional panel after `ui/stage_clear.tscn` — `ui/post_fx.tscn`, the frontend's single full-screen post-processing pass (CanvasLayer 9, PAUSABLE, idempotent `has_signal`-guarded `bind`, contract §11.8) — tolerated when absent exactly like the others, and asserted at most once by -`runner_scaffold_check.gd` when the file is on disk. +`runner_scaffold_check.gd` when the file is on disk. Contract 2.7 adds `ui/run_intro.tscn` after +`ui/boss_cinematic.tscn` on the same terms. ### 10.1 `systems/stage_manager.gd` — phase state machine Vars: `phase: StringName = &"idle"`, `stage_index := 0`, `stage_distance := 0.0`, `stage_distance_target := 0.0`, -`run_clock := 0.0`, `next_turn_direction := 0`, `_phase_timer := 0.0`, `_turn_direction := 0`, -`_turn_announced := false`, `stage_rng := RandomNumberGenerator.new()` -(seeded `seed ^ 0x5F356495` in `reset(seed)` so a run is reproducible). `reset(seed)` → `idle`; `begin()` (called by +`run_clock := 0.0`, `_phase_timer := 0.0`, `_draining := false`. The machine draws no random numbers: the +encounter's own `fight_rng` makes every boss roll. `reset(seed)` → `idle`; `begin()` (called by `RunnerGame.start_run` after every system reset) → `_enter_run()` with `stage_index = 1`. Per physics tick (only while `game.state == &"running"` and not paused), in this order: 1. `run`: `run_clock += delta`; `stage_distance += game.speed × delta`; `stage_distance_target = min(stage_base_distance + stage_distance_step × (stage_index − 1), stage_distance_max)`. When - `stage_distance ≥ target − stage_drain_distance` the drain window opens: `spawner.set_spawning(false)`, and - **the turn direction is drawn here**, `next_turn_direction = stage_rng.randi_range(0, 1) × 2 − 1` (mirrored onto - the facade), so the warning and the bend can never disagree. Then, once per seam, when - `stage_distance ≥ target − pace × turn_warning_seconds` with `pace = max(game.speed, base_for_stage())`, emit - `turn_incoming(next_turn_direction, (target − stage_distance) / pace)` — a drain shorter than that window is - already inside it when it opens, so the warning lands on the same tick as the drain gate. When `stage_distance ≥ target` - and no node in `runner_hazards` with `is_blocking()` has `track_z < stage_clear_line_z`, or `_phase_timer ≥ - stage_drain_timeout` (then `clear()` every remaining blocking hazard silently, no dodge/bonus), enter `turn`. -2. `turn`: `_phase_timer += delta`; `progress = clamp(_phase_timer / turn_seconds, 0, 1)`; call - `turn_rig.set_turn(progress, dir)` and `camera_rig.set_turn(progress, dir)`; at `progress ≥ 1` emit - `turn_finished`, reset both to `(0, 0)`, enter `boss`. -3. `boss`: delegate to `boss_encounter.tick(delta)`; on `boss_encounter.finished` (defeated or retreated, after the + `stage_distance ≥ target − stage_drain_distance` the drain window opens: `spawner.set_spawning(false)`. When + `stage_distance ≥ target` and no node in `runner_hazards` with `is_blocking()` has `track_z < + stage_clear_line_z`, or `_phase_timer ≥ stage_drain_timeout` (then `clear()` every remaining blocking hazard + silently, no dodge/bonus), enter `boss` on that same tick. (Contract 2.0–2.6 put a 2.4 s cinematic turn here; + 2.7 removed it.) +2. `boss`: delegate to `boss_encounter.tick(delta)`; on `boss_encounter.finished` (defeated or retreated, after the outro) enter `stage_clear`. -4. `stage_clear`: `_phase_timer += delta`; at `stage_clear_seconds` → `stage_index += 1`, `_enter_run()`. +3. `stage_clear`: `_phase_timer += delta`; at `stage_clear_seconds` → `stage_index += 1`, `_enter_run()`. `_enter_run()`: `stage_distance = 0`, `spawner.set_spawning(true)`, `speed_controller.set_stage_bonus(b)` and `speed_controller.set_difficulty_floor(StageDefs.difficulty_floor(stage_index, config))` (see 10.2), emit `stage_started(stage_index, StageDefs.boss_name(stage_index), speed_controller.base_for_stage())`. Enter/exit helpers emit `phase_changed(phase)` on change only, and forward `set_phase` / `set_boss_arena` to the -corridor when present (`has_method`). `turn_started(dir, turn_seconds)` is emitted on entering `turn` with -`dir = next_turn_direction`, the value the drain drew (a seam that somehow reached the bend with none — a -zero-length stage, a hand-driven test — draws one there rather than turning by 0). `next_turn_direction` goes -back to 0 with the rig reset at the end of the bend, so it is non-zero exactly from the drain gate to -`turn_finished`. `stage_cleared(stage_index, bonus)` is emitted on entering +corridor when present (`has_method`). `stage_cleared(stage_index, bonus)` is emitted on entering `stage_clear` with `bonus = stage_clear_bonus_base + stage_clear_bonus_per_stage × (stage_index − 1)` added to the score first (`score_manager.add_bonus(bonus, &"stage_clear")`). `caught` → `phase = &"idle"` (emit), boss encounter aborted (`boss_encounter.abort()` frees projectiles/tools and the boss without bonuses). The `result` fields -`stages_cleared`, `stage_reached`, `bosses_defeated`, `bosses_escaped`, `tools_used`, `boss_damage_dealt`, -`turns_taken` are read from StageManager/BossEncounter counters in `_finish_run`. +`stages_cleared`, `stage_reached`, `bosses_defeated`, `bosses_escaped`, `tools_used` and `boss_damage_dealt` are read +from StageManager/BossEncounter counters in `_finish_run`. `data/stage_defs.gd`: static `boss_slot(i)`, `cycle(i)`, `boss_kind(i)`, `boss_name(i)`, `speed_bonus(i, config)`, `difficulty_floor(i, config)`, `distance_target(i, config)`. +**Run intro (contract 2.7 §11.15).** `begin(intro_mode := &"")` opens `intro` first when the mode is `&"full"` / +`&"short"` and `run_intro_enabled` is on; `begin_at_boss(checkpoint)` never does (the boss's short intro is the +checkpoint's lead-in). `_enter_intro(mode)`: spawning off, phase `intro`, `_set_cinematic_hold(true)`, the camera framed, then `run_intro(mode, +seconds)` and any beat due at 0 s. `_tick_intro(delta)`: the clock advances (clamped to the length; a pause stops it +with the rest of the machine), `run_intro_progress` is mirrored, every beat whose time is reached goes out in +order, the camera follows (`camera_rig.set_run_intro(progress, mode, player.global_position)`), and at the end +`_finish_intro()` closes the intro and enters `run`. The last beat (GO) releases the hold before it is sent. +`skip_run_intro()` (guarded by `can_skip_run_intro()`: past `run_intro_skip_after`, GO not yet sent, before the last +`run_intro_skip_tail`, not yet skipped) jumps +the clock to the tail, releases the hold, emits `run_intro_skipped`, then the GO beat if it has not gone out. +`_end_intro()` is the single exit (natural end, `reset()`, catch): hold off, camera reset `(0, &"")`, mirrors +cleared, `run_intro_ended` exactly once. Beat times are made non-decreasing per label, so the order holds whatever +the config says. + ### 10.2 `systems/speed_controller.gd` v2 `speed_at(t)` is evaluated at `game.run_clock` (not `elapsed`). New `stage_bonus := 0.0` set by `set_stage_bonus(b)`; `base`, `max` and `hard_cap` each add `stage_bonus` inside `speed_at()`; `normalized()` keeps `(speed − base_eff) / (max_eff − base_eff)`. `difficulty_at(t) = max(clamp(t / time_to_max_speed, 0, 1), -difficulty_floor)` with `difficulty_floor` set by `set_difficulty_floor(f)` from StageDefs. During `turn`, `boss` +difficulty_floor)` with `difficulty_floor` set by `set_difficulty_floor(f)` from StageDefs. During `boss` and `stage_clear` the speed holds at the value reached (the treadmill keeps moving at that speed for the corridor and projectiles); Nitro still applies. Tests: section 14 re-expresses the terminal-1.0 rule on `run_clock`, adds stage bonus cases (b = 0, 3, 6 → caps 26/29/32; Nitro ≤ 38) and the floor rule. @@ -582,7 +586,7 @@ seconds) and `time_factor()` all read the same clock; it closes with `boss_cinem 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 +current pattern (patterns cycle a fixed beat list; lanes from `fight_rng` where the pattern says "random", never repeating the immediately previous lane mask), emit `boss_attack_telegraph(mask, variant, telegraph)`, call `visual.play_telegraph(...)`, queue `{mask, variant, remaining: telegraph}`; when a queued telegraph reaches 0 spawn `hazards/projectile.tscn` under `Projectiles` at the boss position projected to each lane centre of the mask (one @@ -677,30 +681,15 @@ synchronous free); moves +Z at `game.speed` like chunks (so Nitro raises through `despawn_z`. `powerups/tool_defs.gd`: kinds, display names, damage, weights, spawn y (contract §11.7). Visual mount `powerups/visuals/_visual.tscn`; graybox fallback: an ORANGE sphere r 0.35 with a Label3D tag. -### 10.5 `systems/turn_rig.gd` and `camera_rig.set_turn` - -`turn_rig.gd` (Node3D at `World/TurnRig`): `set_turn(progress, direction)`: if the mounted corridor -`has_method("set_turn")` → forward and keep `rotation.y = 0`; else (graybox) `rotation.y = direction × -deg_to_rad(turn_yaw_degrees) × smoothstep(0, 1, progress)`; `(0, 0)` resets. `camera_rig.gd` gains -`set_turn(progress, direction)` (which only records the pair) and the **ramp–hold–return** envelope -`turn_swing_profile(p)`, replacing the v1 `sin(PI × p)` wave so the corner reads as a committed turn: - -```text -p ≤ 0 or p ≥ 1 -> 0 (endpoints first, so any fractions are safe) -p < in -> smoothstep(0, 1, p / in) ramp up -in ≤ p ≤ 1 − out -> 1 hold at the peak -p > 1 − out -> smoothstep(0, 1, (1 − p) / out) return -in = turn_swing_in_fraction, out = turn_swing_out_fraction, both clamped to 0..1 and scaled by 1/(in + out) when they overlap -``` +### 10.5 `systems/turn_rig.gd` — static mount -`yaw = direction × deg_to_rad(turn_camera_yaw_degrees) × turn_swing_profile(progress)` and -`bank = direction × deg_to_rad(turn_camera_bank_degrees) × turn_swing_profile(progress)`, applied in -`_physics_process` on top of the follow/shake result (yaw about the player's position, so the camera ends exactly -where it started at p = 1). The profile is monotone over each ramp and exactly 0 at both ends, so the camera is -never left displaced; gameplay is untouched, because only the camera and the corridor bend. -`RunnerGame.track_z(node) = turn_rig.to_local(node.global_position).z` when the rig exists, else -`node.global_position.z` (identity today because chunks never sit under the rig); `_track_dodges`, -`clear_hazards_in_range` and the autopilot entry use it. +`turn_rig.gd` (Node3D at `World/TurnRig`) is a static mount since contract 2.7: `setup(game)`, `set_corridor(node)` +(the mounted corridor, which the StageManager forwards `set_phase` / `set_boss_arena` to) and `reset()` (zeroes any +stray rotation). Nothing rotates it, and the name is kept because every mount path, fixture and capture tool +addresses `World/TurnRig`. The camera keeps its rest pose outside boss shots: each physics tick it starts from the +rest rotation and blends a boss shot on top. `RunnerGame.track_z(node) = turn_rig.to_local(node.global_position).z` +when the rig exists, else `node.global_position.z` (the identity, because the rig sits square at the origin and +chunks never sit under it); `_track_dodges`, `clear_hazards_in_range` and the autopilot entry use it. ### 10.6 Spawner and autopilot @@ -714,7 +703,7 @@ stage entry including stage 1. `_clear_road_remaining` is decremented in `_physi corridor scrolls on, after the `state` and `game.paused` guards, so a pause holds the window. 1. **Clamp the frontier.** `frontier_z` is only ever re-read from the last active chunk, so a corridor that - emptied during the drain, the turn, the boss and the clear card leaves it scrolled to at or behind the player. + emptied during the drain, the boss and the clear card leaves it scrolled to at or behind the player. When `active.is_empty()`, `frontier_z = min(frontier_z, −ENTRY_CLEAR_DISTANCE)` (6.0, the authored entry clearance every chunk carries and the validator's `MIN_CLEAR`). Without this the first `_fill()` of the stage parks a chunk at `frontier_z − gap`, i.e. underfoot, and its hazards land while the banner is still up. @@ -781,10 +770,8 @@ Leaderboard `SCHEMA_VERSION = 2`: version-1 boards are read normally and rewritt `runner_logic.gd` new sections: stage machine (targets, drain, timeout clear, phase ordering, one `stage_started` per stage, `run_clock` frozen outside `run`); escalation formula at cycles 0–4 hitting floors/caps; boss HP/phase transitions and one `boss_phase_changed` per crossing; tool damage incl. resisted phases (`tool_collected(kind, 0)`); -boss timeout → `boss_retreated` with the partial bonus and never `boss_defeated`; turn state (signals once, rig and -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, +boss timeout → `boss_retreated` with the partial bonus and never `boss_defeated`; the drain handing straight to +`boss` with the corridor, the TurnRig and the camera rest pose untouched; `boss_intro` exactly once per fight, 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`); @@ -792,7 +779,7 @@ tool inside that window (contract 2.6: the cinematic timings themselves are cove 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 stage-1 boss opens on 40 hp rather than its authored 100), at the -existing acceleration: stage 1 → `turn_incoming` ahead of `turn_started`/`turn_finished` → `boss_spawned` → tools damage → `boss_defeated` → +existing acceleration: stage 1 → drain → `boss_spawned` → tools damage → `boss_defeated` → `stage_cleared` → `stage_started(2, …)` within a bounded wall clock with zero projectile hits; a second probe forcing PURSUED plus a projectile mid-fight ends the run once with the boss stats present; the kill leaves the arena empty (no `runner_tools` node and no blocking projectile two frames later) and raises no engine error. diff --git a/docs/endless-runner.md b/docs/endless-runner.md index 04caa844..719d7e88 100644 --- a/docs/endless-runner.md +++ b/docs/endless-runner.md @@ -37,11 +37,11 @@ Development cameras use (0,4,8), looking at (0,1,-8), FOV 60. The menu camera us | Corridor preview | Space | Toggle pause | | Renderer spike | G / F / R | Toggle glow / fog / 50% or 100% 3D resolution | -Key 6 reaches the F1 26 m/s acceptance limit; key 7 exercises the Nitro cap. The capture script saves PNGs after `RenderingServer.frame_post_draw`, measures the corridor at 26 m/s with preview UI hidden, and reports failure above 14 rest draw calls or 18 turn draw calls. The capture script also saves the Hoppy fixture after 1.5 s to `docs/previews/endless-runner-hoppy.png`. The coordinator produced the 1280x720 environment previews: renderer spike and corridor. +Key 6 reaches the F1 26 m/s acceptance limit; key 7 exercises the Nitro cap. The capture script saves PNGs after `RenderingServer.frame_post_draw`, measures the corridor at 26 m/s with preview UI hidden, and reports failure above 14 rest draw calls (the 18-call turn budget went with the turn in contract 2.7). The capture script also saves the Hoppy fixture after 1.5 s to `docs/previews/endless-runner-hoppy.png`. The coordinator produced the 1280x720 environment previews: renderer spike and corridor. ## Behavior and ownership -F0-F6 introduced the runner menu, routed full-loop integration, capture fixture and documentation. F7 adds legibility and pursuit presentation, F8 adds boss fights, and F9 adds cinematic turns and stage transitions. F10 sharpens announced corners and boss entrances. The milestone sections and v2 tables retain their original verification history; the high-fidelity section below records the integrated asset pass and its evidence limits. +F0-F6 introduced the runner menu, routed full-loop integration, capture fixture and documentation. F7 adds legibility and pursuit presentation, F8 adds boss fights, and F9 adds cinematic turns and stage transitions. F10 sharpens announced corners and boss entrances. Contract 2.7 removed the turns again: the corridor now stays straight (see "Contract 2.7: straight corridor" below), so the turn parts of F9 and F10 are history only. The milestone sections and v2 tables retain their original verification history; the high-fidelity section below records the integrated asset pass and its evidence limits. The hub routes to `RunnerMenu`, a Node3D with Backdrop and Interface children. The backdrop mounts the existing corridor at 3.5 m/s and Hoppy in run at normalized speed 0.25. Hoppy stands at (0.7, 0, -0.3), facing -Z with feet at y=0, in the gap between the buttons and controls card. The camera remains at (2.6, 1.9, 3.4), looking at (0, 1, -2), with its existing slow drift. A shaded 1280x720 composition uses the hub Style helpers, mint eyebrow, cream title, numbered buttons, controls card and footer. It centers in larger viewports and scales to fit smaller ones. Play owns initial focus; Up/Down and Tab wrap through the four buttons. Leaderboard reads `runner_leaderboard.gd.load_entries()` without a game instance, shows ten rank/name/score/mm:ss rows with `---` placeholders, disables the other three menu buttons while keeping Leaderboard enabled and focusable, and initially focuses Close. A "Reset scores" button sits beside Close; it is disabled when the board is empty. Pressing "Reset scores" opens a confirmation modal ("RESET ALL SCORES?") with a safe "Keep scores" button (initial focus) and a destructive "Reset" button; focus wraps between the two. If the player confirms, `RunnerLeaderboard.clear(leaderboard_path)` is called: on success the rows rebuild as placeholders, "Reset scores" is disabled, and Close is focused; on error the confirmation stays open with an error message and focus returns to "Keep scores". Pressing "Keep scores" or pressing `ui_cancel` while the confirmation is open closes it without resetting, and restores focus to "Reset scores". Pressing Leaderboard again closes the panel. Closing restores Leaderboard focus. `ui_cancel` closes the confirmation first (if open), then the leaderboard panel, then returns to the hub. @@ -51,7 +51,7 @@ The teaching layer uses a mint shield for power-ups, an orange wrench for CodeRa HUD is on layer 10, PauseMenu 21 and GameOver 22. HUD listens to facade signals, uses responsive margins, counts up score, displays effects and pursuit, dims on pause and hides gameplay widgets on caught. Backend owns damage camera shake. Pause has no ui_cancel handler. Game Over appears only on run_finished, shows result stats and the local top ten, and saves qualifying names through the facade. Save success locks the name field and focuses Retry; failure stays editable. Retry creates a fresh scene. The mock scene demonstrates the same signals in 40 seconds with in-memory persistence. -Player feedback identified the boss name, HP plate and intro card as blocking the corridor vanishing point. Both now share the top-centre slot at x=300..980: the boss plate occupies y=8..64 with a 10-unit HP track, and the intro card occupies y=48..128 with a compact warning/title row and an 18-unit taunt row. Its 48-unit top inset clears the existing cinematic letterbox while remaining wholly above y=130. They are mutually exclusive through the existing three-second intro and spawned-boss handover. The score plate is narrowed to x=1000..1252, with the timer below it at y=140..196 and BACKLOG below that at y=208..272; APPROVALS stays on the left. This keeps the central x=300..980, y=130..480 viewing region clear of persistent boss widgets, preserves outlined panel-backed type, and leaves the turn warning at y=536 clear of the pursuit alert. Stage-banner and stage-clear timing and layout are unchanged. Headless checks measure final container rects at 1280x720 and 1440x900 against proportionally scaled viewing bounds and check every panel pair, with only the mutually exclusive boss cards allowed to overlap. Existing capture view names are retained for coordinator overwrite of the A7 PNGs. +Player feedback identified the boss name, HP plate and intro card as blocking the corridor vanishing point. Both now share the top-centre slot at x=300..980: the boss plate occupies y=8..64 with a 10-unit HP track, and the intro card occupies y=48..128 with a compact warning/title row and an 18-unit taunt row. Its 48-unit top inset clears the existing cinematic letterbox while remaining wholly above y=130. They are mutually exclusive through the existing three-second intro and spawned-boss handover. The score plate is narrowed to x=1000..1252, with the timer below it at y=140..196 and BACKLOG below that at y=208..272; APPROVALS stays on the left. This keeps the central x=300..980, y=130..480 viewing region clear of persistent boss widgets, preserves outlined panel-backed type, and kept the turn warning at y=536 clear of the pursuit alert (the warning was removed in contract 2.7). Stage-banner and stage-clear timing and layout are unchanged. Headless checks measure final container rects at 1280x720 and 1440x900 against proportionally scaled viewing bounds and check every panel pair, with only the mutually exclusive boss cards allowed to overlap. Existing capture view names are retained for coordinator overwrite of the A7 PNGs. Hoppy supplies thirteen animations (the seven locomotion/reaction states idle/run/jump/fall/roll/hurt/dead plus six gait one-shots: lane_dash_left, lane_dash_right, land, shield_brace, powerup_react and victory) and every player visual hook, retains feet at y=0 and stands 1.8 m. The roll is a full-size (unscaled) baseball-style low slide that stays within 0.9 m: the builder seats it on the floor and caps the whole skinned pose at 0.885 m. Hurt returns to previous locomotion unless interrupted. Hazards implement all eight kinds and every variant; collision artwork stays within the 110% envelope, while ground projections use exact hitbox footprints. Power-ups supply six types with spin/bob/collection reactions. The pursuer uses threat-driven cards, a trip-and-trap catch and a 0.7 s retreat. On the catch, Hoppy trips on his trailing foot, faceplants and tries to crawl away (`player/hifi/knockout_rig.gd`, layered over the dead clip). The QUARANTINE drone leaps over him, lands past his head facing the camera, clamps its claws on him at 1.6 s and drops a red containment field (`pursuer/hifi/quarantine_field.gdshader`) around him until the Game Over panel at `caught_sequence_seconds` (3.0 s). Shared resources are duplicated before mutation. Cosmetic clocks and particles inherit pause. @@ -59,7 +59,7 @@ The corridor scrolls shaders and recycles 24 pillars over 96 m. Its per-viewport ## High-fidelity asset pass (A1–A9) -**Prewarm.** The first corridor `_ready()` calls `environment/hifi/prewarm.gd` before runner chunks or pickups spawn, skips editor hints, and keeps its static guard across retries and menu/game transitions. A3 hazards/projectile meshes and atlas, A4 pickup maps/templates, shared materials/theme, A8 percussives, jump, boss-intro and turn cues, six footsteps and all 16 directional whooshes warm synchronously. `runner_audio.bind()` relies on that same guarded warmup. A2 plating/circuit/floor and A6 pursuer maps, plus A5 boss maps, generate local Images in four WorkerThreadPool tasks. A1 uses the imported Blender GLB and baked textures; prewarm no longer generates unused fur/cloth maps. Main-thread polling finalizes at most one image package or deferred package per frame; remaining audio recipes and boss glyph/mesh/material preparation are deferred on the main thread. No worker touches scene nodes or ImageTextures. Early boss and pursuer use the original lazy fallback; later finalizers preserve their caches. A2 initially binds neutral 1x1 textures, then installs full images into those same texture objects. Instance-owned animated materials remain private. +**Prewarm.** The first corridor `_ready()` calls `environment/hifi/prewarm.gd` before runner chunks or pickups spawn, skips editor hints, and keeps its static guard across retries and menu/game transitions. A3 hazards/projectile meshes and atlas, A4 pickup maps/templates, shared materials/theme, A8 percussives, jump, boss-intro and run-intro cues, six footsteps and the 8 directional projectile whooshes warm synchronously. `runner_audio.bind()` relies on that same guarded warmup. A2 plating/circuit/floor and A6 pursuer maps, plus A5 boss maps, generate local Images in four WorkerThreadPool tasks. A1 uses the imported Blender GLB and baked textures; prewarm no longer generates unused fur/cloth maps. Main-thread polling finalizes at most one image package or deferred package per frame; remaining audio recipes and boss glyph/mesh/material preparation are deferred on the main thread. No worker touches scene nodes or ImageTextures. Early boss and pursuer use the original lazy fallback; later finalizers preserve their caches. A2 initially binds neutral 1x1 textures, then installs full images into those same texture objects. Instance-owned animated materials remain private. **Blender cleanup verification (2026-09-22):** `PREWARM: PASS (311 checks)` after removing the unused player maps, census expectation and repeated cache checks. Cold `warm()` was **722.690 ms**, corridor ready **804.826 ms**, pending repeat **0.002 ms**, completed repeat **0.001 ms**. A1/A2/A4/A7 pass with the counts in the table below. [Complete commands, outputs and exit codes](assets/integration-cleanup-verification.md) record this headless run. @@ -723,9 +723,12 @@ Both collision-removal grep commands printed `0` (grep exit 1 means no matching ## F9: cinematic turns and stage transitions +> Contract 2.7 removed the turn: the corner presentation below (bend, corner wall, chevron, turn preview, turn +> audio and HUD dimming) no longer exists. The stage banner, stage-clear card and stage audio remain. + F0 through F9 are implemented. F9 follows contract §11: the backend owns phases, collision, turn progress, camera yaw/bank and boss lifetime. The frontend corridor never rotates gameplay. -`set_turn(progress, direction)` derives signed curvature `0.0042 * sin(PI * progress)` and an +The corridor's turn hook derived signed curvature `0.0042 * sin(PI * progress)` and an 11-degree root yaw from its arguments. Both endpoints and `(0, 0)` are straight. The shader converts the displacement through the corridor frame so rotated wall planes and CPU pillars bend in the same direction. Each of the four planes has `subdivide_depth = 72`. Post-bend grid @@ -745,8 +748,8 @@ outlined chevron's text surfaces. Budgets are 14 at rest and 18 in a turn. These counts, not a graphical renderer measurement; the host capture prints the actual GPU count. The optional `HUD/StageBanner` and `HUD/StageClear` mounts use `hud_style.gd` and idempotent -`bind(game)`. The banner announces the stage and boss name, defers announcements received during -a turn until `turn_finished`, and closes when a boss spawns. Stage 1 and subsequent RUN entries +`bind(game)`. The banner announces the stage and boss name, deferred announcements received during +a turn until the turn ended (until contract 2.7), and closes when a boss spawns. Stage 1 and subsequent RUN entries announce immediately because the backend emits `stage_started` at RUN entry. The card reads `get_stage_info()` and `get_boss_info()`, tracks elapsed stage time and tool events, and distinguishes FIXED from ESCAPED. Its bonus row counts the already-awarded `stage_cleared` bonus without changing @@ -761,11 +764,10 @@ AudioService still owns exactly two players. | Music event | Held level relative to running music | Pitch / release | |---|---|---| -| `turn_started` | -5 dB | Existing speed pitch; restore on `turn_finished` | | `boss_spawned` | -4 dB | Speed pitch +0.04; restore on defeat or retreat | | `boss_phase_changed` | -8 dB transient, 0.35 s | Returns to the held fight level | | `stage_cleared` | -10 dB through the 2.8 s card | Exactly 1.0; pause freezes the hold | -| Catch, next RUN or teardown | Restore baseline | Remove stage/turn/fight offsets | +| Catch, next RUN or teardown | Restore baseline | Remove stage/fight offsets | F8 capture polish is included: mounted boss HUDs suppress the 3D name plate, gallery plates sit above the body, and shared boss/tool labels use LINEAR filtering and outlines. The swarm cloud @@ -925,8 +927,10 @@ The worktree was clean at task start. Task-touched files: ## F10: sharp announced corners and boss entrances Frontend-only implementation of contract 2.1, sections 11.4, 11.9 and 11.10. No backend API or camera changes. +(Contract 2.7 removed the corner warning, chevrons, corridor yaw, turn preview and turn audio described here; the boss +entrance parts remain.) -- `turn_incoming(direction, seconds)` opens an outlined 42-unit orange warning in a DANGER-bordered panel above the lane reading area. The arrow and remaining-seconds countdown pulse over the supplied duration. One reusable additive MultiMesh draws chevrons in all three lanes. HUD binding mounts it directly under the frontend corridor, inheriting its transform. `turn_started` clears the floor chevrons as the end wall takes over; `turn_finished` clears the banner and pending warning audio. +- The turn announcement opened an outlined 42-unit orange warning in a DANGER-bordered panel above the lane reading area. The arrow and remaining-seconds countdown pulsed over the supplied duration. One reusable additive MultiMesh drew chevrons in all three lanes, mounted directly under the frontend corridor. The bend's start cleared the floor chevrons as the end wall took over; its end cleared the banner and pending warning audio. - 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. @@ -983,7 +987,7 @@ 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). +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 was interpolated the same way until contract 2.7 removed it. SpeedLines emission has one writer (`_refresh_dressing()`, 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. (A rendered-turn yaw measurement here was retired with the turn in contract 2.7.) ## Contract 2.6: boss cinematics (backend hand-off) @@ -1022,3 +1026,55 @@ camera, the player hold and the skip, and the frontend draws from that clock. 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. + +## Contract 2.7: straight corridor (backend) + +The automatic turn-left/turn-right phase is gone. Phases are `run → boss → stage_clear`; the drain at the end of +`run` hands straight to `boss` on the tick it ends, so the boss's own intro cinematic is the stage's lead-in. + +- **Removed:** the three turn signals and `next_turn_direction` on the facade, `turns_taken` in the result and the + boss checkpoint, the RunConfig Turn group, the corridor's turn hook and bend (`CornerWall`, `TurnChevron`, the + `code_wall_fade` shader), the camera swing, the HUD's TURN LEFT/RIGHT warning and floor chevrons + (`environment/turn_fx.gd`), the turn audio recipes, the GPU warmup's mid-turn pose, `environment/dev/turn_preview.*` + and the turn views of the capture tools. +- **Kept:** `World/TurnRig` keeps its name as a static mount, because every mount path, fixture and capture tool + addresses it; `track_z()` still measures through it. The starship corridor still hides the legacy glyph rain and + speed streaks, now in `_refresh_dressing()`, which also re-uploads the corridor frame to the shaders only when the + node moves. The shaders' `bend` uniform stays at its default 0. +- **Entry bulkhead:** `starship_world.gd` closes the near end of the corridor behind Hoppy with a sealed door at + z ≈ +24.4 and a sleeve over z 11.5..24.5, the mirror of the far bulkhead. Gameplay never sees it; the run intro's + reverse shots land on it. +- **Boss-checkpoint retry** opens straight in front of the boss, with no run intro (see below). +- **Main menu:** the fifth runner still is now "JUMP, ROLL, DODGE" (`runner_dodge.png`, a jump over a blocking + hazard captured by `tests/capture_runner_menu_previews.gd`). + +## Contract 2.7: run intro + +A fresh run opens with a six-second in-engine cinematic: letterbox bars frame a camera approach through the corridor, +Hoppy celebrates beneath THE CODEBASE CORRIDOR, and the camera settles behind Hoppy for 3, 2, 1, GO!. Retries use +a 2.2-second READY/GO lead-in; a boss-checkpoint retry goes straight to the boss, whose short intro cinematic is its +lead-in. Space or the active controller jump button skips after 0.3 seconds and until GO; the on-screen skip button +supports pointer/touch input. Skip retains a brief GO burst and 0.4-second +exit. Pause freezes the presentation with the run clock. The shot data lives in +`scenes/runner/data/run_intro_shots.gd`; `ui/run_intro.gd` reads facade progress and beats, and `ui/post_fx.gd` owns +the letterbox. + +Backend hand-off (contract §11.15 is authoritative): + +- **Signals and mirrors.** `run_intro(mode, seconds)` (`&"full"` 6.0 s / `&"short"` 2.2 s), + `run_intro_beat(index, label)` (full `title` 1.0, `3` 3.0, `2` 3.8, `1` 4.6, `GO!` 5.4; short `READY` 1.0, + `GO!` 1.6), `run_intro_skipped()`, `run_intro_ended()` (exactly once per intro, aborts included); + `game.run_intro_mode`, `game.run_intro_progress`, `game.can_skip_run_intro()`, `game.skip_run_intro()`. +- **Who plays it.** `RunnerGame.pending_intro_mode` is full on a fresh run from the menu and set to short by + `retry()`. A boss-checkpoint retry plays no run intro: the boss's own short intro cinematic is the lead-in. +- **Not run time.** `elapsed`, `distance` and the score's base rate stay at zero through the intro, so the HUD + clock reads 00:00 and the score 0 at GO. +- **Hold.** Controls (autopilot included) and the corridor's speed are held from `run_intro` until the GO beat, or + until a skip. The skip window closes at GO, so a jump pressed on GO is a jump. The HUD hides its widgets and GET READY for the intro and opens GET READY with the run. +- **Camera.** `camera_rig.set_run_intro(progress, mode, focus)` places the lens off Hoppy's feet from the frontend + `SHOTS` table (fallback shots built in). The rest pose is `[8.0, 4.0, 0.0, 60.0, 2.5]`. +- **Hoppy.** The `title` beat plays Hoppy's `victory` clip through the player visual's `celebrate()`. +- **Audio.** `intro_whoosh` under the full intro's fly-through, `intro_title` on the title beat, `intro_count` on + each count (and READY), `intro_go` on GO; the music is ducked 12 dB until GO. +- **Capture.** `Godot --path hoppys-adventure-arcade --always-on-top --script + res://scenes/runner/ui/dev/capture_run_intro.gd -- --output=` (S5's fixture; mock by default). diff --git a/docs/main-menu.md b/docs/main-menu.md index f6c83f57..6e987f7a 100644 --- a/docs/main-menu.md +++ b/docs/main-menu.md @@ -14,7 +14,7 @@ Arrow keys, Tab, D-pad, or left stick navigate; Enter / gamepad A activates; Esc ## Behavior and ownership -- Scene-local showcase: idle previews 0–12 every 9 seconds; 1.2-second fades. Focusing a mode cycles its previews every 3 seconds with 0.6-second fades: Platformer cycles three themes; Beat’em Up cycles all three Code City Brawl stages followed by all three Heroes vs. Villains stages, and the Endless Runner cycles four in-game stills (corridor, power-ups, sharp turn, boss). +- Scene-local showcase: idle previews 0–12 every 9 seconds; 1.2-second fades. Focusing a mode cycles its previews every 3 seconds with 0.6-second fades: Platformer cycles three themes; Beat’em Up cycles all three Code City Brawl stages followed by all three Heroes vs. Villains stages, and the Endless Runner cycles four in-game stills (corridor, power-ups, a jump over a hazard, boss). - Focus exit waits 2 seconds, then restores the idle index and remaining dwell. Modals pause showcase timing. Parallax continues behind overlays. - At most two preview nodes exist. During a fade, requests coalesce to the latest selection; the outgoing node is freed before another fade begins. Each preview is a `CanvasGroup`, so a fade blends the flattened scene instead of letting its parallax layers show through each other. Preview textures are loaded once and kept, so transitions never read from disk. - Layout recenters on viewport resize (not every frame). Menu buttons grow 5% around their centre on hover/focus and keep that scale when their container re-sorts. @@ -23,6 +23,7 @@ Arrow keys, Tab, D-pad, or left stick navigate; Enter / gamepad A activates; Esc - AudioService owns a single looping music player and one UI sound player. Music and SFX independently feed Master. Supplied `Assets/Audio/happy_adveture.mp3` is used unchanged; synthesized UI blips are generated in code. MP3 repeat is enabled; musical seamlessness at the track boundary still needs listening review. - SaveService stores validated volume and fullscreen values in `user://settings.cfg`. Sliders apply immediately; closing settings persists them. Save failures appear in the UI. - Quit asks for confirmation and fades to black for 1 second. +- At exit, however the app quits, AudioService stops every audio player, the current scene's included, and waits up to 0.5 s for two audio mixes (about 25 ms with a sound card). Godot frees a stopped voice only after the mixer has run, and reports any voice it still holds at shutdown as "ObjectDB instances were leaked" / "resources still in use". `tools/checks/exit_leak.py` covers this in CI. ## Art and replacement points @@ -33,7 +34,7 @@ As of 2026-09-30, `hoppy.gd` uses the supplied `Assets/Character/Hoppy/2D/hoppy- | Bay Area Blitz | `Assets/bay-area-blitz-assets-only/bay_area/backdrop.png` | | Orbital Ops | `Assets/orbital-ops-assets-only/orbital_ops/` starfield and station hull | | Enchanted Repo | `Assets/enchanted-repo-assets-only/enchanted_repo/` sky and treeline | -| Codebase Corridor, Power-ups, Sharp Turns, Boss Fights | `Assets/runner/menu_previews/runner_*.png`, captured from an autopilot run by `tests/capture_runner_menu_previews.gd` (graphical: `Godot --always-on-top --path hoppys-adventure-arcade --script res://tests/capture_runner_menu_previews.gd`) | +| Codebase Corridor, Power-ups, Jump/Roll/Dodge, Boss Fights | `Assets/runner/menu_previews/runner_*.png`, captured from an autopilot run by `tests/capture_runner_menu_previews.gd` (graphical: `Godot --always-on-top --path hoppys-adventure-arcade --script res://tests/capture_runner_menu_previews.gd`) | | CLI / IDE District | `Assets/code-city-brawl-assets-only/code_city/district_background.png` | | Security Sector | `Assets/code-city-brawl-assets-only/code_city/security_background.png` | | Git Repository HQ | `Assets/code-city-brawl-assets-only/code_city/hq_background.png` | diff --git a/docs/previews/code-city/celebration.png b/docs/previews/code-city/celebration.png new file mode 100644 index 00000000..cc9b5686 Binary files /dev/null and b/docs/previews/code-city/celebration.png differ diff --git a/docs/previews/code-city/district_boss_down.png b/docs/previews/code-city/district_boss_down.png new file mode 100644 index 00000000..5d17f0aa Binary files /dev/null and b/docs/previews/code-city/district_boss_down.png differ diff --git a/docs/previews/code-city/district_boss_runoff.png b/docs/previews/code-city/district_boss_runoff.png new file mode 100644 index 00000000..fc14496b Binary files /dev/null and b/docs/previews/code-city/district_boss_runoff.png differ diff --git a/docs/previews/code-city/knockout.png b/docs/previews/code-city/knockout.png index f015e711..9be293ba 100644 Binary files a/docs/previews/code-city/knockout.png and b/docs/previews/code-city/knockout.png differ diff --git a/docs/previews/code-city/mini_boss_1.png b/docs/previews/code-city/mini_boss_1.png new file mode 100644 index 00000000..bb480dcc Binary files /dev/null and b/docs/previews/code-city/mini_boss_1.png differ diff --git a/docs/previews/code-city/mini_boss_2.png b/docs/previews/code-city/mini_boss_2.png new file mode 100644 index 00000000..060f3e4f Binary files /dev/null and b/docs/previews/code-city/mini_boss_2.png differ diff --git a/docs/soundtrack.md b/docs/soundtrack.md index e104fd92..59347586 100644 --- a/docs/soundtrack.md +++ b/docs/soundtrack.md @@ -119,13 +119,28 @@ Shared settings: Instrumental on, Variety Off. The lyrics field holds section ta | **Heroes vs. Villains: The Agentic Core** | Stage 3 | 1:30+ loop | `tense techno-chiptune, 116 BPM, pulsing square-wave bass, cold FM leads, scanning arpeggios, inverted three-note motif, loopable` | | **Villain boss theme** (shared, as the GDD specifies) | All Heroes vs. Villains bosses | 1:00+ loop, strong hit in the first second | `Saturday-morning cartoon villain battle, 16-bit FM chiptune, 232 BPM double-time feel, minor key, campy menace, orchestra-hit stabs, chromatic runs, motif in minor, huge downbeat in the first second, loopable` | | **Opening fanfare** | Heroes vs. Villains intro | 10–15 s, hard stop | Not 8-bit: the theme's chorus and gang shout, cut from track A. | -| **MERGE SUCCESSFUL** | Win results | 3–4 s, one-shot | Studio **Sounds**, One-Shot, 116 BPM, key: `8-bit victory jingle, rising three-note motif resolving to the tonic, bright square lead` | +| **MERGE SUCCESSFUL** | Code City finale, ending and results | 3.5 s one-shot, then a 16.552 s loop | Delivered: original F-major square-wave synthesis at 116 BPM; rising F-A-C motif resolving to F. See the reproducible build below. | | **BUILD FAILED** | Lose results | 2–3 s, one-shot | Sounds, One-Shot: `8-bit comic fail sting, three-note motif descending in minor, wobbly square lead` | | **Capability unlocked** (×4) | REVIEW / TRIAGE / CHANGE STACK / SECURITY cards | 1–2 s, one-shot | Sounds, One-Shot: `chiptune power-up chime, three-note glockenspiel-like square arpeggio` (one take per capability, rising in pitch) | | **BOSS DOWN!** | Boss defeat | about 2 s | Keep Juhani Junkala's CC0 `fanfare3`, or a Sounds one-shot of the motif. | The results screens pause the game, so their stinger player needs `process_mode = PROCESS_MODE_ALWAYS`. +### Delivered: Code City victory music + +`python3 tools/build_code_city_victory_music.py` builds the following original mono 44.1 kHz, 16-bit PCM files in `hoppys-adventure-arcade/Assets/Audio/Beat'em Up/`: + +| File | Duration | Role | +|---|---|---| +| `code_city_victory_jingle.wav` | 3.500 s | Once after the Force Merge finisher: the F-A-C figure rises to F, then a dominant phrase resolves to the tonic. | +| `code_city_victory_loop.wav` | 16.551723 s | Eight bars at 116 BPM. Square lead, pulsing arpeggios and an octave-hopping bass carry the ending pages and results. | + +The builder is deterministic and uses only Python's standard library. These are original compositions and recordings with no third-party samples or Suno source. Both are dedicated to the public domain under CC0 1.0. The square voices use smoothed discontinuities, short note attack/release envelopes and a gentle output low-pass. An 8 ms cosine taper at each outer boundary lands the loop at exact zero samples, avoiding a seam discontinuity. The final dominant bar returns to the first tonic bar. + +The celebration lasts 5.2 s. It ducks the boss track by 18 dB, starts the jingle at 0.3 s and switches the stage-flow music player to the loop at 3.8 s. The loop survives the celebration callback and plays through the epilogue and results. Cancelling for a stage rebuild or leaving stops only the celebration's own track. The jingle follows the music bus and pauses with the game; all visual timing follows the same unpaused owner clock. Loop settings are applied to a private resource copy, leaving the imported WAV unchanged. These short procedural WAV cues are an intentional exception to the OGG delivery target below. + +To verify a rebuild without touching game assets, pass `--out ` and compare the WAV bytes. The loop's first and last PCM samples are zero. + ### C. Level music (instrumental, outside the theme family) These replace or upgrade tracks under `hoppys-adventure-arcade/Assets/Audio/`. Each keeps its level's character, with an optional motif quote. The current files, and the code that plays them, are listed for drop-in replacement. @@ -138,6 +153,7 @@ These replace or upgrade tracks under `hoppys-adventure-arcade/Assets/Audio/`. E | **Code City Brawl: CLI/IDE District** (`Zodik - Future Travel.ogg`) | Stage 1 and the mode menu (`modes/beatem_up/catalog.gd`) | `neon cyberpunk chiptune beat-em-up stage, 128 BPM, E minor, punchy kick and snare, FM slap bass, cyan square arpeggios, terminal beep accents, street brawler groove, loopable` | | **Code City Brawl: Security Sector** (`helldrake(extended).ogg`) | Stage 2 | `dark industrial chiptune, 136 BPM, F# phrygian, siren-like pulse lead, distorted FM bass, heavy breakbeat, tense, loopable` | | **Code City Brawl: Git HQ** (`kim_lightyear_-_angel_eyes_vision_ii.mp3`) | Stage 3 | `regal funk chiptune, 124 BPM, Bb mixolydian, brass-like FM stabs, strutting hopping bassline, triumphant headquarters, loopable` | +| **Code City Brawl: district bosses** (`Boss Fight 2.mp3`) | Stack Overflow and Zero-Day, via `Catalog.MINI_BOSS_MUSIC` and `flow.swap_music` | Existing CC0 cue by Luis Zuno (ansimuz). The stage exit cancels the swap; the next stage starts its district track. The Grand Merge Conflict keeps its separate final-boss track. | | **Code City Brawl: The Grand Merge Conflict** (`Kim Lightyear - The Final.mp3`) | Boss | `two clashing chiptune melodies in counterpoint, 160 BPM, C minor, colliding phrases resolving together, heavy FM bass, double-time drums, epic final boss, loopable` | | **Heroes vs. Villains stage and boss tracks** (six files in `Assets/Audio/Beat'em Up/Hero vs Villian/`) | `modes/beatem_up/reinvent/catalog.gd` | Replaced by the theme-family cues in section B. | diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_jingle.wav b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_jingle.wav new file mode 100644 index 00000000..558c4cf9 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_jingle.wav differ diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_jingle.wav.import b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_jingle.wav.import new file mode 100644 index 00000000..f1be5b56 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_jingle.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cnh1c3055hsf0" +path="res://.godot/imported/code_city_victory_jingle.wav-a75c2476291107907e55e90abe736703.sample" + +[deps] + +source_file="res://Assets/Audio/Beat'em Up/code_city_victory_jingle.wav" +dest_files=["res://.godot/imported/code_city_victory_jingle.wav-a75c2476291107907e55e90abe736703.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=0 diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_loop.wav b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_loop.wav new file mode 100644 index 00000000..4065a3a1 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_loop.wav differ diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_loop.wav.import b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_loop.wav.import new file mode 100644 index 00000000..a0e0f354 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/code_city_victory_loop.wav.import @@ -0,0 +1,24 @@ +[remap] + +importer="wav" +type="AudioStreamWAV" +uid="uid://cd2jdes6c8xf6" +path="res://.godot/imported/code_city_victory_loop.wav-bebb8b2ebccacc89b0aa5432014e2e16.sample" + +[deps] + +source_file="res://Assets/Audio/Beat'em Up/code_city_victory_loop.wav" +dest_files=["res://.godot/imported/code_city_victory_loop.wav-bebb8b2ebccacc89b0aa5432014e2e16.sample"] + +[params] + +force/8_bit=false +force/mono=false +force/max_rate=false +force/max_rate_hz=44100 +edit/trim=false +edit/normalize=false +edit/loop_mode=0 +edit/loop_begin=0 +edit/loop_end=-1 +compress/mode=0 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/PROMPTS.md b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/PROMPTS.md index 10410dd7..f4b52074 100644 --- a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/PROMPTS.md +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/PROMPTS.md @@ -1,6 +1,6 @@ # Code City Brawl generation prompts -Built-in image generation, October 2, 2026. Phase sheets reference the original boss design. Sources preserve generated alpha; packing only slices, registers, and scales artwork. +Built-in image generation, October 2, 2026; mini-boss additions October 10, 2026. Phase sheets reference the original boss design. Sources preserve generated alpha; packing only slices, registers, and scales artwork. ## district_enemies @@ -65,3 +65,18 @@ Use case: stylized-concept. Reference image is CHARACTER DESIGN ONLY. Make a NEW ## linter_bare Use case: stylized-concept. Reference is character design for the Linter Enforcer, specifically the SMALL BARE ROBOT in bottom row second and third cells. Create a NEW transparent sprite atlas of this UNARMORED Linter Enforcer with eight poses, four equal columns and two rows. Dark mechanical torso, single lime green face optic, thin exposed limbs, small round joints, no white shoulder plates, NO heavy armor. Hold its green ruler baton. Crisp 16-bit pixel art, dark navy outline, face LEFT three-quarter view. Consistent character scale, full silhouette inside each cell, generous transparent outer border and gutters, no grid no labels no background. Poses row-major: bare idle, walking left step A, walking left step B, baton drawn-back warning, baton sweep left with small green arc, recovery, hurt recoiling, defeated scrap. Preserve recognizable bare robot identity from reference, not the bulky armored version. + +## stack_overflow + +Use case: stylized-concept. Production 16-bit pixel-art game sprite atlas. Match reference Linter Enforcer's dark navy outline weight, crisp chunky pixel clusters, slate steel, cyan and lime glow, restrained three-tone shading and belt-scroller three-quarter view. NEW mini-boss STACK OVERFLOW: a tottering tower of FIVE heavy rectangular call-stack terminal frames, cyan/lime horizontal slot glyphs (not words), slightly offset stacked steel slabs forming head and torso, small angry cyan eyes in top slab, thick segmented robotic arms, broad clenched block fists, two short mechanical legs with heavy boots. Tall top-heavy readable silhouette, comic menace. Exactly FOUR columns FOUR rows, 16 full-body complete isolated poses at consistent scale, face LEFT throughout, generous 30px outer margin and wide transparent gutters, no sprite touches another. Row-major cells: 1 idle stacked upright; 2 idle stack tilts slightly; 3 walk left foot forward; 4 walk passing; 5 walk right foot forward; 6 walk passing opposite; 7 slam warning arms rising; 8 slam warning arms overhead and stack stretches; 9 smash both fists DOWN toward left at floor; 10 slam followthrough crouched fists on floor; 11 dash warning crouched forward lean; 12 dash left charging boot stride A; 13 dash left stride B, stack trailing right; 14 recovery braced upright; 15 hurt recoils backward; 16 defeated low toppled heap of the SAME stack frames and limbs, nothing standing. One character per cell. TRUE TRANSPARENT alpha background, no floor/shadows, no labels, text, numbers, borders, UI, detached particles or floating effect rings. The source character should fill about 65% of each cell vertically so packing can make standing height 98px inside a 192px cell. Clean usable sprite sheet, not concept art. + +## zero_day + +Use case: stylized-concept. Production 16-bit pixel-art game sprite atlas. Match reference Linter Enforcer's dark navy outline weight, crisp chunky pixel clusters, slate steel and controlled neon accents, restrained three-tone shading and belt-scroller three-quarter view. NEW mini-boss ZERO-DAY: imposing cloaked exploit knight, midnight navy and charcoal hooded cloak with deep purple lining, narrow red-orange glowing visor in shadow hood, slate angular shoulder armor, steel gauntlets and boots, a broad cyan-edged short exploit blade held in forward hand. Tall broad silhouette with clearly visible bootwork below cloak; cyan and amber highlights, never photorealistic. Exactly FOUR columns FOUR rows, 16 full-body complete isolated poses at consistent scale, face LEFT throughout, generous 30px outer margin and wide transparent gutters. Row-major cells: 1 idle threatening; 2 idle cloak breathing; 3 walk left boot forward; 4 walk passing; 5 walk right boot forward; 6 walk passing opposite; 7 slam warning blade lifting; 8 slam warning both hands with blade overhead; 9 slam blade DOWN into ground left; 10 slam followthrough low braced sword-ground contact; 11 dash warning low crouched blade leading left; 12 dash charging left stride A cloak trails right; 13 dash stride B cloak trails right; 14 recover pulls blade up to ready; 15 hurt recoils backward cloak folds; 16 defeated empty hood, collapsed cloak and blade flat on ground, nothing standing. One character per cell. TRUE TRANSPARENT alpha background, no floor/shadow, no labels, words, numbers, borders, UI, detached particles or motion effects. Source idle height about65% of cell so packing can make standing height98px inside192px cell. Clean usable game sprite sheet. + +## Stack Overflow refinement + +Use case: background-extraction. Edit the provided STACK OVERFLOW pixel-art sprite sheet: remove the ENTIRE dark cloudy gradient and all background glow, leaving each of the16 character sprites isolated on TRUE TRANSPARENT alpha with clean crisp dark navy outlines and absolutely no haze or shadow between poses. Preserve exact 4columns4rows layout, scale, palette, facing and every attack/defeat pose. Also give cell2 (row1 column2 idle) a noticeable small opposing lean: the top two server frames tilt LEFT while bottom three stay supported, so the stack visibly wobbles rather than being a duplicate of cell1. In walking cells3-6 alternate the top two frames' lean with the stepping feet, maintaining connected steel stack. No other additions, no background, text, particles, grid or ground. Keep complete sprites isolated with generous transparent gutters. + + +Zero-day source cells 5 and 6 face right; the builder mirrors those complete poses before packing to enforce facing left. Original generated sources are preserved. Final mini-boss cells are 256 square with pivot (128,230), allowing the full dash cloak at about 98px standing height. diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-dash.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-dash.gif new file mode 100644 index 00000000..2d4d361d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-dash.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-idle.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-idle.gif new file mode 100644 index 00000000..e8a95578 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-idle.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-slam.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-slam.gif new file mode 100644 index 00000000..fee10129 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-slam.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-slam_warning.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-slam_warning.gif new file mode 100644 index 00000000..1b2d6970 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-slam_warning.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-walk.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-walk.gif new file mode 100644 index 00000000..2b734750 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/stack_overflow-walk.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-dash.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-dash.gif new file mode 100644 index 00000000..46c7c904 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-dash.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-idle.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-idle.gif new file mode 100644 index 00000000..354f8466 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-idle.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-slam.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-slam.gif new file mode 100644 index 00000000..45fee84f Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-slam.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-slam_warning.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-slam_warning.gif new file mode 100644 index 00000000..97ea12cd Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-slam_warning.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-walk.gif b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-walk.gif new file mode 100644 index 00000000..b6314098 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/animations/zero_day-walk.gif differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/manifest.json b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/manifest.json index d58f9b63..96582871 100644 --- a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/manifest.json +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/manifest.json @@ -6399,6 +6399,891 @@ "loop": true } ] + }, + "stack_overflow": { + "file": "stack_overflow.png", + "resource": "stack_overflow.tres", + "cell": [ + 256, + 256 + ], + "grid": [ + 4, + 4 + ], + "pivot": [ + 128, + 230 + ], + "facing": "left", + "source": "stack_overflow", + "source_scale": 0.4529680365296804, + "frames": [ + { + "index": 0, + "rect": [ + 0, + 0, + 256, + 256 + ], + "visible_bounds": [ + 74, + 132, + 183, + 231 + ], + "source_rect_ltrb": [ + 83, + 5, + 332, + 232 + ] + }, + { + "index": 1, + "rect": [ + 256, + 0, + 256, + 256 + ], + "visible_bounds": [ + 71, + 130, + 185, + 231 + ], + "source_rect_ltrb": [ + 468, + 1, + 729, + 232 + ] + }, + { + "index": 2, + "rect": [ + 512, + 0, + 256, + 256 + ], + "visible_bounds": [ + 68, + 131, + 187, + 231 + ], + "source_rect_ltrb": [ + 842, + 7, + 1113, + 236 + ] + }, + { + "index": 3, + "rect": [ + 768, + 0, + 256, + 256 + ], + "visible_bounds": [ + 68, + 129, + 188, + 231 + ], + "source_rect_ltrb": [ + 1220, + 3, + 1494, + 237 + ] + }, + { + "index": 4, + "rect": [ + 0, + 256, + 256, + 256 + ], + "visible_bounds": [ + 70, + 121, + 187, + 231 + ], + "source_rect_ltrb": [ + 68, + 250, + 335, + 501 + ] + }, + { + "index": 5, + "rect": [ + 256, + 256, + 256, + 256 + ], + "visible_bounds": [ + 70, + 127, + 185, + 231 + ], + "source_rect_ltrb": [ + 460, + 260, + 722, + 499 + ] + }, + { + "index": 6, + "rect": [ + 512, + 256, + 256, + 256 + ], + "visible_bounds": [ + 60, + 139, + 197, + 231 + ], + "source_rect_ltrb": [ + 811, + 286, + 1122, + 499 + ] + }, + { + "index": 7, + "rect": [ + 768, + 256, + 256, + 256 + ], + "visible_bounds": [ + 80, + 115, + 175, + 231 + ], + "source_rect_ltrb": [ + 1246, + 238, + 1465, + 504 + ] + }, + { + "index": 8, + "rect": [ + 0, + 512, + 256, + 256 + ], + "visible_bounds": [ + 60, + 148, + 197, + 231 + ], + "source_rect_ltrb": [ + 43, + 539, + 354, + 730 + ] + }, + { + "index": 9, + "rect": [ + 256, + 512, + 256, + 256 + ], + "visible_bounds": [ + 62, + 158, + 194, + 231 + ], + "source_rect_ltrb": [ + 442, + 559, + 743, + 730 + ] + }, + { + "index": 10, + "rect": [ + 512, + 512, + 256, + 256 + ], + "visible_bounds": [ + 74, + 153, + 183, + 231 + ], + "source_rect_ltrb": [ + 840, + 544, + 1089, + 725 + ] + }, + { + "index": 11, + "rect": [ + 768, + 512, + 256, + 256 + ], + "visible_bounds": [ + 68, + 135, + 189, + 231 + ], + "source_rect_ltrb": [ + 1222, + 519, + 1499, + 740 + ] + }, + { + "index": 12, + "rect": [ + 0, + 768, + 256, + 256 + ], + "visible_bounds": [ + 60, + 131, + 196, + 231 + ], + "source_rect_ltrb": [ + 47, + 764, + 356, + 993 + ] + }, + { + "index": 13, + "rect": [ + 256, + 768, + 256, + 256 + ], + "visible_bounds": [ + 70, + 125, + 187, + 231 + ], + "source_rect_ltrb": [ + 461, + 743, + 728, + 985 + ] + }, + { + "index": 14, + "rect": [ + 512, + 768, + 256, + 256 + ], + "visible_bounds": [ + 64, + 127, + 193, + 231 + ], + "source_rect_ltrb": [ + 830, + 747, + 1124, + 985 + ] + }, + { + "index": 15, + "rect": [ + 768, + 768, + 256, + 256 + ], + "visible_bounds": [ + 46, + 155, + 209, + 231 + ], + "source_rect_ltrb": [ + 1146, + 823, + 1515, + 999 + ] + } + ], + "clips": [ + { + "name": "idle", + "frames": [ + 0, + 1 + ], + "fps": 3, + "loop": true + }, + { + "name": "walk", + "frames": [ + 2, + 3, + 4, + 5 + ], + "fps": 6, + "loop": true + }, + { + "name": "slam_warning", + "frames": [ + 6, + 7 + ], + "fps": 5, + "loop": false + }, + { + "name": "slam", + "frames": [ + 8, + 9 + ], + "fps": 10, + "loop": false + }, + { + "name": "dash_warning", + "frames": [ + 10 + ], + "fps": 1, + "loop": false + }, + { + "name": "dash", + "frames": [ + 11, + 12 + ], + "fps": 10, + "loop": false + }, + { + "name": "recover", + "frames": [ + 13 + ], + "fps": 1, + "loop": false + }, + { + "name": "hurt", + "frames": [ + 14 + ], + "fps": 1, + "loop": false + }, + { + "name": "defeat", + "frames": [ + 15 + ], + "fps": 1, + "loop": false + } + ], + "source_flip_h": [], + "standing_height_ratio": 1.6, + "display_name": "STACK OVERFLOW" + }, + "zero_day": { + "file": "zero_day.png", + "resource": "zero_day.tres", + "cell": [ + 256, + 256 + ], + "grid": [ + 4, + 4 + ], + "pivot": [ + 128, + 230 + ], + "facing": "left", + "source": "zero_day", + "source_scale": 0.5248677248677249, + "frames": [ + { + "index": 0, + "rect": [ + 0, + 0, + 256, + 256 + ], + "visible_bounds": [ + 58, + 132, + 199, + 231 + ], + "source_rect_ltrb": [ + 85, + 28, + 362, + 225 + ] + }, + { + "index": 1, + "rect": [ + 256, + 0, + 256, + 256 + ], + "visible_bounds": [ + 52, + 132, + 203, + 231 + ], + "source_rect_ltrb": [ + 506, + 28, + 801, + 225 + ] + }, + { + "index": 2, + "rect": [ + 512, + 0, + 256, + 256 + ], + "visible_bounds": [ + 51, + 132, + 205, + 231 + ], + "source_rect_ltrb": [ + 941, + 28, + 1242, + 225 + ] + }, + { + "index": 3, + "rect": [ + 768, + 0, + 256, + 256 + ], + "visible_bounds": [ + 48, + 132, + 209, + 231 + ], + "source_rect_ltrb": [ + 1396, + 28, + 1711, + 225 + ] + }, + { + "index": 4, + "rect": [ + 0, + 256, + 256, + 256 + ], + "visible_bounds": [ + 50, + 132, + 205, + 231 + ], + "source_rect_ltrb": [ + 87, + 251, + 389, + 448 + ] + }, + { + "index": 5, + "rect": [ + 256, + 256, + 256, + 256 + ], + "visible_bounds": [ + 52, + 132, + 205, + 231 + ], + "source_rect_ltrb": [ + 528, + 251, + 827, + 448 + ] + }, + { + "index": 6, + "rect": [ + 512, + 256, + 256, + 256 + ], + "visible_bounds": [ + 64, + 124, + 191, + 231 + ], + "source_rect_ltrb": [ + 1011, + 240, + 1261, + 452 + ] + }, + { + "index": 7, + "rect": [ + 768, + 256, + 256, + 256 + ], + "visible_bounds": [ + 60, + 124, + 196, + 231 + ], + "source_rect_ltrb": [ + 1423, + 240, + 1689, + 451 + ] + }, + { + "index": 8, + "rect": [ + 0, + 512, + 256, + 256 + ], + "visible_bounds": [ + 43, + 142, + 214, + 231 + ], + "source_rect_ltrb": [ + 53, + 486, + 389, + 664 + ] + }, + { + "index": 9, + "rect": [ + 256, + 512, + 256, + 256 + ], + "visible_bounds": [ + 42, + 147, + 213, + 231 + ], + "source_rect_ltrb": [ + 499, + 495, + 832, + 662 + ] + }, + { + "index": 10, + "rect": [ + 512, + 512, + 256, + 256 + ], + "visible_bounds": [ + 34, + 151, + 223, + 231 + ], + "source_rect_ltrb": [ + 942, + 495, + 1309, + 655 + ] + }, + { + "index": 11, + "rect": [ + 768, + 512, + 256, + 256 + ], + "visible_bounds": [ + 34, + 141, + 223, + 231 + ], + "source_rect_ltrb": [ + 1368, + 479, + 1735, + 658 + ] + }, + { + "index": 12, + "rect": [ + 0, + 768, + 256, + 256 + ], + "visible_bounds": [ + 22, + 141, + 233, + 231 + ], + "source_rect_ltrb": [ + 15, + 684, + 425, + 864 + ] + }, + { + "index": 13, + "rect": [ + 256, + 768, + 256, + 256 + ], + "visible_bounds": [ + 47, + 135, + 209, + 231 + ], + "source_rect_ltrb": [ + 526, + 673, + 842, + 864 + ] + }, + { + "index": 14, + "rect": [ + 512, + 768, + 256, + 256 + ], + "visible_bounds": [ + 58, + 135, + 198, + 231 + ], + "source_rect_ltrb": [ + 978, + 678, + 1253, + 868 + ] + }, + { + "index": 15, + "rect": [ + 768, + 768, + 256, + 256 + ], + "visible_bounds": [ + 28, + 181, + 228, + 231 + ], + "source_rect_ltrb": [ + 1352, + 766, + 1741, + 869 + ] + } + ], + "clips": [ + { + "name": "idle", + "frames": [ + 0, + 1 + ], + "fps": 3, + "loop": true + }, + { + "name": "walk", + "frames": [ + 2, + 3, + 4, + 5 + ], + "fps": 6, + "loop": true + }, + { + "name": "slam_warning", + "frames": [ + 6, + 7 + ], + "fps": 5, + "loop": false + }, + { + "name": "slam", + "frames": [ + 8, + 9 + ], + "fps": 10, + "loop": false + }, + { + "name": "dash_warning", + "frames": [ + 10 + ], + "fps": 1, + "loop": false + }, + { + "name": "dash", + "frames": [ + 11, + 12 + ], + "fps": 10, + "loop": false + }, + { + "name": "recover", + "frames": [ + 13 + ], + "fps": 1, + "loop": false + }, + { + "name": "hurt", + "frames": [ + 14 + ], + "fps": 1, + "loop": false + }, + { + "name": "defeat", + "frames": [ + 15 + ], + "fps": 1, + "loop": false + } + ], + "source_flip_h": [ + 4, + 5 + ], + "standing_height_ratio": 1.6, + "display_name": "ZERO-DAY" } }, "backgrounds": { diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.png new file mode 100644 index 00000000..d3f1444f Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.png.import new file mode 100644 index 00000000..34c9b248 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cqkf4xugeuvdl" +path="res://.godot/imported/stack_overflow.png-60e29de93bea695fa02fa8bbbd353611.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/code_city/stack_overflow.png" +dest_files=["res://.godot/imported/stack_overflow.png-60e29de93bea695fa02fa8bbbd353611.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.tres b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.tres new file mode 100644 index 00000000..53ddfabc --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/stack_overflow.tres @@ -0,0 +1,80 @@ +[gd_resource type="SpriteFrames" load_steps=18 format=3] + +[ext_resource type="Texture2D" path="stack_overflow.png" id="1"] + +[sub_resource type="AtlasTexture" id="Frame_0"] +atlas = ExtResource("1") +region = Rect2(0, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_1"] +atlas = ExtResource("1") +region = Rect2(256, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_2"] +atlas = ExtResource("1") +region = Rect2(512, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_3"] +atlas = ExtResource("1") +region = Rect2(768, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_4"] +atlas = ExtResource("1") +region = Rect2(0, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_5"] +atlas = ExtResource("1") +region = Rect2(256, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_6"] +atlas = ExtResource("1") +region = Rect2(512, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_7"] +atlas = ExtResource("1") +region = Rect2(768, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_8"] +atlas = ExtResource("1") +region = Rect2(0, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_9"] +atlas = ExtResource("1") +region = Rect2(256, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_10"] +atlas = ExtResource("1") +region = Rect2(512, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_11"] +atlas = ExtResource("1") +region = Rect2(768, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_12"] +atlas = ExtResource("1") +region = Rect2(0, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_13"] +atlas = ExtResource("1") +region = Rect2(256, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_14"] +atlas = ExtResource("1") +region = Rect2(512, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_15"] +atlas = ExtResource("1") +region = Rect2(768, 768, 256, 256) + +[resource] +animations = [ +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}, {"duration": 1.0, "texture": SubResource("Frame_1")}], "loop": true, "name": &"idle", "speed": 3.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}, {"duration": 1.0, "texture": SubResource("Frame_5")}], "loop": true, "name": &"walk", "speed": 6.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"slam_warning", "speed": 5.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}], "loop": false, "name": &"slam", "speed": 10.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"dash_warning", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}], "loop": false, "name": &"dash", "speed": 10.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"recover", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"defeat", "speed": 1.0} +] diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.png new file mode 100644 index 00000000..0e7bd37c Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.png.import new file mode 100644 index 00000000..dcb51433 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfny01nacb66v" +path="res://.godot/imported/zero_day.png-a35b2ee454cff7bd791833e17f0bd64d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/code_city/zero_day.png" +dest_files=["res://.godot/imported/zero_day.png-a35b2ee454cff7bd791833e17f0bd64d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.tres b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.tres new file mode 100644 index 00000000..7c7223cb --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/code_city/zero_day.tres @@ -0,0 +1,80 @@ +[gd_resource type="SpriteFrames" load_steps=18 format=3] + +[ext_resource type="Texture2D" path="zero_day.png" id="1"] + +[sub_resource type="AtlasTexture" id="Frame_0"] +atlas = ExtResource("1") +region = Rect2(0, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_1"] +atlas = ExtResource("1") +region = Rect2(256, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_2"] +atlas = ExtResource("1") +region = Rect2(512, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_3"] +atlas = ExtResource("1") +region = Rect2(768, 0, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_4"] +atlas = ExtResource("1") +region = Rect2(0, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_5"] +atlas = ExtResource("1") +region = Rect2(256, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_6"] +atlas = ExtResource("1") +region = Rect2(512, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_7"] +atlas = ExtResource("1") +region = Rect2(768, 256, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_8"] +atlas = ExtResource("1") +region = Rect2(0, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_9"] +atlas = ExtResource("1") +region = Rect2(256, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_10"] +atlas = ExtResource("1") +region = Rect2(512, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_11"] +atlas = ExtResource("1") +region = Rect2(768, 512, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_12"] +atlas = ExtResource("1") +region = Rect2(0, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_13"] +atlas = ExtResource("1") +region = Rect2(256, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_14"] +atlas = ExtResource("1") +region = Rect2(512, 768, 256, 256) + +[sub_resource type="AtlasTexture" id="Frame_15"] +atlas = ExtResource("1") +region = Rect2(768, 768, 256, 256) + +[resource] +animations = [ +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}, {"duration": 1.0, "texture": SubResource("Frame_1")}], "loop": true, "name": &"idle", "speed": 3.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}, {"duration": 1.0, "texture": SubResource("Frame_5")}], "loop": true, "name": &"walk", "speed": 6.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"slam_warning", "speed": 5.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}], "loop": false, "name": &"slam", "speed": 10.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"dash_warning", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}], "loop": false, "name": &"dash", "speed": 10.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"recover", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"defeat", "speed": 1.0} +] diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png new file mode 100644 index 00000000..9c39ae8d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png.import new file mode 100644 index 00000000..db09c9fb --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dnupehrg0fo34" +path="res://.godot/imported/00.png-4267ea6d0981cadb4c0e83c9337537cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/00.png" +dest_files=["res://.godot/imported/00.png-4267ea6d0981cadb4c0e83c9337537cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png new file mode 100644 index 00000000..95b99e47 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png.import new file mode 100644 index 00000000..919a30fc --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dya0avpfacsfb" +path="res://.godot/imported/01.png-2c27936c6d821014cb5fd84214534c0b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/01.png" +dest_files=["res://.godot/imported/01.png-2c27936c6d821014cb5fd84214534c0b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png new file mode 100644 index 00000000..03455c57 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png.import new file mode 100644 index 00000000..28e55735 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2doulpof8gwt" +path="res://.godot/imported/02.png-4b011caf34aa2936e93a533f5331de17.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/02.png" +dest_files=["res://.godot/imported/02.png-4b011caf34aa2936e93a533f5331de17.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png new file mode 100644 index 00000000..35269abd Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png.import new file mode 100644 index 00000000..ebaddb61 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwg1p0j5rkhru" +path="res://.godot/imported/03.png-a66383e529da2702317bb7b6375a40c7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/03.png" +dest_files=["res://.godot/imported/03.png-a66383e529da2702317bb7b6375a40c7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png new file mode 100644 index 00000000..31668f09 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png.import new file mode 100644 index 00000000..05a9401b --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://w13vwtlcefvg" +path="res://.godot/imported/04.png-d0246007bbceccbf0b701215c3243b07.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/04.png" +dest_files=["res://.godot/imported/04.png-d0246007bbceccbf0b701215c3243b07.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png new file mode 100644 index 00000000..c15e3240 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png.import new file mode 100644 index 00000000..09c8add7 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddshftxlmrnsx" +path="res://.godot/imported/05.png-c90c783a4234a8361c7ed4a7d4b90051.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/05.png" +dest_files=["res://.godot/imported/05.png-c90c783a4234a8361c7ed4a7d4b90051.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png new file mode 100644 index 00000000..61c97ef0 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png.import new file mode 100644 index 00000000..9d7a05a2 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b5i6k10nxeh30" +path="res://.godot/imported/06.png-4a4afb578cf0e58ca245a48d5488fcc2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/06.png" +dest_files=["res://.godot/imported/06.png-4a4afb578cf0e58ca245a48d5488fcc2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png new file mode 100644 index 00000000..49475bc1 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png.import new file mode 100644 index 00000000..7964e3dc --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c2u4d51l2rs3p" +path="res://.godot/imported/07.png-e32300acea54e87f8ed5cf9f9cb84ed4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/07.png" +dest_files=["res://.godot/imported/07.png-e32300acea54e87f8ed5cf9f9cb84ed4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png new file mode 100644 index 00000000..c5fc0a68 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png.import new file mode 100644 index 00000000..bd8928a7 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcv06vo5fqe8n" +path="res://.godot/imported/08.png-a4e0c7128e1b640eddd6dabc67d4d83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/08.png" +dest_files=["res://.godot/imported/08.png-a4e0c7128e1b640eddd6dabc67d4d83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png new file mode 100644 index 00000000..7f615053 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png.import new file mode 100644 index 00000000..c29b4ba0 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://y7ad76x6voc1" +path="res://.godot/imported/09.png-80d690a8c87b2f1d752181930d2164e2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/09.png" +dest_files=["res://.godot/imported/09.png-80d690a8c87b2f1d752181930d2164e2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png new file mode 100644 index 00000000..1a21c119 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png.import new file mode 100644 index 00000000..ad95a3aa --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blk34op0e20ye" +path="res://.godot/imported/10.png-386fdf2051ef5f367f29df983059222d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/10.png" +dest_files=["res://.godot/imported/10.png-386fdf2051ef5f367f29df983059222d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png new file mode 100644 index 00000000..f73bf35d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png.import new file mode 100644 index 00000000..a52caeb5 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwb7ywyrtwh31" +path="res://.godot/imported/11.png-41888f693aebc87cbaf22752cfce6635.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/11.png" +dest_files=["res://.godot/imported/11.png-41888f693aebc87cbaf22752cfce6635.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png new file mode 100644 index 00000000..202b446e Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png.import new file mode 100644 index 00000000..37580b29 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1vlbub8prve" +path="res://.godot/imported/12.png-ef18f8302a8605db0b2e4fd6db4f62a0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/12.png" +dest_files=["res://.godot/imported/12.png-ef18f8302a8605db0b2e4fd6db4f62a0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png new file mode 100644 index 00000000..2c64c91a Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png.import new file mode 100644 index 00000000..13c28cad --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8y18w2t7bl2d" +path="res://.godot/imported/13.png-5437e7dbd40f11495d23bf96beb7b0b8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/13.png" +dest_files=["res://.godot/imported/13.png-5437e7dbd40f11495d23bf96beb7b0b8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png new file mode 100644 index 00000000..31736770 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png.import new file mode 100644 index 00000000..36d28a29 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://osos2juksbq4" +path="res://.godot/imported/14.png-ee39b3a0bf19f9be9d42f3fab2fb1047.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/14.png" +dest_files=["res://.godot/imported/14.png-ee39b3a0bf19f9be9d42f3fab2fb1047.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png new file mode 100644 index 00000000..b1a3928d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png.import new file mode 100644 index 00000000..ab0477e1 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgwl3cc1n7gnw" +path="res://.godot/imported/15.png-69595f9954734f51ef4380b1795d2686.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/stack_overflow/15.png" +dest_files=["res://.godot/imported/15.png-69595f9954734f51ef4380b1795d2686.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/00.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/00.png new file mode 100644 index 00000000..5dfc3f49 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/00.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_turn.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/00.png.import similarity index 72% rename from hoppys-adventure-arcade/Assets/runner/menu_previews/runner_turn.png.import rename to hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/00.png.import index 1230a735..4181030c 100644 --- a/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_turn.png.import +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/00.png.import @@ -2,16 +2,16 @@ importer="texture" type="CompressedTexture2D" -uid="uid://d1rd4vigxgnpj" -path="res://.godot/imported/runner_turn.png-df8d557867ae5e2c2df6ed0e44c3cfc8.ctex" +uid="uid://d1tco2okvga1e" +path="res://.godot/imported/00.png-0c698a5910090f85765536f33a7a462b.ctex" metadata={ "vram_texture": false } [deps] -source_file="res://Assets/runner/menu_previews/runner_turn.png" -dest_files=["res://.godot/imported/runner_turn.png-df8d557867ae5e2c2df6ed0e44c3cfc8.ctex"] +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/00.png" +dest_files=["res://.godot/imported/00.png-0c698a5910090f85765536f33a7a462b.ctex"] [params] diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/01.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/01.png new file mode 100644 index 00000000..4c3bdf4e Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/01.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/01.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/01.png.import new file mode 100644 index 00000000..8f10ce4a --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d1y2785kf178v" +path="res://.godot/imported/01.png-95093a67c05dc3bb3e7d57a1d2f735c0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/01.png" +dest_files=["res://.godot/imported/01.png-95093a67c05dc3bb3e7d57a1d2f735c0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/02.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/02.png new file mode 100644 index 00000000..00a96f85 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/02.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/02.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/02.png.import new file mode 100644 index 00000000..757f4308 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dw2h8xn7kubl5" +path="res://.godot/imported/02.png-a0a7b1ddfe92b0980d2235f60ebaf939.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/02.png" +dest_files=["res://.godot/imported/02.png-a0a7b1ddfe92b0980d2235f60ebaf939.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/03.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/03.png new file mode 100644 index 00000000..b5629567 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/03.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/03.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/03.png.import new file mode 100644 index 00000000..91c53af5 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://smkk4hoj7f3m" +path="res://.godot/imported/03.png-97ea5d3465c8c2cc7c77c4603e6163be.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/03.png" +dest_files=["res://.godot/imported/03.png-97ea5d3465c8c2cc7c77c4603e6163be.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/04.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/04.png new file mode 100644 index 00000000..e3a22fd1 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/04.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/04.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/04.png.import new file mode 100644 index 00000000..5f2aa99a --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/04.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8yssf8qrbuhw" +path="res://.godot/imported/04.png-8f85cca8c52de01db68baf81bab00ec8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/04.png" +dest_files=["res://.godot/imported/04.png-8f85cca8c52de01db68baf81bab00ec8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/05.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/05.png new file mode 100644 index 00000000..ee613501 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/05.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/05.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/05.png.import new file mode 100644 index 00000000..66bbaa07 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/05.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsyp0d5jodd1l" +path="res://.godot/imported/05.png-5c0b5afe77b1803c6f36879ba1a17812.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/05.png" +dest_files=["res://.godot/imported/05.png-5c0b5afe77b1803c6f36879ba1a17812.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/06.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/06.png new file mode 100644 index 00000000..ae3259e2 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/06.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/06.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/06.png.import new file mode 100644 index 00000000..25dd0651 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/06.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c58h0hde2dpgk" +path="res://.godot/imported/06.png-d3a4d49c580376168263b857df628f4a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/06.png" +dest_files=["res://.godot/imported/06.png-d3a4d49c580376168263b857df628f4a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/07.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/07.png new file mode 100644 index 00000000..db83e5b4 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/07.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/07.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/07.png.import new file mode 100644 index 00000000..1b8dbda5 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/07.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://da6oonxboucp0" +path="res://.godot/imported/07.png-4bb9b6879fd14932057ff517c3f0c02e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/07.png" +dest_files=["res://.godot/imported/07.png-4bb9b6879fd14932057ff517c3f0c02e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/08.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/08.png new file mode 100644 index 00000000..2c3544a5 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/08.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/08.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/08.png.import new file mode 100644 index 00000000..6a9a0e93 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/08.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdfxh8fjc6jk3" +path="res://.godot/imported/08.png-c7cbb4d0d20faec76bacc6cd73275e82.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/08.png" +dest_files=["res://.godot/imported/08.png-c7cbb4d0d20faec76bacc6cd73275e82.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/09.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/09.png new file mode 100644 index 00000000..50396163 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/09.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/09.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/09.png.import new file mode 100644 index 00000000..8bf211b3 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/09.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bde7gexbka8gl" +path="res://.godot/imported/09.png-cd5ca0504e6984e9d2ae99cb88c1f5f5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/09.png" +dest_files=["res://.godot/imported/09.png-cd5ca0504e6984e9d2ae99cb88c1f5f5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/10.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/10.png new file mode 100644 index 00000000..fd923c9b Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/10.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/10.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/10.png.import new file mode 100644 index 00000000..8f5b5e03 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/10.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dw76byciqq8lj" +path="res://.godot/imported/10.png-3812eefa81d63488f7013f57de1e218e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/10.png" +dest_files=["res://.godot/imported/10.png-3812eefa81d63488f7013f57de1e218e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/11.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/11.png new file mode 100644 index 00000000..8b13499d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/11.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/11.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/11.png.import new file mode 100644 index 00000000..491044ff --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/11.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://do8h74wo5om44" +path="res://.godot/imported/11.png-42c3208c52f5338b3fabb2bcd1814d60.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/11.png" +dest_files=["res://.godot/imported/11.png-42c3208c52f5338b3fabb2bcd1814d60.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/12.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/12.png new file mode 100644 index 00000000..31866571 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/12.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/12.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/12.png.import new file mode 100644 index 00000000..33d6fed0 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/12.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djmxln8mqj2c2" +path="res://.godot/imported/12.png-8e8cab897aec9af6fbf9edb9579b2f85.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/12.png" +dest_files=["res://.godot/imported/12.png-8e8cab897aec9af6fbf9edb9579b2f85.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/13.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/13.png new file mode 100644 index 00000000..908ed6b8 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/13.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/13.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/13.png.import new file mode 100644 index 00000000..97bc8ca8 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/13.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drao4tbmyl6wh" +path="res://.godot/imported/13.png-9b04ebab5d0a5ba0b614f4e9e15d46da.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/13.png" +dest_files=["res://.godot/imported/13.png-9b04ebab5d0a5ba0b614f4e9e15d46da.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/14.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/14.png new file mode 100644 index 00000000..86168e14 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/14.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/14.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/14.png.import new file mode 100644 index 00000000..b555a326 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/14.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://brg6kvnv5aeo0" +path="res://.godot/imported/14.png-a940b68b046ddbe6977be77b3afd42ae.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/14.png" +dest_files=["res://.godot/imported/14.png-a940b68b046ddbe6977be77b3afd42ae.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/15.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/15.png new file mode 100644 index 00000000..52fd1051 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/15.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/15.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/15.png.import new file mode 100644 index 00000000..6f5b89be --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/frames/zero_day/15.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8ynw65agrd7p" +path="res://.godot/imported/15.png-0e310fd2fa6dd624b9dc0d45f02bbd1a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/frames/zero_day/15.png" +dest_files=["res://.godot/imported/15.png-0e310fd2fa6dd624b9dc0d45f02bbd1a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png new file mode 100644 index 00000000..f73bf35d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png.import new file mode 100644 index 00000000..4acb7409 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dk8qojg2gouy7" +path="res://.godot/imported/dash.png-b0ff0780756acadec6508d406efba098.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/dash.png" +dest_files=["res://.godot/imported/dash.png-b0ff0780756acadec6508d406efba098.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png new file mode 100644 index 00000000..1a21c119 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png.import new file mode 100644 index 00000000..717c108e --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlc1xgyww8r3u" +path="res://.godot/imported/dash_warning.png-1f6398babc21dbd583dde9fdf30ceabe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/dash_warning.png" +dest_files=["res://.godot/imported/dash_warning.png-1f6398babc21dbd583dde9fdf30ceabe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png new file mode 100644 index 00000000..b1a3928d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png.import new file mode 100644 index 00000000..4cb6db1e --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://540n01qk6v82" +path="res://.godot/imported/defeat.png-7ba63e42cc4959ed7435c5b3502c3b78.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/defeat.png" +dest_files=["res://.godot/imported/defeat.png-7ba63e42cc4959ed7435c5b3502c3b78.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png new file mode 100644 index 00000000..31736770 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png.import new file mode 100644 index 00000000..ab8e9dd0 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://eh1uhnlmpu4i" +path="res://.godot/imported/hurt.png-0f8b6fb2c14885eaf6d4d1e8d75af568.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/hurt.png" +dest_files=["res://.godot/imported/hurt.png-0f8b6fb2c14885eaf6d4d1e8d75af568.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png new file mode 100644 index 00000000..9c39ae8d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png.import new file mode 100644 index 00000000..690b18b2 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5tcgtuak7b3a" +path="res://.godot/imported/idle.png-883812862cac900908dec44bc18bb874.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/idle.png" +dest_files=["res://.godot/imported/idle.png-883812862cac900908dec44bc18bb874.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png new file mode 100644 index 00000000..2c64c91a Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png.import new file mode 100644 index 00000000..37aab5d7 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds0ckgudfxxol" +path="res://.godot/imported/recover.png-30fa169d60be69daf8853c3c79c58f84.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/recover.png" +dest_files=["res://.godot/imported/recover.png-30fa169d60be69daf8853c3c79c58f84.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png new file mode 100644 index 00000000..c5fc0a68 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png.import new file mode 100644 index 00000000..8e230a6f --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhdquoj83p0q8" +path="res://.godot/imported/slam.png-73655f9250cda6af10be68ce7acc509f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/slam.png" +dest_files=["res://.godot/imported/slam.png-73655f9250cda6af10be68ce7acc509f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png new file mode 100644 index 00000000..61c97ef0 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png.import new file mode 100644 index 00000000..0c24fd1c --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://coot0420wkm8h" +path="res://.godot/imported/slam_warning.png-e9909e0d31c132c67b01f77d24c7745e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/slam_warning.png" +dest_files=["res://.godot/imported/slam_warning.png-e9909e0d31c132c67b01f77d24c7745e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png new file mode 100644 index 00000000..03455c57 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png.import new file mode 100644 index 00000000..d020917b --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqxqr2cg3w0dg" +path="res://.godot/imported/walk.png-7c981539b4b07a5c94ac8a38d64888f9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/stack_overflow/walk.png" +dest_files=["res://.godot/imported/walk.png-7c981539b4b07a5c94ac8a38d64888f9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash.png new file mode 100644 index 00000000..8b13499d Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash.png.import new file mode 100644 index 00000000..822d0563 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c70foxvy4anhi" +path="res://.godot/imported/dash.png-c2706f094793b688f2c7daeb994b0fb1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/dash.png" +dest_files=["res://.godot/imported/dash.png-c2706f094793b688f2c7daeb994b0fb1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png new file mode 100644 index 00000000..fd923c9b Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png.import new file mode 100644 index 00000000..00946388 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bh4eeqbwggd44" +path="res://.godot/imported/dash_warning.png-1937e6b0419cc1a78dead43ab38c6e7a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/dash_warning.png" +dest_files=["res://.godot/imported/dash_warning.png-1937e6b0419cc1a78dead43ab38c6e7a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png new file mode 100644 index 00000000..52fd1051 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png.import new file mode 100644 index 00000000..916cbce8 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6llocv7x0agn" +path="res://.godot/imported/defeat.png-3e7cb4c0ee876998215c4455778ad014.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/defeat.png" +dest_files=["res://.godot/imported/defeat.png-3e7cb4c0ee876998215c4455778ad014.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png new file mode 100644 index 00000000..86168e14 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png.import new file mode 100644 index 00000000..5c48eb08 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dbtaqqec0ksxy" +path="res://.godot/imported/hurt.png-e9c400afbdd5a7386daa527dc4536bed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/hurt.png" +dest_files=["res://.godot/imported/hurt.png-e9c400afbdd5a7386daa527dc4536bed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/idle.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/idle.png new file mode 100644 index 00000000..5dfc3f49 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/idle.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/idle.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/idle.png.import new file mode 100644 index 00000000..665b3fed --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2lypkv2wuew6" +path="res://.godot/imported/idle.png-35077c8e575b8a9e40d28693ed088170.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/idle.png" +dest_files=["res://.godot/imported/idle.png-35077c8e575b8a9e40d28693ed088170.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/recover.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/recover.png new file mode 100644 index 00000000..908ed6b8 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/recover.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/recover.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/recover.png.import new file mode 100644 index 00000000..10ef81f1 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/recover.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bdxrwetmnn3in" +path="res://.godot/imported/recover.png-c4cd16d012c1b52f5e5a75bd9036c13d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/recover.png" +dest_files=["res://.godot/imported/recover.png-c4cd16d012c1b52f5e5a75bd9036c13d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam.png new file mode 100644 index 00000000..2c3544a5 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam.png.import new file mode 100644 index 00000000..129ca901 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1f80xeda5e3n" +path="res://.godot/imported/slam.png-4c9c100e58f25db76f94c8e32247d70e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/slam.png" +dest_files=["res://.godot/imported/slam.png-4c9c100e58f25db76f94c8e32247d70e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png new file mode 100644 index 00000000..ae3259e2 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png.import new file mode 100644 index 00000000..4a867d1a --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c80khhqy276n0" +path="res://.godot/imported/slam_warning.png-01320b10c71aec1a901d0ae1c984ccb6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/slam_warning.png" +dest_files=["res://.godot/imported/slam_warning.png-01320b10c71aec1a901d0ae1c984ccb6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/walk.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/walk.png new file mode 100644 index 00000000..00a96f85 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/walk.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/walk.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/walk.png.import new file mode 100644 index 00000000..708d1a01 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/individual/zero_day/walk.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bw11473s4gl04" +path="res://.godot/imported/walk.png-b2f146f1603a67928bde5e1666e94686.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/individual/zero_day/walk.png" +dest_files=["res://.godot/imported/walk.png-b2f146f1603a67928bde5e1666e94686.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/mini_boss_generation.json b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/mini_boss_generation.json new file mode 100644 index 00000000..06de3076 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/mini_boss_generation.json @@ -0,0 +1,7 @@ +{ + "generated_on": "2026-10-10", + "prompts": { + "stack_overflow": "Use case: stylized-concept. Production 16-bit pixel-art game sprite atlas. Match reference Linter Enforcer's dark navy outline weight, crisp chunky pixel clusters, slate steel, cyan and lime glow, restrained three-tone shading and belt-scroller three-quarter view. NEW mini-boss STACK OVERFLOW: a tottering tower of FIVE heavy rectangular call-stack terminal frames, cyan/lime horizontal slot glyphs (not words), slightly offset stacked steel slabs forming head and torso, small angry cyan eyes in top slab, thick segmented robotic arms, broad clenched block fists, two short mechanical legs with heavy boots. Tall top-heavy readable silhouette, comic menace. Exactly FOUR columns FOUR rows, 16 full-body complete isolated poses at consistent scale, face LEFT throughout, generous 30px outer margin and wide transparent gutters, no sprite touches another. Row-major cells: 1 idle stacked upright; 2 idle stack tilts slightly; 3 walk left foot forward; 4 walk passing; 5 walk right foot forward; 6 walk passing opposite; 7 slam warning arms rising; 8 slam warning arms overhead and stack stretches; 9 smash both fists DOWN toward left at floor; 10 slam followthrough crouched fists on floor; 11 dash warning crouched forward lean; 12 dash left charging boot stride A; 13 dash left stride B, stack trailing right; 14 recovery braced upright; 15 hurt recoils backward; 16 defeated low toppled heap of the SAME stack frames and limbs, nothing standing. One character per cell. TRUE TRANSPARENT alpha background, no floor/shadows, no labels, text, numbers, borders, UI, detached particles or floating effect rings. The source character should fill about 65% of each cell vertically so packing can make standing height 98px inside a 192px cell. Clean usable sprite sheet, not concept art.", + "zero_day": "Use case: stylized-concept. Production 16-bit pixel-art game sprite atlas. Match reference Linter Enforcer's dark navy outline weight, crisp chunky pixel clusters, slate steel and controlled neon accents, restrained three-tone shading and belt-scroller three-quarter view. NEW mini-boss ZERO-DAY: imposing cloaked exploit knight, midnight navy and charcoal hooded cloak with deep purple lining, narrow red-orange glowing visor in shadow hood, slate angular shoulder armor, steel gauntlets and boots, a broad cyan-edged short exploit blade held in forward hand. Tall broad silhouette with clearly visible bootwork below cloak; cyan and amber highlights, never photorealistic. Exactly FOUR columns FOUR rows, 16 full-body complete isolated poses at consistent scale, face LEFT throughout, generous 30px outer margin and wide transparent gutters. Row-major cells: 1 idle threatening; 2 idle cloak breathing; 3 walk left boot forward; 4 walk passing; 5 walk right boot forward; 6 walk passing opposite; 7 slam warning blade lifting; 8 slam warning both hands with blade overhead; 9 slam blade DOWN into ground left; 10 slam followthrough low braced sword-ground contact; 11 dash warning low crouched blade leading left; 12 dash charging left stride A cloak trails right; 13 dash stride B cloak trails right; 14 recover pulls blade up to ready; 15 hurt recoils backward cloak folds; 16 defeated empty hood, collapsed cloak and blade flat on ground, nothing standing. One character per cell. TRUE TRANSPARENT alpha background, no floor/shadow, no labels, words, numbers, borders, UI, detached particles or motion effects. Source idle height about65% of cell so packing can make standing height98px inside192px cell. Clean usable game sprite sheet." + } +} diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/preview.html b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/preview.html index baf0d01c..0c0b826d 100644 --- a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/preview.html +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/preview.html @@ -1,4 +1,4 @@ Code City Brawl · Asset Library -
HOPPY'S ADVENTURE ARCADE / MODE 1 / ASSETS ONLY

Code City Brawl

Three districts. Nine enemies. One unresolved conflict.
Inspect transparent sprites and named poses below. Short cycles are animated; single-frame entries are key poses. Combat, collisions and level layouts follow in the gameplay pass.

Stage panoramas

Finite 1280×720 plates. Independent midground and foreground pieces live in each stage kit. These are not seamless tiles.

- +
HOPPY'S ADVENTURE ARCADE / MODE 1 / ASSETS ONLY

Code City Brawl

Three districts. Nine enemies. Two district bosses. One unresolved conflict.
Inspect transparent sprites and named poses below. Short cycles are animated; single-frame entries are key poses. Stack Overflow and Zero-Day each have nine combat clips. Inspect armed walking in four directions.

Stage panoramas

Finite 1280×720 plates. Independent midground and foreground pieces live in each stage kit. These are not seamless tiles.

+ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png new file mode 100644 index 00000000..eb15307e Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png.import new file mode 100644 index 00000000..aed9c967 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rqcwojb6gl6g" +path="res://.godot/imported/stack_overflow-generation0.png-3c7d8e612bdaea851f5cf6cd9ad5c7bd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/sources/stack_overflow-generation0.png" +dest_files=["res://.godot/imported/stack_overflow-generation0.png-3c7d8e612bdaea851f5cf6cd9ad5c7bd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow.png new file mode 100644 index 00000000..202307d5 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow.png.import new file mode 100644 index 00000000..405db157 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/stack_overflow.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhd4mri2m5edi" +path="res://.godot/imported/stack_overflow.png-14296c4624fd163542f5b180d35a3daa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/sources/stack_overflow.png" +dest_files=["res://.godot/imported/stack_overflow.png-14296c4624fd163542f5b180d35a3daa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/zero_day.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/zero_day.png new file mode 100644 index 00000000..f18baf9b Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/zero_day.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/zero_day.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/zero_day.png.import new file mode 100644 index 00000000..771de886 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/sources/zero_day.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bc24p3wvqtqs7" +path="res://.godot/imported/zero_day.png-54d31c5ac738037d3b14fa7a7c9a45dd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/sources/zero_day.png" +dest_files=["res://.godot/imported/zero_day.png-54d31c5ac738037d3b14fa7a7c9a45dd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack-refinement.txt b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack-refinement.txt new file mode 100644 index 00000000..f0f705dc --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack-refinement.txt @@ -0,0 +1 @@ +Use case: background-extraction. Edit the provided STACK OVERFLOW pixel-art sprite sheet: remove the ENTIRE dark cloudy gradient and all background glow, leaving each of the16 character sprites isolated on TRUE TRANSPARENT alpha with clean crisp dark navy outlines and absolutely no haze or shadow between poses. Preserve exact 4columns4rows layout, scale, palette, facing and every attack/defeat pose. Also give cell2 (row1 column2 idle) a noticeable small opposing lean: the top two server frames tilt LEFT while bottom three stay supported, so the stack visibly wobbles rather than being a duplicate of cell1. In walking cells3-6 alternate the top two frames' lean with the stepping feet, maintaining connected steel stack. No other additions, no background, text, particles, grid or ground. Keep complete sprites isolated with generous transparent gutters. diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack_overflow_preview.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack_overflow_preview.png new file mode 100644 index 00000000..d9f3e422 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack_overflow_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack_overflow_preview.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack_overflow_preview.png.import new file mode 100644 index 00000000..58298c17 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/stack_overflow_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://csax63hoap734" +path="res://.godot/imported/stack_overflow_preview.png-51aeb169a5b706b581582d638ec56116.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/stack_overflow_preview.png" +dest_files=["res://.godot/imported/stack_overflow_preview.png-51aeb169a5b706b581582d638ec56116.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/zero_day_preview.png b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/zero_day_preview.png new file mode 100644 index 00000000..7a501ff2 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/zero_day_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/zero_day_preview.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/zero_day_preview.png.import new file mode 100644 index 00000000..fadf026b --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-assets-only/zero_day_preview.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cp5j562t86wjf" +path="res://.godot/imported/zero_day_preview.png-f40c7512920c19ce63cfc8fd89431f1b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-assets-only/zero_day_preview.png" +dest_files=["res://.godot/imported/zero_day_preview.png-f40c7512920c19ce63cfc8fd89431f1b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-player/PROMPT.md b/hoppys-adventure-arcade/Assets/code-city-brawl-player/PROMPT.md index 93668c80..9134ca35 100644 --- a/hoppys-adventure-arcade/Assets/code-city-brawl-player/PROMPT.md +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-player/PROMPT.md @@ -3,3 +3,10 @@ Built-in image generation, October 5, 2026. Existing Platformer Hoppy sprite sheet used as character reference. Generated alpha preserved. Mechanical packing: `tools/build_hoppy_brawler.py`. 96px cells, foot pivot (48,86); faces right. Sideways run/death reuse the original Platformer sheet; new depth movement and combo poses live here. Use case: stylized-concept. Reference image is Hoppy's existing character design, not a layout to copy. Create a NEW 16-bit pixel-art brawler animation sheet of EXACTLY this orange rabbit with cream muzzle, long orange ears, black shirt, orange legs, small cream tail, friendly determined eyes. Transparent alpha background. FOUR columns FOUR rows, sixteen equally spaced complete sprites. Maintain reference proportions and color palette and pixel art. Large empty transparent gutters and 30px outer margin, no text no borders. Face RIGHT for attacks. Same size and baseline every pose. Row-major: 1 walking away/up back view step A, 2 walking away/up step B, 3 walking toward viewer down step A, 4 walking toward viewer down step B. 5 rightward jab windup, 6 full rightward fist jab. 7 overhead swing windup both hands high, 8 overhead swing impact forward/down. 9 spin kick windup crouched, 10 full rightward extended kicking foot, 11 spinning kick followthrough with back partially turned. 12 bend down reaching for pickup, 13 stand lifting hand from ground. 14 stagger hurt, 15 airborne tuck jump, 16 combat ready idle. No props or weapons, no detached effects, no ground or shadow. This is an in-game sprite sheet, not a concept illustration. + + +## Victory loop supplement + +Built-in image generation, October 10, 2026. Character/style reference: `source.png`. Untouched generated master: `victory-source.png`. Packed into cells 16, 17 and 18 of `combat.png`, 96 by 96 cells, the same (48,86) foot pivot, 5 fps looping. The original sixteen cells keep their pixels and frame indices. Generated alpha is preserved, with nearest-neighbour scaling. + +Use case: stylized-concept. Asset type: transparent sprite animation strip for Code City Brawl. Input image is a character and pixel-art style reference only. Create exactly THREE new victory animation cels of the same orange Hoppy rabbit in black short-sleeve shirt, cream muzzle and tail, thick near-black pixel outline, facing three-quarter RIGHT. Arrange in one evenly spaced horizontal row, equal square cells, on true transparent background, no floor, no shadows, no text. Each pose shows the entire character including ear tips and feet. All three have identical proportions, apparent size, camera, ground baseline and stationary planted feet. Pose1: proud smile, both fists raised beside head, elbows bent. Pose2: both fists lifted high in a celebratory V, ears bounce slightly outward, bright smile. Pose3: fists settle just slightly back toward pose1, head tilts cheerfully with eyes happily closed. Small differences suited to a looping 3-frame victory cheer. Match reference's restrained orange, cream and black palette and crisp low-resolution arcade pixel clusters, no smooth vector or painted shading. Keep feet anchored and preserve recognisable Hoppy identity, orange limbs, black shirt, no trousers, no accessories, no other characters. Transparent margins between each cel and around outer edges. Landscape canvas. diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.png b/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.png index 52a15b1c..cb678ee7 100644 Binary files a/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.png and b/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.tres b/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.tres index 1d6fbfa4..2e0624de 100644 --- a/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.tres +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-player/combat.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=21 format=3] [ext_resource type="Texture2D" path="combat.png" id="1"] [sub_resource type="AtlasTexture" id="F0"] atlas = ExtResource("1") @@ -48,6 +48,15 @@ region = Rect2(192, 288, 96, 96) [sub_resource type="AtlasTexture" id="F15"] atlas = ExtResource("1") region = Rect2(288, 288, 96, 96) +[sub_resource type="AtlasTexture" id="F16"] +atlas = ExtResource("1") +region = Rect2(0, 384, 96, 96) +[sub_resource type="AtlasTexture" id="F17"] +atlas = ExtResource("1") +region = Rect2(96, 384, 96, 96) +[sub_resource type="AtlasTexture" id="F18"] +atlas = ExtResource("1") +region = Rect2(192, 384, 96, 96) [resource] animations = [{"frames":[{"duration":1.0,"texture":SubResource("F0")}, {"duration":1.0,"texture":SubResource("F1")}],"loop":true,"name":&"walk_up","speed":8.0}, {"frames":[{"duration":1.0,"texture":SubResource("F2")}, {"duration":1.0,"texture":SubResource("F3")}],"loop":true,"name":&"walk_down","speed":8.0}, @@ -57,4 +66,5 @@ animations = [{"frames":[{"duration":1.0,"texture":SubResource("F0")}, {"duratio {"frames":[{"duration":1.0,"texture":SubResource("F11")}, {"duration":1.0,"texture":SubResource("F12")}],"loop":false,"name":&"pickup","speed":8.0}, {"frames":[{"duration":1.0,"texture":SubResource("F13")}],"loop":false,"name":&"hurt","speed":1.0}, {"frames":[{"duration":1.0,"texture":SubResource("F14")}],"loop":false,"name":&"jump","speed":1.0}, -{"frames":[{"duration":1.0,"texture":SubResource("F15")}],"loop":true,"name":&"idle","speed":1.0}] +{"frames":[{"duration":1.0,"texture":SubResource("F15")}],"loop":true,"name":&"idle","speed":1.0}, +{"frames":[{"duration":1.0,"texture":SubResource("F16")}, {"duration":1.0,"texture":SubResource("F17")}, {"duration":1.0,"texture":SubResource("F18")}],"loop":true,"name":&"victory","speed":5.0}] diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-player/manifest.json b/hoppys-adventure-arcade/Assets/code-city-brawl-player/manifest.json index fac30c19..9532bf21 100644 --- a/hoppys-adventure-arcade/Assets/code-city-brawl-player/manifest.json +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-player/manifest.json @@ -78,6 +78,15 @@ ], 1, true + ], + "victory": [ + [ + 16, + 17, + 18 + ], + 5, + true ] } -} \ No newline at end of file +} diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-player/victory-source.png b/hoppys-adventure-arcade/Assets/code-city-brawl-player/victory-source.png new file mode 100644 index 00000000..045dbe16 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/code-city-brawl-player/victory-source.png differ diff --git a/hoppys-adventure-arcade/Assets/code-city-brawl-player/victory-source.png.import b/hoppys-adventure-arcade/Assets/code-city-brawl-player/victory-source.png.import new file mode 100644 index 00000000..c1f99e43 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/code-city-brawl-player/victory-source.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://5qu3fevh2c38" +path="res://.godot/imported/victory-source.png-1ba539a1e9adc368f43a4a5e33723b11.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/code-city-brawl-player/victory-source.png" +dest_files=["res://.godot/imported/victory-source.png-1ba539a1e9adc368f43a4a5e33723b11.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/README.md b/hoppys-adventure-arcade/Assets/hoppy-armed/README.md index 519bcbc3..5011c6e0 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/README.md +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/README.md @@ -2,31 +2,35 @@ Baked sprite sheets of Hoppy holding each beat'em up weapon. The weapon is drawn into Hoppy's frames, so the grip, pixel density and layering match the body in every pose. There are eleven sheets: six for Code City Brawl (`code_city/`) and five for Heroes vs. Villains (`heroes/`). -The masters come from built-in image generation. `tools/build_hoppy_armed.py` never paints or edits a master. It only finds the sixteen sprites, scales them with nearest-neighbour sampling, plants their feet on the pivot and packs the outputs, so the same masters always rebuild the same files. +The masters come from built-in image generation. `tools/build_hoppy_armed.py` never paints or edits a master. It finds the generated sprites, scales them with nearest-neighbour sampling, aligns their body and ground references and packs the outputs, so the same masters always rebuild the same files. ## Cells and pivots | Mode | Directory | Cell | Foot pivot | Unarmed idle height | `run` speed | Weapons | |---|---|---|---|---:|---:|---| -| Code City Brawl | `code_city/` | 192 × 128 | (72, 118) | 59 px | 12 fps | `debug_stick`, `linter_staff`, `api_key_whip`, `git_branch`, `refactor_hammer`, `firewall_shield` | +| Code City Brawl | `code_city/` | 192 × 128 | (72, 118) | 59 px | 13 fps | `debug_stick`, `linter_staff`, `api_key_whip`, `git_branch`, `refactor_hammer`, `firewall_shield` | | Heroes vs. Villains | `heroes/` | 256 × 160 | (96, 147) | 86 px | 7 fps | `review_stamp`, `triage_hammer`, `change_stack_baton`, `security_scanner`, `pr_merger` | Every sheet faces right and is mirrored at runtime. The cells are wider than the body so a whip lash or an overhead hammer fits; the pivot sits left of centre to leave forward room. Each sheet uses one uniform scale, chosen so that the body in `hold_idle` matches the mode's unarmed idle height to within 2 px. The scale shrinks only if the tallest pose wouldn't otherwise fit above the pivot. ## Sheet layout -Each `.png` is a 4 × 4 atlas, read row-major: +Heroes `.png` sheets remain 4 × 4 atlases. Code City sheets are 4 × 8, with 30 populated cells and two transparent padding cells. The original cells retain their indices: | Frames | Clip | Content | |---|---|---| | 0 | `hold_idle` | Ready stance, weapon at carry height (1 frame, loops) | -| 1–4 | `run` | Sideways run cycle carrying the weapon (loops) | +| 1–4 | Heroes `run` | Original sideways run; retained but unused in Code City | | 5–7 | `step1` | Combo step 1: windup, contact, follow-through | | 8–10 | `step2` | Combo step 2: windup, contact, follow-through | | 11–13 | `step3` | Combo step 3: windup, contact, follow-through | | 14 | `equip` | Lifting the weapon after a pickup | | 15 | `hurt` | Stagger while holding it (the Firewall Shield's block recoil) | +Code City appends six `run` frames at cells 16-21 (13 fps), four `walk_up` frames at 22-25 and four `walk_down` frames at 26-29 (9 fps). All locomotion clips loop. These are separately generated full-body poses holding the weapon. The six run frames alternate open contacts and bent-knee passing poses. The torso stays within 2 cell pixels of x72 across idle, run, both vertical walks, equip and hurt. The side run registers the back edge of the shirt and checks the head separately, so moving paws cannot pull the torso sideways. Locomotion has at most 3px of vertical bob. Feet cycle inside the cell while the player node moves at 240 px/s; runtime cadence still follows movement speed. + +The `locomotion` manifest object records source filenames, source cell indices, extraction bounds, per-source scale adjustments, paw and foot anchors. Its `cadence_fps` map gives each clip's rate (`run`: 13, `walk_up`: 9, `walk_down`: 9), derived from the same clip table as the SpriteFrames resource. The `body_registration` object records the measured torso/head landmarks and whole-frame offsets. Git branch combines corrected back views with two complete front views from generation zero. Earlier, clipped rightmost source poses are never packed. Every source is preserved unchanged. + The attack steps are SpriteFrames clips at 9 fps, but the game picks their frame from attack progress: windup before 30%, contact until 64%, follow-through after that. Damage still lands at 44%, inside the contact frame. ## Files in each mode directory @@ -35,13 +39,15 @@ The attack steps are SpriteFrames clips at 9 fps, but the game picks their frame |---|---| | `.png`, `.tres` | Packed atlas and its SpriteFrames resource | | `manifest.json` | Per weapon: cell, pivot, facing, body height, source scale, clips, source component bounds, fragment attachments, and per-frame `hand`, `foot` and contact-frame `tip` anchors. `carry_calibration` records how every carry icon was scaled. | -| `carry/.png` | The weapon cel, scaled to the weapon's length in the baked `hold_idle` so it has the sheet's pixel density. On poses with no armed frame, the runtime draws it at the paw, at the body's scale: walking toward or away from the camera, jumping and, in Heroes vs. Villains, defeat and victory. Each mode's weapon rig draws it behind the body wherever it would otherwise cover Hoppy's face (`carry_behind`). | -| `_preview.png` | Contact sheet of all sixteen cells next to the unarmed idle, at 2× | +| `carry/.png` | The weapon cel, scaled to the weapon's length in the baked `hold_idle` so it has the sheet's pixel density. On poses with no armed frame, the runtime draws it at the paw, at the body's scale: jumping and, in Heroes vs. Villains, vertical walking, defeat and victory. Code City vertical walking uses the new baked clips when player routing enables them. Each mode's weapon rig draws it behind the body wherever it would otherwise cover Hoppy's face (`carry_behind`). | +| `_preview.png` | Contact sheet of all populated cells next to the unarmed idle, at 2× | | `_clips.png` | Each runtime clip on a ground line drawn at the foot pivot, for motion review | | `preview.png`, `clips.png` | The same views for the mode's first (pilot) weapon | | `sources/.png` | The retained generated master, untouched. Earlier generations kept for the record are named `sources/-generation.png`; the builder only reads `sources/.png`. | +| `locomotion_contact.png`, `_locomotion.png` | Four directional walking strips, including runtime horizontal mirroring | +| `sources/-locomotion.png`, `locomotion.json` | Code City locomotion masters and exact generation prompts | | `refinements.json` | Per weapon: the exact generation prompt, the number of regenerations and what changed | -| `PROMPTS.md` | Rendered by the builder from `refinements.json`, plus the pilot's generation-zero prompt (`Mode.prompt`) when no record already holds it; don't edit it by hand | +| `PROMPTS.md` | Rendered by the builder from `refinements.json`, the Code City locomotion/refinement prompts, plus the pilot's generation-zero prompt (`Mode.prompt`) when no record already holds it; don't edit it by hand | ## Rebuilding @@ -56,7 +62,8 @@ Leave out `--mode` to rebuild both. Run it from the repository root, then reimpo - a small fragment (such as the thin end of a whip) lies more than 10 source pixels from every sprite; - a packed frame touches its cell edge; - with `per_frame_foot_pivot`, the planted foot (the painted run at each frame's registered planted x) moves more than about 3 px between frames of a clip, or a sole misses the pivot row. Run clips are exempt from the planted-foot check, since a run alternates its feet by design; -- the body moves more than 3 px between consecutive run frames, the loop seam included. The body is measured weapon-free, from the opaque pixels behind the paw, and the builder prints each weapon's run body x; +- for Heroes and the unused legacy Code City run cells, the body moves more than 3 px between consecutive run frames, the loop seam included. The body is measured weapon-free, from the opaque pixels behind the paw, and the builder prints each weapon's run body x; +- for active Code City movement, the torso shifts more than 3px between frames or is more than 2px from the common axis, or the run head moves more than 2px either side of its cycle centre; - a hand or tip anchor has no opaque pixel near its hand-picked target; - a weapon's `hold_idle_weapon_bounds` fall outside the `hold_idle` cell or cover no opaque pixels; - a carry icon drifts more than 10% from its calibrated length; @@ -81,7 +88,7 @@ Opt-in fields. Leaving one out keeps the default behaviour, and turning one on f | `per_frame_foot_pivot` | Registers each frame horizontally on its planted foot instead of using one sheet-wide offset. Idle, run, equip and hurt use the centre of the feet; attack frames use the rear foot. The build fails if that foot moves more than about 3 px between frames of a clip, so Hoppy doesn't skate. | | `foot_plant_overrides`, `foot_plant_note` | Per-frame `center` / `rear` / `front` choice for the planted foot, and a note explaining it in the manifest. | | `body_foot_targets` | 16 optional `(left, right)` x spans, in packed-cell pixels before registration, that name the foot to plant for frames where a low weapon shares the foot row. | -| `run_register` | `"body"` registers each run frame so the weapon-free body holds still over the cycle while the feet stay on the pivot row and the legs cycle. Frames with a `body_foot_target` keep it. All eleven weapons use it; without it, run frames are registered on the foot centre and the body can surge 25 px or more between frames. | +| `run_register` | `"body"` registers each run frame so the weapon-free body holds still over the cycle while the feet stay on the pivot row and the legs cycle. Frames with a `body_foot_target` keep it. Heroes and the preserved original Code City cells use it; Code City additionally aligns every active ready and locomotion pose with its shirt landmark; without it, run frames are registered on the foot centre and the body can surge 25 px or more between frames. | | `source_order` | Maps each packed frame to a source cell, for masters whose poses came out in a different order, or to reuse a cell. Four Heroes vs. Villains sheets reorder their step-3 cells this way. `api_key_whip` reuses its step-1 follow-through (source cell 7) as the step-3 follow-through (frame 13). | | `carry_orientation`, `carry_grip` | Flips or rotates the carry icon (`flip_h`, `flip_v` or `rotate_180`) for a weapon whose sheet holds it by the other end from its cel. `carry_grip` is required with it: the oriented icon's texel held in the paw, which must be opaque. Both are written to `carry_calibration` in the manifest; Code City's weapon rig uses that grip in place of its cel grip. `api_key_whip` and `refactor_hammer` use `flip_h`, so their carries put the key or the hammer head at the far end, as on the sheet. (Heroes vs. Villains turns its scanner carry in the rig instead, so the screen stays clear of Hoppy's face.) | diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/PROMPTS.md b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/PROMPTS.md index 5d34beae..32415e9a 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/PROMPTS.md +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/PROMPTS.md @@ -63,3 +63,49 @@ Create a NEW 16-bit pixel-art brawler animation sheet of the approved orange Cod ### Refinements Regenerations: 1. Attempt 1 produced seventeen disconnected major components and was rejected by the source gate. Attempt 2 passed transparent alpha, sixteen connected components, and no edge touching. The shield stays forward in guard throughout, and slot 16 is a braced block recoil. + +## Locomotion masters: October 10, 2026 + +Built-in image generation. Six run, four back walk and four front walk poses per weapon. Original combat masters are preserved. New movement occupies cells 16-29; idle, equip and hurt are body-registered alongside it. + +### debug_stick: locomotion + +Use case: stylized-concept. Create a new production pixel-art locomotion sprite sheet for EXACTLY the orange rabbit Hoppy and cyan DEBUG STICK in the reference sheet. Reference is for character identity, palette, chunky pixel density and dark outline only. Black short-sleeve shirt, cream muzzle and tail, two long orange ears, orange legs, no trousers or armor. Both paws visibly grip the same short cyan baton with navy handle and gold collar in EVERY pose. TRUE TRANSPARENT background. Exactly FOUR columns by FOUR rows, 16 complete isolated sprites with large clean transparent gutters, same body proportions and scale. No labels, borders, ground, shadow, motion lines or detached effects. Read strictly row-major: cells 1-6 are SIX DISTINCT sequential SIDE-VIEW RUN poses facing RIGHT: 1 left foot forward heel contact and right foot far back; 2 down weight absorbing left leg, right knee folding; 3 passing right knee high and left foot under hip; 4 opposite right foot forward contact with left foot far back; 5 down on right leg with left knee folding; 6 passing left knee high. Exaggerate alternating orange feet, open stride and bent knees, not six near-identical shuffles; ears trail and torso bobs subtly, baton steady at waist. Cells 7-10 are FOUR distinct WALK AWAY poses, rabbit's BACK faces camera: black shirt back and cream tail fully visible, ears symmetrical, face NOT visible, both paws grip baton across front beyond body with baton end visible on right; alternating feet and soles. Cells 11-14 are FOUR distinct WALK TOWARD camera poses: FRONT face with two eyes, cream muzzle centered, both paws on baton across waist; alternating foreshortened left and right forward feet. Cells 15-16 are front and back neutral carry poses. Every sprite fits fully inside its own quarter cell. Authentic crisp 16-bit pixel art compatible with reference, no illustration smoothing. Frame geometry must show real leg animation. + +### linter_staff: locomotion + +Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and LINTER_STAFF weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY lime green staff with dark steel rectangular green terminal head and charcoal grip in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish. + +### api_key_whip: locomotion + +Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and API_KEY_WHIP weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY blue flexible cable whip with gold handle collar and a gold key dangling from the tip; keep it coiled neatly at waist with key above soles in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish. + +### git_branch: locomotion + +Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and GIT_BRANCH weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY purple branching club with five glowing purple round nodes and navy grip in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish. + +### refactor_hammer: locomotion + +Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and REFACTOR_HAMMER weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY purple and dark steel sledgehammer with heavy rectangular purple-lit head and gold end plates in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish. + +### firewall_shield: locomotion + +Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and FIREWALL_SHIELD weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY red-orange hexagonal honeycomb shield with navy rim; paws hold the inner handle, shield offset to the rabbit's right in front views so face and feet are readable in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish. + +### Layout and gutter refinements + +Use case: precise-object-edit. Correct ONLY the two sprites in row3 column1 and row3 column2 (cells9 and10 in row-major order) of this git-branch locomotion sprite sheet: these must be BACK-VIEW WALK AWAY poses of the orange rabbit, not front facing. Black shirt back and cream tail visible, NO eyes or muzzle; four away cycle poses across cells7,8,9,10, so in cell9 left foot is raised with sole visible and right foot planted, cell10 right foot raised and left planted. Hold the SAME purple branching weapon in front of body with its purple nodes visible to rabbit's right, at waist. Keep all other14 sprites unchanged and preserve precise 4x4 grid, style, size, palette and transparent background. Do not add anything else. Fully transparent alpha. + + +Use case: precise-object-edit. Repair the provided production sprite sheet while preserving every pose, direction, exact rabbit identity, weapon shape/colors and crisp pixel-art styling. Some rightmost weapon tips touch the outer canvas edge. Make ALL sixteen characters uniformly 15% smaller INSIDE their own existing 4-by-4 grid cell, with each complete rabbit AND weapon entirely visible, and transparent margins of at least30px on all four outer sides and between sprites. Fill in any previously cropped weapon end so it has a complete closed dark outline. Keep exact row-major order and sixteen sprites, 4columns4rows. Six RIGHT running sprites, then four BACK walking sprites (cells7,8,9,10 all back), then four FRONT walking sprites (cells11,12,13,14 all front), then neutral front/back. Do NOT reverse or change any directional pose. TRUE TRANSPARENT alpha with absolutely no background haze, no text, grid, shadow, effects or additional sprites. + + + +### Whip carry clearance refinement + +Use case: precise-object-edit. Refine ONLY the weapon cable and dangling key in every pose of this 4x4 orange rabbit pixel-art locomotion sheet. Preserve all16 rabbit poses, exact identity, black shirt, scale, legs, facing, layout, crisp pixel clusters and empty transparent gutters. The blue API KEY WHIP is carried while RUNNING, so coil the loose blue cord into a compact loop near the paws at waist height. The gold key hangs only to KNEE HEIGHT, NEVER lower than the knees or feet. Shorten the visible hanging curve about35% and raise the gold key inside each sprite, keeping the same recognizable blue cord and gold collar/key. Do not change any rabbit, feet, grid spacing, character direction or arm pose. Real transparent alpha, absolutely no background, glow haze, shadows or labels. Six running right frames first, four back-facing walk frames next, four front-facing walk frames then front/back neutral; keep exactly this existing order. + + +The gutter refinement applies to linter_staff, firewall_shield and git_branch. Their earlier masters are retained. Git branch uses the refined master for run and back views; two front steps come from generation-zero cells 9 and 10, rescaled to match the refined source. No atlas is painted. + +Body registration keeps the torso near pivot x72 in ready poses and every locomotion frame. The run uses the shirt back edge, avoiding the moving forearm; a separate head measurement limits head sway. Vertical bob is limited to 3px. Run remains 13 fps and vertical walks 9 fps; feet cycle inside the cell instead of shifting the whole body to hold a planted foot. Generated masters are unchanged, and the builder records every registration offset and measured landmark. diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.png index 098e964d..1131f14a 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.tres b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.tres index 930ada1a..c425887c 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.tres +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=32 format=3] [ext_resource type="Texture2D" path="api_key_whip.png" id="1"] @@ -66,13 +66,71 @@ region = Rect2(384, 384, 192, 128) atlas = ExtResource("1") region = Rect2(576, 384, 192, 128) +[sub_resource type="AtlasTexture" id="Frame_16"] +atlas = ExtResource("1") +region = Rect2(0, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_17"] +atlas = ExtResource("1") +region = Rect2(192, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_18"] +atlas = ExtResource("1") +region = Rect2(384, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_19"] +atlas = ExtResource("1") +region = Rect2(576, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_20"] +atlas = ExtResource("1") +region = Rect2(0, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_21"] +atlas = ExtResource("1") +region = Rect2(192, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_22"] +atlas = ExtResource("1") +region = Rect2(384, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_23"] +atlas = ExtResource("1") +region = Rect2(576, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_24"] +atlas = ExtResource("1") +region = Rect2(0, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_25"] +atlas = ExtResource("1") +region = Rect2(192, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_26"] +atlas = ExtResource("1") +region = Rect2(384, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_27"] +atlas = ExtResource("1") +region = Rect2(576, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_28"] +atlas = ExtResource("1") +region = Rect2(0, 896, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_29"] +atlas = ExtResource("1") +region = Rect2(192, 896, 192, 128) + [resource] animations = [ {"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}], "loop": true, "name": &"hold_idle", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_1")}, {"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}], "loop": true, "name": &"run", "speed": 12.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_16")}, {"duration": 1.0, "texture": SubResource("Frame_17")}, {"duration": 1.0, "texture": SubResource("Frame_18")}, {"duration": 1.0, "texture": SubResource("Frame_19")}, {"duration": 1.0, "texture": SubResource("Frame_20")}, {"duration": 1.0, "texture": SubResource("Frame_21")}], "loop": true, "name": &"run", "speed": 13.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_5")}, {"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"step1", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}, {"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"step2", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}, {"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"step3", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"equip", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0} +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_22")}, {"duration": 1.0, "texture": SubResource("Frame_23")}, {"duration": 1.0, "texture": SubResource("Frame_24")}, {"duration": 1.0, "texture": SubResource("Frame_25")}], "loop": true, "name": &"walk_up", "speed": 9.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_26")}, {"duration": 1.0, "texture": SubResource("Frame_27")}, {"duration": 1.0, "texture": SubResource("Frame_28")}, {"duration": 1.0, "texture": SubResource("Frame_29")}], "loop": true, "name": &"walk_down", "speed": 9.0} ] diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_clips.png index ed8a4d9c..490f88f2 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_locomotion.png new file mode 100644 index 00000000..f130a3cd Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_locomotion.png.import new file mode 100644 index 00000000..de9cba78 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwnfms6wd3483" +path="res://.godot/imported/api_key_whip_locomotion.png-9f1a2c5ff014309ebd47dbec6916ff45.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/api_key_whip_locomotion.png" +dest_files=["res://.godot/imported/api_key_whip_locomotion.png-9f1a2c5ff014309ebd47dbec6916ff45.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_preview.png index 0fb65dca..b02aa565 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/api_key_whip_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/clips.png index 04183789..d3558ce8 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.png index 01d50e96..f352c510 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.tres b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.tres index c28f6a11..6fa1c4c8 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.tres +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=32 format=3] [ext_resource type="Texture2D" path="debug_stick.png" id="1"] @@ -66,13 +66,71 @@ region = Rect2(384, 384, 192, 128) atlas = ExtResource("1") region = Rect2(576, 384, 192, 128) +[sub_resource type="AtlasTexture" id="Frame_16"] +atlas = ExtResource("1") +region = Rect2(0, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_17"] +atlas = ExtResource("1") +region = Rect2(192, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_18"] +atlas = ExtResource("1") +region = Rect2(384, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_19"] +atlas = ExtResource("1") +region = Rect2(576, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_20"] +atlas = ExtResource("1") +region = Rect2(0, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_21"] +atlas = ExtResource("1") +region = Rect2(192, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_22"] +atlas = ExtResource("1") +region = Rect2(384, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_23"] +atlas = ExtResource("1") +region = Rect2(576, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_24"] +atlas = ExtResource("1") +region = Rect2(0, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_25"] +atlas = ExtResource("1") +region = Rect2(192, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_26"] +atlas = ExtResource("1") +region = Rect2(384, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_27"] +atlas = ExtResource("1") +region = Rect2(576, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_28"] +atlas = ExtResource("1") +region = Rect2(0, 896, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_29"] +atlas = ExtResource("1") +region = Rect2(192, 896, 192, 128) + [resource] animations = [ {"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}], "loop": true, "name": &"hold_idle", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_1")}, {"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}], "loop": true, "name": &"run", "speed": 12.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_16")}, {"duration": 1.0, "texture": SubResource("Frame_17")}, {"duration": 1.0, "texture": SubResource("Frame_18")}, {"duration": 1.0, "texture": SubResource("Frame_19")}, {"duration": 1.0, "texture": SubResource("Frame_20")}, {"duration": 1.0, "texture": SubResource("Frame_21")}], "loop": true, "name": &"run", "speed": 13.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_5")}, {"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"step1", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}, {"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"step2", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}, {"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"step3", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"equip", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0} +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_22")}, {"duration": 1.0, "texture": SubResource("Frame_23")}, {"duration": 1.0, "texture": SubResource("Frame_24")}, {"duration": 1.0, "texture": SubResource("Frame_25")}], "loop": true, "name": &"walk_up", "speed": 9.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_26")}, {"duration": 1.0, "texture": SubResource("Frame_27")}, {"duration": 1.0, "texture": SubResource("Frame_28")}, {"duration": 1.0, "texture": SubResource("Frame_29")}], "loop": true, "name": &"walk_down", "speed": 9.0} ] diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_clips.png index 04183789..d3558ce8 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_locomotion.png new file mode 100644 index 00000000..77bed2ca Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_locomotion.png.import new file mode 100644 index 00000000..8d4a82da --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dg03pwou1nj2b" +path="res://.godot/imported/debug_stick_locomotion.png-f9580e1c8f7450dca38917c058028cfd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/debug_stick_locomotion.png" +dest_files=["res://.godot/imported/debug_stick_locomotion.png-f9580e1c8f7450dca38917c058028cfd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_preview.png index 240eb3ee..d452bc7b 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/debug_stick_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.png index 2f3dc78e..476d9f2f 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.tres b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.tres index 752968ce..ea240657 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.tres +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=32 format=3] [ext_resource type="Texture2D" path="firewall_shield.png" id="1"] @@ -66,13 +66,71 @@ region = Rect2(384, 384, 192, 128) atlas = ExtResource("1") region = Rect2(576, 384, 192, 128) +[sub_resource type="AtlasTexture" id="Frame_16"] +atlas = ExtResource("1") +region = Rect2(0, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_17"] +atlas = ExtResource("1") +region = Rect2(192, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_18"] +atlas = ExtResource("1") +region = Rect2(384, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_19"] +atlas = ExtResource("1") +region = Rect2(576, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_20"] +atlas = ExtResource("1") +region = Rect2(0, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_21"] +atlas = ExtResource("1") +region = Rect2(192, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_22"] +atlas = ExtResource("1") +region = Rect2(384, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_23"] +atlas = ExtResource("1") +region = Rect2(576, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_24"] +atlas = ExtResource("1") +region = Rect2(0, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_25"] +atlas = ExtResource("1") +region = Rect2(192, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_26"] +atlas = ExtResource("1") +region = Rect2(384, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_27"] +atlas = ExtResource("1") +region = Rect2(576, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_28"] +atlas = ExtResource("1") +region = Rect2(0, 896, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_29"] +atlas = ExtResource("1") +region = Rect2(192, 896, 192, 128) + [resource] animations = [ {"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}], "loop": true, "name": &"hold_idle", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_1")}, {"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}], "loop": true, "name": &"run", "speed": 12.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_16")}, {"duration": 1.0, "texture": SubResource("Frame_17")}, {"duration": 1.0, "texture": SubResource("Frame_18")}, {"duration": 1.0, "texture": SubResource("Frame_19")}, {"duration": 1.0, "texture": SubResource("Frame_20")}, {"duration": 1.0, "texture": SubResource("Frame_21")}], "loop": true, "name": &"run", "speed": 13.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_5")}, {"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"step1", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}, {"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"step2", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}, {"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"step3", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"equip", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0} +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_22")}, {"duration": 1.0, "texture": SubResource("Frame_23")}, {"duration": 1.0, "texture": SubResource("Frame_24")}, {"duration": 1.0, "texture": SubResource("Frame_25")}], "loop": true, "name": &"walk_up", "speed": 9.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_26")}, {"duration": 1.0, "texture": SubResource("Frame_27")}, {"duration": 1.0, "texture": SubResource("Frame_28")}, {"duration": 1.0, "texture": SubResource("Frame_29")}], "loop": true, "name": &"walk_down", "speed": 9.0} ] diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_clips.png index 4192995d..46706f3f 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_locomotion.png new file mode 100644 index 00000000..9cfabca8 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_locomotion.png.import new file mode 100644 index 00000000..c7960a00 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8j6ffu3bv4ku" +path="res://.godot/imported/firewall_shield_locomotion.png-30d465c554c57496bf498e8fadb15b71.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/firewall_shield_locomotion.png" +dest_files=["res://.godot/imported/firewall_shield_locomotion.png-30d465c554c57496bf498e8fadb15b71.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_preview.png index 8d017bdf..705fa8b2 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/firewall_shield_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.png index 2519de4d..fc3e145a 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.tres b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.tres index 798052b4..85bed7ba 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.tres +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=32 format=3] [ext_resource type="Texture2D" path="git_branch.png" id="1"] @@ -66,13 +66,71 @@ region = Rect2(384, 384, 192, 128) atlas = ExtResource("1") region = Rect2(576, 384, 192, 128) +[sub_resource type="AtlasTexture" id="Frame_16"] +atlas = ExtResource("1") +region = Rect2(0, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_17"] +atlas = ExtResource("1") +region = Rect2(192, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_18"] +atlas = ExtResource("1") +region = Rect2(384, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_19"] +atlas = ExtResource("1") +region = Rect2(576, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_20"] +atlas = ExtResource("1") +region = Rect2(0, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_21"] +atlas = ExtResource("1") +region = Rect2(192, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_22"] +atlas = ExtResource("1") +region = Rect2(384, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_23"] +atlas = ExtResource("1") +region = Rect2(576, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_24"] +atlas = ExtResource("1") +region = Rect2(0, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_25"] +atlas = ExtResource("1") +region = Rect2(192, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_26"] +atlas = ExtResource("1") +region = Rect2(384, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_27"] +atlas = ExtResource("1") +region = Rect2(576, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_28"] +atlas = ExtResource("1") +region = Rect2(0, 896, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_29"] +atlas = ExtResource("1") +region = Rect2(192, 896, 192, 128) + [resource] animations = [ {"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}], "loop": true, "name": &"hold_idle", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_1")}, {"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}], "loop": true, "name": &"run", "speed": 12.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_16")}, {"duration": 1.0, "texture": SubResource("Frame_17")}, {"duration": 1.0, "texture": SubResource("Frame_18")}, {"duration": 1.0, "texture": SubResource("Frame_19")}, {"duration": 1.0, "texture": SubResource("Frame_20")}, {"duration": 1.0, "texture": SubResource("Frame_21")}], "loop": true, "name": &"run", "speed": 13.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_5")}, {"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"step1", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}, {"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"step2", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}, {"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"step3", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"equip", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0} +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_22")}, {"duration": 1.0, "texture": SubResource("Frame_23")}, {"duration": 1.0, "texture": SubResource("Frame_24")}, {"duration": 1.0, "texture": SubResource("Frame_25")}], "loop": true, "name": &"walk_up", "speed": 9.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_26")}, {"duration": 1.0, "texture": SubResource("Frame_27")}, {"duration": 1.0, "texture": SubResource("Frame_28")}, {"duration": 1.0, "texture": SubResource("Frame_29")}], "loop": true, "name": &"walk_down", "speed": 9.0} ] diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_clips.png index 41ba7649..fbfc2fd4 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_locomotion.png new file mode 100644 index 00000000..be4aed00 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_locomotion.png.import new file mode 100644 index 00000000..66241422 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8uuvi88iyvg8" +path="res://.godot/imported/git_branch_locomotion.png-bc8d51bf043aaf98d3459b5672e12d9c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/git_branch_locomotion.png" +dest_files=["res://.godot/imported/git_branch_locomotion.png-bc8d51bf043aaf98d3459b5672e12d9c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_preview.png index 6c48ef0c..4716970c 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/git_branch_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.png index 9e54b09e..b2d4b417 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.tres b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.tres index 9b7f7480..e5229403 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.tres +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=32 format=3] [ext_resource type="Texture2D" path="linter_staff.png" id="1"] @@ -66,13 +66,71 @@ region = Rect2(384, 384, 192, 128) atlas = ExtResource("1") region = Rect2(576, 384, 192, 128) +[sub_resource type="AtlasTexture" id="Frame_16"] +atlas = ExtResource("1") +region = Rect2(0, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_17"] +atlas = ExtResource("1") +region = Rect2(192, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_18"] +atlas = ExtResource("1") +region = Rect2(384, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_19"] +atlas = ExtResource("1") +region = Rect2(576, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_20"] +atlas = ExtResource("1") +region = Rect2(0, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_21"] +atlas = ExtResource("1") +region = Rect2(192, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_22"] +atlas = ExtResource("1") +region = Rect2(384, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_23"] +atlas = ExtResource("1") +region = Rect2(576, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_24"] +atlas = ExtResource("1") +region = Rect2(0, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_25"] +atlas = ExtResource("1") +region = Rect2(192, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_26"] +atlas = ExtResource("1") +region = Rect2(384, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_27"] +atlas = ExtResource("1") +region = Rect2(576, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_28"] +atlas = ExtResource("1") +region = Rect2(0, 896, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_29"] +atlas = ExtResource("1") +region = Rect2(192, 896, 192, 128) + [resource] animations = [ {"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}], "loop": true, "name": &"hold_idle", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_1")}, {"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}], "loop": true, "name": &"run", "speed": 12.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_16")}, {"duration": 1.0, "texture": SubResource("Frame_17")}, {"duration": 1.0, "texture": SubResource("Frame_18")}, {"duration": 1.0, "texture": SubResource("Frame_19")}, {"duration": 1.0, "texture": SubResource("Frame_20")}, {"duration": 1.0, "texture": SubResource("Frame_21")}], "loop": true, "name": &"run", "speed": 13.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_5")}, {"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"step1", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}, {"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"step2", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}, {"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"step3", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"equip", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0} +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_22")}, {"duration": 1.0, "texture": SubResource("Frame_23")}, {"duration": 1.0, "texture": SubResource("Frame_24")}, {"duration": 1.0, "texture": SubResource("Frame_25")}], "loop": true, "name": &"walk_up", "speed": 9.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_26")}, {"duration": 1.0, "texture": SubResource("Frame_27")}, {"duration": 1.0, "texture": SubResource("Frame_28")}, {"duration": 1.0, "texture": SubResource("Frame_29")}], "loop": true, "name": &"walk_down", "speed": 9.0} ] diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_clips.png index 07847b93..8fbc835c 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_locomotion.png new file mode 100644 index 00000000..009bdf7a Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_locomotion.png.import new file mode 100644 index 00000000..a3750805 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://krk31o8rqn3s" +path="res://.godot/imported/linter_staff_locomotion.png-bdd021b1156afb4a29ee24b46c4044fb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/linter_staff_locomotion.png" +dest_files=["res://.godot/imported/linter_staff_locomotion.png-bdd021b1156afb4a29ee24b46c4044fb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_preview.png index eb0f635d..9ae2e287 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/linter_staff_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-gutters.txt b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-gutters.txt new file mode 100644 index 00000000..d1732826 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-gutters.txt @@ -0,0 +1 @@ +Use case: precise-object-edit. Repair the provided production sprite sheet while preserving every pose, direction, exact rabbit identity, weapon shape/colors and crisp pixel-art styling. Some rightmost weapon tips touch the outer canvas edge. Make ALL sixteen characters uniformly 15% smaller INSIDE their own existing 4-by-4 grid cell, with each complete rabbit AND weapon entirely visible, and transparent margins of at least30px on all four outer sides and between sprites. Fill in any previously cropped weapon end so it has a complete closed dark outline. Keep exact row-major order and sixteen sprites, 4columns4rows. Six RIGHT running sprites, then four BACK walking sprites (cells7,8,9,10 all back), then four FRONT walking sprites (cells11,12,13,14 all front), then neutral front/back. Do NOT reverse or change any directional pose. TRUE TRANSPARENT alpha with absolutely no background haze, no text, grid, shadow, effects or additional sprites. diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-refinement.txt b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-refinement.txt new file mode 100644 index 00000000..e4fa04a3 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-refinement.txt @@ -0,0 +1 @@ +Use case: precise-object-edit. Correct ONLY the two sprites in row3 column1 and row3 column2 (cells9 and10 in row-major order) of this git-branch locomotion sprite sheet: these must be BACK-VIEW WALK AWAY poses of the orange rabbit, not front facing. Black shirt back and cream tail visible, NO eyes or muzzle; four away cycle poses across cells7,8,9,10, so in cell9 left foot is raised with sole visible and right foot planted, cell10 right foot raised and left planted. Hold the SAME purple branching weapon in front of body with its purple nodes visible to rabbit's right, at waist. Keep all other14 sprites unchanged and preserve precise 4x4 grid, style, size, palette and transparent background. Do not add anything else. Fully transparent alpha. diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-whip-refinement.txt b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-whip-refinement.txt new file mode 100644 index 00000000..857ae441 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion-whip-refinement.txt @@ -0,0 +1 @@ +Use case: precise-object-edit. Refine ONLY the weapon cable and dangling key in every pose of this 4x4 orange rabbit pixel-art locomotion sheet. Preserve all16 rabbit poses, exact identity, black shirt, scale, legs, facing, layout, crisp pixel clusters and empty transparent gutters. The blue API KEY WHIP is carried while RUNNING, so coil the loose blue cord into a compact loop near the paws at waist height. The gold key hangs only to KNEE HEIGHT, NEVER lower than the knees or feet. Shorten the visible hanging curve about35% and raise the gold key inside each sprite, keeping the same recognizable blue cord and gold collar/key. Do not change any rabbit, feet, grid spacing, character direction or arm pose. Real transparent alpha, absolutely no background, glow haze, shadows or labels. Six running right frames first, four back-facing walk frames next, four front-facing walk frames then front/back neutral; keep exactly this existing order. diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion.json b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion.json new file mode 100644 index 00000000..f8398acb --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion.json @@ -0,0 +1,13 @@ +{ + "generated_on": "2026-10-10", + "prompts": { + "debug_stick": "Use case: stylized-concept. Create a new production pixel-art locomotion sprite sheet for EXACTLY the orange rabbit Hoppy and cyan DEBUG STICK in the reference sheet. Reference is for character identity, palette, chunky pixel density and dark outline only. Black short-sleeve shirt, cream muzzle and tail, two long orange ears, orange legs, no trousers or armor. Both paws visibly grip the same short cyan baton with navy handle and gold collar in EVERY pose. TRUE TRANSPARENT background. Exactly FOUR columns by FOUR rows, 16 complete isolated sprites with large clean transparent gutters, same body proportions and scale. No labels, borders, ground, shadow, motion lines or detached effects. Read strictly row-major: cells 1-6 are SIX DISTINCT sequential SIDE-VIEW RUN poses facing RIGHT: 1 left foot forward heel contact and right foot far back; 2 down weight absorbing left leg, right knee folding; 3 passing right knee high and left foot under hip; 4 opposite right foot forward contact with left foot far back; 5 down on right leg with left knee folding; 6 passing left knee high. Exaggerate alternating orange feet, open stride and bent knees, not six near-identical shuffles; ears trail and torso bobs subtly, baton steady at waist. Cells 7-10 are FOUR distinct WALK AWAY poses, rabbit's BACK faces camera: black shirt back and cream tail fully visible, ears symmetrical, face NOT visible, both paws grip baton across front beyond body with baton end visible on right; alternating feet and soles. Cells 11-14 are FOUR distinct WALK TOWARD camera poses: FRONT face with two eyes, cream muzzle centered, both paws on baton across waist; alternating foreshortened left and right forward feet. Cells 15-16 are front and back neutral carry poses. Every sprite fits fully inside its own quarter cell. Authentic crisp 16-bit pixel art compatible with reference, no illustration smoothing. Frame geometry must show real leg animation.", + "linter_staff": "Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and LINTER_STAFF weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY lime green staff with dark steel rectangular green terminal head and charcoal grip in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish.", + "api_key_whip": "Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and API_KEY_WHIP weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY blue flexible cable whip with gold handle collar and a gold key dangling from the tip; keep it coiled neatly at waist with key above soles in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish.", + "git_branch": "Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and GIT_BRANCH weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY purple branching club with five glowing purple round nodes and navy grip in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish.", + "refactor_hammer": "Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and REFACTOR_HAMMER weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY purple and dark steel sledgehammer with heavy rectangular purple-lit head and gold end plates in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish.", + "firewall_shield": "Use case: stylized-concept. Production pixel-art locomotion sheet. Image 1 is the exact character and FIREWALL_SHIELD weapon identity reference. Image 2 is the new debug-stick locomotion sheet, use its precise 4-by-4 layout, full-body scale, chunky outlined pixel style and direction ordering. Create EXACTLY the same orange rabbit: black shirt, cream muzzle and tail, orange bare legs, two long ears. Hold ONLY red-orange hexagonal honeycomb shield with navy rim; paws hold the inner handle, shield offset to the rabbit's right in front views so face and feet are readable in EVERY pose with both paws visibly gripping it. Replace the blue stick with this weapon, preserving its reference colors and shape. TRUE TRANSPARENT background; no background color, ground, shadows, labels, grid lines or detached effects. Exactly 16 complete sprites, FOUR columns FOUR rows, generous empty gutters, no cropping. Cells 1-6 row-major: six distinct RUN RIGHT poses, alternating feet with long open strides and clearly different bent-knee passing poses: left-foot contact, down/compression, right knee passing, opposite right-foot contact, down/compression, left knee passing. Not near-identical shuffles. Subtle torso bob, consistent head/weapon size, planted sole and raised trailing foot easy to distinguish. Cells 7-10: four WALK AWAY/back-view poses, black shirt back and cream tail visible, NO face, alternating feet and soles, weapon carried in front of body with end visible on right. Cells 11-14: four WALK TOWARD/front-view poses, centered cream muzzle and two eyes, weapon at waist so it never obscures face, alternating foreshortened left/right forward feet and compression. Cells 15-16: neutral carry facing front then back. Same character size across every pose. Clean 16-bit game art, crisp dark outline, flat limited shading and no smooth illustration finish." + }, + "registration": {}, + "refinements": [] +} diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion_contact.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion_contact.png new file mode 100644 index 00000000..a6828d3f Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion_contact.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion_contact.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion_contact.png.import new file mode 100644 index 00000000..1975ae13 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/locomotion_contact.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cc3txbl0l8ajq" +path="res://.godot/imported/locomotion_contact.png-9b0c2b742118027ad405a631d4b1d560.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/locomotion_contact.png" +dest_files=["res://.godot/imported/locomotion_contact.png-9b0c2b742118027ad405a631d4b1d560.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/manifest.json b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/manifest.json index 0885c2be..db0d1a00 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/manifest.json +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/manifest.json @@ -59,7 +59,7 @@ "rule": "Baked hold_idle visible weapon bounds for this armed sheet.", "carry_scale": 0.36666666666666664, "hold_idle_bounds": [ - 84, + 87, 92, 33, 31 @@ -83,7 +83,7 @@ "rule": "Baked hold_idle visible weapon bounds for this armed sheet.", "carry_scale": 0.4166666666666667, "hold_idle_bounds": [ - 78, + 77, 83, 40, 30 @@ -102,7 +102,7 @@ "rule": "Baked hold_idle visible weapon bounds for this armed sheet.", "carry_scale": 0.5135135135135135, "hold_idle_bounds": [ - 84, + 88, 74, 32, 38 @@ -121,7 +121,7 @@ "rule": "Baked hold_idle visible weapon bounds for this armed sheet.", "carry_scale": 0.41904761904761906, "hold_idle_bounds": [ - 80, + 83, 82, 44, 35 @@ -146,7 +146,7 @@ ], "grid": [ 4, - 4 + 8 ], "pivot": [ 72, @@ -255,7 +255,7 @@ ] ], "fragment_attachments": [], - "foot_anchor_semantics": "Ground midpoint for registration validation; it can lie between painted feet and is not an effect anchor. Body-registered run frames keep their feet on the pivot row, not on pivot x.", + "foot_anchor_semantics": "Ready poses and locomotion share the torso axis at pivot x; feet cycle freely. Foot anchors follow the translated soles, with at most 3px run bob around the ground row. Attacks and unused legacy run cells retain their original registration.", "clips": { "hold_idle": { "frames": [ @@ -266,12 +266,14 @@ }, "run": { "frames": [ - 1, - 2, - 3, - 4 - ], - "fps": 12, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "fps": 13, "loop": true }, "step1": { @@ -314,6 +316,26 @@ ], "fps": 1, "loop": false + }, + "walk_up": { + "frames": [ + 22, + 23, + 24, + 25 + ], + "fps": 9, + "loop": true + }, + "walk_down": { + "frames": [ + 26, + 27, + 28, + 29 + ], + "fps": 9, + "loop": true } }, "anchors": { @@ -375,12 +397,68 @@ 107 ], "14": [ - 82, + 87, 102 ], "15": [ + 82, + 96 + ], + "16": [ + 82, + 100 + ], + "17": [ + 87, + 101 + ], + "18": [ + 86, + 101 + ], + "19": [ + 84, + 99 + ], + "20": [ + 85, + 100 + ], + "21": [ + 85, + 101 + ], + "22": [ + 78, + 104 + ], + "23": [ + 79, + 104 + ], + "24": [ + 78, + 105 + ], + "25": [ + 78, + 104 + ], + "26": [ + 81, + 97 + ], + "27": [ + 82, + 97 + ], + "28": [ 83, 96 + ], + "29": [ + 79, + 104 ] }, "tip": { @@ -455,472 +533,2816 @@ 118 ], "14": [ - 70, + 75, 118 ], "15": [ - 72, + 71, 118 - ] - } - }, - "run_registration": "body" - }, - "linter_staff": { - "weapon": "linter_staff", - "file": "linter_staff.png", - "resource": "linter_staff.tres", - "cell": [ - 192, - 128 - ], - "grid": [ - 4, - 4 - ], - "pivot": [ - 72, - 118 - ], - "facing": "right", - "body_height": 59, - "unarmed_idle_height": 59, - "source_scale": 0.2783018867924528, - "source_component_bounds": [ - [ - 64, - 81, - 299, - 293 - ], - [ - 394, - 88, - 629, - 291 - ], - [ - 679, - 87, - 932, - 303 - ], - [ - 1005, - 88, - 1243, - 293 - ], - [ - 61, - 398, - 314, - 598 - ], - [ - 318, - 398, - 580, - 597 - ], - [ - 658, - 416, - 961, - 597 - ], - [ - 990, - 411, - 1242, - 597 - ], - [ - 35, - 713, - 288, - 901 - ], - [ - 358, - 721, - 647, - 899 - ], - [ - 679, - 713, - 983, - 899 - ], - [ - 1004, - 634, - 1205, - 902 - ], - [ - 77, - 976, - 361, - 1183 - ], - [ - 381, - 977, - 672, - 1182 - ], - [ - 689, - 933, - 932, - 1183 - ], - [ - 989, - 983, - 1239, - 1183 - ] - ], - "fragment_attachments": [], - "foot_anchor_semantics": "Per-frame planted foot for registration validation; attacks use the rear foot unless overridden. Body-registered run frames keep their feet on the pivot row, not on pivot x.", - "clips": { - "hold_idle": { - "frames": [ - 0 ], - "fps": 1, - "loop": true - }, - "run": { - "frames": [ - 1, - 2, - 3, - 4 + "16": [ + 85, + 117 ], - "fps": 12, - "loop": true - }, - "step1": { - "frames": [ - 5, - 6, - 7 + "17": [ + 73, + 118 ], - "fps": 9, - "loop": false - }, - "step2": { - "frames": [ - 8, - 9, - 10 + "18": [ + 65, + 119 ], - "fps": 9, - "loop": false - }, - "step3": { - "frames": [ - 11, - 12, - 13 + "19": [ + 55, + 116 ], - "fps": 9, - "loop": false - }, - "equip": { - "frames": [ - 14 + "20": [ + 79, + 118 ], - "fps": 1, - "loop": false - }, - "hurt": { - "frames": [ - 15 + "21": [ + 62, + 120 ], - "fps": 1, - "loop": false - } - }, - "anchors": { - "hand": { - "0": [ - 78, - 106 + "22": [ + 68, + 118 ], - "1": [ - 73, - 110 + "23": [ + 75, + 118 + ], + "24": [ + 67, + 118 + ], + "25": [ + 69, + 118 + ], + "26": [ + 75, + 118 + ], + "27": [ + 75, + 118 + ], + "28": [ + 73, + 118 + ], + "29": [ + 75, + 118 + ] + } + }, + "run_registration": "body", + "body_registration": { + "method": "shirt back edge + 6px for run, shirt centroid for other ready poses", + "torso_x": 72, + "frame_offsets": { + "0": [ + 0, + 0 + ], + "14": [ + 5, + 0 + ], + "15": [ + -1, + 0 + ], + "16": [ + 13, + -1 + ], + "17": [ + 1, + 0 + ], + "18": [ + -7, + 1 + ], + "19": [ + -17, + -2 + ], + "20": [ + 7, + 0 + ], + "21": [ + -10, + 2 + ], + "22": [ + -4, + 0 + ], + "23": [ + 3, + 0 + ], + "24": [ + -5, + 0 + ], + "25": [ + -3, + 0 + ], + "26": [ + 3, + 0 + ], + "27": [ + 3, + 0 + ], + "28": [ + 1, + 0 + ], + "29": [ + 3, + 0 + ] + }, + "landmarks": { + "0": { + "torso": [ + 72.4314, + 97.3922 + ], + "head_x": 73.2564 + }, + "14": { + "torso": [ + 72.4848, + 100.5354 + ], + "head_x": 68.6379 + }, + "15": { + "torso": [ + 72.4839, + 92.0645 + ], + "head_x": 58.6332 + }, + "16": { + "torso": [ + 72, + 95.6733 + ], + "head_x": 76.5352 + }, + "17": { + "torso": [ + 72, + 94.9247 + ], + "head_x": 78.0588 + }, + "18": { + "torso": [ + 72, + 94.3647 + ], + "head_x": 75.5957 + }, + "19": { + "torso": [ + 72, + 95.0256 + ], + "head_x": 78.9866 + }, + "20": { + "torso": [ + 72, + 93.5176 + ], + "head_x": 77.4447 + }, + "21": { + "torso": [ + 72, + 94.4804 + ], + "head_x": 76.5039 + }, + "22": { + "torso": [ + 71.75, + 94.1202 + ], + "head_x": 72.0694 + }, + "23": { + "torso": [ + 72.1373, + 94.1422 + ], + "head_x": 72.4699 + }, + "24": { + "torso": [ + 71.573, + 95.1685 + ], + "head_x": 72.1749 + }, + "25": { + "torso": [ + 72.2077, + 94.0193 + ], + "head_x": 71.9437 + }, + "26": { + "torso": [ + 71.8095, + 92.3333 + ], + "head_x": 73.7973 + }, + "27": { + "torso": [ + 71.98, + 91.82 + ], + "head_x": 73.1153 + }, + "28": { + "torso": [ + 72.1935, + 92.3226 + ], + "head_x": 76.0 + }, + "29": { + "torso": [ + 71.8654, + 91.8462 + ], + "head_x": 73.3891 + } + } + }, + "locomotion": { + "sources": [ + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png", + "debug_stick-locomotion.png" + ], + "source_frames": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "source_component_bounds": [ + [ + 48, + 62, + 297, + 289 + ], + [ + 393, + 62, + 613, + 292 + ], + [ + 695, + 59, + 916, + 292 + ], + [ + 1002, + 63, + 1252, + 291 + ], + [ + 71, + 379, + 301, + 613 + ], + [ + 393, + 365, + 613, + 609 + ], + [ + 717, + 374, + 910, + 616 + ], + [ + 1032, + 374, + 1220, + 616 + ], + [ + 86, + 671, + 281, + 909 + ], + [ + 407, + 671, + 596, + 914 + ], + [ + 722, + 663, + 909, + 914 + ], + [ + 1032, + 665, + 1216, + 916 + ], + [ + 95, + 961, + 280, + 1211 + ], + [ + 413, + 961, + 596, + 1211 + ] + ], + "source_scale": 0.2695035460992908, + "relative_scales": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "fragment_attachments": [], + "hand": { + "16": [ + 82, + 100 + ], + "17": [ + 87, + 101 + ], + "18": [ + 86, + 101 + ], + "19": [ + 84, + 99 + ], + "20": [ + 85, + 100 + ], + "21": [ + 85, + 101 + ], + "22": [ + 78, + 104 + ], + "23": [ + 79, + 104 + ], + "24": [ + 78, + 105 + ], + "25": [ + 78, + 104 + ], + "26": [ + 81, + 97 + ], + "27": [ + 82, + 97 + ], + "28": [ + 83, + 96 + ], + "29": [ + 79, + 104 + ] + }, + "foot": { + "16": [ + 85, + 117 + ], + "17": [ + 73, + 118 + ], + "18": [ + 65, + 119 + ], + "19": [ + 55, + 116 + ], + "20": [ + 79, + 118 + ], + "21": [ + 62, + 120 + ], + "22": [ + 68, + 118 + ], + "23": [ + 75, + 118 + ], + "24": [ + 67, + 118 + ], + "25": [ + 69, + 118 + ], + "26": [ + 75, + 118 + ], + "27": [ + 75, + 118 + ], + "28": [ + 73, + 118 + ], + "29": [ + 75, + 118 + ] + }, + "visible_bounds": [ + [ + 46, + 58, + 113, + 119 + ], + [ + 56, + 58, + 115, + 120 + ], + [ + 54, + 59, + 114, + 121 + ], + [ + 48, + 57, + 115, + 117 + ], + [ + 52, + 57, + 114, + 120 + ], + [ + 54, + 56, + 113, + 122 + ], + [ + 53, + 55, + 105, + 120 + ], + [ + 53, + 55, + 104, + 120 + ], + [ + 53, + 56, + 105, + 120 + ], + [ + 53, + 55, + 104, + 120 + ], + [ + 57, + 52, + 106, + 120 + ], + [ + 56, + 52, + 106, + 120 + ], + [ + 59, + 53, + 109, + 119 + ], + [ + 57, + 53, + 106, + 119 + ] + ], + "cadence_fps": { + "run": 13, + "walk_up": 9, + "walk_down": 9 + }, + "body_scale": 1.55 + }, + "unused_legacy_cells": [ + 1, + 2, + 3, + 4 + ] + }, + "linter_staff": { + "weapon": "linter_staff", + "file": "linter_staff.png", + "resource": "linter_staff.tres", + "cell": [ + 192, + 128 + ], + "grid": [ + 4, + 8 + ], + "pivot": [ + 72, + 118 + ], + "facing": "right", + "body_height": 59, + "unarmed_idle_height": 59, + "source_scale": 0.2783018867924528, + "source_component_bounds": [ + [ + 64, + 81, + 299, + 293 + ], + [ + 394, + 88, + 629, + 291 + ], + [ + 679, + 87, + 932, + 303 + ], + [ + 1005, + 88, + 1243, + 293 + ], + [ + 61, + 398, + 314, + 598 + ], + [ + 318, + 398, + 580, + 597 + ], + [ + 658, + 416, + 961, + 597 + ], + [ + 990, + 411, + 1242, + 597 + ], + [ + 35, + 713, + 288, + 901 + ], + [ + 358, + 721, + 647, + 899 + ], + [ + 679, + 713, + 983, + 899 + ], + [ + 1004, + 634, + 1205, + 902 + ], + [ + 77, + 976, + 361, + 1183 + ], + [ + 381, + 977, + 672, + 1182 + ], + [ + 689, + 933, + 932, + 1183 + ], + [ + 989, + 983, + 1239, + 1183 + ] + ], + "fragment_attachments": [], + "foot_anchor_semantics": "Ready poses and locomotion share the torso axis at pivot x; feet cycle freely. Foot anchors follow the translated soles, with at most 3px run bob around the ground row. Attacks and unused legacy run cells retain their original registration.", + "clips": { + "hold_idle": { + "frames": [ + 0 + ], + "fps": 1, + "loop": true + }, + "run": { + "frames": [ + 16, + 17, + 18, + 19, + 20, + 21 + ], + "fps": 13, + "loop": true + }, + "step1": { + "frames": [ + 5, + 6, + 7 + ], + "fps": 9, + "loop": false + }, + "step2": { + "frames": [ + 8, + 9, + 10 + ], + "fps": 9, + "loop": false + }, + "step3": { + "frames": [ + 11, + 12, + 13 + ], + "fps": 9, + "loop": false + }, + "equip": { + "frames": [ + 14 + ], + "fps": 1, + "loop": false + }, + "hurt": { + "frames": [ + 15 + ], + "fps": 1, + "loop": false + }, + "walk_up": { + "frames": [ + 22, + 23, + 24, + 25 + ], + "fps": 9, + "loop": true + }, + "walk_down": { + "frames": [ + 26, + 27, + 28, + 29 + ], + "fps": 9, + "loop": true + } + }, + "anchors": { + "hand": { + "0": [ + 78, + 106 + ], + "1": [ + 73, + 110 + ], + "2": [ + 75, + 107 + ], + "3": [ + 73, + 109 + ], + "4": [ + 73, + 109 + ], + "5": [ + 72, + 87 + ], + "6": [ + 111, + 97 + ], + "7": [ + 108, + 99 + ], + "8": [ + 77, + 96 + ], + "9": [ + 104, + 103 + ], + "10": [ + 114, + 102 + ], + "11": [ + 103, + 81 + ], + "12": [ + 95, + 89 + ], + "13": [ + 108, + 100 + ], + "14": [ + 92, + 89 + ], + "15": [ + 78, + 107 + ], + "16": [ + 88, + 100 + ], + "17": [ + 90, + 99 + ], + "18": [ + 91, + 99 + ], + "19": [ + 88, + 100 + ], + "20": [ + 90, + 99 + ], + "21": [ + 90, + 100 + ], + "22": [ + 81, + 101 + ], + "23": [ + 81, + 100 + ], + "24": [ + 80, + 101 + ], + "25": [ + 80, + 101 + ], + "26": [ + 82, + 96 + ], + "27": [ + 82, + 96 + ], + "28": [ + 82, + 96 + ], + "29": [ + 82, + 97 + ] + }, + "tip": { + "6": [ + 144, + 96 + ], + "9": [ + 139, + 102 + ], + "12": [ + 129, + 85 + ] + }, + "foot": { + "0": [ + 72, + 118 + ], + "1": [ + 70, + 118 + ], + "2": [ + 80, + 118 + ], + "3": [ + 67, + 118 + ], + "4": [ + 71, + 118 + ], + "5": [ + 72, + 118 + ], + "6": [ + 72, + 118 + ], + "7": [ + 72, + 118 + ], + "8": [ + 72, + 118 + ], + "9": [ + 72, + 118 + ], + "10": [ + 72, + 118 + ], + "11": [ + 72, + 118 + ], + "12": [ + 72, + 118 + ], + "13": [ + 72, + 118 + ], + "14": [ + 78, + 118 + ], + "15": [ + 81, + 118 + ], + "16": [ + 91, + 119 + ], + "17": [ + 82, + 117 + ], + "18": [ + 86, + 118 + ], + "19": [ + 88, + 118 + ], + "20": [ + 86, + 117 + ], + "21": [ + 82, + 118 + ], + "22": [ + 77, + 117 + ], + "23": [ + 76, + 118 + ], + "24": [ + 69, + 119 + ], + "25": [ + 68, + 117 + ], + "26": [ + 73, + 118 + ], + "27": [ + 71, + 118 + ], + "28": [ + 67, + 118 + ], + "29": [ + 73, + 118 + ] + } + }, + "run_registration": "body", + "body_registration": { + "method": "shirt back edge + 6px for run, shirt centroid for other ready poses", + "torso_x": 72, + "frame_offsets": { + "0": [ + 0, + 0 + ], + "14": [ + 6, + 0 + ], + "15": [ + 9, + 0 + ], + "16": [ + 19, + 1 + ], + "17": [ + 10, + -1 + ], + "18": [ + 14, + 0 + ], + "19": [ + 16, + 0 + ], + "20": [ + 14, + -1 + ], + "21": [ + 10, + 0 + ], + "22": [ + 5, + -1 + ], + "23": [ + 4, + 0 + ], + "24": [ + -3, + 1 + ], + "25": [ + -4, + -1 + ], + "26": [ + 1, + 0 + ], + "27": [ + -1, + 0 + ], + "28": [ + -5, + 0 + ], + "29": [ + 1, + 0 + ] + }, + "landmarks": { + "0": { + "torso": [ + 72.3222, + 95.6444 + ], + "head_x": 72.8057 + }, + "14": { + "torso": [ + 72.3429, + 97.0286 + ], + "head_x": 71.6767 + }, + "15": { + "torso": [ + 71.6444, + 97.6444 + ], + "head_x": 66.0714 + }, + "16": { + "torso": [ + 72, + 92.964 + ], + "head_x": 76.7443 + }, + "17": { + "torso": [ + 72, + 93.6107 + ], + "head_x": 77.8402 + }, + "18": { + "torso": [ + 72, + 92.6739 + ], + "head_x": 79.1497 + }, + "19": { + "torso": [ + 72, + 92.7154 + ], + "head_x": 78.0503 + }, + "20": { + "torso": [ + 72, + 93.6954 + ], + "head_x": 78.7781 + }, + "21": { + "torso": [ + 72, + 93.8971 + ], + "head_x": 77.3817 + }, + "22": { + "torso": [ + 72.3983, + 91.6224 + ], + "head_x": 72.692 + }, + "23": { + "torso": [ + 72.0043, + 89.9957 + ], + "head_x": 73.1421 + }, + "24": { + "torso": [ + 71.797, + 90.5752 + ], + "head_x": 72.3595 + }, + "25": { + "torso": [ + 72.1718, + 91.5153 + ], + "head_x": 73.1833 + }, + "26": { + "torso": [ + 71.8804, + 89.6087 + ], + "head_x": 72.2528 + }, + "27": { + "torso": [ + 72.2708, + 89.9583 + ], + "head_x": 73.0921 + }, + "28": { + "torso": [ + 72.061, + 89.2439 + ], + "head_x": 72.683 + }, + "29": { + "torso": [ + 72.0682, + 91.0568 + ], + "head_x": 72.4026 + } + } + }, + "locomotion": { + "sources": [ + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png", + "linter_staff-locomotion.png" + ], + "source_frames": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "source_component_bounds": [ + [ + 84, + 111, + 300, + 291 + ], + [ + 410, + 111, + 613, + 291 + ], + [ + 687, + 111, + 902, + 296 + ], + [ + 979, + 111, + 1199, + 295 + ], + [ + 79, + 395, + 302, + 575 + ], + [ + 397, + 395, + 614, + 575 + ], + [ + 709, + 388, + 896, + 578 + ], + [ + 1018, + 388, + 1200, + 586 + ], + [ + 105, + 668, + 294, + 870 + ], + [ + 415, + 668, + 601, + 862 + ], + [ + 709, + 666, + 892, + 870 + ], + [ + 1019, + 665, + 1199, + 870 + ], + [ + 109, + 953, + 292, + 1154 + ], + [ + 419, + 951, + 604, + 1150 + ] + ], + "source_scale": 0.35625, + "relative_scales": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "fragment_attachments": [], + "hand": { + "16": [ + 88, + 100 + ], + "17": [ + 90, + 99 + ], + "18": [ + 91, + 99 + ], + "19": [ + 88, + 100 + ], + "20": [ + 90, + 99 + ], + "21": [ + 90, + 100 + ], + "22": [ + 81, + 101 + ], + "23": [ + 81, + 100 + ], + "24": [ + 80, + 101 + ], + "25": [ + 80, + 101 + ], + "26": [ + 82, + 96 + ], + "27": [ + 82, + 96 + ], + "28": [ + 82, + 96 + ], + "29": [ + 82, + 97 + ] + }, + "foot": { + "16": [ + 91, + 119 + ], + "17": [ + 82, + 117 + ], + "18": [ + 86, + 118 + ], + "19": [ + 88, + 118 + ], + "20": [ + 86, + 117 + ], + "21": [ + 82, + 118 + ], + "22": [ + 77, + 117 + ], + "23": [ + 76, + 118 + ], + "24": [ + 69, + 119 + ], + "25": [ + 68, + 117 + ], + "26": [ + 73, + 118 + ], + "27": [ + 71, + 118 + ], + "28": [ + 67, + 118 + ], + "29": [ + 73, + 118 + ] + }, + "visible_bounds": [ + [ + 47, + 57, + 124, + 121 + ], + [ + 54, + 55, + 126, + 119 + ], + [ + 51, + 54, + 128, + 120 + ], + [ + 47, + 54, + 125, + 120 + ], + [ + 48, + 55, + 127, + 119 + ], + [ + 49, + 56, + 126, + 120 + ], + [ + 52, + 51, + 119, + 119 + ], + [ + 53, + 49, + 118, + 120 + ], + [ + 51, + 49, + 118, + 121 + ], + [ + 52, + 50, + 118, + 119 + ], + [ + 52, + 47, + 117, + 120 + ], + [ + 53, + 47, + 117, + 120 + ], + [ + 53, + 48, + 118, + 120 + ], + [ + 53, + 49, + 119, + 120 + ] + ], + "cadence_fps": { + "run": 13, + "walk_up": 9, + "walk_down": 9 + }, + "body_scale": 1.55 + }, + "unused_legacy_cells": [ + 1, + 2, + 3, + 4 + ], + "foot_planting": { + "default": "Idle, equip, hurt and locomotion use body registration. Attacks and unused legacy run cells preserve original registration. See locomotion for source provenance.", + "overrides": {}, + "note": "" + } + }, + "api_key_whip": { + "weapon": "api_key_whip", + "file": "api_key_whip.png", + "resource": "api_key_whip.tres", + "cell": [ + 192, + 128 + ], + "grid": [ + 4, + 8 + ], + "pivot": [ + 72, + 118 + ], + "facing": "right", + "body_height": 59, + "unarmed_idle_height": 59, + "source_scale": 0.2757009345794392, + "source_component_bounds": [ + [ + 100, + 83, + 320, + 297 + ], + [ + 395, + 85, + 629, + 291 + ], + [ + 701, + 89, + 939, + 286 + ], + [ + 991, + 90, + 1237, + 303 + ], + [ + 88, + 399, + 345, + 612 + ], + [ + 396, + 371, + 588, + 600 + ], + [ + 659, + 407, + 972, + 598 + ], + [ + 982, + 415, + 1248, + 618 + ], + [ + 55, + 714, + 283, + 897 + ], + [ + 365, + 723, + 676, + 898 + ], + [ + 683, + 712, + 959, + 912 + ], + [ + 990, + 651, + 1196, + 898 + ], + [ + 68, + 1016, + 364, + 1189 + ], + [ + 982, + 415, + 1248, + 618 + ], + [ + 704, + 993, + 941, + 1216 + ], + [ + 963, + 959, + 1218, + 1192 + ] + ], + "fragment_attachments": [], + "foot_anchor_semantics": "Ready poses and locomotion share the torso axis at pivot x; feet cycle freely. Foot anchors follow the translated soles, with at most 3px run bob around the ground row. Attacks and unused legacy run cells retain their original registration.", + "clips": { + "hold_idle": { + "frames": [ + 0 + ], + "fps": 1, + "loop": true + }, + "run": { + "frames": [ + 16, + 17, + 18, + 19, + 20, + 21 + ], + "fps": 13, + "loop": true + }, + "step1": { + "frames": [ + 5, + 6, + 7 + ], + "fps": 9, + "loop": false + }, + "step2": { + "frames": [ + 8, + 9, + 10 + ], + "fps": 9, + "loop": false + }, + "step3": { + "frames": [ + 11, + 12, + 13 + ], + "fps": 9, + "loop": false + }, + "equip": { + "frames": [ + 14 + ], + "fps": 1, + "loop": false + }, + "hurt": { + "frames": [ + 15 + ], + "fps": 1, + "loop": false + }, + "walk_up": { + "frames": [ + 22, + 23, + 24, + 25 + ], + "fps": 9, + "loop": true + }, + "walk_down": { + "frames": [ + 26, + 27, + 28, + 29 + ], + "fps": 9, + "loop": true + } + }, + "anchors": { + "hand": { + "0": [ + 89, + 100 + ], + "1": [ + 112, + 112 + ], + "2": [ + 114, + 112 + ], + "3": [ + 115, + 116 + ], + "4": [ + 114, + 112 + ], + "5": [ + 65, + 81 + ], + "6": [ + 103, + 101 + ], + "7": [ + 108, + 101 + ], + "8": [ + 75, + 109 + ], + "9": [ + 102, + 101 + ], + "10": [ + 104, + 100 + ], + "11": [ + 95, + 82 + ], + "12": [ + 103, + 100 + ], + "13": [ + 108, + 101 + ], + "14": [ + 109, + 100 + ], + "15": [ + 102, + 98 + ], + "16": [ + 85, + 100 + ], + "17": [ + 87, + 100 + ], + "18": [ + 86, + 100 + ], + "19": [ + 85, + 100 + ], + "20": [ + 86, + 101 + ], + "21": [ + 86, + 100 + ], + "22": [ + 78, + 102 + ], + "23": [ + 79, + 102 + ], + "24": [ + 78, + 102 + ], + "25": [ + 78, + 103 + ], + "26": [ + 79, + 102 + ], + "27": [ + 76, + 102 + ], + "28": [ + 77, + 103 + ], + "29": [ + 77, + 102 + ] + }, + "tip": { + "6": [ + 149, + 94 + ], + "9": [ + 149, + 91 + ], + "12": [ + 145, + 96 + ] + }, + "foot": { + "0": [ + 75, + 118 + ], + "1": [ + 72, + 118 + ], + "2": [ + 72, + 118 + ], + "3": [ + 72, + 118 + ], + "4": [ + 72, + 118 + ], + "5": [ + 72, + 118 + ], + "6": [ + 72, + 118 + ], + "7": [ + 72, + 118 + ], + "8": [ + 72, + 118 + ], + "9": [ + 72, + 118 + ], + "10": [ + 72, + 118 + ], + "11": [ + 72, + 118 + ], + "12": [ + 72, + 118 + ], + "13": [ + 72, + 118 + ], + "14": [ + 74, + 118 + ], + "15": [ + 84, + 118 + ], + "16": [ + 90, + 118 + ], + "17": [ + 73, + 118 + ], + "18": [ + 59, + 118 + ], + "19": [ + 54, + 118 + ], + "20": [ + 75, + 118 + ], + "21": [ + 57, + 118 + ], + "22": [ + 66, + 118 + ], + "23": [ + 77, + 118 + ], + "24": [ + 68, + 118 + ], + "25": [ + 68, + 118 + ], + "26": [ + 76, + 118 ], - "2": [ + "27": [ + 74, + 118 + ], + "28": [ + 70, + 118 + ], + "29": [ 75, - 107 + 118 + ] + } + }, + "run_registration": "body", + "body_registration": { + "method": "shirt back edge + 6px for run, shirt centroid for other ready poses", + "torso_x": 72, + "frame_offsets": { + "0": [ + 3, + 0 ], - "3": [ - 73, - 109 + "14": [ + 2, + 0 ], - "4": [ - 73, - 109 + "15": [ + 12, + 0 ], - "5": [ - 72, - 87 + "16": [ + 18, + 0 ], - "6": [ - 111, - 97 + "17": [ + 1, + 0 ], - "7": [ - 108, - 99 + "18": [ + -13, + 0 ], - "8": [ - 77, - 96 + "19": [ + -18, + 0 ], - "9": [ - 104, - 103 + "20": [ + 3, + 0 ], - "10": [ - 114, - 102 + "21": [ + -15, + 0 ], - "11": [ - 103, - 81 + "22": [ + -6, + 0 ], - "12": [ - 95, - 89 + "23": [ + 5, + 0 ], - "13": [ - 108, - 100 + "24": [ + -4, + 0 ], - "14": [ - 86, - 89 + "25": [ + -4, + 0 ], - "15": [ - 69, - 107 + "26": [ + 4, + 0 + ], + "27": [ + 2, + 0 + ], + "28": [ + -2, + 0 + ], + "29": [ + 3, + 0 ] }, - "tip": { - "6": [ - 144, - 96 + "landmarks": { + "0": { + "torso": [ + 72.1013, + 100.6582 + ], + "head_x": 75.2509 + }, + "14": { + "torso": [ + 71.7083, + 100.5417 + ], + "head_x": 75.6731 + }, + "15": { + "torso": [ + 71.6897, + 99.0862 + ], + "head_x": 63.9517 + }, + "16": { + "torso": [ + 72, + 95.374 + ], + "head_x": 76.8836 + }, + "17": { + "torso": [ + 72, + 95.6016 + ], + "head_x": 76.9549 + }, + "18": { + "torso": [ + 72, + 95.2077 + ], + "head_x": 77.154 + }, + "19": { + "torso": [ + 72, + 95.2416 + ], + "head_x": 77.0902 + }, + "20": { + "torso": [ + 72, + 96.7008 + ], + "head_x": 75.877 + }, + "21": { + "torso": [ + 72, + 95.3358 + ], + "head_x": 77.4545 + }, + "22": { + "torso": [ + 71.9578, + 91.7004 + ], + "head_x": 72.4453 + }, + "23": { + "torso": [ + 71.6422, + 91.5459 + ], + "head_x": 72.9211 + }, + "24": { + "torso": [ + 72.1651, + 91.867 + ], + "head_x": 73.2025 + }, + "25": { + "torso": [ + 72.0879, + 92.6862 + ], + "head_x": 72.1703 + }, + "26": { + "torso": [ + 71.7333, + 90.6222 + ], + "head_x": 73.537 + }, + "27": { + "torso": [ + 71.5, + 90.6667 + ], + "head_x": 73.2094 + }, + "28": { + "torso": [ + 72.1026, + 91.2564 + ], + "head_x": 74.2124 + }, + "29": { + "torso": [ + 71.5333, + 90.4 + ], + "head_x": 73.2591 + } + } + }, + "locomotion": { + "sources": [ + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png", + "api_key_whip-locomotion.png" + ], + "source_frames": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "source_component_bounds": [ + [ + 60, + 61, + 292, + 284 + ], + [ + 393, + 61, + 603, + 282 + ], + [ + 685, + 61, + 915, + 282 + ], + [ + 994, + 61, + 1230, + 284 + ], + [ + 79, + 380, + 292, + 593 + ], + [ + 373, + 372, + 603, + 593 + ], + [ + 691, + 361, + 885, + 601 + ], + [ + 1014, + 362, + 1209, + 602 + ], + [ + 70, + 660, + 266, + 906 + ], + [ + 395, + 659, + 586, + 898 + ], + [ + 693, + 661, + 879, + 906 + ], + [ + 1030, + 659, + 1214, + 905 + ], + [ + 91, + 955, + 275, + 1198 + ], + [ + 400, + 955, + 590, + 1202 + ] + ], + "source_scale": 0.285, + "relative_scales": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "fragment_attachments": [], + "hand": { + "16": [ + 85, + 100 ], - "9": [ - 139, + "17": [ + 87, + 100 + ], + "18": [ + 86, + 100 + ], + "19": [ + 85, + 100 + ], + "20": [ + 86, + 101 + ], + "21": [ + 86, + 100 + ], + "22": [ + 78, 102 ], - "12": [ - 129, - 85 + "23": [ + 79, + 102 + ], + "24": [ + 78, + 102 + ], + "25": [ + 78, + 103 + ], + "26": [ + 79, + 102 + ], + "27": [ + 76, + 102 + ], + "28": [ + 77, + 103 + ], + "29": [ + 77, + 102 ] }, "foot": { - "0": [ - 72, + "16": [ + 90, 118 ], - "1": [ - 70, + "17": [ + 73, 118 ], - "2": [ - 80, + "18": [ + 59, 118 ], - "3": [ - 67, + "19": [ + 54, 118 ], - "4": [ - 71, + "20": [ + 75, 118 ], - "5": [ - 72, + "21": [ + 57, 118 ], - "6": [ - 72, + "22": [ + 66, 118 ], - "7": [ - 72, + "23": [ + 77, 118 ], - "8": [ - 72, + "24": [ + 68, 118 ], - "9": [ - 72, + "25": [ + 68, 118 ], - "10": [ - 72, + "26": [ + 76, 118 ], - "11": [ - 72, + "27": [ + 74, 118 ], - "12": [ - 72, + "28": [ + 70, 118 ], - "13": [ - 72, + "29": [ + 75, 118 + ] + }, + "visible_bounds": [ + [ + 49, + 57, + 115, + 120 + ], + [ + 55, + 57, + 115, + 120 + ], + [ + 51, + 57, + 117, + 120 + ], + [ + 48, + 56, + 115, + 120 + ], + [ + 54, + 59, + 115, + 120 + ], + [ + 50, + 57, + 116, + 120 + ], + [ + 52, + 52, + 107, + 120 + ], + [ + 52, + 52, + 108, + 120 + ], + [ + 51, + 51, + 107, + 120 + ], + [ + 52, + 52, + 105, + 120 + ], + [ + 54, + 50, + 107, + 119 + ], + [ + 54, + 50, + 106, + 120 + ], + [ + 55, + 51, + 107, + 120 + ], + [ + 55, + 50, + 108, + 120 + ] + ], + "cadence_fps": { + "run": 13, + "walk_up": 9, + "walk_down": 9 + }, + "body_scale": 1.55 + }, + "unused_legacy_cells": [ + 1, + 2, + 3, + 4 + ], + "source_order": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 7, + 14, + 15 + ], + "foot_planting": { + "default": "Idle, equip, hurt and locomotion use body registration. Attacks and unused legacy run cells preserve original registration. See locomotion for source provenance.", + "overrides": {}, + "note": "Attack frames (step1-step3: windup, contact, follow) plant the rear foot, including step3 contact and step3 follow (the reused step1 follow cel), so the foot stays planted through the recoil. Idle, run, and equip plant the body foot named by body_foot_targets, which already holds the run body steady; hurt uses the foot centre.", + "body_foot_targets": { + "0": [ + 75, + 95 + ], + "1": [ + 67, + 93 + ], + "2": [ + 64, + 94 + ], + "3": [ + 57, + 87 ], - "14": [ - 72, - 118 + "4": [ + 74, + 104 ], - "15": [ - 72, - 118 + "14": [ + 64, + 89 ] } - }, - "run_registration": "body", - "foot_planting": { - "default": "idle, equip, and hurt use the foot centre; run frames hold the body steady; attack frames use the rear foot.", - "overrides": {}, - "note": "" } }, - "api_key_whip": { - "weapon": "api_key_whip", - "file": "api_key_whip.png", - "resource": "api_key_whip.tres", + "git_branch": { + "weapon": "git_branch", + "file": "git_branch.png", + "resource": "git_branch.tres", "cell": [ 192, 128 ], "grid": [ 4, - 4 + 8 ], "pivot": [ 72, 118 ], "facing": "right", - "body_height": 59, + "body_height": 57, "unarmed_idle_height": 59, - "source_scale": 0.2757009345794392, + "source_scale": 0.271889400921659, "source_component_bounds": [ [ - 100, - 83, - 320, - 297 + 70, + 76, + 294, + 293 ], [ - 395, + 374, 85, - 629, + 608, 291 ], [ - 701, - 89, - 939, - 286 + 682, + 90, + 944, + 292 ], [ - 991, + 998, 90, - 1237, - 303 + 1247, + 296 ], [ - 88, - 399, - 345, - 612 + 38, + 400, + 308, + 599 ], [ - 396, - 371, - 588, - 600 + 318, + 394, + 549, + 599 ], [ - 659, - 407, - 972, - 598 + 630, + 414, + 946, + 597 ], [ - 982, - 415, - 1248, - 618 + 970, + 411, + 1250, + 596 ], [ - 55, + 43, 714, - 283, - 897 + 316, + 900 ], [ - 365, - 723, - 676, - 898 + 372, + 703, + 668, + 897 ], [ - 683, - 712, - 959, - 912 + 695, + 686, + 973, + 898 ], [ - 990, - 651, - 1196, - 898 + 1005, + 681, + 1235, + 900 ], [ - 68, - 1016, - 364, - 1189 + 45, + 1010, + 349, + 1202 ], [ - 982, - 415, - 1248, - 618 + 361, + 1025, + 658, + 1197 ], [ - 704, - 993, - 941, - 1216 + 690, + 965, + 945, + 1202 ], [ - 963, - 959, - 1218, - 1192 + 969, + 1007, + 1245, + 1202 ] ], "fragment_attachments": [], - "foot_anchor_semantics": "Per-frame planted foot for registration validation; attacks use the rear foot unless overridden. Body-registered run frames keep their feet on the pivot row, not on pivot x.", + "foot_anchor_semantics": "Ready poses and locomotion share the torso axis at pivot x; feet cycle freely. Foot anchors follow the translated soles, with at most 3px run bob around the ground row. Attacks and unused legacy run cells retain their original registration.", "clips": { "hold_idle": { "frames": [ @@ -931,12 +3353,14 @@ }, "run": { "frames": [ - 1, - 2, - 3, - 4 - ], - "fps": 12, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "fps": 13, "loop": true }, "step1": { @@ -979,108 +3403,184 @@ ], "fps": 1, "loop": false + }, + "walk_up": { + "frames": [ + 22, + 23, + 24, + 25 + ], + "fps": 9, + "loop": true + }, + "walk_down": { + "frames": [ + 26, + 27, + 28, + 29 + ], + "fps": 9, + "loop": true } }, "anchors": { "hand": { "0": [ - 86, + 85, 100 ], "1": [ - 112, - 112 + 97, + 94 ], "2": [ - 114, - 112 + 92, + 102 ], "3": [ - 115, - 116 + 93, + 99 ], "4": [ - 114, - 112 + 100, + 99 ], "5": [ - 65, - 81 + 80, + 87 ], "6": [ - 103, + 95, 101 ], "7": [ - 108, - 101 + 96, + 100 ], "8": [ - 75, - 109 + 100, + 102 ], "9": [ - 102, - 101 + 97, + 102 ], "10": [ - 104, - 100 + 103, + 91 ], "11": [ - 95, - 82 + 103, + 96 ], "12": [ - 103, - 100 + 100, + 102 ], "13": [ - 108, + 100, 101 ], "14": [ - 107, + 94, + 84 + ], + "15": [ + 76, + 98 + ], + "16": [ + 88, + 100 + ], + "17": [ + 89, + 100 + ], + "18": [ + 90, + 100 + ], + "19": [ + 90, + 100 + ], + "20": [ + 90, + 100 + ], + "21": [ + 93, + 100 + ], + "22": [ + 84, 100 ], - "15": [ - 90, + "23": [ + 82, + 102 + ], + "24": [ + 84, + 99 + ], + "25": [ + 84, + 99 + ], + "26": [ + 82, + 98 + ], + "27": [ + 82, + 97 + ], + "28": [ + 83, + 98 + ], + "29": [ + 84, 98 ] }, "tip": { "6": [ 149, - 94 + 98 ], "9": [ - 149, - 91 + 142, + 107 ], "12": [ 145, - 96 + 98 ] }, "foot": { "0": [ - 72, + 71, 118 ], "1": [ - 72, + 84, 118 ], "2": [ - 72, + 87, 118 ], "3": [ - 72, + 62, 118 ], "4": [ - 72, + 55, 118 ], "5": [ @@ -1120,186 +3620,745 @@ 118 ], "14": [ - 72, + 75, 118 ], "15": [ - 72, + 73, + 118 + ], + "16": [ + 67, + 118 + ], + "17": [ + 64, + 118 + ], + "18": [ + 84, + 118 + ], + "19": [ + 86, + 118 + ], + "20": [ + 83, + 118 + ], + "21": [ + 63, + 118 + ], + "22": [ + 68, + 118 + ], + "23": [ + 76, + 118 + ], + "24": [ + 75, + 118 + ], + "25": [ + 67, + 118 + ], + "26": [ + 75, + 118 + ], + "27": [ + 74, + 118 + ], + "28": [ + 76, + 118 + ], + "29": [ + 77, 118 ] } }, "run_registration": "body", - "source_order": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 7, - 14, - 15 - ], - "foot_planting": { - "default": "idle, equip, and hurt use the foot centre; run frames hold the body steady; attack frames use the rear foot.", - "overrides": {}, - "note": "Attack frames (step1-step3: windup, contact, follow) plant the rear foot, including step3 contact and step3 follow (the reused step1 follow cel), so the foot stays planted through the recoil. Idle, run, and equip plant the body foot named by body_foot_targets, which already holds the run body steady; hurt uses the foot centre.", - "body_foot_targets": { + "body_registration": { + "method": "shirt back edge + 6px for run, shirt centroid for other ready poses", + "torso_x": 72, + "frame_offsets": { "0": [ - 75, - 95 + -1, + 0 ], - "1": [ + "14": [ + 3, + 0 + ], + "15": [ + 1, + 0 + ], + "16": [ + -5, + 0 + ], + "17": [ + -8, + 0 + ], + "18": [ + 12, + 0 + ], + "19": [ + 14, + 0 + ], + "20": [ + 11, + 0 + ], + "21": [ + -9, + 0 + ], + "22": [ + -4, + 0 + ], + "23": [ + 4, + 0 + ], + "24": [ + 3, + 0 + ], + "25": [ + -5, + 0 + ], + "26": [ + 3, + 0 + ], + "27": [ + 2, + 0 + ], + "28": [ + 4, + 0 + ], + "29": [ + 5, + 0 + ] + }, + "landmarks": { + "0": { + "torso": [ + 71.902, + 97.1765 + ], + "head_x": 73.4373 + }, + "14": { + "torso": [ + 71.8727, + 99.9364 + ], + "head_x": 69.5298 + }, + "15": { + "torso": [ + 72.381, + 93.381 + ], + "head_x": 58.3763 + }, + "16": { + "torso": [ + 72, + 95.4938 + ], + "head_x": 78.4538 + }, + "17": { + "torso": [ + 72, + 95.3303 + ], + "head_x": 77.8491 + }, + "18": { + "torso": [ + 72, + 95.1415 + ], + "head_x": 77.6016 + }, + "19": { + "torso": [ + 72, + 95.225 + ], + "head_x": 77.2487 + }, + "20": { + "torso": [ + 72, + 95.2558 + ], + "head_x": 78.6103 + }, + "21": { + "torso": [ + 72, + 94.9128 + ], + "head_x": 79.6692 + }, + "22": { + "torso": [ + 72.1931, + 93.6395 + ], + "head_x": 71.4025 + }, + "23": { + "torso": [ + 72.2078, + 91.9134 + ], + "head_x": 74.1032 + }, + "24": { + "torso": [ + 71.5792, + 93.0594 + ], + "head_x": 72.3712 + }, + "25": { + "torso": [ + 72.25, + 92.6682 + ], + "head_x": 72.1123 + }, + "26": { + "torso": [ + 71.6786, + 91.25 + ], + "head_x": 73.0902 + }, + "27": { + "torso": [ + 71.96, + 89.72 + ], + "head_x": 72.1064 + }, + "28": { + "torso": [ + 71.9494, + 90.9873 + ], + "head_x": 73.0577 + }, + "29": { + "torso": [ + 72.4038, + 90.2885 + ], + "head_x": 73.668 + } + } + }, + "locomotion": { + "sources": [ + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion.png", + "git_branch-locomotion-generation0.png", + "git_branch-locomotion-generation0.png" + ], + "source_frames": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 8, + 9 + ], + "source_component_bounds": [ + [ + 79, + 98, + 292, + 296 + ], + [ + 378, + 98, + 604, + 295 + ], + [ + 680, + 97, + 918, + 294 + ], + [ + 988, + 96, + 1212, + 294 + ], + [ + 62, + 397, + 303, + 595 + ], + [ + 391, + 394, + 622, + 595 + ], + [ + 694, + 391, + 914, + 597 + ], + [ + 1006, + 392, + 1215, + 602 + ], + [ + 88, + 692, + 298, + 903 + ], + [ + 400, + 691, + 615, + 904 + ], + [ + 701, + 692, + 913, + 907 + ], + [ + 1012, + 691, + 1225, + 908 + ], + [ + 73, + 693, + 305, + 923 + ], + [ + 390, + 691, + 614, + 926 + ] + ], + "source_scale": 0.3220338983050847, + "relative_scales": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.9391304347826087, + 0.9191489361702128 + ], + "fragment_attachments": [], + "hand": { + "16": [ + 88, + 100 + ], + "17": [ + 89, + 100 + ], + "18": [ + 90, + 100 + ], + "19": [ + 90, + 100 + ], + "20": [ + 90, + 100 + ], + "21": [ + 93, + 100 + ], + "22": [ + 84, + 100 + ], + "23": [ + 82, + 102 + ], + "24": [ + 84, + 99 + ], + "25": [ + 84, + 99 + ], + "26": [ + 82, + 98 + ], + "27": [ + 82, + 97 + ], + "28": [ + 83, + 98 + ], + "29": [ + 84, + 98 + ] + }, + "foot": { + "16": [ 67, - 93 + 118 ], - "2": [ + "17": [ 64, - 94 + 118 ], - "3": [ - 57, - 87 + "18": [ + 84, + 118 ], - "4": [ + "19": [ + 86, + 118 + ], + "20": [ + 83, + 118 + ], + "21": [ + 63, + 118 + ], + "22": [ + 68, + 118 + ], + "23": [ + 76, + 118 + ], + "24": [ + 75, + 118 + ], + "25": [ + 67, + 118 + ], + "26": [ + 75, + 118 + ], + "27": [ 74, - 104 + 118 ], - "14": [ - 64, - 89 + "28": [ + 76, + 118 + ], + "29": [ + 77, + 118 + ] + }, + "visible_bounds": [ + [ + 55, + 56, + 124, + 120 + ], + [ + 55, + 57, + 128, + 120 + ], + [ + 51, + 57, + 128, + 120 + ], + [ + 53, + 56, + 125, + 120 + ], + [ + 49, + 56, + 127, + 120 + ], + [ + 55, + 55, + 129, + 120 + ], + [ + 51, + 54, + 122, + 120 + ], + [ + 53, + 52, + 120, + 120 + ], + [ + 51, + 52, + 119, + 120 + ], + [ + 51, + 51, + 120, + 120 + ], + [ + 53, + 51, + 121, + 120 + ], + [ + 52, + 50, + 121, + 120 + ], + [ + 53, + 50, + 123, + 120 + ], + [ + 55, + 50, + 121, + 120 ] - } + ], + "cadence_fps": { + "run": 13, + "walk_up": 9, + "walk_down": 9 + }, + "body_scale": 1.55 + }, + "unused_legacy_cells": [ + 1, + 2, + 3, + 4 + ], + "foot_planting": { + "default": "Idle, equip, hurt and locomotion use body registration. Attacks and unused legacy run cells preserve original registration. See locomotion for source provenance.", + "overrides": {}, + "note": "" } }, - "git_branch": { - "weapon": "git_branch", - "file": "git_branch.png", - "resource": "git_branch.tres", + "refactor_hammer": { + "weapon": "refactor_hammer", + "file": "refactor_hammer.png", + "resource": "refactor_hammer.tres", "cell": [ 192, 128 ], "grid": [ 4, - 4 + 8 ], "pivot": [ 72, 118 ], "facing": "right", - "body_height": 57, + "body_height": 59, "unarmed_idle_height": 59, - "source_scale": 0.271889400921659, + "source_scale": 0.2964824120603015, "source_component_bounds": [ [ - 70, - 76, - 294, - 293 + 81, + 26, + 321, + 225 ], [ - 374, - 85, - 608, - 291 + 454, + 23, + 734, + 224 ], [ - 682, - 90, - 944, - 292 + 812, + 30, + 1095, + 224 ], [ - 998, - 90, - 1247, - 296 + 1191, + 23, + 1494, + 224 ], [ - 38, - 400, - 308, - 599 + 75, + 286, + 367, + 488 ], [ - 318, - 394, - 549, - 599 + 417, + 246, + 640, + 496 ], [ - 630, - 414, - 946, - 597 + 796, + 313, + 1106, + 496 ], [ - 970, - 411, - 1250, - 596 + 1203, + 312, + 1495, + 490 ], [ - 43, - 714, - 316, - 900 + 55, + 542, + 301, + 744 ], [ - 372, - 703, - 668, - 897 + 440, + 556, + 768, + 743 ], [ - 695, - 686, - 973, - 898 + 834, + 552, + 1141, + 742 ], [ - 1005, - 681, - 1235, - 900 + 1184, + 507, + 1405, + 746 ], [ - 45, - 1010, - 349, - 1202 + 51, + 809, + 379, + 996 ], [ - 361, - 1025, - 658, - 1197 + 441, + 793, + 757, + 996 ], [ - 690, - 965, - 945, - 1202 + 826, + 757, + 1106, + 987 ], [ - 969, - 1007, - 1245, - 1202 + 1211, + 801, + 1515, + 989 ] ], "fragment_attachments": [], - "foot_anchor_semantics": "Per-frame planted foot for registration validation; attacks use the rear foot unless overridden. Body-registered run frames keep their feet on the pivot row, not on pivot x.", + "foot_anchor_semantics": "Ready poses and locomotion share the torso axis at pivot x; feet cycle freely. Foot anchors follow the translated soles, with at most 3px run bob around the ground row. Attacks and unused legacy run cells retain their original registration.", "clips": { "hold_idle": { "frames": [ @@ -1310,12 +4369,14 @@ }, "run": { "frames": [ - 1, - 2, - 3, - 4 - ], - "fps": 12, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "fps": 13, "loop": true }, "step1": { @@ -1358,108 +4419,184 @@ ], "fps": 1, "loop": false + }, + "walk_up": { + "frames": [ + 22, + 23, + 24, + 25 + ], + "fps": 9, + "loop": true + }, + "walk_down": { + "frames": [ + 26, + 27, + 28, + 29 + ], + "fps": 9, + "loop": true } }, "anchors": { "hand": { "0": [ - 86, - 100 + 93, + 98 ], "1": [ - 97, - 94 + 89, + 96 ], "2": [ - 92, - 102 + 102, + 97 ], "3": [ - 93, - 99 + 101, + 97 ], "4": [ - 100, - 99 + 99, + 95 ], "5": [ - 80, - 87 + 100, + 81 ], "6": [ - 95, + 119, 101 ], "7": [ - 96, - 100 + 111, + 101 ], "8": [ - 100, - 102 + 92, + 100 ], "9": [ - 97, - 102 + 111, + 100 ], "10": [ - 103, - 91 + 110, + 100 ], "11": [ - 103, - 96 + 91, + 77 ], "12": [ - 100, - 102 + 113, + 101 ], "13": [ + 112, + 100 + ], + "14": [ + 100, + 85 + ], + "15": [ 100, + 91 + ], + "16": [ + 91, + 101 + ], + "17": [ + 94, + 100 + ], + "18": [ + 94, + 100 + ], + "19": [ + 89, + 100 + ], + "20": [ + 88, + 100 + ], + "21": [ + 94, + 99 + ], + "22": [ + 81, + 101 + ], + "23": [ + 78, + 102 + ], + "24": [ + 81, 101 ], - "14": [ - 91, - 84 + "25": [ + 79, + 102 + ], + "26": [ + 80, + 97 ], - "15": [ - 75, + "27": [ + 80, + 98 + ], + "28": [ + 80, 98 + ], + "29": [ + 81, + 99 ] }, "tip": { "6": [ - 149, - 98 + 154, + 110 ], "9": [ - 142, - 107 + 158, + 91 ], "12": [ - 145, - 98 + 159, + 102 ] }, "foot": { "0": [ - 72, + 75, 118 ], "1": [ - 84, + 64, 118 ], "2": [ - 87, + 80, 118 ], "3": [ - 62, + 74, 118 ], "4": [ - 55, + 71, 118 ], "5": [ @@ -1499,353 +4636,621 @@ 118 ], "14": [ - 72, + 74, 118 ], "15": [ - 72, + 78, + 118 + ], + "16": [ + 88, + 118 + ], + "17": [ + 79, + 116 + ], + "18": [ + 85, + 117 + ], + "19": [ + 92, + 117 + ], + "20": [ + 82, + 118 + ], + "21": [ + 69, + 120 + ], + "22": [ + 76, + 118 + ], + "23": [ + 76, + 118 + ], + "24": [ + 77, + 118 + ], + "25": [ + 68, + 118 + ], + "26": [ + 75, + 118 + ], + "27": [ + 76, + 118 + ], + "28": [ + 69, + 118 + ], + "29": [ + 67, 118 ] } }, "run_registration": "body", - "foot_planting": { - "default": "idle, equip, and hurt use the foot centre; run frames hold the body steady; attack frames use the rear foot.", - "overrides": {}, - "note": "" - } - }, - "refactor_hammer": { - "weapon": "refactor_hammer", - "file": "refactor_hammer.png", - "resource": "refactor_hammer.tres", - "cell": [ - 192, - 128 - ], - "grid": [ - 4, - 4 - ], - "pivot": [ - 72, - 118 - ], - "facing": "right", - "body_height": 59, - "unarmed_idle_height": 59, - "source_scale": 0.2964824120603015, - "source_component_bounds": [ - [ - 81, - 26, - 321, - 225 - ], - [ - 454, - 23, - 734, - 224 - ], - [ - 812, - 30, - 1095, - 224 - ], - [ - 1191, - 23, - 1494, - 224 - ], - [ - 75, - 286, - 367, - 488 - ], - [ - 417, - 246, - 640, - 496 - ], - [ - 796, - 313, - 1106, - 496 - ], - [ - 1203, - 312, - 1495, - 490 - ], - [ - 55, - 542, - 301, - 744 - ], - [ - 440, - 556, - 768, - 743 - ], - [ - 834, - 552, - 1141, - 742 - ], - [ - 1184, - 507, - 1405, - 746 - ], - [ - 51, - 809, - 379, - 996 - ], - [ - 441, - 793, - 757, - 996 - ], - [ - 826, - 757, - 1106, - 987 - ], - [ - 1211, - 801, - 1515, - 989 - ] - ], - "fragment_attachments": [], - "foot_anchor_semantics": "Per-frame planted foot for registration validation; attacks use the rear foot unless overridden. Body-registered run frames keep their feet on the pivot row, not on pivot x.", - "clips": { - "hold_idle": { - "frames": [ + "body_registration": { + "method": "shirt back edge + 6px for run, shirt centroid for other ready poses", + "torso_x": 72, + "frame_offsets": { + "0": [ + 3, 0 ], - "fps": 1, - "loop": true - }, - "run": { - "frames": [ - 1, + "14": [ 2, - 3, - 4 + 0 ], - "fps": 12, - "loop": true - }, - "step1": { - "frames": [ - 5, + "15": [ 6, - 7 + 0 ], - "fps": 9, - "loop": false - }, - "step2": { - "frames": [ - 8, - 9, - 10 + "16": [ + 16, + 0 ], - "fps": 9, - "loop": false - }, - "step3": { - "frames": [ - 11, - 12, - 13 + "17": [ + 7, + -2 ], - "fps": 9, - "loop": false - }, - "equip": { - "frames": [ - 14 + "18": [ + 13, + -1 ], - "fps": 1, - "loop": false - }, - "hurt": { - "frames": [ - 15 + "19": [ + 20, + -1 + ], + "20": [ + 10, + 0 + ], + "21": [ + -3, + 2 + ], + "22": [ + 4, + 0 ], - "fps": 1, - "loop": false - } - }, - "anchors": { - "hand": { - "0": [ - 90, - 98 + "23": [ + 4, + 0 ], - "1": [ - 89, - 96 + "24": [ + 5, + 0 ], - "2": [ - 102, - 97 + "25": [ + -4, + 0 ], - "3": [ - 101, - 97 + "26": [ + 3, + 0 ], - "4": [ - 99, - 95 + "27": [ + 4, + 0 ], - "5": [ - 100, - 81 + "28": [ + -3, + 0 ], - "6": [ - 119, + "29": [ + -5, + 0 + ] + }, + "landmarks": { + "0": { + "torso": [ + 72.3, + 97.6857 + ], + "head_x": 80.043 + }, + "14": { + "torso": [ + 72.194, + 99.1642 + ], + "head_x": 72.5355 + }, + "15": { + "torso": [ + 71.6783, + 98.9826 + ], + "head_x": 63.1073 + }, + "16": { + "torso": [ + 72, + 95.0403 + ], + "head_x": 77.1408 + }, + "17": { + "torso": [ + 72, + 94.74 + ], + "head_x": 77.0828 + }, + "18": { + "torso": [ + 72, + 95.1681 + ], + "head_x": 77.3616 + }, + "19": { + "torso": [ + 72, + 94.8413 + ], + "head_x": 78.4758 + }, + "20": { + "torso": [ + 72, + 93.6667 + ], + "head_x": 77.9946 + }, + "21": { + "torso": [ + 72, + 93.561 + ], + "head_x": 79.1488 + }, + "22": { + "torso": [ + 71.5982, + 91.2603 + ], + "head_x": 73.0583 + }, + "23": { + "torso": [ + 71.9786, + 91.5128 + ], + "head_x": 72.5414 + }, + "24": { + "torso": [ + 72.0512, + 90.9843 + ], + "head_x": 72.0633 + }, + "25": { + "torso": [ + 71.9449, + 91.1654 + ], + "head_x": 72.2491 + }, + "26": { + "torso": [ + 72.0645, + 90.2796 + ], + "head_x": 72.9116 + }, + "27": { + "torso": [ + 72.3452, + 91.1548 + ], + "head_x": 73.8841 + }, + "28": { + "torso": [ + 71.9, + 91.3143 + ], + "head_x": 71.9253 + }, + "29": { + "torso": [ + 71.8615, + 92.5385 + ], + "head_x": 71.4741 + } + } + }, + "locomotion": { + "sources": [ + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png", + "refactor_hammer-locomotion.png" + ], + "source_frames": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "source_component_bounds": [ + [ + 9, + 46, + 309, + 272 + ], + [ + 354, + 53, + 623, + 266 + ], + [ + 658, + 42, + 937, + 268 + ], + [ + 976, + 46, + 1251, + 269 + ], + [ + 20, + 347, + 315, + 579 + ], + [ + 371, + 343, + 626, + 580 + ], + [ + 689, + 336, + 918, + 587 + ], + [ + 1019, + 336, + 1240, + 587 + ], + [ + 59, + 643, + 291, + 898 + ], + [ + 383, + 643, + 611, + 897 + ], + [ + 702, + 636, + 931, + 902 + ], + [ + 1024, + 636, + 1242, + 899 + ], + [ + 76, + 948, + 289, + 1202 + ], + [ + 391, + 948, + 613, + 1199 + ] + ], + "source_scale": 0.2753623188405797, + "relative_scales": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "fragment_attachments": [], + "hand": { + "16": [ + 91, 101 ], - "7": [ - 111, - 101 + "17": [ + 94, + 100 ], - "8": [ - 92, + "18": [ + 94, 100 ], - "9": [ - 111, + "19": [ + 89, 100 ], - "10": [ - 110, + "20": [ + 88, 100 ], - "11": [ - 91, - 77 + "21": [ + 94, + 99 ], - "12": [ - 113, + "22": [ + 81, 101 ], - "13": [ - 112, - 100 + "23": [ + 78, + 102 ], - "14": [ - 98, - 85 + "24": [ + 81, + 101 ], - "15": [ - 94, - 91 - ] - }, - "tip": { - "6": [ - 154, - 110 + "25": [ + 79, + 102 ], - "9": [ - 158, - 91 + "26": [ + 80, + 97 ], - "12": [ - 159, - 102 + "27": [ + 80, + 98 + ], + "28": [ + 80, + 98 + ], + "29": [ + 81, + 99 ] }, "foot": { - "0": [ - 72, + "16": [ + 88, 118 ], - "1": [ - 64, - 118 + "17": [ + 79, + 116 ], - "2": [ - 80, - 118 + "18": [ + 85, + 117 ], - "3": [ - 74, - 118 + "19": [ + 92, + 117 ], - "4": [ - 71, + "20": [ + 82, 118 ], - "5": [ - 72, - 118 + "21": [ + 69, + 120 ], - "6": [ - 72, + "22": [ + 76, 118 ], - "7": [ - 72, + "23": [ + 76, 118 ], - "8": [ - 72, + "24": [ + 77, 118 ], - "9": [ - 72, + "25": [ + 68, 118 ], - "10": [ - 72, + "26": [ + 75, 118 ], - "11": [ - 72, + "27": [ + 76, 118 ], - "12": [ - 72, + "28": [ + 69, 118 ], - "13": [ - 72, + "29": [ + 67, 118 + ] + }, + "visible_bounds": [ + [ + 46, + 58, + 129, + 120 ], - "14": [ - 72, + [ + 55, + 59, + 129, 118 ], - "15": [ - 72, - 118 + [ + 53, + 57, + 130, + 119 + ], + [ + 50, + 58, + 126, + 119 + ], + [ + 45, + 56, + 126, + 120 + ], + [ + 57, + 57, + 127, + 122 + ], + [ + 52, + 51, + 115, + 120 + ], + [ + 52, + 52, + 113, + 120 + ], + [ + 52, + 50, + 115, + 120 + ], + [ + 52, + 50, + 115, + 120 + ], + [ + 53, + 47, + 116, + 120 + ], + [ + 54, + 48, + 114, + 120 + ], + [ + 54, + 50, + 113, + 120 + ], + [ + 54, + 51, + 115, + 119 ] - } + ], + "cadence_fps": { + "run": 13, + "walk_up": 9, + "walk_down": 9 + }, + "body_scale": 1.55 }, - "run_registration": "body", + "unused_legacy_cells": [ + 1, + 2, + 3, + 4 + ], "foot_planting": { - "default": "idle, equip, and hurt use the foot centre; run frames hold the body steady; attack frames use the rear foot.", + "default": "Idle, equip, hurt and locomotion use body registration. Attacks and unused legacy run cells preserve original registration. See locomotion for source provenance.", "overrides": {}, "note": "Attack frames (step1-step3: windup, contact, follow) plant the rear foot; run frames hold the body steady; idle, equip, and hurt use the foot centre." } @@ -1860,7 +5265,7 @@ ], "grid": [ 4, - 4 + 8 ], "pivot": [ 72, @@ -1969,7 +5374,7 @@ ] ], "fragment_attachments": [], - "foot_anchor_semantics": "Per-frame planted foot for registration validation; attacks use the rear foot unless overridden. Body-registered run frames keep their feet on the pivot row, not on pivot x.", + "foot_anchor_semantics": "Ready poses and locomotion share the torso axis at pivot x; feet cycle freely. Foot anchors follow the translated soles, with at most 3px run bob around the ground row. Attacks and unused legacy run cells retain their original registration.", "clips": { "hold_idle": { "frames": [ @@ -1980,12 +5385,14 @@ }, "run": { "frames": [ - 1, - 2, - 3, - 4 - ], - "fps": 12, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "fps": 13, "loop": true }, "step1": { @@ -2028,12 +5435,32 @@ ], "fps": 1, "loop": false + }, + "walk_up": { + "frames": [ + 22, + 23, + 24, + 25 + ], + "fps": 9, + "loop": true + }, + "walk_down": { + "frames": [ + 26, + 27, + 28, + 29 + ], + "fps": 9, + "loop": true } }, "anchors": { "hand": { "0": [ - 90, + 94, 101 ], "1": [ @@ -2072,29 +5499,85 @@ 101, 108 ], - "10": [ - 111, - 109 + "10": [ + 111, + 109 + ], + "11": [ + 98, + 82 + ], + "12": [ + 95, + 112 + ], + "13": [ + 80, + 109 + ], + "14": [ + 89, + 110 + ], + "15": [ + 92, + 106 + ], + "16": [ + 89, + 100 + ], + "17": [ + 90, + 100 + ], + "18": [ + 86, + 98 + ], + "19": [ + 85, + 99 + ], + "20": [ + 90, + 97 + ], + "21": [ + 90, + 98 + ], + "22": [ + 78, + 103 + ], + "23": [ + 78, + 102 + ], + "24": [ + 78, + 102 ], - "11": [ - 98, - 82 + "25": [ + 78, + 102 ], - "12": [ - 95, - 112 + "26": [ + 77, + 99 ], - "13": [ - 80, - 109 + "27": [ + 78, + 99 ], - "14": [ - 84, - 110 + "28": [ + 78, + 98 ], - "15": [ - 90, - 106 + "29": [ + 78, + 99 ] }, "tip": { @@ -2113,7 +5596,7 @@ }, "foot": { "0": [ - 72, + 76, 118 ], "1": [ @@ -2169,18 +5652,621 @@ 118 ], "14": [ - 72, + 77, 118 ], "15": [ - 72, + 74, + 118 + ], + "16": [ + 88, + 118 + ], + "17": [ + 74, + 118 + ], + "18": [ + 83, + 118 + ], + "19": [ + 88, + 118 + ], + "20": [ + 61, + 118 + ], + "21": [ + 59, + 118 + ], + "22": [ + 77, + 117 + ], + "23": [ + 69, + 118 + ], + "24": [ + 76, + 118 + ], + "25": [ + 77, + 119 + ], + "26": [ + 74, + 118 + ], + "27": [ + 69, + 118 + ], + "28": [ + 73, + 118 + ], + "29": [ + 75, 118 ] } }, "run_registration": "body", + "body_registration": { + "method": "shirt back edge + 6px for run, shirt centroid for other ready poses", + "torso_x": 72, + "frame_offsets": { + "0": [ + 4, + 0 + ], + "14": [ + 5, + 0 + ], + "15": [ + 2, + 0 + ], + "16": [ + 16, + 0 + ], + "17": [ + 2, + 0 + ], + "18": [ + 11, + 0 + ], + "19": [ + 16, + 0 + ], + "20": [ + -11, + 0 + ], + "21": [ + -13, + 0 + ], + "22": [ + 5, + -1 + ], + "23": [ + -3, + 0 + ], + "24": [ + 4, + 0 + ], + "25": [ + 5, + 1 + ], + "26": [ + 2, + 0 + ], + "27": [ + -3, + 0 + ], + "28": [ + 1, + 0 + ], + "29": [ + 3, + 0 + ] + }, + "landmarks": { + "0": { + "torso": [ + 72.3, + 97.9889 + ], + "head_x": 77.6371 + }, + "14": { + "torso": [ + 72.456, + 97.576 + ], + "head_x": 70.6835 + }, + "15": { + "torso": [ + 72.3421, + 98.3684 + ], + "head_x": 74.7155 + }, + "16": { + "torso": [ + 72, + 94.5702 + ], + "head_x": 77.3503 + }, + "17": { + "torso": [ + 72, + 95.0 + ], + "head_x": 77.8036 + }, + "18": { + "torso": [ + 72, + 93.3798 + ], + "head_x": 76.4667 + }, + "19": { + "torso": [ + 72, + 94.7619 + ], + "head_x": 76.9911 + }, + "20": { + "torso": [ + 72, + 92.561 + ], + "head_x": 77.41 + }, + "21": { + "torso": [ + 72, + 93.5145 + ], + "head_x": 77.3832 + }, + "22": { + "torso": [ + 72.379, + 93.0282 + ], + "head_x": 72.4702 + }, + "23": { + "torso": [ + 72.3723, + 91.7706 + ], + "head_x": 71.9432 + }, + "24": { + "torso": [ + 72.1241, + 91.8869 + ], + "head_x": 71.9336 + }, + "25": { + "torso": [ + 72.2561, + 91.8293 + ], + "head_x": 73.033 + }, + "26": { + "torso": [ + 72.4615, + 92.5 + ], + "head_x": 70.8672 + }, + "27": { + "torso": [ + 72.4286, + 92.9643 + ], + "head_x": 71.828 + }, + "28": { + "torso": [ + 71.9744, + 90.8462 + ], + "head_x": 72.7848 + }, + "29": { + "torso": [ + 72.1591, + 93.2727 + ], + "head_x": 72.9083 + } + } + }, + "locomotion": { + "sources": [ + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png", + "firewall_shield-locomotion.png" + ], + "source_frames": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "source_component_bounds": [ + [ + 76, + 96, + 299, + 294 + ], + [ + 399, + 98, + 606, + 294 + ], + [ + 675, + 97, + 902, + 300 + ], + [ + 980, + 96, + 1207, + 295 + ], + [ + 81, + 382, + 299, + 587 + ], + [ + 398, + 385, + 611, + 584 + ], + [ + 708, + 382, + 893, + 588 + ], + [ + 1023, + 383, + 1204, + 594 + ], + [ + 97, + 670, + 289, + 884 + ], + [ + 409, + 668, + 604, + 887 + ], + [ + 706, + 669, + 885, + 885 + ], + [ + 1025, + 666, + 1205, + 881 + ], + [ + 102, + 951, + 286, + 1171 + ], + [ + 413, + 951, + 598, + 1168 + ] + ], + "source_scale": 0.31666666666666665, + "relative_scales": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "fragment_attachments": [], + "hand": { + "16": [ + 89, + 100 + ], + "17": [ + 90, + 100 + ], + "18": [ + 86, + 98 + ], + "19": [ + 85, + 99 + ], + "20": [ + 90, + 97 + ], + "21": [ + 90, + 98 + ], + "22": [ + 78, + 103 + ], + "23": [ + 78, + 102 + ], + "24": [ + 78, + 102 + ], + "25": [ + 78, + 102 + ], + "26": [ + 77, + 99 + ], + "27": [ + 78, + 99 + ], + "28": [ + 78, + 98 + ], + "29": [ + 78, + 99 + ] + }, + "foot": { + "16": [ + 88, + 118 + ], + "17": [ + 74, + 118 + ], + "18": [ + 83, + 118 + ], + "19": [ + 88, + 118 + ], + "20": [ + 61, + 118 + ], + "21": [ + 59, + 118 + ], + "22": [ + 77, + 117 + ], + "23": [ + 69, + 118 + ], + "24": [ + 76, + 118 + ], + "25": [ + 77, + 119 + ], + "26": [ + 74, + 118 + ], + "27": [ + 69, + 118 + ], + "28": [ + 73, + 118 + ], + "29": [ + 75, + 118 + ] + }, + "visible_bounds": [ + [ + 50, + 57, + 121, + 120 + ], + [ + 55, + 58, + 121, + 120 + ], + [ + 50, + 56, + 121, + 120 + ], + [ + 48, + 57, + 120, + 120 + ], + [ + 52, + 55, + 121, + 120 + ], + [ + 54, + 57, + 121, + 120 + ], + [ + 49, + 54, + 108, + 119 + ], + [ + 50, + 53, + 107, + 120 + ], + [ + 48, + 52, + 109, + 120 + ], + [ + 49, + 52, + 111, + 121 + ], + [ + 52, + 52, + 109, + 120 + ], + [ + 53, + 52, + 110, + 120 + ], + [ + 53, + 50, + 111, + 120 + ], + [ + 53, + 51, + 112, + 120 + ] + ], + "cadence_fps": { + "run": 13, + "walk_up": 9, + "walk_down": 9 + }, + "body_scale": 1.55 + }, + "unused_legacy_cells": [ + 1, + 2, + 3, + 4 + ], "foot_planting": { - "default": "idle, equip, and hurt use the foot centre; run frames hold the body steady; attack frames use the rear foot.", + "default": "Idle, equip, hurt and locomotion use body registration. Attacks and unused legacy run cells preserve original registration. See locomotion for source provenance.", "overrides": {}, "note": "" } diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/preview.png index 240eb3ee..d452bc7b 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.png index 2431d5f5..c6e45852 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.tres b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.tres index 7dede14e..7c8714ae 100644 --- a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.tres +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer.tres @@ -1,4 +1,4 @@ -[gd_resource type="SpriteFrames" load_steps=18 format=3] +[gd_resource type="SpriteFrames" load_steps=32 format=3] [ext_resource type="Texture2D" path="refactor_hammer.png" id="1"] @@ -66,13 +66,71 @@ region = Rect2(384, 384, 192, 128) atlas = ExtResource("1") region = Rect2(576, 384, 192, 128) +[sub_resource type="AtlasTexture" id="Frame_16"] +atlas = ExtResource("1") +region = Rect2(0, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_17"] +atlas = ExtResource("1") +region = Rect2(192, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_18"] +atlas = ExtResource("1") +region = Rect2(384, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_19"] +atlas = ExtResource("1") +region = Rect2(576, 512, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_20"] +atlas = ExtResource("1") +region = Rect2(0, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_21"] +atlas = ExtResource("1") +region = Rect2(192, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_22"] +atlas = ExtResource("1") +region = Rect2(384, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_23"] +atlas = ExtResource("1") +region = Rect2(576, 640, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_24"] +atlas = ExtResource("1") +region = Rect2(0, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_25"] +atlas = ExtResource("1") +region = Rect2(192, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_26"] +atlas = ExtResource("1") +region = Rect2(384, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_27"] +atlas = ExtResource("1") +region = Rect2(576, 768, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_28"] +atlas = ExtResource("1") +region = Rect2(0, 896, 192, 128) + +[sub_resource type="AtlasTexture" id="Frame_29"] +atlas = ExtResource("1") +region = Rect2(192, 896, 192, 128) + [resource] animations = [ {"frames": [{"duration": 1.0, "texture": SubResource("Frame_0")}], "loop": true, "name": &"hold_idle", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_1")}, {"duration": 1.0, "texture": SubResource("Frame_2")}, {"duration": 1.0, "texture": SubResource("Frame_3")}, {"duration": 1.0, "texture": SubResource("Frame_4")}], "loop": true, "name": &"run", "speed": 12.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_16")}, {"duration": 1.0, "texture": SubResource("Frame_17")}, {"duration": 1.0, "texture": SubResource("Frame_18")}, {"duration": 1.0, "texture": SubResource("Frame_19")}, {"duration": 1.0, "texture": SubResource("Frame_20")}, {"duration": 1.0, "texture": SubResource("Frame_21")}], "loop": true, "name": &"run", "speed": 13.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_5")}, {"duration": 1.0, "texture": SubResource("Frame_6")}, {"duration": 1.0, "texture": SubResource("Frame_7")}], "loop": false, "name": &"step1", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_8")}, {"duration": 1.0, "texture": SubResource("Frame_9")}, {"duration": 1.0, "texture": SubResource("Frame_10")}], "loop": false, "name": &"step2", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_11")}, {"duration": 1.0, "texture": SubResource("Frame_12")}, {"duration": 1.0, "texture": SubResource("Frame_13")}], "loop": false, "name": &"step3", "speed": 9.0}, {"frames": [{"duration": 1.0, "texture": SubResource("Frame_14")}], "loop": false, "name": &"equip", "speed": 1.0}, -{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0} +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_15")}], "loop": false, "name": &"hurt", "speed": 1.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_22")}, {"duration": 1.0, "texture": SubResource("Frame_23")}, {"duration": 1.0, "texture": SubResource("Frame_24")}, {"duration": 1.0, "texture": SubResource("Frame_25")}], "loop": true, "name": &"walk_up", "speed": 9.0}, +{"frames": [{"duration": 1.0, "texture": SubResource("Frame_26")}, {"duration": 1.0, "texture": SubResource("Frame_27")}, {"duration": 1.0, "texture": SubResource("Frame_28")}, {"duration": 1.0, "texture": SubResource("Frame_29")}], "loop": true, "name": &"walk_down", "speed": 9.0} ] diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_clips.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_clips.png index 91857106..01ce9054 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_clips.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_clips.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png new file mode 100644 index 00000000..b0e89131 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png.import new file mode 100644 index 00000000..34ccd4d6 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsk4man0am3hv" +path="res://.godot/imported/refactor_hammer_locomotion.png-5cee729e409e362c498bf5a2111d2c72.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/refactor_hammer_locomotion.png" +dest_files=["res://.godot/imported/refactor_hammer_locomotion.png-5cee729e409e362c498bf5a2111d2c72.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_preview.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_preview.png index 32b813e1..3c0a9d2b 100644 Binary files a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_preview.png and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/refactor_hammer_preview.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png new file mode 100644 index 00000000..2f8c5c41 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png.import new file mode 100644 index 00000000..66682f54 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8o5jgi7aqus3" +path="res://.godot/imported/api_key_whip-locomotion-generation0.png-1d3140bd90a1b659bb8723ca2d9b1cc3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion-generation0.png" +dest_files=["res://.godot/imported/api_key_whip-locomotion-generation0.png-1d3140bd90a1b659bb8723ca2d9b1cc3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png new file mode 100644 index 00000000..15e34711 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png.import new file mode 100644 index 00000000..b658d048 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c50hvqj1jbur" +path="res://.godot/imported/api_key_whip-locomotion.png-7aee895f2547812ad7b05c309ac9a789.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/api_key_whip-locomotion.png" +dest_files=["res://.godot/imported/api_key_whip-locomotion.png-7aee895f2547812ad7b05c309ac9a789.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png new file mode 100644 index 00000000..266f4e50 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png.import new file mode 100644 index 00000000..b9b84730 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2qyh1hfn2rex" +path="res://.godot/imported/debug_stick-locomotion.png-649b694f61c8cb432ba58ba822c60a62.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/debug_stick-locomotion.png" +dest_files=["res://.godot/imported/debug_stick-locomotion.png-649b694f61c8cb432ba58ba822c60a62.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png new file mode 100644 index 00000000..6164ff47 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png.import new file mode 100644 index 00000000..277be1fc --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://df4oubtw0h6en" +path="res://.godot/imported/firewall_shield-locomotion-gutter0.png-247405e9e4c56889508a492f767dd9cf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion-gutter0.png" +dest_files=["res://.godot/imported/firewall_shield-locomotion-gutter0.png-247405e9e4c56889508a492f767dd9cf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png new file mode 100644 index 00000000..1063525b Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png.import new file mode 100644 index 00000000..dc8a377e --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cay83swupbprk" +path="res://.godot/imported/firewall_shield-locomotion.png-d195e798242651b3ad1d3f990ca64ce1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/firewall_shield-locomotion.png" +dest_files=["res://.godot/imported/firewall_shield-locomotion.png-d195e798242651b3ad1d3f990ca64ce1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png new file mode 100644 index 00000000..1be8ee03 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png.import new file mode 100644 index 00000000..abe0af07 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwhd1exn2kedb" +path="res://.godot/imported/git_branch-locomotion-generation0.png-8eabfeb2e4d02055f1c3aad83f60e7e5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/git_branch-locomotion-generation0.png" +dest_files=["res://.godot/imported/git_branch-locomotion-generation0.png-8eabfeb2e4d02055f1c3aad83f60e7e5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png new file mode 100644 index 00000000..24d77581 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png.import new file mode 100644 index 00000000..fc9b3f56 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b115vb1rjeoi0" +path="res://.godot/imported/git_branch-locomotion-gutter0.png-f875811e1e859c066fb18ebda77f1f91.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/git_branch-locomotion-gutter0.png" +dest_files=["res://.godot/imported/git_branch-locomotion-gutter0.png-f875811e1e859c066fb18ebda77f1f91.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png new file mode 100644 index 00000000..e9afe569 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png.import new file mode 100644 index 00000000..3b04c2fc --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bx0dxh1vhq7a8" +path="res://.godot/imported/git_branch-locomotion.png-cabda2a467ce3d5760bc513cc9e12901.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/git_branch-locomotion.png" +dest_files=["res://.godot/imported/git_branch-locomotion.png-cabda2a467ce3d5760bc513cc9e12901.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png new file mode 100644 index 00000000..c5091629 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png.import new file mode 100644 index 00000000..845f9184 --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://e63bh8j34oi7" +path="res://.godot/imported/linter_staff-locomotion-gutter0.png-98a5eaf64c0e32f7fe10854bda50ab7b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/linter_staff-locomotion-gutter0.png" +dest_files=["res://.godot/imported/linter_staff-locomotion-gutter0.png-98a5eaf64c0e32f7fe10854bda50ab7b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png new file mode 100644 index 00000000..f972570f Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png.import new file mode 100644 index 00000000..772fb7ff --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://eyhxn67bao1o" +path="res://.godot/imported/linter_staff-locomotion.png-ef2d9f863d1d6cf82a9265c2206a97a2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/linter_staff-locomotion.png" +dest_files=["res://.godot/imported/linter_staff-locomotion.png-ef2d9f863d1d6cf82a9265c2206a97a2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png new file mode 100644 index 00000000..899425ec Binary files /dev/null and b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png differ diff --git a/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png.import b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png.import new file mode 100644 index 00000000..0f349afa --- /dev/null +++ b/hoppys-adventure-arcade/Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://beay6ojrpbsnc" +path="res://.godot/imported/refactor_hammer-locomotion.png-9df18f2f4be034224b8ee2bcbda3868c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/hoppy-armed/code_city/sources/refactor_hammer-locomotion.png" +dest_files=["res://.godot/imported/refactor_hammer-locomotion.png-9df18f2f4be034224b8ee2bcbda3868c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_dodge.png b/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_dodge.png new file mode 100644 index 00000000..b462ccc0 Binary files /dev/null and b/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_dodge.png differ diff --git a/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_dodge.png.import b/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_dodge.png.import new file mode 100644 index 00000000..e7aaa06b --- /dev/null +++ b/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_dodge.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bipt005l6ywj2" +path="res://.godot/imported/runner_dodge.png-e2b355f055264e4a8670b51c80490a3a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://Assets/runner/menu_previews/runner_dodge.png" +dest_files=["res://.godot/imported/runner_dodge.png-e2b355f055264e4a8670b51c80490a3a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_turn.png b/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_turn.png deleted file mode 100644 index e074d79c..00000000 Binary files a/hoppys-adventure-arcade/Assets/runner/menu_previews/runner_turn.png and /dev/null differ diff --git a/hoppys-adventure-arcade/app/audio_service.gd b/hoppys-adventure-arcade/app/audio_service.gd index 42b73af6..563afd31 100644 --- a/hoppys-adventure-arcade/app/audio_service.gd +++ b/hoppys-adventure-arcade/app/audio_service.gd @@ -1,4 +1,7 @@ extends Node +## Longest the exit waits for the mixer to release stopped voices (see _exit_tree). Two mixes take +## about 25 ms with a sound card and about 140 ms headless. +const RELEASE_MSEC := 500 var music := AudioStreamPlayer.new() var sfx := AudioStreamPlayer.new() var tones: Array[AudioStreamWAV] = [] @@ -93,5 +96,27 @@ func shutdown() -> void: music.stream = null sfx.stream = null +## The autoload only leaves the tree when the app exits, after the current scene has left it. +## A voice the mixer still holds when the engine shuts down is reported as leaked ("ObjectDB +## instances were leaked", "resources still in use"), and a stopped voice is let go only by the +## next mix. So, whichever way the app quit, stop every player (the scene's and ours) and let the +## mixer run twice: the first mix to start after the stops has then finished. func _exit_tree() -> void: + _stop_players(get_parent()) shutdown() + var stopped := Time.get_ticks_usec() + var deadline := stopped+RELEASE_MSEC*1000 + var mixes := 0 + var last_mix := stopped + while mixes < 2 and Time.get_ticks_usec() < deadline: + OS.delay_usec(500) + var mixed := Time.get_ticks_usec()-int(AudioServer.get_time_since_last_mix()*1000000.0) + # A new mix starts at least a buffer (several ms) after the last; 1 ms absorbs rounding. + if mixed > last_mix+1000: + mixes += 1 + last_mix = mixed + +static func _stop_players(top: Node) -> void: + for node in top.find_children("*", "", true, false): + if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D: + node.stop() diff --git a/hoppys-adventure-arcade/modes/beatem_up/catalog.gd b/hoppys-adventure-arcade/modes/beatem_up/catalog.gd index 7ab95cd7..2b657b0d 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/catalog.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/catalog.gd @@ -7,15 +7,42 @@ const MUSIC := [ preload("res://Assets/Audio/Beat'em Up/helldrake(extended).ogg"), preload("res://Assets/Audio/Beat'em Up/kim_lightyear_-_angel_eyes_vision_ii.mp3")] const BOSS_MUSIC = preload("res://Assets/Audio/Beat'em Up/Kim Lightyear - The Final.mp3") +## The district bosses' fight track (mini_boss.gd); BOSS_MUSIC stays the Grand Merge Conflict's. +const MINI_BOSS_MUSIC = preload("res://Assets/Audio/Beat'em Up/Boss Fight 2.mp3") ## Hands-free stage flow (seconds, world px, px/s, dB). stage_flow.gd reads its DEFAULTS keys; ## the rest are game.gd's: intro page hold, room-0 telegraph grace, boss-music dip, exit banner, -## and the hold of each victory-epilogue page. +## the hold of each victory-epilogue page, and the district-boss victory hop (length, height). const FLOW := { "run_speed":300.0, "exit_min":.6, "exit_max":1.6, "exit_margin":60.0, "fade_out":.35, "fade_in":.35, "music_floor_db":-40.0, "enter_from_x":-60.0, "enter_to_x":180.0, "enter_max":1.6, "stage_banner":2.8, "banner_slide":.3, "banner_fade":.6, - "exit_banner":2.0, "intro_hold":4.5, "enemy_grace":.8, "boss_dip":.6, "ending_hold":5.0} + "exit_banner":2.0, "intro_hold":4.5, "enemy_grace":.8, "boss_dip":.6, "ending_hold":5.0, + "boss_victory":1.4, "boss_hop":36.0} +## The district boss (mini_boss.gd) that guards each stage's third room, by stage; "" is none. +## The last district keeps the Grand Merge Conflict (boss.gd). +const MINI_BOSS_STAGES := ["stack_overflow", "zero_day", ""] +## District bosses (seconds, world px, px/s). `sheet` is the boss's own atlas in the Code City +## pack, drawn at `scale`. Until that atlas is in the pack's manifest, `fallback` draws an enemy +## sheet at about twice its enemy scale, playing clips[pose] for each of the boss's poses. +## Below `rage_at` of its HP the tells shorten by `rage` and it calls in two `add`s, once. +const MINI_BOSSES := { + "stack_overflow": {"title":"STACK OVERFLOW", "sheet":"stack_overflow", "scale":1.7, "hp":14.0, + "speed":110.0, "slam_tell":1.0, "dash_tell":.85, "leap":.55, "dash_speed":520.0, + "dash_time":.7, "recover":1.1, "rest":.6, "stagger_hits":4, "stagger":.6, + "rage_at":.5, "rage":.8, "add":"cli_gremlin", "shot":"syntax_projectile", "shot_speed":220.0, + "fallback":{"sheet":"linter_enforcer", "scale":3.4, "clips":{ + "idle":"armored_idle", "walk":"armored_walk", "slam_warning":"baton_warning", + "slam":"baton", "dash_warning":"baton_warning", "dash":"armored_walk", + "recover":"armor_break", "hurt":"bare_hurt", "defeat":"defeat"}}}, + "zero_day": {"title":"ZERO-DAY", "sheet":"zero_day", "scale":1.7, "hp":18.0, + "speed":130.0, "slam_tell":.95, "dash_tell":.8, "leap":.5, "dash_speed":600.0, + "dash_time":.7, "recover":1.0, "rest":.5, "stagger_hits":4, "stagger":.6, + "rage_at":.5, "rage":.8, "add":"dependency_bot", "shot":"injection_projectile", "shot_speed":260.0, + "fallback":{"sheet":"force_push_bandit", "scale":3.4, "clips":{ + "idle":"idle", "walk":"walk", "slam_warning":"charge_warning", "slam":"charge", + "dash_warning":"charge_warning", "dash":"charge", "recover":"recover", + "hurt":"hurt", "defeat":"defeat"}}}} const FX_ROOT := "res://Assets/code-city-brawl-assets-only/code_city/" ## Last-life knockout (knockout.gd DEFAULTS keys; seconds since the hit, world px, radians, dB). ## Hoppy recoils facing the hit, then turns with the blow and the death clip does the tipping diff --git a/hoppys-adventure-arcade/modes/beatem_up/celebration.gd b/hoppys-adventure-arcade/modes/beatem_up/celebration.gd new file mode 100644 index 00000000..87d21981 --- /dev/null +++ b/hoppys-adventure-arcade/modes/beatem_up/celebration.gd @@ -0,0 +1,253 @@ +extends RefCounted +## Clock-owned Code City finale. Owner stops combat and ticks this while unpaused, then done +## opens the ending pages. The flow's music player owns the victory loop after the party ends. +## cancel() on stage rebuild or leave also stops that loop, but never a newer stage track. +## Owner: player (body, show_pose), ui_layer, flow (StageFlow). camera and player.held are optional. +## Optional knockout_hud_nodes() returns gameplay chrome, excluding pause, story and results UI. +## Tick after the owner's HUD refreshes. Visibility is restored before done or on cancel. + +const Style = preload("res://scenes/main_menu/menu_style.gd") +const AUDIO := "res://Assets/Audio/Beat'em Up/" +const JINGLE_PATH := AUDIO+"code_city_victory_jingle.wav" +const LOOP_PATH := AUDIO+"code_city_victory_loop.wav" +const LENGTH := 5.2 +const JINGLE_AT := .3 +const LOOP_AT := 3.8 +const COLORS := [Color("79e0c4"),Color("ffd36b"),Color("ff884d"),Color("a0bcff"),Color("fff0cb")] + +var clock := 0.0 +var phase := "" +var root: Control +var banner: Label +var confetti: Control +var jingle: AudioStreamPlayer +var player: Node2D +var _arena: Node +var _flow: Node +var _done := Callable() +var _jingle_started := false +var _loop_started := false +var _loop_stream: AudioStream +var _camera: Camera2D +var _camera_position := Vector2.ZERO +var _camera_zoom := Vector2.ONE +var _body_position := Vector2.ZERO +var _body_rotation := 0.0 +var _held_visible := false +var _hud: Array[Dictionary] = [] + +func active() -> bool: + return phase != "" + +func play(owner: Node, done: Callable) -> void: + cancel() + _arena = owner + _flow = owner.flow + player = owner.player + _done = done + clock = 0.0 + phase = "celebration" + _jingle_started = false + _loop_started = false + if owner.has_method("knockout_hud_nodes"): + for item in owner.knockout_hud_nodes(): + if is_instance_valid(item) and item is CanvasItem: + _hud.append({"node":item,"visible":item.visible}) + _hide_hud() + _body_position = player.body.position + _body_rotation = player.body.rotation + player.body.rotation = 0.0 + player.body.modulate = Color.WHITE + var held = player.get("held") + _held_visible = is_instance_valid(held) and held is CanvasItem and held.visible + if held is CanvasItem: held.hide() + player.show_pose("victory") + player.body.pause() + _camera = owner.get("camera") as Camera2D + if is_instance_valid(_camera): + _camera_position = _camera.position + _camera_zoom = _camera.zoom + _build_ui(owner.ui_layer) + _flow.duck(-18.0) + _apply() + +func tick(dt: float) -> void: + if not active(): return + if not is_instance_valid(_arena) or not is_instance_valid(player) or not is_instance_valid(_flow): + cancel() + return + if _arena.is_inside_tree() and _arena.get_tree().paused: return + clock = minf(clock+maxf(dt,0.0),LENGTH) + if clock >= JINGLE_AT and not _jingle_started: + _jingle_started = true + jingle = AudioStreamPlayer.new() + jingle.name = "VictoryJingle" + jingle.process_mode = Node.PROCESS_MODE_PAUSABLE + jingle.stream = load(JINGLE_PATH) + jingle.volume_db = _flow.base_db + if is_instance_valid(_flow.music): jingle.bus = _flow.music.bus + _arena.add_child(jingle) + jingle.play(minf(clock-JINGLE_AT,jingle.stream.get_length())) + if clock >= LOOP_AT and not _loop_started: + _loop_started = true + _stop_jingle() + var source: AudioStream = load(LOOP_PATH) + # Duplicate before setting loop properties, so the imported shared resource stays immutable. + var loop := source.duplicate() as AudioStreamWAV + loop.loop_mode = AudioStreamWAV.LOOP_FORWARD + loop.loop_begin = 0 + loop.loop_end = roundi(loop.get_length()*loop.mix_rate) + _loop_stream = loop + _flow.play_music(loop,source) + _flow.duck(0.0) + _apply() + if clock >= LENGTH-1e-6: + var done := _done + _done = Callable() + phase = "" + _clear_visuals() + player = null + if done.is_valid(): done.call() + +## Explicit cancellation suppresses done and clears every owned transient. Completion leaves +## the loop playing; this method ends it only if the flow still holds our exact stream instance. +func cancel() -> void: + phase = "" + _done = Callable() + _stop_jingle() + _clear_visuals() + if is_instance_valid(_flow): + _flow.duck(0.0) + if _loop_stream != null and is_instance_valid(_flow.music) and _flow.music.stream == _loop_stream: + _flow.music.stop() + _flow.music.stream = null + _flow.music_track = null + _loop_stream = null + _flow = null + _arena = null + player = null + +func _stop_jingle() -> void: + if is_instance_valid(jingle): + jingle.stop() + jingle.queue_free() + jingle = null + +func _clear_visuals() -> void: + for item in _hud: + if is_instance_valid(item.node): item.node.visible = item.visible + _hud.clear() + if is_instance_valid(root): + root.hide() + root.queue_free() + root = null + banner = null + confetti = null + if is_instance_valid(player): + player.body.position = _body_position + player.body.rotation = _body_rotation + var held = player.get("held") + if held is CanvasItem: held.visible = _held_visible + if is_instance_valid(_camera): + _camera.position = _camera_position + _camera.zoom = _camera_zoom + _camera = null + +func _apply() -> void: + _hide_hud() + var pop := smoothstep(.15,.65,clock) + var fade := smoothstep(0.0,.35,LENGTH-clock) + root.modulate.a = fade + banner.modulate.a = pop + banner.pivot_offset = banner.size*.5 + banner.scale = Vector2.ONE*lerpf(1.16,1.0,pop) + # Advance on our clock; pause must freeze the pose along with the confetti and camera. + var body: AnimatedSprite2D = player.body + var count := body.sprite_frames.get_frame_count(body.animation) + if count > 0: body.frame = int(clock*5.0)%count + body.position = Vector2(0,-maxf(0,sin(clock*TAU/1.1))*12.0) + confetti.queue_redraw() + if is_instance_valid(_camera): + var push := smoothstep(0.0,2.0,clock)*fade + _camera.zoom = _camera_zoom*lerpf(1.0,1.3,push) + var focus := player.global_position+Vector2(0,-140) + if _camera.get_parent() is Node2D: + focus = (_camera.get_parent() as Node2D).to_local(focus) + _camera.position = _camera_position.lerp(focus,push*.8) + +func _hide_hud() -> void: + # The owner may refresh minimap/toast visibility each physics tick, even outside combat. + for item in _hud: + if is_instance_valid(item.node): item.node.hide() + +func _build_ui(layer: CanvasLayer) -> void: + root = Control.new() + root.name = "Celebration" + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.theme = Style.theme() + layer.add_child(root) + # Stay beneath pause/menu controls, with the stage and its shader behind the celebration. + var screen_fx = _arena.get("screen_fx") + if screen_fx is Node and screen_fx.get_parent() == layer: + layer.move_child(root,mini(screen_fx.get_index()+1,layer.get_child_count()-1)) + confetti = Control.new() + confetti.name = "Confetti" + confetti.mouse_filter = Control.MOUSE_FILTER_IGNORE + confetti.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + confetti.draw.connect(_draw_confetti) + root.add_child(confetti) + for bottom in [false,true]: + var bar := ColorRect.new() + bar.color = Color("091521") + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE if bottom else Control.PRESET_TOP_WIDE) + if bottom: bar.offset_top = -45 + else: bar.offset_bottom = 45 + root.add_child(bar) + var ribbon := ColorRect.new() + ribbon.color = Color(.025,.07,.1,.92) + ribbon.mouse_filter = Control.MOUSE_FILTER_IGNORE + ribbon.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + ribbon.offset_top = 151 + ribbon.offset_bottom = 308 + root.add_child(ribbon) + for top in [151,305]: + var line := ColorRect.new() + line.color = COLORS[0] + line.mouse_filter = Control.MOUSE_FILTER_IGNORE + line.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + line.offset_top = top + line.offset_bottom = top+3 + root.add_child(line) + _label("CODE CITY IS BACK ONLINE",18,COLORS[0],168,194) + banner = _label("MERGE SUCCESSFUL",58,COLORS[1],194,269) + _label("THREE DISTRICTS. ONE CLEAN HISTORY.",18,Style.CREAM,272,297) + +func _label(text: String, size: int, color: Color, top: float, bottom: float) -> Label: + var label := Style.label(text,size,color) + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + label.offset_top = top + label.offset_bottom = bottom + root.add_child(label) + return label + +## Deterministic falling ribbons. Their clock is the party clock, never an independent timer +## or particle system, so a pause or a headless step has exactly the same visual state. +func _draw_confetti() -> void: + var view := confetti.size + for i in 112: + var age := clock-float(i%14)*.025 + if age < 0: continue + var lane := float((i*47)%113)/112.0 + var speed := 115.0+float((i*31)%120) + var x := lane*view.x+sin(age*2.0+i)*34.0 + var seed_y := float((i*97)%1009)/1009.0*(view.y+160.0) + var y := fposmod(seed_y+age*speed,view.y+160.0)-80.0 + var width := 3.0+absf(cos(age*5.0+i))*7.0 + var height := 5.0+float(i%4) + confetti.draw_set_transform(Vector2(x,y),age*(1.0+float(i%3))) + confetti.draw_rect(Rect2(-width*.5,-height*.5,width,height),COLORS[i%COLORS.size()]) + confetti.draw_set_transform(Vector2.ZERO) diff --git a/hoppys-adventure-arcade/modes/beatem_up/celebration.gd.uid b/hoppys-adventure-arcade/modes/beatem_up/celebration.gd.uid new file mode 100644 index 00000000..28e1a6b2 --- /dev/null +++ b/hoppys-adventure-arcade/modes/beatem_up/celebration.gd.uid @@ -0,0 +1 @@ +uid://bawv8pbgxhwl2 diff --git a/hoppys-adventure-arcade/modes/beatem_up/game.gd b/hoppys-adventure-arcade/modes/beatem_up/game.gd index 0bfd2274..9d9b7853 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/game.gd @@ -4,15 +4,17 @@ const Catalog = preload("res://modes/beatem_up/catalog.gd") const Player = preload("res://modes/beatem_up/player.gd") const Enemy = preload("res://modes/beatem_up/enemy.gd") const Boss = preload("res://modes/beatem_up/boss.gd") +const MiniBoss = preload("res://modes/beatem_up/mini_boss.gd") const StageFlow = preload("res://modes/beatem_up/stage_flow.gd") const Knockout = preload("res://modes/beatem_up/knockout.gd") +const Celebration = preload("res://modes/beatem_up/celebration.gd") const StoryScene = preload("res://modes/beatem_up/story_scene.gd") const VictoryScreen = preload("res://modes/beatem_up/victory_screen.gd") const Minimap = preload("res://modes/beatem_up/minimap.gd") const Style = preload("res://scenes/main_menu/menu_style.gd") -const PAUSABLE := ["combat","intro","ending","stage_enter","stage_exit","knockout"] +const PAUSABLE := ["combat","intro","ending","stage_enter","stage_exit","knockout","boss_victory","celebration"] ## The minimap is up while Hoppy is in a room, and hidden under the story scenes and end screens. -const MINIMAP_STATES := ["stage_enter","combat","stage_exit","finisher","knockout"] +const MINIMAP_STATES := ["stage_enter","combat","stage_exit","boss_victory","finisher","knockout"] ## Results and game-over menus arm this long after appearing (see _input). const OVERLAY_ARM := .5 const ARMED_OVERLAYS := ["won","game_over"] @@ -43,6 +45,12 @@ var overlay_clock := 0.0 var flow: StageFlow ## The last-life knockout between the fatal hit and game_over(). var ko := Knockout.new(Catalog.KNOCKOUT) +## The celebration between the Force Merge and the ending pages. +var party := Celebration.new() +## This stage's district boss has been called in (it guards room 3 once the wave is down). +var mini_boss_called := false +## Seconds into the district-boss victory hop. +var victory_clock := 0.0 var gate_open := false var gate: AnimatedSprite2D var world: Node2D @@ -73,6 +81,9 @@ var toast_left := 0.0 var held_toast := "" var held_toast_seconds := 2.0 var ui_layer: CanvasLayer +## The HUD bar's backing strip and the control hints along the bottom (knockout_hud_nodes). +var hud_strip: ColorRect +var controls_hint: Label var prompt: Label var overlay: ColorRect var panel: VBoxContainer @@ -124,12 +135,15 @@ func clamp_ground(pos: Vector2) -> Vector2: func start_stage(index: int, cinematic := false) -> void: ko.cancel() + party.cancel() playing = false get_tree().paused = false for child in world.get_children(): child.queue_free() enemies.clear(); props.clear(); pickups.clear(); bullets.clear(); warnings.clear(); allies.clear() boss = null merge_button = null + mini_boss_called = false + victory_clock = 0 stage_index = index room = 0 gate_open = false @@ -229,6 +243,26 @@ func spawn_enemy(kind: String, pos: Vector2, split := true) -> Node2D: enemies.append(enemy) return enemy +## Room 3 of districts 1 and 2: the cleared wave calls in the district boss instead of opening +## the gate. It drops in on the right and shares the `boss` slot (HUD bar, minimap, no hazards). +func call_mini_boss() -> void: + mini_boss_called = true + var key: String = Catalog.MINI_BOSS_STAGES[stage_index] + boss = MiniBoss.new() + boss.arena = self + boss.key = key + boss.position = Vector2(room_start()+1000,560) + actors.add_child(boss) + clear_threats() + # Its own fight track (the Grand Merge Conflict keeps BOSS_MUSIC); the next district's + # start_stage brings that district's track back. + flow.swap_music(Catalog.looped(Catalog.MINI_BOSS_MUSIC),Catalog.FLOW.boss_dip,Catalog.MINI_BOSS_MUSIC) + toast("DISTRICT BOSS · %s" % Catalog.MINI_BOSSES[key].title,3) + _update_hud() + +func has_mini_boss() -> bool: + return is_instance_valid(boss) and boss is MiniBoss + func living_enemies() -> int: var count := 0 for e in enemies: @@ -363,6 +397,13 @@ func clear_threats() -> void: bullets.clear() warnings.clear() +## Removes the warnings `owner_node` planted, such as a staggered boss's dropped move. +func drop_warnings(owner_node: Node) -> void: + for i in range(warnings.size()-1,-1,-1): + if warnings[i].owner == owner_node: + warnings[i].node.queue_free() + warnings.remove_at(i) + func fx(sheet: String, pose: String, pos: Vector2, magnify: float, lifetime: float, mirror := false) -> void: var sprite := Art.sprite(sheet,pose,magnify,false) sprite.position = pos @@ -383,7 +424,11 @@ func sound(key: String, pos: Vector2) -> void: func _physics_process(dt: float) -> void: if not is_instance_valid(player) or get_tree().paused or state=="leaving": return overlay_clock += dt + # The celebration ticks after the minimap and toast refresh below, so the chrome it hides stays + # hidden; never on the frame the finisher starts it. + var celebrating := state=="celebration" if state=="finisher": _tick_finisher(dt) + elif state=="boss_victory": _tick_boss_victory(dt) elif state in ["intro","ending"]: _tick_intro(dt) elif state=="won" and victory.advance(dt): sound("win",player.position) _refresh_minimap(dt) @@ -393,6 +438,7 @@ func _physics_process(dt: float) -> void: flow.tick(dt,player) if held_toast != "" and state=="combat" and not flow.banner_active(): toast(held_toast,held_toast_seconds) if state=="knockout": _tick_knockout(dt) + if celebrating and state=="celebration": party.tick(dt) if not playing: return elapsed += dt shake = maxf(0,shake-dt*28) @@ -406,15 +452,14 @@ func _physics_process(dt: float) -> void: if not playing: return _tick_allies(dt) if not gate_open and living_enemies()==0 and not is_instance_valid(boss): - gate_open = true - gate.hide() - clear_threats() - toast("BLOCKERS CLEARED — GO RIGHT →",3) - if gate_open and player.position.x > room_start()+1205: - if room<2: enter_room(room+1) - elif stage_index+1 < Catalog.TITLES.size(): - exit_stage() - return + if room==2 and Catalog.MINI_BOSS_STAGES[stage_index]!="" and not mini_boss_called: call_mini_boss() + else: + gate_open = true + gate.hide() + clear_threats() + toast("BLOCKERS CLEARED — GO RIGHT →",3) + # Room 3 has no walk-out: its boss's defeat runs Hoppy off (mini_boss_defeated, the finisher). + if gate_open and room<2 and player.position.x > room_start()+1205: enter_room(room+1) if not gate_open: hazard_clock -= dt if hazard_clock <= 0: @@ -562,7 +607,10 @@ func game_over() -> void: func boss_defeated() -> void: # A knocked-out Hoppy can't win: the knockout ends in game over. - if state in ["finisher","ending","won","knockout"]: return + if state in ["finisher","celebration","ending","won","knockout","boss_victory"]: return + if has_mini_boss(): + mini_boss_defeated() + return # No stage sequence may outlive the boss; a boss-music swap still finishes (it isn't one). if flow.active(): flow.cancel() playing = false @@ -581,6 +629,51 @@ func boss_defeated() -> void: toast("FORCE MERGE — SHIP THE FIX",5) sound("phase",boss.position) +## A district boss is down: play stops, its adds and threats clear, and Hoppy hops in his victory +## pose under DISTRICT BOSS DOWN for FLOW.boss_victory seconds before running off (exit_stage). +func mini_boss_defeated() -> void: + # No stage sequence may outlive the boss; a boss-music swap still finishes (it isn't one). + if flow.active(): flow.cancel() + playing = false + state = "boss_victory" + victory_clock = 0 + clear_threats() + for enemy in enemies: + if is_instance_valid(enemy): enemy.queue_free() + enemies.clear() + score += 500 + gate_open = true + gate.hide() + player.swing_left = 0 + player.queued = false + player.height = 0 + player.rise = 0 + player.velocity = Vector2.ZERO + player.knock = Vector2.ZERO + player.body.position = Vector2.ZERO + player.body.modulate = Color.WHITE + player.held.hide() + player.show_pose("victory") + # Keep the celebration in front of the fading boss. + player.z_index = 2 + shake = 0 + camera.offset = Vector2.ZERO + hide_toast() + prompt.text = "" + flow.banner("DISTRICT BOSS DOWN","%s defeated · +500" % boss.title,float(Catalog.FLOW.boss_victory)) + sound("win",player.position) + _update_hud() + +func _tick_boss_victory(dt: float) -> void: + var length := float(Catalog.FLOW.boss_victory) + victory_clock = minf(length,victory_clock+dt) + # One short hop on the spot; the ground position and combat stay frozen. + player.body.position.y = -float(Catalog.FLOW.boss_hop)*sin(PI*clampf(victory_clock/(length*.5),0,1)) + if is_instance_valid(boss) and boss.has_method("defeat_tick"): boss.defeat_tick(dt) + if victory_clock >= length: + player.body.position.y = 0 + exit_stage() + func _tick_finisher(dt: float) -> void: finish_clock += dt player.position = player.position.move_toward(Vector2(room_start()+650,600),230*dt) @@ -600,13 +693,24 @@ func _tick_finisher(dt: float) -> void: score += 1000 _update_hud() screen_fx.material.set_shader_parameter("phase",0.0) - if finish_clock >= 4.0: start_ending() + if finish_clock >= 4.0: celebrate() + +## The celebration (confetti, the victory cheer, the jingle into the victory loop), then the +## ending pages. +func celebrate() -> void: + if state!="finisher": return + state = "celebration" + hide_toast() + prompt.text = "" + party.play(self,start_ending) ## The victory epilogue: the ENDING story scenes play like the opening (Next / Skip, pausable), ## then the results screen counts the score up. func start_ending() -> void: - if state!="finisher": return + if state not in ["finisher","celebration"]: return state = "ending" + # The celebration restores the chrome it hid just before calling this; the minimap goes at once. + _refresh_minimap(0.0) story_pages = "ENDING" intro_page = 0 intro_clock = 0 @@ -658,6 +762,7 @@ func _build_ui() -> void: ui.theme = Style.theme() layer.add_child(ui) var top := ColorRect.new() + hud_strip = top top.color = Color(.025,.045,.075,.93) top.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) top.offset_bottom = 105 @@ -679,6 +784,12 @@ func _build_ui() -> void: boss_bar.position = Vector2(-430,47) boss_bar.size = Vector2(405,15) boss_bar.show_percentage = false + var bar_back := StyleBoxFlat.new() + bar_back.bg_color = Color("1d2733") + var bar_fill := StyleBoxFlat.new() + bar_fill.bg_color = Color("ffc66c") + boss_bar.add_theme_stylebox_override("background",bar_back) + boss_bar.add_theme_stylebox_override("fill",bar_fill) ui.add_child(boss_bar) toast_label = Style.label("",22,Color("ffe6a6")) toast_label.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) @@ -696,6 +807,7 @@ func _build_ui() -> void: prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ui.add_child(prompt) var controls := Style.label("",13) + controls_hint = controls Prompts.track(controls, func() -> String: return "%s · Move %s · Jump %s · Combo %s · Pickup %s · Pause" % [ Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")), @@ -737,6 +849,13 @@ func _build_ui() -> void: panel.add_theme_constant_override("separation",18) center.add_child(panel) +## The gameplay chrome the knockout cinematic hides and then restores (knockout.gd): the HUD +## strip and its labels, the boss bar, toast, prompt, control hints and minimap. Menus, story +## scenes and the screen shader are not chrome. +func knockout_hud_nodes() -> Array[CanvasItem]: + var nodes: Array[CanvasItem] = [hud_strip,hud,objective,boss_text,boss_bar,toast_label,prompt,controls_hint,minimap] + return nodes + func _update_hud() -> void: var health := "" for i in 3: health += "♥ " if i void: for effect in player.effects: statuses += " "+effect.replace("_"," ").to_upper()+" %ds" % ceili(player.effects[effect]) if player.shield: statuses += " APPROVAL SHIELD" objective.text = "%s · %d/3\n%s%s" % [Catalog.TITLES[stage_index],room+1,weapon,statuses] - boss_bar.visible = is_instance_valid(boss) + # A fallen district boss leaves the HUD; the Grand Merge Conflict's bar stays for the finisher. + boss_bar.visible = is_instance_valid(boss) and not (has_mini_boss() and boss.dead) boss_text.visible = boss_bar.visible if is_instance_valid(boss): boss_bar.max_value = boss.max_hp boss_bar.value = boss.hp - boss_text.text = "GRAND MERGE CONFLICT · PHASE %d" % boss.phase + boss_text.text = ("DISTRICT BOSS · %s" % boss.title) if has_mini_boss() else ("GRAND MERGE CONFLICT · PHASE %d" % boss.phase) ## Feeds the minimap this frame's room: Hoppy, the living enemies and the boss, and the gate. func _refresh_minimap(dt: float) -> void: @@ -763,7 +883,7 @@ func _refresh_minimap(dt: float) -> void: if is_instance_valid(enemy) and not enemy.dead: foes.append(enemy.position) var heads := PackedVector2Array() if is_instance_valid(boss) and not boss.dead: heads.append(boss.position) - minimap.tick(dt,{"stage":stage_index, "room":room, "cleared":gate_open or state=="finisher", + minimap.tick(dt,{"stage":stage_index, "room":room, "cleared":gate_open or state in ["finisher","celebration"], "gate_open":gate_open, "room_left":room_start(), "player":player.position, "enemies":foes, "bosses":heads}) @@ -874,7 +994,7 @@ func resume() -> void: "intro", "ending": state = back show_intro() - "stage_enter", "stage_exit": + "stage_enter", "stage_exit", "boss_victory", "celebration": state = back overlay.hide() "knockout": @@ -885,6 +1005,7 @@ func resume() -> void: func leave() -> void: if state=="leaving": return ko.cancel() + party.cancel() flow.cancel() # The scene keeps running through the router's fade: nothing may start play or music again. state = "leaving" diff --git a/hoppys-adventure-arcade/modes/beatem_up/knockout.gd b/hoppys-adventure-arcade/modes/beatem_up/knockout.gd index 12d0a2fb..4d134a23 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/knockout.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/knockout.gd @@ -1,13 +1,14 @@ extends RefCounted ## Last-life knockout shared by the beat'em up modes: a hit-stop under a white flash, Hoppy thrown ## back on an arc away from the hit, a landing with dust and a bounce, then dizzy stars and a -## "K.O." callout under ducked music, and finally the owner's game over. Clock-driven from the +## "K.O." callout under ducked music, a failure card, and finally the owner's game over. Clock-driven from the ## owner's _physics_process (no Tweens), so tests step it with tick(); a paused tree freezes it ## because the owner stops ticking. Its effects run on the same clock and are freed when it ends. ## ## Owner contract (duck-typed, as boss.gd uses its arena): `player` (body, facing, show_pose), ## `actors`, `ui_layer`, `screen_fx`, `flow`, `shake`, sound(key, pos), clamp_ground(pos) and -## room_start(). +## room_start(). Optional camera and knockout_hud_nodes() -> Array[CanvasItem]: return only +## gameplay chrome, never pause/retry menus. Visibility is captured once and restored on exit. ## The owner stops play, calls play() with its game over, then tick(dt) on every unpaused physics ## frame while its state is "knockout", and keeps its camera shake decaying. cancel() on any path ## that rebuilds or leaves the stage. @@ -15,6 +16,7 @@ extends RefCounted const Style = preload("res://scenes/main_menu/menu_style.gd") ## Both modes' rooms are one 1280 px screen wide (room_start() = room*1280). const ROOM_WIDTH := 1280.0 +const ROOM_HEIGHT := 720.0 ## Every key a mode's KNOCKOUT may override. Seconds since the hit, world px, radians and dB. const DEFAULTS := { # Phase ends: hit-stop, launch (touchdown), land (settled), out cold (game over). @@ -41,10 +43,15 @@ const DEFAULTS := { "head":Vector2(30,-40), "stars":3, "star_orbit":Vector2(24,7), "star_size":7.0, "star_spin":1.4, "star_color":Color("ffe36e"), "callout":"K.O.", "callout_size":96, "callout_color":Color("ffd36b"), "callout_top":215.0, - "callout_pop":.18} + "callout_pop":.18, + # The pose clock slows only between these pose times; all previous keys keep their units. + "slowmo_from":.12, "slowmo_to":.75, "slowmo_scale":.55, + "zoom":2.0, "zoom_in":.45, "zoom_out":.25, "bars":90.0, + "card_hold":.8, "card_title":"REVIEW FAILED", "card_line":"Every great merge takes another try."} var tuning: Dictionary = DEFAULTS.duplicate() -var phase := "" # "", "hit_stop", "launch", "land", "out_cold" +var phase := "" # "", "hit_stop", "launch", "land", "out_cold", "card" var clock := 0.0 # seconds since the hit +var elapsed := 0.0 # unwarped seconds; pose clock stops at length for the card var player: Node2D var direction := 1.0 # world x direction of the throw, away from the hit var start := Vector2.ZERO @@ -53,11 +60,19 @@ var root: Control var flash: ColorRect var callout: Label var stars: Node2D +var top_bar: ColorRect +var bottom_bar: ColorRect +var card: Control +var card_title: Label +var _camera: Camera2D +var _camera_position := Vector2.ZERO +var _camera_zoom := Vector2.ONE var _arena: Node var _done := Callable() var _pose := "" var _landed := false var _fx: Array[Dictionary] = [] +var _hud: Array[Dictionary] = [] func _init(overrides := {}) -> void: tuning.merge(overrides,true) @@ -72,6 +87,7 @@ func play(owner: Node, source: Vector2, done: Callable) -> void: player = owner.player _done = done clock = 0 + elapsed = 0 _landed = false _pose = "" var away := signf(player.position.x-source.x) @@ -79,6 +95,15 @@ func play(owner: Node, source: Vector2, done: Callable) -> void: if direction == 0: direction = -1.0 player.facing = -direction # He takes the hit facing it and flies back from it. start = player.position + if owner.has_method("knockout_hud_nodes"): + for item in owner.knockout_hud_nodes(): + if is_instance_valid(item) and item is CanvasItem: + _hud.append({"node":item,"visible":item.visible}) + _hide_hud() + _camera = owner.get("camera") as Camera2D + if is_instance_valid(_camera): + _camera_position = _camera.position + _camera_zoom = _camera.zoom landing = owner.clamp_ground(start+Vector2(direction*float(tuning.distance),0)) var left: float = owner.room_start()+float(tuning.edge) landing.x = clampf(landing.x,left,maxf(left,owner.room_start()+ROOM_WIDTH-float(tuning.edge))) @@ -110,6 +135,13 @@ func cancel() -> void: stars.queue_free() stars = null if is_instance_valid(root): root.hide() + for item in _hud: + if is_instance_valid(item.node): item.node.visible = item.visible + _hud.clear() + if is_instance_valid(_camera): + _camera.position = _camera_position + _camera.zoom = _camera_zoom + _camera = null if is_instance_valid(player): player.set("stowed",false) player.body.rotation = 0 @@ -118,27 +150,51 @@ func cancel() -> void: func tick(dt: float) -> void: if phase == "": return - if not is_instance_valid(player): + if not is_instance_valid(player) or not is_instance_valid(_arena): cancel() return - clock += dt - # Summed 1/60 s ticks fall a hair short of 2.2 s; without the slack the end slips a tick. - if clock >= float(tuning.length)-1e-6: + if _arena.is_inside_tree() and _arena.get_tree().paused: return + elapsed += maxf(dt,0.0) + # Analytical mapping crosses both slow-motion boundaries correctly even on a coarse tick. + clock = minf(_pose_time(elapsed),float(tuning.length)) + if elapsed >= duration()-1e-6: var done := _done cancel() if done.is_valid(): done.call() return _apply() +func _slow_bounds() -> Vector3: + var first := clampf(float(tuning.slowmo_from),0,float(tuning.length)) + var last := clampf(float(tuning.slowmo_to),first,float(tuning.length)) + return Vector3(first,last,clampf(float(tuning.slowmo_scale),.1,1.0)) + +func _pose_time(seconds: float) -> float: + var slow := _slow_bounds() + var end := slow.x+(slow.y-slow.x)/slow.z + if seconds <= slow.x: return seconds + if seconds < end: return slow.x+(seconds-slow.x)*slow.z + return slow.y+seconds-end + +func _fall_duration() -> float: + var slow := _slow_bounds() + return float(tuning.length)+(slow.y-slow.x)*(1.0/slow.z-1.0) + +## Both catalogs finish in 3.516 seconds. A hard ceiling also bounds custom tuning. +func duration() -> float: + return minf(4.0,_fall_duration()+maxf(float(tuning.card_hold),0.0)) + ## Poses, places and decorates Hoppy for the current clock. Touchdown's dust, sound and shake ## fire once, on the first tick at or past launch_end. func _apply() -> void: + _hide_hud() var hit_stop := float(tuning.hit_stop) var launch_end := float(tuning.launch_end) if clock < hit_stop: phase = "hit_stop" elif clock < launch_end: phase = "launch" elif clock < float(tuning.land_end): phase = "land" - else: phase = "out_cold" + elif elapsed < _fall_duration(): phase = "out_cold" + else: phase = "card" var airborne := clock < launch_end var u := clampf((clock-hit_stop)/maxf(launch_end-hit_stop,.001),0,1) var height := 0.0 @@ -164,19 +220,45 @@ func _apply() -> void: if held is Node2D: held.position = body.position _tick_fx() _tick_ui() + _tick_camera() if is_instance_valid(stars): var head: Vector2 = tuning.head stars.position = Vector2(head.x*float(player.facing),head.y-height) - stars.visible = phase == "out_cold" + stars.visible = phase in ["out_cold","card"] stars.queue_redraw() var duck := 0.0 var land_end := float(tuning.land_end) if clock >= land_end: var down := clampf((clock-land_end)/maxf(float(tuning.duck_in),.001),0,1) - var up := clampf((float(tuning.length)-clock)/maxf(float(tuning.duck_out),.001),0,1) + var up := clampf((duration()-elapsed)/maxf(float(tuning.duck_out),.001),0,1) duck = float(tuning.duck_db)*minf(down,up) _arena.flow.duck(duck) +func _hide_hud() -> void: + # The owner refreshes minimap/toast visibility on each physics tick, including during KO. + for item in _hud: + if is_instance_valid(item.node): item.node.hide() + +func _tick_camera() -> void: + if not is_instance_valid(_camera): return + var inward := smoothstep(0.0,maxf(float(tuning.zoom_in),.001),elapsed) + var outward := smoothstep(0.0,maxf(float(tuning.zoom_out),.001),duration()-elapsed) + var strength := inward*outward + _camera.zoom = _camera_zoom*lerpf(1.0,float(tuning.zoom),strength) + # Leave offset to the owner's shake, including Code City's shake after ko.tick(). + var focus := player.global_position+Vector2(0,-80) + if _camera.get_parent() is Node2D: + focus = (_camera.get_parent() as Node2D).to_local(focus) + _camera.position = _camera_position.lerp(focus,strength) + # A throw near a door must not pan past the room art and expose the empty canvas. + var half_view := _camera.get_viewport_rect().size.x/maxf(_camera.zoom.x,.001)*.5 + var room_left: float = _arena.room_start() + if half_view < ROOM_WIDTH*.5: + _camera.position.x = clampf(_camera.position.x,room_left+half_view,room_left+ROOM_WIDTH-half_view) + var half_height := _camera.get_viewport_rect().size.y/maxf(_camera.zoom.y,.001)*.5 + if half_height < ROOM_HEIGHT*.5: + _camera.position.y = clampf(_camera.position.y,half_height,ROOM_HEIGHT-half_height) + func _touchdown() -> void: _landed = true _spawn(tuning.dust_fx,landing) @@ -256,26 +338,72 @@ func _build_ui(ui_layer: CanvasLayer, screen_fx: Node) -> void: callout.add_theme_constant_override("shadow_offset_y",5) callout.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(callout) + for bottom in [false,true]: + var bar := ColorRect.new() + bar.color = Color.BLACK + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE if bottom else Control.PRESET_TOP_WIDE) + root.add_child(bar) + if bottom: bottom_bar = bar + else: top_bar = bar + card = Control.new() + card.name = "FailureCard" + card.mouse_filter = Control.MOUSE_FILTER_IGNORE + card.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + card.offset_top = 158 + card.offset_bottom = 330 + root.add_child(card) + var panel := ColorRect.new() + panel.color = Color(.025,.035,.06,.94) + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + card.add_child(panel) + for bottom in [false,true]: + var line := ColorRect.new() + line.color = tuning.callout_color + line.mouse_filter = Control.MOUSE_FILTER_IGNORE + line.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE if bottom else Control.PRESET_TOP_WIDE) + if bottom: line.offset_top = -3 + else: line.offset_bottom = 3 + card.add_child(line) + card_title = _card_label(str(tuning.card_title),52,tuning.callout_color,24,90) + _card_label(str(tuning.card_line),22,Style.CREAM,105,145) # Over the world and its screen effect, under the owner's HUD and menus. if is_instance_valid(screen_fx) and screen_fx.get_parent() == ui_layer: ui_layer.move_child(root,mini(screen_fx.get_index()+1,ui_layer.get_child_count()-1)) root.show() +func _card_label(text: String, size: int, color: Color, top: float, bottom: float) -> Label: + var label := Style.label(text,size,color) + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + label.offset_top = top + label.offset_bottom = bottom + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + card.add_child(label) + return label + func _tick_ui() -> void: flash.color.a = float(tuning.flash)*clampf(1.0-clock/maxf(float(tuning.flash_fade),.001),0,1) var shown := clock-float(tuning.land_end) - callout.visible = shown >= 0 + callout.visible = shown >= 0 and phase != "card" if callout.visible: var pop := clampf(shown/maxf(float(tuning.callout_pop),.001),0,1) callout.modulate.a = pop callout.pivot_offset = callout.size*.5 callout.scale = Vector2.ONE*lerpf(1.8,1.0,1.0-pow(1.0-pop,3)) + # Fully letterboxed by the first airborne frame, held through the failure card. + var bars_in := smoothstep(0.0,maxf(float(tuning.hit_stop),.001),elapsed) + top_bar.offset_bottom = float(tuning.bars)*bars_in + bottom_bar.offset_top = -float(tuning.bars)*bars_in + card.visible = phase == "card" + card.modulate.a = clampf((elapsed-_fall_duration())/.12,0,1) ## Stars circling the lying head, larger and brighter on the near side of the orbit. func _draw_stars(canvas: Node2D) -> void: var count := int(tuning.stars) var orbit: Vector2 = tuning.star_orbit - var seen := clock-float(tuning.land_end) + var seen := clock-float(tuning.land_end)+maxf(0.0,elapsed-_fall_duration()) var fade := clampf(seen/.15,0,1) for i in count: var a := TAU*(float(tuning.star_spin)*seen+float(i)/count) diff --git a/hoppys-adventure-arcade/modes/beatem_up/mini_boss.gd b/hoppys-adventure-arcade/modes/beatem_up/mini_boss.gd new file mode 100644 index 00000000..3b684253 --- /dev/null +++ b/hoppys-adventure-arcade/modes/beatem_up/mini_boss.gd @@ -0,0 +1,298 @@ +extends Node2D +## A district boss guarding the third room of districts 1 and 2. It takes game.gd's `boss` slot, +## so it answers the same duck-typed interface as boss.gd: hp, max_hp, phase, dead, body, +## melee_reaches() and take_hit() for the swing, the HUD bar, the minimap and the hazard pause. +## Its defeat goes through arena.boss_defeated(), which hands it to mini_boss_defeated(). +## +## It alternates two telegraphed moves: a slam (a leap onto a warned spot that sends a shot +## along the lane each way) and a dash along Hoppy's lane past warning markers. Every hit +## lands; every `stagger_hits` hits it reels for `stagger` seconds. Below `rage_at` of its HP it +## tells faster and calls in two adds, once. Clock-driven: tick() runs while the arena plays, +## defeat_tick() while the arena celebrates. +const Art = preload("res://modes/beatem_up/art.gd") +const Catalog = preload("res://modes/beatem_up/catalog.gd") +## Its body is about twice an enemy's, so a swing reaches it a little further out. +const REACH_PADDING := 30.0 +const DEPTH_PADDING := 12.0 +const DROP_HEIGHT := 420.0 +const DROP_TIME := .55 +const INTRO_GRACE := .8 +const SLAM_RADIUS := Vector2(105,44) +const LEAP_HEIGHT := 110.0 +const DASH_REACH := 70.0 +const DASH_DEPTH := 40.0 +const DASH_FORCE := 300.0 +## How fast it slides onto the warned lane while winding up a dash (px/s). +const LINE_UP := 260.0 +const DEFEAT_HOLD := .4 +const DEFEAT_FADE := .9 +var arena: Node2D +var key := "stack_overflow" +var data: Dictionary +var title := "" +var body: AnimatedSprite2D +## The pose → clip map of the fallback sheet; empty while the boss draws its own atlas. +var clips: Dictionary = {} +var magnify := 1.0 +var hp := 14.0 +var max_hp := 14.0 +## One phase: game.gd feeds boss.phase to the conflict shader, which stays clear below 2. +var phase := 1 +var dead := false +var enraged := false +var adds_called := false +var state := "intro" # intro, chase, tell, active, recover, stagger +var move := "" # "slam" or "dash" while telegraphing or acting +var timer := 0.0 +var cycle := 0 +var hits := 0 +var aim := Vector2.ZERO +var launch := Vector2.ZERO +var dash_direction := 1.0 +var leaping := false +var landed := false +var knock := Vector2.ZERO +var flash := 0.0 +var defeat_clock := 0.0 + +func _ready() -> void: + data = Catalog.MINI_BOSSES[key] + title = data.title + max_hp = float(data.hp) + hp = max_hp + add_child(Art.shadow(55)) + var sheet: String = data.sheet + magnify = float(data.scale) + if not sheet_ready(sheet): + var fallback: Dictionary = data.fallback + sheet = fallback.sheet + magnify = float(fallback.scale) + clips = fallback.clips + body = Art.sprite(sheet,clip("idle"),magnify) + add_child(body) + # It drops in from above; the arena's slam feedback marks the landing. + state = "intro" + timer = DROP_TIME+INTRO_GRACE + body.position.y = -DROP_HEIGHT + +## True once S2's atlas for `sheet` is in the pack: its resource and its manifest entry (cell +## and pivot), which Art.sprite() needs to stand it on its feet. +static func sheet_ready(sheet: String) -> bool: + if not ResourceLoader.exists(Art.BASE+sheet+".tres"): return false + if Art.manifest.is_empty(): Art.manifest = JSON.parse_string(FileAccess.get_file_as_string(Art.BASE+"manifest.json")) + return Art.manifest.get("atlases",{}).has(sheet) + +## The sheet's clip for one of the boss's poses (the fallback maps them onto an enemy's). +func clip(pose_name: String) -> String: + return clips.get(pose_name,pose_name) + +func pose(pose_name: String) -> void: + var name := clip(pose_name) + if not body.sprite_frames.has_animation(name): name = clip("idle") + if body.animation != name: body.play(name) + +func _physics_process(dt: float) -> void: + if arena.playing: tick(dt) + +func tick(dt: float) -> void: + if dead: return + timer -= dt + flash = maxf(0,flash-dt) + body.modulate = Color(1.6,.75,.75) if flash > 0 else Color.WHITE + match state: + "intro": _tick_intro() + "chase": _tick_chase(dt) + "tell": _tick_tell(dt) + "active": _tick_active(dt) + "recover": + if timer <= 0: _rest() + "stagger": + position = arena_point(position+knock*dt) + knock = knock.move_toward(Vector2.ZERO,900*dt) + if timer <= 0: _rest() + queue_redraw() + +func _tick_intro() -> void: + var t := clampf(1.0-(timer-INTRO_GRACE)/DROP_TIME,0,1) + body.position.y = -DROP_HEIGHT*(1.0-t*t) + if t >= 1 and not landed: + landed = true + arena.shake = maxf(arena.shake,9) + arena.sound("kick",position) + arena.fx("combat_fx","slam_impact",position,1.8,.3) + if timer <= 0: _rest(0.0) + +func _tick_chase(dt: float) -> void: + var player: Node2D = arena.player + var side := -1.0 if player.position.x < position.x else 1.0 + _face(player.position.x) + var goal := arena_point(player.position-Vector2(side*150,0)) + var before := position + position = position.move_toward(goal,float(data.speed)*dt) + pose("walk" if position.distance_to(before) > .1 else "idle") + if timer <= 0: telegraph() + +func _tick_tell(dt: float) -> void: + if move == "dash": + # Line up on the warned lane while winding up. + position.y = move_toward(position.y,aim.y,LINE_UP*dt) + var charge := 1.0-clampf(timer/maxf(tell_time(move),.001),0,1) + body.scale = Vector2(1+charge*.06,1-charge*.06)*magnify + if timer <= 0: fire() + +func _tick_active(dt: float) -> void: + if move == "slam": + var t := clampf(1.0-timer/float(data.leap),0,1) + position = launch.lerp(aim,smoothstep(0,1,t)) + body.position.y = -sin(t*PI)*LEAP_HEIGHT + if timer <= 0: _land() + return + position = arena_point(position+Vector2(dash_direction*float(data.dash_speed)*dt,0)) + var player: Node2D = arena.player + var offset: Vector2 = player.position-position + if absf(offset.x) < DASH_REACH and absf(offset.y) < DASH_DEPTH: + player.take_hit(position,"",DASH_FORCE) + var wall: float = arena.room_start()+(1160.0 if dash_direction > 0 else 120.0) + if timer <= 0 or absf(position.x-wall) < 1: _recover() + +## Leave room for the boss's silhouette and for attacks from either side. +func arena_point(point: Vector2) -> Vector2: + return Vector2(clampf(point.x,arena.room_start()+120,arena.room_start()+1160),clampf(point.y,470,630)) + +## The tells shorten once it is enraged. +func tell_time(kind: String) -> float: + return float(data.slam_tell if kind == "slam" else data.dash_tell)*(float(data.rage) if enraged else 1.0) + +func telegraph() -> void: + cycle += 1 + move = "slam" if cycle%2 == 1 else "dash" + state = "tell" + timer = tell_time(move) + var player: Node2D = arena.player + _face(player.position.x) + body.position = Vector2.ZERO + if move == "slam": + aim = arena_point(player.position) + pose("slam_warning") + # The warning fires on landing, after the tell and the flight. + arena.ground_warning(aim,SLAM_RADIUS,timer+float(data.leap),.25,"slam",self) + else: + aim = Vector2(position.x,clampf(player.position.y,470,630)) + dash_direction = 1.0 if player.position.x >= position.x else -1.0 + pose("dash_warning") + for n in 3: + var marker := arena_point(Vector2(position.x+dash_direction*(170+n*190),aim.y)) + arena.ground_warning(marker,Vector2(60,24),timer,.2,"signal",self) + +func fire() -> void: + state = "active" + body.scale = Vector2.ONE*magnify + if move == "slam": + leaping = true + launch = position + timer = float(data.leap) + pose("slam") + else: + # The dash runs exactly along the warned lane. + position.y = aim.y + timer = float(data.dash_time) + pose("dash") + arena.sound("swing",position) + +func _land() -> void: + leaping = false + position = aim + body.position = Vector2.ZERO + arena.shake = maxf(arena.shake,8) + arena.sound("kick",position) + arena.fx("combat_fx","slam_impact",position,1.8,.3) + for side in [-1.0,1.0]: + arena.projectile(position+Vector2(side*50,0),Vector2(side*float(data.shot_speed),0),data.shot) + _recover() + +func _recover() -> void: + leaping = false + body.position = Vector2.ZERO + body.scale = Vector2.ONE*magnify + state = "recover" + timer = float(data.recover) + pose("recover") + if hits >= int(data.stagger_hits): stagger() + +func _rest(seconds := -1.0) -> void: + state = "chase" + move = "" + timer = float(data.rest) if seconds < 0 else seconds + body.position = Vector2.ZERO + body.scale = Vector2.ONE*magnify + pose("idle") + +func _face(x: float) -> void: + # Its sheets face left. + body.flip_h = x > position.x + +## Reels for `stagger` seconds, dropping a telegraphed move and its warnings. +func stagger() -> void: + hits = 0 + state = "stagger" + timer = float(data.stagger) + move = "" + body.position = Vector2.ZERO + body.scale = Vector2.ONE*magnify + arena.drop_warnings(self) + pose("hurt") + +func melee_reaches(offset: Vector2, facing: float, reach: float, depth: float) -> bool: + if dead: return false + var forward := offset.x*facing + return forward >= -20 and forward <= reach+REACH_PADDING and absf(offset.y) <= depth+DEPTH_PADDING + +func take_hit(damage: float, force: float, from: Vector2, _finisher := false) -> bool: + if dead or state == "intro": return false + hp = maxf(0,hp-damage) + hits += 1 + flash = .1 + arena.shake = maxf(arena.shake,4) + arena.sound("jab",position) + if hp <= 0: + _defeat() + return true + if not enraged and hp <= max_hp*float(data.rage_at): _enrage() + # A committed leap lands first; its recovery staggers instead. + if hits >= int(data.stagger_hits) and not leaping: + knock = Vector2(signf(position.x-from.x),0)*minf(force,200.0) + stagger() + return true + +func _enrage() -> void: + enraged = true + arena.toast("%s ENRAGED — adds incoming" % title,1.6) + arena.sound("phase",position) + if adds_called: return + adds_called = true + for side in [-1.0,1.0]: + var spot: Vector2 = arena.clamp_ground(Vector2(arena.room_start()+640+side*430,position.y+side*45)) + var add: Node2D = arena.spawn_enemy(data.add,spot,false) + if add: arena.fx("combat_fx","duplicate_spawn",spot,1,.6) + +func _defeat() -> void: + dead = true + leaping = false + state = "defeat" + body.position = Vector2.ZERO + body.scale = Vector2.ONE*magnify + body.modulate = Color.WHITE + defeat_clock = 0 + pose("defeat") + queue_redraw() + arena.boss_defeated() + +## The arena ticks this through the victory hop: a beat on the defeat pose, then a fade. +func defeat_tick(dt: float) -> void: + if not dead: return + defeat_clock += dt + modulate.a = 1.0-clampf((defeat_clock-DEFEAT_HOLD)/DEFEAT_FADE,0,1) + +func _draw() -> void: + if dead or state != "tell": return + draw_arc(Vector2.ZERO,60,0,TAU,32,Color("ffb84a"),3) diff --git a/hoppys-adventure-arcade/modes/beatem_up/mini_boss.gd.uid b/hoppys-adventure-arcade/modes/beatem_up/mini_boss.gd.uid new file mode 100644 index 00000000..53dc4fe8 --- /dev/null +++ b/hoppys-adventure-arcade/modes/beatem_up/mini_boss.gd.uid @@ -0,0 +1 @@ +uid://co0nq8dbnrh13 diff --git a/hoppys-adventure-arcade/modes/beatem_up/player.gd b/hoppys-adventure-arcade/modes/beatem_up/player.gd index e27190c2..00acc94b 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/player.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/player.gd @@ -5,8 +5,11 @@ const BASE_FRAMES = preload("res://Assets/bay-area-blitz-assets-only/bay_area/ho const COMBAT_FRAMES = preload("res://Assets/code-city-brawl-player/combat.tres") const WeaponRig = preload("res://modes/beatem_up/weapon_rig.gd") const BODY_OFFSET := Vector2(0,-38) -## Poses with a baked armed clip. The three attack poses become step1..3 by combo. -const ARMED_CLIPS := {"idle":"hold_idle","run":"run","pickup":"equip","hurt":"hurt"} +## Walking speed in px/s; the armed run's foot cadence is calibrated for it. +const RUN_SPEED := 240.0 +## Poses with a baked armed clip. The three attack poses become step1..3 by combo. A sheet +## without one of these clips (walk_up and walk_down are newer) falls back to the unarmed pose. +const ARMED_CLIPS := {"idle":"hold_idle","run":"run","pickup":"equip","hurt":"hurt","walk_up":"walk_up","walk_down":"walk_down"} const ATTACK_POSES := ["jab","swing","kick"] var arena: Node2D var body: AnimatedSprite2D @@ -15,6 +18,9 @@ var visual_weapon := "" var block_time := 0.0 var hp := 3 var facing := 1.0 +## The way the stick last pointed past the deadzone. A swing keeps its facing, so a turn asked +## for mid-swing waits here for the swing's end or the next step; 0 once idle with no input. +var steer := 0.0 var height := 0.0 var rise := 0.0 var invulnerable := 0.0 @@ -71,7 +77,8 @@ func tick(dt: float) -> void: if controlled and input_lock <= 0 and stagger <= 0: movement = Input.get_vector("move_left","move_right","move_up","move_down") if effects.has("confused"): movement = -movement - if absf(movement.x) > .15 and swing_left<=0: facing = signf(movement.x) + if absf(movement.x) > .15: steer = signf(movement.x) + if steer != 0 and swing_left <= 0: facing = steer if Input.is_action_just_pressed("brawl_jump"): jump() if Input.is_action_just_pressed("brawl_attack"): request_attack() if Input.is_action_just_pressed("brawl_pickup") and swing_left <= 0 and height < 20: @@ -81,12 +88,17 @@ func tick(dt: float) -> void: if not swing_fired and swing_left <= swing_duration * .56: swing_fired = true arena.resolve_swing(self) + # A swing that stops play (a boss's last hit) hands Hoppy to a scripted sequence; the rest + # of this tick would overwrite its pose, weapon and flicker. + if not arena.playing: return if swing_left <= 0: combo_window = .48 + if steer != 0: facing = steer if queued: queued = false start_attack() - var speed := 240.0 + elif absf(movement.x) <= .15: steer = 0.0 + var speed := RUN_SPEED if effects.has("speed_boost"): speed *= 1.4 if effects.has("slow"): speed *= .6 if swing_left > 0: speed *= .3 @@ -116,6 +128,8 @@ func tick(dt: float) -> void: pose = "run" base = true show_pose(pose,base) + # The stride keeps pace with travel under analog input and speed effects, so feet don't slide. + body.speed_scale = movement.length()*speed/RUN_SPEED if pose in ["run","walk_up","walk_down"] else 1.0 var attacking := swing_left>0 var progress := clampf(1.0-swing_left/swing_duration,0,1) body.position.x=0 @@ -143,6 +157,8 @@ func armed_sheet() -> SpriteFrames: return Art.armed(kind) if kind != "" and held.visible else null func show_pose(pose: String, base := false) -> void: + # Until the combat sheet has its victory cheer, the jump pose stands in. + if pose=="victory" and not COMBAT_FRAMES.has_animation("victory"): pose = "jump" var sheet := armed_sheet() var clip: String = ARMED_CLIPS.get(pose,"") if swing_left > 0 and pose in ATTACK_POSES: clip = "step%d" % combo @@ -182,6 +198,8 @@ func attack_pose() -> String: return ["jab","swing","kick"][maxi(0,combo-1)] func start_attack() -> void: + # Each swing starts facing the way the stick last asked, so a buffered step can turn. + if steer != 0: facing = steer combo = combo%3+1 if combo_window > 0 else 1 attack_weapon = weapon if weapon != "firewall_shield" else "" visual_weapon = weapon diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd index 2cc42cb8..121f78a1 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd @@ -56,6 +56,9 @@ var toast_left := 0.0 var held_toast := "" var held_toast_seconds := 2.0 var ui_layer: CanvasLayer +## The HUD bar's backing strip and the control hints along the bottom (knockout_hud_nodes). +var hud_strip: ColorRect +var controls_hint: Label var prompt: Label var overlay: ColorRect var panel: VBoxContainer @@ -629,6 +632,13 @@ func toast(message: String, seconds := 2.0) -> void: toast_label.show() toast_left = seconds +## The gameplay chrome the knockout cinematic hides and then restores (knockout.gd): the HUD +## strip and its labels, both boss bars, the capability line, toast, prompt, control hints and +## minimap. Cards, menus, the results screen and the screen shader are not chrome. +func knockout_hud_nodes() -> Array[CanvasItem]: + var nodes: Array[CanvasItem] = [hud_strip,hud,objective,capability_hud,boss_text,boss_bar,second_boss_text,second_boss_bar,toast_label,prompt,controls_hint,minimap] + return nodes + func _build_ui() -> void: var layer := CanvasLayer.new() ui_layer=layer @@ -646,6 +656,7 @@ func _build_ui() -> void: ui.theme = Style.theme() layer.add_child(ui) var top := ColorRect.new() + hud_strip = top top.color = Color(.035,.025,.025,.94) top.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) top.offset_bottom = 139 @@ -684,6 +695,7 @@ func _build_ui() -> void: prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER ui.add_child(prompt) var controls := Style.label("",13) + controls_hint = controls Prompts.track(controls, func() -> String: return "%s · Move %s · Jump %s · Combo %s · Pickup Hold %s · REVIEW %s · Pause" % [ Prompts.keys_or_pad("WASD", Prompts.if_pad("stick")), diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd index 0d816fdf..9edc4118 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd @@ -15,6 +15,9 @@ var visual_weapon := "" var block_time := 0.0 var hp := 3 var facing := 1.0 +## The way the stick last pointed past the deadzone. A swing keeps its facing, so a turn asked +## for mid-swing waits here for the swing's end or the next step; 0 once idle with no input. +var steer := 0.0 var height := 0.0 var rise := 0.0 var invulnerable := 0.0 @@ -80,7 +83,8 @@ func tick(dt: float) -> void: if controlled and input_lock <= 0 and stagger <= 0: movement = Input.get_vector("move_left","move_right","move_up","move_down") if effects.has("confused"): movement = -movement - if absf(movement.x) > .15 and swing_left<=0: facing = signf(movement.x) + if absf(movement.x) > .15: steer = signf(movement.x) + if steer != 0 and swing_left <= 0: facing = steer if Input.is_action_just_pressed("brawl_jump"): jump() if Input.is_action_just_pressed("brawl_attack"): request_attack() if Input.is_action_just_pressed("brawl_pickup") and swing_left <= 0 and height < 20: @@ -90,11 +94,16 @@ func tick(dt: float) -> void: if not swing_fired and swing_left <= swing_duration * .56: swing_fired = true arena.resolve_swing(self) + # A swing that stops play (a boss's last hit) hands Hoppy to a scripted sequence; the rest + # of this tick would overwrite its pose, weapon and flicker. + if not arena.playing: return if swing_left <= 0: combo_window = .48 + if steer != 0: facing = steer if queued: queued = false start_attack() + elif absf(movement.x) <= .15: steer = 0.0 var speed := MOVE_SPEED if effects.has("speed_surge"): speed *= 1.4 if effects.has("overwhelmed"): speed *= .6 @@ -230,6 +239,8 @@ func attack_pose() -> String: return ["jab","swing","kick"][maxi(0,combo-1)] func start_attack() -> void: + # Each swing starts facing the way the stick last asked, so a buffered step can turn. + if steer != 0: facing = steer combo = combo%3+1 if combo_window > 0 else 1 attack_weapon = weapon visual_weapon = weapon diff --git a/hoppys-adventure-arcade/scenes/main_menu/preview.gd b/hoppys-adventure-arcade/scenes/main_menu/preview.gd index 17534310..f565e3d6 100644 --- a/hoppys-adventure-arcade/scenes/main_menu/preview.gd +++ b/hoppys-adventure-arcade/scenes/main_menu/preview.gd @@ -1,7 +1,7 @@ extends CanvasGroup ## Scene-local artwork only, flattened so crossfades do not ghost through individual layers. const TITLES = ["BAY AREA BLITZ", "ORBITAL OPS", "ENCHANTED REPO", - "THE CODEBASE CORRIDOR", "GRAB THE POWER-UPS", "SHARP TURNS AHEAD", "BOSS FIGHTS", + "THE CODEBASE CORRIDOR", "GRAB THE POWER-UPS", "JUMP, ROLL, DODGE", "BOSS FIGHTS", "CLI / IDE DISTRICT", "SECURITY SECTOR", "GIT REPOSITORY HQ", "THE GENERATION FLOOR", "THE REVIEW GAUNTLET", "THE AGENTIC CORE"] const COLORS = [Color("65dec9"), Color("a99bff"), Color("b6dc87"), @@ -26,7 +26,7 @@ const LAYERS := { const STILLS := { 3: "res://Assets/runner/menu_previews/runner_gameplay.png", 4: "res://Assets/runner/menu_previews/runner_powerup.png", - 5: "res://Assets/runner/menu_previews/runner_turn.png", + 5: "res://Assets/runner/menu_previews/runner_dodge.png", 6: "res://Assets/runner/menu_previews/runner_boss.png" } const STILL_DRIFT := 0.035 diff --git a/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd b/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd index e90d8cb1..64e3e9a6 100644 --- a/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd +++ b/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd @@ -28,16 +28,17 @@ 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 var _boss_active := false +## The run intro holds the music ducked until its GO beat (contract 2.7 §11.15). +var _run_intro_held := false 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, + &"boss_intro": true, &"stage_clear": true, &"boss_defeat": true, &"boss_phase": true, &"tool_fire": true, + &"intro_title": true, &"intro_count": true, &"intro_go": true, &"alert_sting": true, &"hit": true, &"caught": true, &"game_over": true, &"shield_block": true, &"pursuit_start": true, &"retreat": true, &"recovered_chime": true, &"powerup": true, } @@ -73,7 +74,7 @@ func _ready() -> void: player.process_mode = Node.PROCESS_MODE_PAUSABLE func bind(game: Node) -> void: - # Shared warmup also covers turn cues and every panned whoosh variant. bind() runs inside the + # Shared warmup also covers the cinematic cues and every panned whoosh variant. bind() runs inside the # game's _ready (veil opaque, before the first frame), so whatever the menu did not absorb is # completed here rather than one package per frame during play; then every runtime shader is # drawn once. Both are no-ops after the runner menu or an earlier run. @@ -92,8 +93,9 @@ func bind(game: Node) -> void: _normalized = maxf(0.0, float(game.get("normalized_speed"))) _pitch = 1.0 _connections = { - &"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, + &"boss_cinematic": _on_cinematic, &"boss_cinematic_skipped": _on_cinematic_skipped, &"boss_cinematic_ended": _on_cinematic_ended, + &"run_intro": _on_run_intro, &"run_intro_beat": _on_run_intro_beat, + &"run_intro_skipped": _on_run_intro_skipped, &"run_intro_ended": _on_run_intro_ended, &"stage_cleared": _on_stage_clear, &"phase_changed": _on_phase, &"boss_spawned": _on_boss_spawned, &"boss_phase_changed": _on_boss_phase, &"boss_defeated": _on_boss_defeated, &"boss_retreated": _on_boss_retreated, @@ -182,7 +184,6 @@ func _play(kind: StringName, player: AudioStreamPlayer = null) -> void: if _paused or _clock - float(_last_play.get(kind, -1.0)) < float(GATE.get(kind, 0.04)): return var stream := Synth.panned_whoosh(_whoosh_mask) if kind == &"projectile_whoosh" else Synth.sound(kind) - if kind == &"turn_whoosh": stream = Synth.panned_whoosh(_turn_mask, kind) if kind == &"footstep": stream = Synth.footstep(_surface, _tread % 2) _tread += 1 @@ -199,7 +200,7 @@ func _play(kind: StringName, player: AudioStreamPlayer = null) -> void: if not HIGH_PRIORITY.has(kind) and _high_pending_count() > 0: _queue(kind) return - var voice := _playback.play_stream(stream, 0.0, 0.0, 1.0, AudioServer.PLAYBACK_TYPE_DEFAULT, _whoosh_bus if kind in [&"turn_whoosh", &"projectile_whoosh", &"nitro"] else &"SFX") + var voice := _playback.play_stream(stream, 0.0, 0.0, 1.0, AudioServer.PLAYBACK_TYPE_DEFAULT, _whoosh_bus if kind in [&"projectile_whoosh", &"nitro"] else &"SFX") if voice == AudioStreamPlaybackPolyphonic.INVALID_ID: _queue(kind) var poly := one_shots.stream as AudioStreamPolyphonic @@ -400,13 +401,13 @@ func _duck(db: float, seconds: float) -> void: _audio_service.duck_music(db, seconds) func _set_boss_mix(active: bool) -> void: - 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)) + var target := -18.0 if _cinematic_mode != &"" else -12.0 if _intro_remaining > 0.0 or _run_intro_held else -10.0 if _clear_remaining > 0.0 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. if _audio_service != null and _audio_service.has_method("duck_music") and "music_volume_db" in _audio_service: _audio_service.set("music_volume_db", float(_audio_service.get("music_volume_db")) + target - _boss_duck) - _duck(0, 1.2 if _clear_remaining > 0.0 else (0.5 if _turn_active else (0.6 if active else 0.2))) + _duck(0, 1.2 if _clear_remaining > 0.0 else (0.6 if active else 0.2)) _boss_duck = target func _on_boss_spawned(_kind: StringName, _hp: int) -> void: @@ -420,10 +421,10 @@ func _on_boss_phase(_phase: int) -> void: func _end_boss_audio() -> void: _on_cinematic_ended(_cinematic_mode) - for kind in [&"boss_defeat", &"boss_explosion", &"boss_resolved", &"turn_warning", &"turn_whoosh"]: _stop_cue(kind) + for kind in [&"boss_defeat", &"boss_explosion", &"boss_resolved"]: _stop_cue(kind) _intro_remaining = 0.0 _boss_active = false - _turn_active = false + _run_intro_held = false _clear_remaining = 0.0 _scheduled.clear() _set_boss_mix(false) @@ -455,20 +456,6 @@ func _on_tool(_kind: StringName, damage: int) -> void: _scheduled.append({"kind": &"tool_fire", "remaining": 0.10}) if damage > 0: _scheduled.append({"kind": &"boss_hit", "remaining": 0.14}) -func _on_turn(direction: int, _seconds: float) -> void: - _turn_active = true - _turn_mask = 1 if direction < 0 else 4 - _set_boss_mix(false) - _stop_cue(&"turn_warning") - _play(&"turn_whoosh") - _pitch_cue(&"turn_whoosh", 2.4 / maxf(_seconds, 0.05)) - -func _on_turn_finished() -> void: - _stop_cue(&"turn_warning") - _stop_cue(&"turn_whoosh") - _turn_active = false - _set_boss_mix(_boss_active) - func _on_stage_clear(_index: int, _bonus: int) -> void: _clear_remaining = 2.8 _pitch = 1.0 @@ -478,14 +465,36 @@ func _on_stage_clear(_index: int, _bonus: int) -> void: func _on_phase(value: StringName) -> void: if value in [&"idle", &"run"]: - _turn_active = false _clear_remaining = 0.0 _set_boss_mix(false) -func _on_turn_warning(_direction: int, seconds: float) -> void: - _play(&"turn_warning") - # Scale the three authored ticks to the facade's warning window. - _pitch_cue(&"turn_warning", 3.0 / maxf(seconds, 0.05)) +## Contract 2.7 §11.15: the music ducks under the intro; the full one opens on the fly-through whoosh. +func _on_run_intro(mode: StringName, _seconds: float) -> void: + _run_intro_held = true + _set_boss_mix(_boss_active) + if mode == &"full": _play(&"intro_whoosh") + +## The title stings, each count ticks, and GO lifts the duck with its chord. +func _on_run_intro_beat(_index: int, label: String) -> void: + if label == "title": + _play(&"intro_title") + elif label.begins_with("GO"): + _run_intro_held = false + _set_boss_mix(_boss_active) + _play(&"intro_go") + else: + _play(&"intro_count") + +## A skip cuts the long cues; the GO beat that follows it still plays its chord. +func _on_run_intro_skipped() -> void: + _stop_cue(&"intro_whoosh") + _stop_cue(&"intro_title") + +func _on_run_intro_ended() -> void: + _stop_cue(&"intro_whoosh") + _stop_cue(&"intro_title") + _run_intro_held = false + _set_boss_mix(_boss_active) func _on_cinematic(_kind: StringName, mode: StringName, seconds: float) -> void: _cinematic_mode = mode diff --git a/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd b/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd index 4ba8b482..76a1021f 100644 --- a/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd +++ b/hoppys-adventure-arcade/scenes/runner/audio/sfx_synth.gd @@ -3,14 +3,17 @@ extends RefCounted 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": [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], + # Contract 2.7 §11.15 run intro: the fly-through, the title sting, the countdown tick and GO. + &"intro_whoosh": [2.2, 220.0, 1800.0, "sweep", 0.5], + &"intro_title": [0.9, 110.0, 220.0, "detuned", 0.6], + &"intro_count": [0.14, 880.0, 880.0, "square", 0.42], + &"intro_go": [0.7, 659.25, 1318.5, "chord", 0.62], &"stage_clear": [1.0, 440.0, 880.0, "arpeggio", 0.65], &"telegraph_tick": [0.06, 1600.0, 1600.0, "square", 0.35], &"projectile_whoosh": [0.30, 900.0, 180.0, "noise", 0.45], @@ -41,7 +44,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 := {&"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 PERCUSSIVE := {&"intro_count": true, &"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 := { @@ -54,6 +57,9 @@ const LAYERS := { &"alarm": [0.42, 0.22, 0.08, 0.04, 0.24], } const FOOTSTEP_SOFTNESS := [0.85, 0.38, 0.16] +## LAYERS' drone weights, typed for drone_sample(), which runs once per sample. Built when the +## script compiles, on the thread that loads it, before any worker can read it. +static var _drone_weights := PackedFloat64Array(LAYERS[&"drone"]) static var _cache: Dictionary = {} static var raw_peaks: Dictionary = {} @@ -64,7 +70,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", &"boss_skip"]: return &"whoosh" + if kind in [&"projectile_whoosh", &"nitro", &"retreat", &"boss_skip", &"intro_whoosh"]: return &"whoosh" return &"alarm" @@ -76,7 +82,8 @@ static func sound(kind: StringName) -> AudioStreamWAV: return install(kind, render(kind)) ## Pure PCM synthesis: reads only constants and allocates no resources, so prewarm can run it on -## WorkerThreadPool. install() turns the result into the cached stream on the main thread. +## WorkerThreadPool. install() turns the result into the cached stream on the main thread. Keep +## every operand typed here and in the helpers it calls (see `weights`). static func render(kind: StringName) -> Dictionary: var recipe: Array = RECIPES[kind] var seconds := float(recipe[0]) @@ -94,7 +101,11 @@ static func render(kind: StringName) -> Dictionary: samples.resize(count * channels) var waveform := String(recipe[3]) var family := family_for(kind) - var weights: Array = LAYERS[family] + # Typed, so the mixing line below compiles to validated float operators. Prewarm runs every + # recipe here at once on workers. An untyped `a * b` caches its evaluator in the shared bytecode + # on first run, without synchronisation, and threads arriving together could call a null one + # (tests/runner_synth_threads.gd). + var weights := PackedFloat64Array(LAYERS[family]) var directional := kind == &"lane_left" or kind == &"lane_right" var left_pan := kind == &"lane_left" var intro := kind == &"boss_intro" @@ -109,10 +120,6 @@ static func render(kind: StringName) -> Dictionary: var envelope := _envelope(family, t, seconds) var sample := sin(phase) match waveform: - "warning": - frequency = 700.0 * pow(1.3, floor(t)) - var beat := fposmod(t, 1.0) - envelope = sin(PI * beat / 0.12) if beat < 0.12 else 0.0 "intro": sample = (sin(phase) + sin(phase * 1.012)) * 0.5 envelope = smoothstep(0.0, 0.7, progress) * (1.0 - smoothstep(0.8, 1.0, progress)) @@ -120,14 +127,11 @@ static func render(kind: StringName) -> Dictionary: var impact_envelope := exp(-(progress - 0.8) * 35.0) sample = sample * 0.4 + rng.randf_range(-1.0, 1.0) * 0.6 envelope = impact_envelope - "turn": - frequency = lerpf(300.0, 2400.0, sin(progress * PI)) + "sweep": + # Rises to the top and falls back, under a noise bed: the camera flying down the corridor. + frequency = lerpf(float(recipe[1]), float(recipe[2]), sin(progress * PI)) sample = sample * 0.4 + rng.randf_range(-1, 1) * 0.6 envelope = sin(progress * PI) - # Snap at the camera hold boundary, inside the same voice. - if progress >= 0.3 and progress < 0.34: - sample = rng.randf_range(-1.0, 1.0) - envelope = 1.0 - (progress - 0.3) / 0.04 "arpeggio": frequency = 440.0 * float(ARPEGGIO[mini(3, int(progress * 4))]) "alert": @@ -163,7 +167,7 @@ static func render(kind: StringName) -> Dictionary: sample = sample * 0.78 + sin(phase * 2.0 + 0.3 * sin(phase * 3.0)) * 0.16 if not looping: sample = sample * weights[0] + body * weights[1] + transient * weights[2] + low_noise * weights[3] + sub * weights[4] - # Keep authored radar gaps, warning ticks and heartbeat handover precise. + # Keep authored radar gaps and heartbeat handover precise. var edge := 1.0 if looping else minf(t / 0.002, 1.0) * minf((seconds - t - 1.0 / RATE) / 0.006, 1.0) for channel in channels: var side := sin(phase * 1.007) * width * (1.0 if channel == 0 else -1.0) if stereo else 0.0 @@ -276,8 +280,7 @@ static func sting_sub(t: float, duration: float, intro: bool) -> float: ## Integral cycles for every layer, with no artificial seam fade. static func drone_sample(t: float) -> float: - var weights: Array = LAYERS[&"drone"] - return sin(TAU * 55.0 * t) * weights[0] + sin(TAU * 57.0 * t) * weights[1] + sin(TAU * 27.5 * t) * weights[4] + return sin(TAU * 55.0 * t) * _drone_weights[0] + sin(TAU * 57.0 * t) * _drone_weights[1] + sin(TAU * 27.5 * t) * _drone_weights[4] ## Surface and strike variation are independent cache/seed axes. static func footstep(surface: int, variant: int = 0) -> AudioStreamWAV: @@ -319,7 +322,7 @@ static func footstep_pcm(source: PackedByteArray, material: int, strike: int) -> static func render_family(kind: StringName) -> Dictionary: var rendered := render(kind) var variants := {} - if kind in [&"projectile_whoosh", &"turn_whoosh"]: + if kind == &"projectile_whoosh": for mask in 8: variants[mask] = panned_pcm(rendered.data, rendered.stereo, mask) elif kind == &"footstep": for surface in 3: diff --git a/hoppys-adventure-arcade/scenes/runner/data/run_config.gd b/hoppys-adventure-arcade/scenes/runner/data/run_config.gd index 036734df..9bb1f27c 100644 --- a/hoppys-adventure-arcade/scenes/runner/data/run_config.gd +++ b/hoppys-adventure-arcade/scenes/runner/data/run_config.gd @@ -117,18 +117,24 @@ extends Resource ## hazard is still this much travel away (contract §11.1). @export var stage_banner_reaction_seconds := 1.0 -@export_group("Turn") -@export var turn_seconds := 2.4 -## Seconds of warning turn_incoming gives before the bend is expected to start (contract §11.4). -@export var turn_warning_seconds := 3.0 -## Graybox fallback only: the yaw the TurnRig sweeps when the corridor has no set_turn (§11.9). -@export var turn_yaw_degrees := 90.0 -@export var turn_camera_yaw_degrees := 55.0 -@export var turn_camera_bank_degrees := 12.0 -## Fractions of the bend the camera swing spends ramping to the peak and returning from it; it -## holds at the peak in between (contract §11.9). -@export var turn_swing_in_fraction := 0.3 -@export var turn_swing_out_fraction := 0.3 +@export_group("Run intro") +## The in-engine cinematic before a run (contract 2.7 §11.15). Tests that drive the run from its first +## tick turn it off on a duplicated config. +@export var run_intro_enabled := true +## The full intro on a fresh run from the menu, and the short lead-in on a retry. A boss-checkpoint +## retry plays no run intro; the boss's own short intro cinematic is its lead-in. +@export var run_intro_full_seconds := 6.0 +@export var run_intro_short_seconds := 2.2 +## An intro can be skipped once it has played this long; a skip jumps to the last run_intro_skip_tail +## seconds, so the camera still lands behind Hoppy. +@export var run_intro_skip_after := 0.3 +@export var run_intro_skip_tail := 0.4 +## run_intro_beat labels and the intro seconds they land at, in order. The last beat is GO: controls +## (and the corridor's speed) come back on it. +@export var run_intro_full_beats := PackedStringArray(["title", "3", "2", "1", "GO!"]) +@export var run_intro_full_beat_times := PackedFloat32Array([1.0, 3.0, 3.8, 4.6, 5.4]) +@export var run_intro_short_beats := PackedStringArray(["READY", "GO!"]) +@export var run_intro_short_beat_times := PackedFloat32Array([1.0, 1.6]) @export_group("Boss") @export var boss_z := -14.0 diff --git a/hoppys-adventure-arcade/scenes/runner/data/run_intro_shots.gd b/hoppys-adventure-arcade/scenes/runner/data/run_intro_shots.gd new file mode 100644 index 00000000..4a19fb73 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/data/run_intro_shots.gd @@ -0,0 +1,30 @@ +extends RefCounted +## Camera keys use the boss shot schema: [p, distance, height, orbit, fov, look_bias]. +## Focus is Hoppy's world position. The front approach reveals their victory pose before +## a tight orbit through the corridor hands back to the normal follow camera. +const SHOTS := { + &"full": { + "cut": 0.0, + "ret": 0.82, + "keys": [ + [0.0, 24.0, 3.6, -180.0, 68.0, 1.0], + [0.167, 5.3, 2.2, -180.0, 56.0, 1.0], + [0.33, 5.1, 2.2, -174.0, 56.0, 1.0], + [0.40, 3.4, 2.5, -125.0, 60.0, 1.0], + [0.445, 3.4, 3.0, -65.0, 60.0, 1.7], + [0.495, 8.0, 4.0, 0.0, 60.0, 2.5], + [0.82, 8.0, 4.0, 0.0, 60.0, 2.5], + [1.0, 8.0, 4.0, 0.0, 60.0, 2.5], + ], + }, + &"short": { + "cut": 0.10, + "ret": 0.73, + "keys": [ + [0.0, 8.0, 4.0, 0.0, 60.0, 2.5], + [0.45, 6.8, 3.3, 0.0, 56.0, 1.7], + [0.73, 8.0, 4.0, 0.0, 60.0, 2.5], + [1.0, 8.0, 4.0, 0.0, 60.0, 2.5], + ], + }, +} diff --git a/hoppys-adventure-arcade/scenes/runner/data/run_intro_shots.gd.uid b/hoppys-adventure-arcade/scenes/runner/data/run_intro_shots.gd.uid new file mode 100644 index 00000000..a129b8bc --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/data/run_intro_shots.gd.uid @@ -0,0 +1 @@ +uid://72t60y7swo2v diff --git a/hoppys-adventure-arcade/scenes/runner/environment/corridor.gd b/hoppys-adventure-arcade/scenes/runner/environment/corridor.gd index 8c22f0c6..daefa2e3 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/corridor.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/corridor.gd @@ -13,19 +13,8 @@ var _rack_batches: Array[MultiMesh] = [] var _racks: MultiMesh const Atlas = preload("res://scenes/runner/environment/hifi/wall_atlas.gd") @export_range(0.25, 1.0, 0.05) var retro_scale := 0.5 -# Mirror RunConfig defaults. Keep these fractions and yaw in sync with the backend camera. -const TURN_SWING_IN := 0.3 -const TURN_SWING_OUT := 0.3 -const TURN_YAW := 55.0 -var turn_progress := 0.0 -var turn_direction := 0 -var bend := 0.0 var phase: StringName = &"idle" var boss_arena := false -signal turn_applied(progress: float, direction: int) -var _corner: MeshInstance3D -var _turn_chevron: Label3D -var _corner_material: ShaderMaterial var _dust: GPUParticles3D var _speed := 0.0 var _offset := 0.0 @@ -39,7 +28,7 @@ var _sun: DirectionalLight3D var _scroll_materials: Array[ShaderMaterial] = [] var _clock_materials: Array[ShaderMaterial] = [] var _frame_materials: Array[ShaderMaterial] = [] -var _metal_bend_materials: Array[ShaderMaterial] = [] +var _rack_materials: Array[ShaderMaterial] = [] var _pillar_z := PackedFloat32Array() var _pillars: MultiMesh var _rain: GPUParticles3D @@ -48,15 +37,10 @@ var _far_material: ShaderMaterial ## The spawner of the bound run, if any. While one is bound the scroll is read off the same physics ## ticks that move the chunks, so floor markers and hazards can never swim against each other. var _spawner: Node = null -## Signed turn wave (direction x profile) and progress at the previous and latest physics tick that -## called set_turn(), so the bend is drawn between ticks exactly like the interpolated camera swing. -var _turn_wave_from := 0.0 -var _turn_wave_to := 0.0 -var _turn_progress_from := 0.0 -var _turn_direction_from := 0 -## Physics frame of the latest in-tick set_turn(); -1 when the last call came from outside physics -## (fixtures, menus), which snaps instead of interpolating. -var _turn_tick := -1 +## The corridor frame last written to the frame-space shaders. The corridor never turns, so it only +## changes when a fixture moves the node; until then nothing is re-uploaded. +var _dressing_frame := Transform3D() +var _dressing_frame_written := false ## Speed the particle process materials were last written for; -1 forces the first write. var _particle_speed := -1.0 ## Rain drift and streak velocity only need to track the speed to a few cm/s. @@ -121,22 +105,6 @@ func _ready() -> void: _quad("LaneGlow", Vector2(6, 8), Vector3(0, 0.012, 0), Vector3(-PI / 2, 0, 0), lane) _far_material = _shader(preload("res://scenes/runner/shaders/far_glow.gdshader")) _quad("FarGlow", Vector2(8, 8), Vector3(0, 3, -88), Vector3.ZERO, _far_material) - _corner_material = _shader(preload("res://scenes/runner/shaders/code_wall_fade.gdshader")) - _corner_material.set_shader_parameter("atlas", Atlas.texture()) - _corner_material.set_shader_parameter("wall_atlas", Atlas.texture()) - _corner_material.set_shader_parameter("horizontal_axis", Vector3.RIGHT) - _corner_material.set_shader_parameter("intensity", 0.4) - _corner_material.set_shader_parameter("panel_period", 8.0) - _corner_material.set_shader_parameter("full_face", true) - _quad("CornerWall", Vector2(18, 8), Vector3(0, 3, -30), Vector3.ZERO, _corner_material) - _corner = get_node("CornerWall") as MeshInstance3D - var sign := Materials.label(self, "TURN ►", 104, Palette.MINT) - _turn_chevron = sign - sign.name = "TurnChevron" - sign.reparent(_corner, false) - sign.position = Vector3(0, 0.5, 6.0) - sign.pixel_size = 0.014 - sign.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR _build_pillars() _build_racks() _rain = Materials.glyph_particles(64, Vector3(3.9, 0.2, 45)) @@ -145,6 +113,7 @@ func _ready() -> void: _rain.position = Vector3(0, 6.2, -44) # Particle AABBs are local to the emitter; global Z coverage is -96..+12. _rain.visibility_aabb = AABB(Vector3(-5, -8, -52), Vector3(10, 10, 108)) + _rain.amount_ratio = 0.55 add_child(_rain) _speed_lines = _simple_particles("SpeedLines", 64, Vector2(0.018, 0.75), 0.18) _speed_lines.emitting = false @@ -156,14 +125,14 @@ func _ready() -> void: dust_process.initial_velocity_max = 0.3 # Persistent capability lists avoid temporary arrays and dead uniform sets per frame. _clock_materials.assign(_scroll_materials) - _clock_materials.append_array([_corner_material, _far_material, _pillar_material, lane]) - _clock_materials.append_array(_metal_bend_materials) + _clock_materials.append_array([_far_material, _pillar_material, lane]) + _clock_materials.append_array(_rack_materials) _frame_materials.assign(_scroll_materials) _frame_materials.append(_far_material) _configure_cosmetics(self) set_scroll_speed(_speed) set_pursuit(_pursuit) - _apply_turn() + _refresh_dressing() _update_pillars() func _shader(shader: Shader) -> ShaderMaterial: @@ -223,7 +192,7 @@ func _metal() -> ShaderMaterial: func _build_racks() -> void: var material := _metal() material.set_shader_parameter("module_mode", true) - _metal_bend_materials.append(material) + _rack_materials.append(material) for batch_index in Racks.BATCH_COUNT: var batch := MultiMesh.new() batch.transform_format = MultiMesh.TRANSFORM_3D @@ -255,14 +224,12 @@ func _update_pillars() -> void: var slots := [0, 0, 0] for i in 12: var z := 12.0 - fposmod(float(i) * 8.0 - _offset, 96.0) - var d := maxf(0.0, -z - 6.0) - var yaw := -atan(2.0 * bend * d) - var x := bend * d * d - _rack_poses[i] = Transform3D(Basis(Vector3.UP, yaw), Vector3(x, 0, z)) + _rack_poses[i] = Transform3D(Basis.IDENTITY, Vector3(0, 0, z)) var variant: int = Racks.PATTERN[i] var batch_index: int = Racks.BATCH_FOR_VARIANT[variant] var batch := _rack_batches[batch_index] - _rack_data[i] = Color(z, x, cos(yaw), Racks.instance_tag(i)) + # Custom data is (z, x offset, cos yaw, tag); the corridor is straight, so x is 0 and cos 1. + _rack_data[i] = Color(z, 0.0, 1.0, Racks.instance_tag(i)) batch.set_instance_custom_data(slots[batch_index], _rack_data[i]) batch.set_instance_transform(slots[batch_index], _rack_poses[i]) slots[batch_index] += 1 @@ -270,7 +237,7 @@ func _update_pillars() -> void: var z := 8.0 - fposmod(float(i % 12) * 8.0 - _offset, 96.0) _pillar_z[i] = z var x := -3.92 if i < 12 else 3.92 - _pillars.set_instance_transform(i, Transform3D(Basis(Vector3.UP, -atan(2.0 * bend * maxf(0.0, -z - 6.0))), Vector3(x + bend * pow(maxf(0.0, -z - 6.0), 2), 3, z))) + _pillars.set_instance_transform(i, Transform3D(Basis.IDENTITY, Vector3(x, 3, z))) func _simple_particles(node_name: String, count: int, size_: Vector2, alpha: float) -> GPUParticles3D: var particles := Materials.glyph_particles(count, Vector3(3.8, 2.5, 45)) @@ -310,7 +277,7 @@ func set_scroll_speed(speed: float) -> void: var velocity := Vector2(1.8, _speed * 0.12).length() rain_process.initial_velocity_min = velocity * 0.8 rain_process.initial_velocity_max = velocity * 1.2 - # Emission is decided in _apply_turn() alone; a second writer here made the streaks sputter. + # Emission is decided in _refresh_dressing() alone; a second writer here made the streaks sputter. var lines_process := _speed_lines.process_material as ParticleProcessMaterial lines_process.direction = Vector3.BACK lines_process.initial_velocity_min = _speed * 0.7 @@ -325,7 +292,7 @@ func set_pursuit(t: float) -> void: _far_material.set_shader_parameter("color", Color(Palette.MATRIX.lerp(Palette.DANGER, _pursuit), 0.3)) func _process(delta: float) -> void: - _apply_turn() + _refresh_dressing() # 96 is a common period of the 2 m grid, wall glyph seed, and 8 m pillars. var travel := _bound_travel() _offset = fposmod(travel if not is_nan(travel) else _offset + _speed * delta, 96.0) @@ -390,88 +357,30 @@ func _exit_tree() -> void: _scaling_owners.erase(viewport_id) _scaling_registered = false -func set_turn(progress: float, direction: int) -> void: - var clamped := clampf(progress, 0.0, 1.0) - var sign_ := signi(direction) - if Engine.is_in_physics_frame(): - # First call of this tick: the latest tick's pose becomes the one to interpolate from. - var tick := Engine.get_physics_frames() - if tick != _turn_tick: - _turn_tick = tick - _turn_wave_from = _turn_wave_to - _turn_progress_from = turn_progress - _turn_direction_from = turn_direction - else: - _turn_tick = -1 - turn_progress = clamped - turn_direction = sign_ - _turn_wave_to = float(sign_) * turn_profile(clamped) if sign_ != 0 else 0.0 - turn_applied.emit(turn_progress, turn_direction) - -func _apply_turn() -> void: - # The StageManager advances the turn on physics ticks and the camera swing is interpolated - # between them, so the bend is drawn at the same point between the same two ticks. - var signed := _turn_wave_to - var progress := turn_progress - var direction := turn_direction - if _turn_tick >= 0 and _turn_tick == Engine.get_physics_frames() and _interpolating(): - var fraction := Engine.get_physics_interpolation_fraction() - signed = lerpf(_turn_wave_from, _turn_wave_to, fraction) - if _turn_direction_from == turn_direction: - progress = lerpf(_turn_progress_from, turn_progress, fraction) - elif direction == 0: - direction = _turn_direction_from - var wave := absf(signed) - bend = 0.010 * signed - rotation.y = deg_to_rad(TURN_YAW) * signed +## Per-frame dressing: the corridor frame the frame-space shaders read (written only when the node +## has moved), and the speed-line emission. +func _refresh_dressing() -> void: if _far_material == null: return - for material in _frame_materials: - material.set_shader_parameter("bend", bend) - material.set_shader_parameter("bend_frame", global_transform) - for material in _metal_bend_materials: - material.set_shader_parameter("bend", bend) - _speed_lines.draw_pass_1.material.set_shader_parameter("corridor_frame", global_transform) - var sweep := float(direction) * progress * 48.0 if wave > 0.0 else 0.0 - _scroll_materials[2].set_shader_parameter("lateral_scroll", sweep) - var blend := wave - _far_material.set_shader_parameter("opacity", 1.0 - blend) - _corner_material.set_shader_parameter("opacity", blend) - _corner_material.set_shader_parameter("lateral_scroll", sweep) - # Panel transform owns the bend once; its child sign inherits the exact pose. - _corner_material.set_shader_parameter("bend", 0.0) - _corner.visible = blend > 0.0 - _corner.position.x = bend * 24.0 * 24.0 - _corner.rotation.y = 0.0 - _turn_chevron.visible = blend > 0.0 - _turn_chevron.modulate.a = blend - _turn_chevron.text = "◄ TURN" if direction < 0 else "TURN ►" - _turn_chevron.position.x = bend * pow(24.0 - _turn_chevron.position.z, 2) - _corner.position.x - _turn_chevron.rotation.y = 0.0 - _rain.amount_ratio = lerpf(0.55, 1.0, wave) - _dust.amount_ratio = 1.0 + if not _dressing_frame_written or global_transform != _dressing_frame: + _dressing_frame = global_transform + _dressing_frame_written = true + for material in _frame_materials: + material.set_shader_parameter("bend_frame", _dressing_frame) + _speed_lines.draw_pass_1.material.set_shader_parameter("corridor_frame", _dressing_frame) # Single writer for emission, and only on a change: a hidden emitter (the starship corridor hides # the legacy streaks) is never switched on just to be switched off again in the same frame. - var streaks := _speed_lines.visible and (wave > 0.95 or clampf((_speed - 12.0) / 12.0, 0.0, 1.0) > 0.6) + var streaks := _speed_lines.visible and clampf((_speed - 12.0) / 12.0, 0.0, 1.0) > 0.6 if _speed_lines.emitting != streaks: _speed_lines.emitting = streaks - _speed_lines.speed_scale = 1.0 + wave - _rain.speed_scale = 1.0 + wave * 1.5 func set_phase(value: StringName) -> void: phase = value - if value != &"turn": set_turn(0.0, 0) func set_boss_arena(active: bool) -> void: boss_arena = active if _far_material != null: _far_material.set_shader_parameter("arena", 0.45 if active else 1.0) -static func turn_profile(p: float) -> float: - if p <= 0.0 or p >= 1.0: return 0.0 - if p < TURN_SWING_IN: return smoothstep(0.0, 1.0, p / TURN_SWING_IN) - if p > 1.0 - TURN_SWING_OUT: return smoothstep(0.0, 1.0, (1.0 - p) / TURN_SWING_OUT) - return 1.0 - func _configure_cosmetics(node: Node) -> void: for child in node.get_children(): child.process_mode = Node.PROCESS_MODE_PAUSABLE diff --git a/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_a2.gd b/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_a2.gd index 7511c96c..cfd3c276 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_a2.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_a2.gd @@ -26,12 +26,11 @@ func run() -> void: var output := ProjectSettings.globalize_path("res://../docs/previews/assets") DirAccess.make_dir_recursive_absolute(output) var failures := 0 - for view in ["gameplay", "close-up", "turn"]: + for view in ["gameplay", "close-up"]: # Freeze a repeatable module phase so the near frame-face ROI cannot drift off hardware. corridor.set_process(false) corridor._offset = 6.4 if view == "close-up" else 0.0 corridor._clock = 1.0 - corridor.set_turn(0.5 if view == "turn" else 0.0, 1) corridor._process(0.0) var eye := Vector3(0, 4, 8) var target := Vector3(0, 1, -8) @@ -68,13 +67,7 @@ func run() -> void: var ink := Metrics.screen_stats(saved, region) print("ASSET A2 SCREEN-INK: view=%s rect=%s p95=%.3f edge=%.3f %s" % [view, region, ink.p95, ink.edge, "PASS" if ink["pass"] else "FAIL"]) if not ink["pass"]: failures += 1 - if view == "turn" and error == OK: - var saved := Image.load_from_file(output + "/a2-turn.png") - for sample_index in Metrics.END_WALL_REGIONS.size(): - var wall := Metrics.end_wall_stats(saved, sample_index) - print("ASSET A2 END-WALL: rect=%s mean=%.3f minimum=%.2f %s" % [Metrics.END_WALL_REGIONS[sample_index], wall.mean, Metrics.END_WALL_MIN_MEANS[sample_index], "PASS" if wall["pass"] else "FAIL"]) - if not wall["pass"]: failures += 1 - if error != OK or calls > (18 if view == "turn" else 14): failures += 1 + if error != OK or calls > 14: failures += 1 await Cleanup.release(self, [corridor, camera]) print("ASSET A2 CAPTURE: " + ("PASS" if failures == 0 else "FAIL")) diff --git a/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_previews.gd b/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_previews.gd index 8fd67083..ad7d9996 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_previews.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/dev/capture_previews.gd @@ -73,25 +73,20 @@ func run() -> void: failures += 1 hud_preview.free() await process_frame - # Deterministic F10 cards. Capture is deliberately graphical-only. - for fixture in ["turn-warning", "boss-intro"]: + # Deterministic F10 card. Capture is deliberately graphical-only. + for fixture in ["boss-intro"]: var preview := hud_scene.instantiate() preview.autoplay = false root.add_child(preview) preview.start_run() preview.hud.ready_panel.hide() preview.stage_banner.hide() - if fixture == "turn-warning": - preview.turn_incoming.emit(-1, 3.0) - preview.hud.warning_age = 0.4 - preview.hud._update_turn_warning() - else: - preview.boss_hp = 140 - preview.mount_demo_boss() - preview.boss_intro.emit(preview.boss_kind, "Merge Conflict Hydra", 3.0) - preview.boss_spawned.emit(preview.boss_kind, 140) - preview.boss_overlay._process(1.2) - preview.boss.get_child(0)._process(1.2) + preview.boss_hp = 140 + preview.mount_demo_boss() + preview.boss_intro.emit(preview.boss_kind, "Merge Conflict Hydra", 3.0) + preview.boss_spawned.emit(preview.boss_kind, 140) + preview.boss_overlay._process(1.2) + preview.boss.get_child(0)._process(1.2) preview.hud.set_process(false) preview.boss_overlay.set_process(false) for visual in preview.boss.get_children(): visual.set_process(false) @@ -119,21 +114,6 @@ func run() -> void: if error != OK or calls > 184: failures += 1 bosses.free() await process_frame - var turn := preload("res://scenes/runner/environment/dev/turn_preview.tscn").instantiate() - root.add_child(turn) - turn.progress = 0.5 - turn.corridor.set_turn(0.5, 1) - for child in turn.get_children(): - if child is CanvasLayer: child.hide() - await create_timer(0.3).timeout - await RenderingServer.frame_post_draw - var turn_calls := root.get_render_info(Viewport.RENDER_INFO_TYPE_VISIBLE, Viewport.RENDER_INFO_DRAW_CALLS_IN_FRAME) - var turn_error := root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://../docs/previews/endless-runner-turn.png")) - print("CAPTURE: turn error=", turn_error, " draw_calls=", turn_calls) - print("TURN DRAW BUDGET: ", turn_calls, " / 18 (preview UI hidden)") - if turn_error != OK or turn_calls > 18: failures += 1 - turn.free() - await process_frame var clear := (load("res://scenes/runner/ui/dev/hud_preview.tscn") as PackedScene).instantiate() clear.autoplay = false root.add_child(clear) diff --git a/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.gd b/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.gd deleted file mode 100644 index 15ba03f3..00000000 --- a/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.gd +++ /dev/null @@ -1,74 +0,0 @@ -extends Node3D -const Corridor = preload("res://scenes/runner/environment/corridor.tscn") -const Style = preload("res://scenes/runner/ui/hud_style.gd") -var camera: Camera3D -var base_rotation := Vector3.ZERO -var corridor: Node3D -var progress := 0.0 -var direction := 1 -var playing := false -var readout: Label -var _previous_paused := false - -func _ready() -> void: - _previous_paused = get_tree().paused - process_mode = Node.PROCESS_MODE_ALWAYS - corridor = Corridor.instantiate() - add_child(corridor) - corridor.set_scroll_speed(20.0) - camera = Camera3D.new() - camera.position = Vector3(0, 4, 8) - camera.fov = 60 - add_child(camera) - camera.look_at(Vector3(0, 1, -8)) - camera.current = true - base_rotation = camera.rotation - var layer := CanvasLayer.new() - add_child(layer) - var panel := PanelContainer.new() - panel.position = Vector2(28, 28) - panel.add_theme_stylebox_override("panel", Style.panel(Style.MINT)) - layer.add_child(panel) - readout = Style.label("", 20) - panel.add_child(readout) - _process(0.0) - -func _process(delta: float) -> void: - if playing and not get_tree().paused: - progress = minf(1.0, progress + delta / 2.5) - corridor.set_turn(progress, direction) - if progress >= 1.0: playing = false - apply_camera_swing(camera, base_rotation, progress, direction) - var calls := get_viewport().get_render_info(Viewport.RENDER_INFO_TYPE_VISIBLE, Viewport.RENDER_INFO_DRAW_CALLS_IN_FRAME) - readout.text = "LEFT / RIGHT play [ / ] scrub SPACE pause\np %.2f direction %+d bend %.5f yaw %.1f° draw calls %d" % [progress, direction, corridor.bend, corridor.rotation_degrees.y, calls] - -func _unhandled_key_input(event: InputEvent) -> void: - if not event is InputEventKey or not event.pressed or event.echo: return - match event.physical_keycode: - KEY_LEFT, KEY_RIGHT: - direction = -1 if event.physical_keycode == KEY_LEFT else 1 - progress = 0.0 - playing = true - KEY_BRACKETLEFT, KEY_BRACKETRIGHT: - if get_tree().paused: return - playing = false - progress = clampf(progress + (-0.05 if event.physical_keycode == KEY_BRACKETLEFT else 0.05), 0, 1) - corridor.set_turn(progress, direction) - KEY_SPACE: get_tree().paused = not get_tree().paused - -func _exit_tree() -> void: - get_tree().paused = _previous_paused - -## Mirrors camera_rig's default ramp, sign, Euler rotation and player-centred orbit. -static func apply_camera_swing(target: Camera3D, rest_rotation: Vector3, value: float, turn_direction: int) -> void: - var p := clampf(value, 0.0, 1.0) - var profile := 0.0 - if p > 0.0 and p < 1.0: - profile = 1.0 - if p < 0.3: profile = smoothstep(0.0, 1.0, p / 0.3) - elif p > 0.7: profile = smoothstep(0.0, 1.0, (1.0 - p) / 0.3) - var swing := float(signi(turn_direction)) * profile - var yaw := deg_to_rad(55.0) * swing - var bank := deg_to_rad(12.0) * swing - target.position = Vector3(0, 4, 8).rotated(Vector3.UP, yaw) - target.rotation = rest_rotation + Vector3(0.0, yaw, bank) diff --git a/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.gd.uid b/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.gd.uid deleted file mode 100644 index d102202c..00000000 --- a/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxcwl58wfo62y diff --git a/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.tscn b/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.tscn deleted file mode 100644 index a1cba539..00000000 --- a/hoppys-adventure-arcade/scenes/runner/environment/dev/turn_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://scenes/runner/environment/dev/turn_preview.gd" id="1"] - -[node name="TurnPreview" type="Node3D"] -script = ExtResource("1") diff --git a/hoppys-adventure-arcade/scenes/runner/environment/hifi/capture_metrics.gd b/hoppys-adventure-arcade/scenes/runner/environment/hifi/capture_metrics.gd index 186849f2..0066b336 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/hifi/capture_metrics.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/hifi/capture_metrics.gd @@ -130,11 +130,3 @@ static func screen_stats(image: Image, region: Rect2i = SCREEN_REGION) -> Dictio result.edge = detail_energy(image, region) result["pass"] = result.p95 >= SCREEN_MIN_P95 and result.edge >= SCREEN_MIN_EDGE return result - -# Three unobscured terminator samples, calibrated to the round-6 host capture. -const END_WALL_REGIONS := [Rect2i(630, 205, 120, 35), Rect2i(630, 285, 140, 25), Rect2i(660, 195, 90, 25)] -const END_WALL_MIN_MEANS := [14.84, 19.01, 10.69] -static func end_wall_stats(image: Image, sample_index: int) -> Dictionary: - var stats := frame_stats(image, END_WALL_REGIONS[sample_index], END_WALL_MIN_MEANS[sample_index], 0.0) - stats["pass"] = stats.mean >= END_WALL_MIN_MEANS[sample_index] - return stats diff --git a/hoppys-adventure-arcade/scenes/runner/environment/starship/capture_starship.gd b/hoppys-adventure-arcade/scenes/runner/environment/starship/capture_starship.gd index bbefd642..ea39869c 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/starship/capture_starship.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/starship/capture_starship.gd @@ -32,7 +32,7 @@ func capture() -> void: camera.fov = 60 var output := ProjectSettings.globalize_path("res://../docs/previews/starship") DirAccess.make_dir_recursive_absolute(output) - for view in ["run","architecture","boss","turn"]: + for view in ["run","architecture","boss","entry"]: camera.position = Vector3(0,4,8) camera.look_at(Vector3(0,1,-8)) if view == "architecture": @@ -40,7 +40,7 @@ func capture() -> void: camera.look_at(Vector3(-1.8,3.2,-9)) if view == "boss": corridor.set_boss_arena(true) - corridor.starship.sync(5.0,1.0,0.0,corridor.global_transform,true,2.0) + corridor.starship.sync(5.0,1.0,corridor.global_transform,true,2.0) var mount := Node3D.new() mount.position = Vector3(0,2.6,-14) world.add_child(mount) @@ -52,14 +52,14 @@ func capture() -> void: boss._process(1.4) boss.set_process(false) mount.name = "BossFixture" - if view == "turn": + if view == "entry": + # The run intro's reverse angle: down the corridor, looking back past Hoppy at the sealed + # entry bulkhead that closes the near end. world.get_node("BossFixture").queue_free() corridor.set_boss_arena(false) - corridor.set_turn(0.5,1) corridor._process(2.0) - camera.position = corridor.transform * Vector3(0,4,8) - camera.look_at(corridor.transform * Vector3(0,1,-8)) - hero.visible = false + camera.position = corridor.transform * Vector3(0,2.4,-6.0) + camera.look_at(corridor.transform * Vector3(0,1.6,8.0)) await create_timer(0.8).timeout await RenderingServer.frame_post_draw var error := root.get_texture().get_image().save_png(output+"/starship-"+view+".png") diff --git a/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_corridor.gd b/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_corridor.gd index a665a9e3..d5dbfb0c 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_corridor.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_corridor.gd @@ -39,20 +39,21 @@ func _ready() -> void: key.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF add_child(key) set_pursuit(_pursuit) - starship.sync(_offset,_clock,bend,global_transform,boss_arena,0.0) + starship.sync(_offset,_clock,global_transform,boss_arena,0.0) func _process(delta: float) -> void: super._process(delta) if is_instance_valid(starship): - starship.sync(_offset,_clock,bend,global_transform,boss_arena,delta) + starship.sync(_offset,_clock,global_transform,boss_arena,delta) -func _apply_turn() -> void: - super._apply_turn() - if is_instance_valid(_corner): _corner.visible = false - if is_instance_valid(_rain): +## The starship keeps the legacy glyph rain and speed streaks hidden; only a visible emitter is +## touched, so a frame that finds them already off writes nothing. +func _refresh_dressing() -> void: + super._refresh_dressing() + if is_instance_valid(_rain) and (_rain.visible or _rain.emitting): _rain.visible = false _rain.emitting = false - if is_instance_valid(_speed_lines): + if is_instance_valid(_speed_lines) and (_speed_lines.visible or _speed_lines.emitting): _speed_lines.visible = false _speed_lines.emitting = false diff --git a/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_world.gd b/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_world.gd index 48fa0188..38acfa73 100644 --- a/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_world.gd +++ b/hoppys-adventure-arcade/scenes/runner/environment/starship/starship_world.gd @@ -5,6 +5,10 @@ var materials: Array[ShaderMaterial] = [] var reactor_dock: Node3D var arena_blend := 0.0 var deck_light: OmniLight3D +## The corridor frame last handed to the surface materials; the corridor never turns, so it is only +## re-uploaded when a fixture moves the node. +var _frame := Transform3D() +var _frame_written := false func _ready() -> void: name = "StarshipWorld" @@ -32,6 +36,7 @@ func _ready() -> void: floor_mat.set_shader_parameter("tint",Color("435365")) _plane("ArmouredDeck",Vector2(8,108),Vector3(0,-0.018,-40),floor_mat) _end_cap() + _entry_bulkhead() # The docking reactor remains behind the existing boss and its telegraphs. reactor_dock = Node3D.new() reactor_dock.name = "BossReactorDock" @@ -73,7 +78,7 @@ func _ready() -> void: ## Opaque far bulkhead past the last recycled module (modules start between z -84 and -72) and ## beyond the -90 m spawn horizon. A sleeve behind the module walls covers z -92..-71.5, so the -## far end is closed at every scroll offset; the surface material rides the same turn bend. +## far end is closed at every scroll offset. func _end_cap() -> void: var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) @@ -96,6 +101,56 @@ func _end_cap() -> void: cap.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF add_child(cap) +## The near end, behind Hoppy: a sealed entry door the run intro's reverse shots land on. Modules +## span local z 0..12 and recycle with their origin in (-84, 12], so the near wall edge wanders +## between +12 and +24 and the deck stops at +14. A wall at +24.4 with a sleeve behind the module +## walls over z 11.5..24.5 (ceiling, sides, and the floor past the deck) closes the end at every +## scroll offset. It sits behind the gameplay camera and every boss shot, and it keeps a zero cull +## margin (it never bends, unlike the scrolling modules), so frustum culling drops both of its draw +## calls from every gameplay frame; the pursuer (z 4.5..12) always stays in front of it. +func _entry_bulkhead() -> void: + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + Assets.slab(st,Vector3(0,3.6,24.4),Vector3(12.6,8.4,0.3),0.55) + Assets.slab(st,Vector3(0,2.8,24.2),Vector3(4.4,5.2,0.12),0.85) + Assets.slab(st,Vector3(0,2.8,24.12),Vector3(0.08,5.2,0.05),0.3) + Assets.slab(st,Vector3(0,5.55,24.1),Vector3(5.2,0.35,0.3),0.4) + Assets.slab(st,Vector3(0,7.45,18.0),Vector3(12.8,0.3,13.0),0.45) + Assets.slab(st,Vector3(0,-0.2,19.25),Vector3(12.6,0.3,10.5),0.5) + for side in [-1.0,1.0]: + Assets.slab(st,Vector3(side*2.45,2.8,24.1),Vector3(0.3,5.6,0.3),0.4) + Assets.slab(st,Vector3(side*6.1,3.6,18.0),Vector3(0.3,8.4,13.0),0.5) + Assets.slab(st,Vector3(side*4.3,1.2,24.2),Vector3(2.6,0.24,0.1),0.7) + st.generate_tangents() + var door := MeshInstance3D.new() + door.name = "EntryBulkhead" + door.mesh = st.commit() + var material := Assets.material(Assets.HULL) + materials.append(material) + door.material_override = material + door.extra_cull_margin = 0.0 + door.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + door.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + add_child(door) + # The sealed-door glow: two conduit strips up the door leaves and a lintel bar, on the glowing + # conduit material so the end reads as a door rather than a blank wall. + var glow := SurfaceTool.new() + glow.begin(Mesh.PRIMITIVE_TRIANGLES) + for side in [-1.0,1.0]: + Assets.slab(glow,Vector3(side*1.6,2.8,24.1),Vector3(0.1,4.6,0.05),1.0) + Assets.slab(glow,Vector3(0,5.3,24.0),Vector3(4.0,0.08,0.05),1.0) + glow.generate_tangents() + var strips := MeshInstance3D.new() + strips.name = "EntryDoorGlow" + strips.mesh = glow.commit() + var conduit := Assets.material(Assets.CONDUIT) + materials.append(conduit) + strips.material_override = conduit + strips.extra_cull_margin = 0.0 + strips.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + strips.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + door.add_child(strips) + func _plane(title: String, size: Vector2, location: Vector3, material: ShaderMaterial) -> void: var mesh := PlaneMesh.new() mesh.size = size @@ -110,16 +165,20 @@ func _plane(title: String, size: Vector2, location: Vector3, material: ShaderMat add_child(node) if not materials.has(material): materials.append(material) -func sync(offset: float, clock: float, bend: float, frame: Transform3D, arena: bool, delta: float) -> void: +## `frame` is the corridor's global transform: the floor-mode surfaces measure their plating in it. +func sync(offset: float, clock: float, frame: Transform3D, arena: bool, delta: float) -> void: for batch in batches: for i in batch.instance_count: var z := 12.0-fposmod(i*12.0-offset,96.0) batch.set_instance_transform(i,Transform3D(Basis.IDENTITY,Vector3(0,0,z))) + var frame_changed := not _frame_written or frame != _frame + _frame = frame + _frame_written = true for material in materials: material.set_shader_parameter("scroll",offset) material.set_shader_parameter("clock",clock) - material.set_shader_parameter("bend",bend) - material.set_shader_parameter("bend_frame",frame) + if frame_changed: + material.set_shader_parameter("bend_frame",frame) arena_blend = move_toward(arena_blend,1.0 if arena else 0.0,delta*0.65) reactor_dock.visible = arena_blend > 0.001 reactor_dock.position.z = lerpf(-72.0,-26.0,smoothstep(0.0,1.0,arena_blend)) diff --git a/hoppys-adventure-arcade/scenes/runner/environment/turn_fx.gd b/hoppys-adventure-arcade/scenes/runner/environment/turn_fx.gd deleted file mode 100644 index 8bdd7585..00000000 --- a/hoppys-adventure-arcade/scenes/runner/environment/turn_fx.gd +++ /dev/null @@ -1,31 +0,0 @@ -extends MultiMeshInstance3D -const Materials = preload("res://scenes/runner/environment/runner_materials.gd") -const Palette = preload("res://scenes/runner/environment/runner_palette.gd") -var direction := 0 - -func _ready() -> void: - process_mode = Node.PROCESS_MODE_PAUSABLE - physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF - cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - multimesh = MultiMesh.new() - multimesh.transform_format = MultiMesh.TRANSFORM_3D - var bar := BoxMesh.new() - bar.size = Vector3(0.65, 0.015, 0.10) - multimesh.mesh = bar - multimesh.instance_count = 18 - material_override = Materials.additive(Palette.ORANGE, 0.85).duplicate() - clear() - -func announce(value: int) -> void: - direction = signi(value) - for lane in 3: - for row in 3: - for side in 2: - var z := float(side * 2 - 1) - var basis := Basis(Vector3.UP, float(direction) * z * PI / 4.0) - multimesh.set_instance_transform(lane * 6 + row * 2 + side, Transform3D(basis, Vector3((lane - 1) * 2.0, 0.035, -5.0 - row * 5.0 + z * 0.22))) - show() - -func clear() -> void: - direction = 0 - hide() diff --git a/hoppys-adventure-arcade/scenes/runner/environment/turn_fx.gd.uid b/hoppys-adventure-arcade/scenes/runner/environment/turn_fx.gd.uid deleted file mode 100644 index 376e77af..00000000 --- a/hoppys-adventure-arcade/scenes/runner/environment/turn_fx.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dv32oupa2upkk diff --git a/hoppys-adventure-arcade/scenes/runner/player/runner_player.gd b/hoppys-adventure-arcade/scenes/runner/player/runner_player.gd index 66b81d9a..143ca056 100644 --- a/hoppys-adventure-arcade/scenes/runner/player/runner_player.gd +++ b/hoppys-adventure-arcade/scenes/runner/player/runner_player.gd @@ -18,6 +18,8 @@ const ROLL_HINTS: Array[StringName] = [&"ROLL", &"ROLL_OR_JUMP"] const VISUAL_MOVES: Array[StringName] = [&"lane_left", &"lane_right", &"land"] ## damage_blocked reason that means the Approve Shield popped (hit_manager.gd REASON_SHIELD). const SHIELD_BLOCK_REASON := &"shield" +## The run_intro_beat label the title card plays on; Hoppy celebrates under it. +const RUN_INTRO_TITLE_BEAT := "title" ## How long the hurt pose holds before the normal state selection takes over again. const HURT_ANIM_SECONDS := 0.25 ## Small downward bias while grounded so move_and_slide keeps floor contact. @@ -493,6 +495,9 @@ func _connect_visual_events() -> void: [game.player_action, _forward_action], [game.damage_blocked, _forward_blocked], [game.power_up_collected, _forward_power_up], [game.power_up_expired, _forward_power_up_end], [game.stage_cleared, _forward_stage_cleared], [game.boss_defeated, _forward_boss_defeated]] + # Contract 2.7 §11.15: optional, so a facade fixture without the run intro still binds. + if game.has_signal(&"run_intro_beat"): + links.append([Signal(game, &"run_intro_beat"), _forward_run_intro_beat]) for link: Array in links: var event: Signal = link[0] if not event.is_connected(link[1]): @@ -525,6 +530,11 @@ func _forward_boss_defeated(_kind: StringName, _bonus: int) -> void: if _has_visual_hook(&"celebrate"): visual.celebrate() +## The run intro's title card plays under Hoppy's victory pose (contract 2.7 §11.15). +func _forward_run_intro_beat(_index: int, label: String) -> void: + if label == RUN_INTRO_TITLE_BEAT and _has_visual_hook(&"celebrate"): + visual.celebrate() + # --- visual mount ------------------------------------------------------------------------------- func _mount_visual() -> void: diff --git a/hoppys-adventure-arcade/scenes/runner/runner_game.gd b/hoppys-adventure-arcade/scenes/runner/runner_game.gd index 2c2df9c9..9154d454 100644 --- a/hoppys-adventure-arcade/scenes/runner/runner_game.gd +++ b/hoppys-adventure-arcade/scenes/runner/runner_game.gd @@ -39,6 +39,9 @@ const FRONTEND_MOUNTS := { ## 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", + ## Contract 2.7 §11.15: the run intro's title card, countdown and skip prompt, driven only off + ## run_intro_progress and the run_intro_* signals. + "res://scenes/runner/ui/run_intro.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" @@ -87,9 +90,6 @@ signal paused_changed(paused: bool) signal phase_changed(phase: StringName) signal stage_started(index: int, name: StringName, speed: float) signal stage_cleared(index: int, bonus: int) -signal turn_incoming(direction: int, seconds: float) -signal turn_started(direction: int, seconds: float) -signal turn_finished signal boss_intro(kind: StringName, name: String, seconds: float) signal boss_spawned(kind: StringName, hp: int) signal boss_health_changed(hp: int, max_hp: int) @@ -113,6 +113,16 @@ signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) 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) +## Contract 2.7 §11.15: the run intro of `seconds` opens (`mode` &"full" or &"short"), with phase +## already &"intro" and the run_intro_* mirrors set. +signal run_intro(mode: StringName, seconds: float) +## Each beat as the intro's clock reaches it, in index order: full "title", "3", "2", "1", "GO!"; +## short "READY", "GO!". Controls come back on the last one (GO). Beats a skip jumps over are never sent. +signal run_intro_beat(index: int, label: String) +## The player skipped: the intro jumps to its last run_intro_skip_tail seconds and the GO beat follows. +signal run_intro_skipped +## Exactly once per run_intro: played out, skipped tail done, or aborted (a catch, a new run). +signal run_intro_ended signal tool_collected(kind: StringName, damage: int) signal pursuit_alert(kind: StringName, seconds_to_catch: float) signal pursuer_distance_changed(normalized: float) @@ -129,7 +139,7 @@ var state: StringName = &"ready" var paused := false var seed := 0 var run_id := "" -## Unpaused running seconds. +## Unpaused running seconds, the run intro excluded (contract 2.7 §11.15). var elapsed := 0.0 ## Metres travelled. RunnerGame owns this accumulator; the spawner moves chunks by the same dz but ## must not add to it, or the distance would double-count. @@ -149,7 +159,7 @@ var cosmetic_rng := RandomNumberGenerator.new() # Read-only v2 properties (contract §11.4). The StageManager writes the stage block every tick and # the boss block is mirrored off the boss signals, so both survive the encounter being freed. -## idle / run / turn / boss / stage_clear, orthogonal to `state`. +## idle / run / boss / stage_clear, orthogonal to `state`. var phase: StringName = &"idle" ## 1-based, never resets between stages; 0 before the first stage starts. var stage_index := 0 @@ -159,10 +169,6 @@ var stage_distance := 0.0 var stage_distance_target := 0.0 ## RUN-phase seconds only; drives speed and difficulty (contract §11.1). var run_clock := 0.0 -## -1 left, +1 right for the turn this seam is about to take; 0 when none is pending. Set when the -## stage enters its drain window, which is before turn_incoming announces it, and back to 0 once -## the bend is over (contract §11.4). -var next_turn_direction := 0 ## The live boss node, or null outside a fight. var boss: Node3D = null var boss_kind: StringName = &"" @@ -176,6 +182,10 @@ var boss_phase := 0 var boss_cinematic_mode: StringName = &"" var boss_cinematic_progress := 0.0 var boss_time_factor := 1.0 +## Contract 2.7 §11.15, mirrored by the StageManager: the run intro playing (&"" when none) and 0..1 +## through it, frozen by a pause and jumped by a skip. +var run_intro_mode: StringName = &"" +var run_intro_progress := 0.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 @@ -186,6 +196,12 @@ var checkpoint_retry := false ## The checkpoint retry_boss() hands to the next runner_game instance. Static because the retry is ## a fresh scene through AppRouter, like retry(); start_run() consumes it and empties it at once. static var pending_boss_checkpoint := {} +## Contract 2.7 §11.15: the run intro the next start_run() opens with. A fresh run from the menu gets +## the full one; retry() asks for the short one; &"" opens the next run with no intro (capture +## tools). A boss-checkpoint retry never plays one, whatever this holds: the boss's own short intro is +## its lead-in. Static for the same reason as pending_boss_checkpoint, and consumed (back to full) by +## start_run(). +static var pending_intro_mode: StringName = &"full" @onready var systems: Node = $Systems @onready var speed_controller: Node = $Systems/SpeedController @@ -233,9 +249,9 @@ func _ready() -> void: config = load(CONFIG_PATH) as RunConfig hp = int(config.max_hp) speed = float(config.base_speed) - # The TurnRig and the boss arena are set up before the StageManager, so the machine finds a - # square rig and a live encounter; the frontend mounts come last, because a corridor is handed - # to the rig only once it exists. + # The TurnRig and the boss arena are set up before the StageManager, so the machine finds its + # mount and a live encounter; the frontend mounts come last, because a corridor is handed to the + # rig only once it exists. for node in [speed_controller, hit_manager, score_manager, effect_manager, spawner, turn_rig, boss_encounter, stage_manager, autopilot, player, pursuer, camera_rig, debug_overlay]: if node != null and node.has_method("setup"): @@ -254,6 +270,8 @@ func start_run(run_seed := 0) -> void: # Consumed before anything else, so a checkpoint can never leak into a later run. var checkpoint: Dictionary = pending_boss_checkpoint pending_boss_checkpoint = {} + var intro_mode: StringName = pending_intro_mode + pending_intro_mode = &"full" checkpoint_retry = not checkpoint.is_empty() boss_checkpoint = {} if checkpoint_retry: @@ -283,7 +301,8 @@ func start_run(run_seed := 0) -> void: run_clock = 0.0 stage_distance = 0.0 stage_distance_target = 0.0 - next_turn_direction = 0 + run_intro_mode = &"" + run_intro_progress = 0.0 _clear_boss_state() for node in [speed_controller, hit_manager, score_manager, effect_manager, autopilot, player, pursuer, camera_rig, turn_rig]: @@ -304,11 +323,12 @@ func start_run(run_seed := 0) -> void: speed_changed.emit(speed, normalized_speed) # Stage 1 opens only once every system is back at zero and the run is live, so the first # stage_started lands after run_started and the spawner fills against a running phase. A boss - # checkpoint opens on the turn into that stage's boss instead. + # checkpoint opens in front of that stage's boss instead, with no run intro: the boss's short + # intro cinematic is the lead-in, so the player is not handed control at GO only to lose it again. if checkpoint_retry and stage_manager != null and stage_manager.has_method("begin_at_boss"): stage_manager.begin_at_boss(checkpoint) elif stage_manager != null and stage_manager.has_method("begin"): - stage_manager.begin() + stage_manager.begin(intro_mode) ## Put the facade, the ScoreManager and the systems' Game Over tallies back where the checkpoint ## left them. HP, running power-up effects and the pursuit are not restored: a checkpoint retry @@ -382,10 +402,12 @@ func retry() -> void: resume() # A full restart always starts from zero, even after a Retry Boss that never got to route. pending_boss_checkpoint = {} + # The player has just seen the full intro; a retry opens on the short lead-in. + pending_intro_mode = &"short" _take_route("runner_game") -## Contract §11.13: a fresh session that opens on the turn into the boss the checkpoint was taken -## at, with the score, clocks and tallies it held and full HP. Without a checkpoint this is retry(). +## Contract §11.13: a fresh session that opens in front of the boss the checkpoint was taken at, +## with the score, clocks and tallies it held and full HP. Without a checkpoint this is retry(). func retry_boss() -> void: if _route_in_flight(): return @@ -401,6 +423,7 @@ func go_to_menu() -> void: return resume() pending_boss_checkpoint = {} + pending_intro_mode = &"full" _take_route("runner") ## The first route asked for wins. AppRouter ignores a go() while its veil is fading, but Game Over @@ -516,8 +539,9 @@ func _empty_boss_info() -> Dictionary: "pending_attacks": empty_attacks } -## Rig-local z for anything on the track (systems spec §10.5). Gameplay never rotates, so this is -## the identity today; it exists so a bent corridor can never skew a z test. +## Rig-local z for anything on the track (systems spec §10.5). The TurnRig is a static mount and +## gameplay never rotates, so this is the identity; z tests still go through it rather than +## assuming the rig sits at the origin. func track_z(node: Node3D) -> float: if node == null or not is_instance_valid(node): return 0.0 @@ -566,6 +590,22 @@ func _can_skip_boss_cinematic() -> bool: return false return bool(boss_encounter.can_skip_cinematic()) +## Contract 2.7 §11.15: skip the run intro playing now. It only takes while running and unpaused, once +## the intro has played run_intro_skip_after seconds and before its tail; returns true when it did. +## Jump and ui_accept reach it through _unhandled_input; the autopilot never calls it. +func skip_run_intro() -> bool: + if not can_skip_run_intro(): + return false + return bool(stage_manager.skip_run_intro()) + +## Contract 2.7 §11.15: true while skip_run_intro() would take. Read-only, for a skip prompt. +func can_skip_run_intro() -> bool: + if state != &"running" or paused: + return false + if stage_manager == null or not is_instance_valid(stage_manager) or not stage_manager.has_method("can_skip_run_intro"): + return false + return bool(stage_manager.can_skip_run_intro()) + ## 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 — ## unless a boss cinematic is holding the player (contract §11.14), which hands it back itself. @@ -574,9 +614,9 @@ func set_autopilot(enabled: bool) -> void: autopilot.set_enabled(enabled) _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. +## Manual controls are live while the run is, the bot is off and no cinematic holds the player (a boss +## cinematic, contract 2.6 §11.14, or the run intro up to its GO beat, 2.7 §11.15). 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")) @@ -596,8 +636,11 @@ func _physics_process(delta: float) -> void: if paused: return if state == &"running": - elapsed += delta - distance += speed * delta + # The run intro is a cinematic, not run time: the clock and the distance start at GO's + # hand-over to `run` (contract 2.7 §11.15), as the ScoreManager's base rate does. + if run_intro_mode == &"": + elapsed += delta + distance += speed * delta _track_dodges() elif state == &"caught": distance += speed * delta @@ -805,7 +848,7 @@ func _finish_run(outcome: StringName) -> void: "max_speed_reached": _system_float(speed_controller, &"max_speed_reached", speed), "best_multiplier": _system_float(score_manager, &"best_multiplier", multiplier), "chunks_spawned": _system_int(spawner, &"chunks_spawned", 0), - # Contract §11.4. The StageManager tallies all seven off the facade signals, so they are + # Contract §11.4. The StageManager tallies all six off the facade signals, so they are # right whether or not a boss encounter was ever mounted. "stages_cleared": _system_int(stage_manager, &"stages_cleared", 0), "stage_reached": _system_int(stage_manager, &"stage_index", stage_index), @@ -813,7 +856,6 @@ func _finish_run(outcome: StringName) -> void: "bosses_escaped": _system_int(stage_manager, &"bosses_escaped", 0), "tools_used": _system_int(stage_manager, &"tools_used", 0), "boss_damage_dealt": _system_int(stage_manager, &"boss_damage_dealt", 0), - "turns_taken": _system_int(stage_manager, &"turns_taken", 0), # Contract §11.13: true when this run was started from a boss checkpoint. "checkpoint_retry": checkpoint_retry, "score_rule_revision": int(config.score_rule_revision), @@ -886,8 +928,8 @@ func _mount_frontend() -> void: instance.process_mode = Node.PROCESS_MODE_PAUSABLE if instance.has_method("bind"): instance.bind(self) - # A real corridor replaces the backend graybox lighting and grid (contract §3), and the - # TurnRig hands it the bend instead of yawing the graybox itself (contract §11.9). + # A real corridor replaces the backend graybox lighting and grid (contract §3); the + # StageManager reaches it through the TurnRig mount (contract §11.9). if mount == environment_mount: _corridor = instance if turn_rig != null and is_instance_valid(turn_rig) and turn_rig.has_method("set_corridor"): @@ -911,10 +953,15 @@ 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(): + # Contract 2.6 §11.14 / 2.7 §11.15: jump or confirm skips the run intro or 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. + var skip_press := event.is_action_pressed("jump") or event.is_action_pressed("ui_accept") + if skip_press and can_skip_run_intro(): + skip_run_intro() + get_viewport().set_input_as_handled() + return + if skip_press and _can_skip_boss_cinematic(): skip_boss_cinematic() get_viewport().set_input_as_handled() return diff --git a/hoppys-adventure-arcade/scenes/runner/shaders/code_wall_fade.gdshader b/hoppys-adventure-arcade/scenes/runner/shaders/code_wall_fade.gdshader deleted file mode 100644 index c86ac8a1..00000000 --- a/hoppys-adventure-arcade/scenes/runner/shaders/code_wall_fade.gdshader +++ /dev/null @@ -1,130 +0,0 @@ -shader_type spatial; -render_mode cull_disabled; -uniform mat4 bend_frame = mat4(1.0); -uniform float bend = 0.0; -uniform float bend_start = 6.0; -uniform sampler2D atlas : filter_nearest, repeat_disable; -uniform sampler2D wall_atlas : filter_linear_mipmap_anisotropic, repeat_disable; -uniform float opacity = 1.0; -uniform bool full_face = false; -uniform float lateral_scroll = 0.0; -uniform float scroll = 0.0; -uniform float clock = 0.0; -uniform float intensity = 0.25; -// Opt-in corridor bezel spacing. Shared boss/pickup users have no gap. -uniform float panel_period = 0.0; -// Local long axis: Z for corridor PlaneMeshes, X for front-facing QuadMeshes. -uniform vec3 horizontal_axis = vec3(0.0, 0.0, 1.0); -varying vec3 world; -varying float surface_horizontal; -void vertex() { - vec3 before = (inverse(bend_frame) * MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; - float d = max(0.0, -before.z - bend_start); - // Convert the world-X offset to local space, including rotated wall planes. - VERTEX += (inverse(MODEL_MATRIX) * bend_frame * vec4(bend * d * d, 0.0, 0.0, 0.0)).xyz; - world = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; - surface_horizontal = dot(VERTEX, horizontal_axis); -} - -float hash_cell(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); } -float glyph_ink(vec2 p, float code) { - vec2 tile = vec2(mod(code,8.0),floor(code/8.0)); - // Different glyph IDs are discontinuous atlas addresses, not large pixel footprints. - // Derive LOD from the continuous glyph plane, never from tile selection/fract(). - return textureGrad(wall_atlas,(tile+clamp(fract(p),vec2(0.015),vec2(0.985)))/8.0,dFdx(p)/8.0,dFdy(p)/8.0).a; -} -float rain(vec2 p, float speed, float density, float salt) { - float column = mod(floor(p.x),384.0); - float seed = hash_cell(vec2(column,salt)); - p.y += clock*speed*(0.7+seed); - float cell = mod(floor(p.y),32.0); - float trail = mod(cell+seed*32.0,16.0); - float gate = 1.0-smoothstep(density*16.0-1.0,density*16.0,trail); - return glyph_ink(p,1.0+floor(hash_cell(vec2(column,cell+salt))*63.0))*gate*(0.8+0.2*trail/(density*16.0)); -} -float module_variant(float index) { - if (index==1.0 || index==5.0 || index==10.0) return 1.0; - if (index==2.0 || index==7.0 || index==9.0) return 2.0; - if (index==4.0 || index==8.0 || index==11.0) return 3.0; - return 0.0; -} - -void fragment() { - if (panel_period <= 0.0) { - - float column = mod(floor((surface_horizontal - scroll - lateral_scroll) * 2.0), 192.0); - float seed = fract(sin(column * 12.9898) * 43758.5453); - vec2 p = vec2((surface_horizontal-scroll-lateral_scroll)*2.0, world.y*2.5 + clock*(0.4+seed)); - float glyph = mod(abs(column * 7.0 + floor(p.y) * 13.0), 63.0) + 1.0; - vec2 tile = vec2(mod(glyph, 8.0), floor(glyph/8.0)); - float ink = texture(atlas, (tile + fract(p))/8.0).a; - ink *= 1.0 - smoothstep(0.3, 1.5, length(max(fwidth(world), vec3(0.004)))); - ALPHA = opacity; - // A slower second glyph plane shifts with viewing angle behind the front glass. - // 0.75 preserves the exact 96 m wrap (192 columns become 144). - vec2 rear = p*0.75 + vec2(VIEW.x, VIEW.y)*0.18 + vec2(0.0, clock*0.09); - float rear_glyph = mod(abs(column*11.0+floor(rear.y)*17.0),63.0)+1.0; - vec2 rear_tile = vec2(mod(rear_glyph,8.0),floor(rear_glyph/8.0)); - float back_ink = texture(atlas,(rear_tile+fract(rear))/8.0).a; - float panel_mask = 1.0; - if (panel_period > 0.0) { - float panel_u = fract((surface_horizontal-scroll)/panel_period); - panel_mask = smoothstep(0.02,0.07,panel_u) * (1.0-smoothstep(0.93,0.98,panel_u)); - } - float glass_rim = pow(1.0-abs(dot(normalize(NORMAL),normalize(VIEW))),3.0); - float scan = 0.8 + 0.2 * sin(world.y*110.0); - float fade = 1.0-smoothstep(22.0, 88.0, -world.z); - ALBEDO = vec3(0.018,0.033,0.05); - METALLIC = 0.65; - ROUGHNESS = 0.28; - EMISSION = vec3(0.388,0.976,0.698) * ((ink+back_ink*0.15)*intensity*scan*(0.3+seed*0.7)+glass_rim*0.025)*fade*panel_mask; - - } else { - // Material coordinates repeat only after the complete 96 m loop. - float along = mod(surface_horizontal-scroll,96.0); - float index = mod(floor((56.0-along)/8.0),12.0); - float variant = module_variant(index); - float width = 6.3; - float height = 2.35; - float center = 3.0; - float shift = 0.0; - if (variant==1.0) { width=5.5; height=2.25; center=3.3; shift=-0.4; } - if (variant==2.0) { width=6.1; height=2.3; center=2.7; shift=0.4; } - if (variant==3.0) { width=6.5; height=2.65; } - vec2 screen = vec2((mod(along,8.0)-4.0-shift)/width+0.5,(world.y-center)/height+0.5); - if (full_face) { screen = UV; variant = 0.0; } - vec2 edge = min(screen,1.0-screen); - float recess = smoothstep(0.0,0.018,min(edge.x,edge.y)); - float vignette = 1.0-0.08*dot(screen-0.5,screen-0.5)*2.0; - vec2 p = vec2(mod(along-lateral_scroll,96.0)*2.0,world.y*2.5); - // Three independent depths, speeds and densities; all wrap column counts divide 96 m. - float front = rain(p+VIEW.xy*0.025,1.7,0.78,3.0); - float middle = rain(p*0.75+VIEW.xy*0.10,0.9,0.60,19.0); - float rear = rain(p*0.5+VIEW.xy*0.22,0.35,0.64,41.0); - vec2 listing_p = vec2(along*3.0,world.y*3.5); - float listing_code = 1.0+floor(hash_cell(floor(listing_p))*63.0); - float listing = glyph_ink(listing_p,listing_code)*0.30; - float token = step(0.975,hash_cell(floor(p)))*glyph_ink(p,33.0+mod(floor(p.x),26.0)); - float error = step(0.998,hash_cell(vec2(index,floor(clock*2.0))))*step(0.80,fract(clock*7.0))*glyph_ink(p,1.0); - float scan = 0.91+0.09*sin(world.y*175.0)*exp(-fwidth(world.y)*175.0); - float rim = pow(1.0-abs(dot(normalize(NORMAL),normalize(VIEW))),3.0); - float sheen = pow(max(0.0,1.0-abs(screen.x+screen.y*0.35-0.6+VIEW.x*0.2)*3.0),12.0); - // Hero module: schematic nodes and circuit routes behind the code, same accent. - vec2 diagram_p = (screen-0.5)*vec2(5.0,3.0); - float ring_d = abs(length(diagram_p)-0.8); - float diagram = (1.0-smoothstep(0.018,0.018+max(fwidth(ring_d),0.005),ring_d))*0.24; - float routes = min(abs(diagram_p.y),abs(diagram_p.x)); - diagram += (1.0-smoothstep(0.012,0.012+max(fwidth(routes),0.005),routes))*0.13; - vec2 node_cell = mod(diagram_p+0.5,1.0)-0.5; - float node_d = abs(length(node_cell)-0.10); - diagram += (1.0-smoothstep(0.015,0.015+max(fwidth(node_d),0.005),node_d))*0.22; - diagram *= step(2.5,variant); - float fade = 1.0-smoothstep(22.0,88.0,-world.z); - ALBEDO = vec3(0.025,0.045,0.055)*recess; - METALLIC = 0.72; - ROUGHNESS = 0.24; - EMISSION = (vec3(0.388,0.976,0.698)*((front*1.35+middle*0.48+rear*0.24+listing+token*1.8+diagram)*intensity*scan*vignette+rim*0.012+sheen*0.012)+vec3(1.0,0.302,0.239)*error*0.3)*recess*fade; - - } - ALPHA = opacity; -} diff --git a/hoppys-adventure-arcade/scenes/runner/shaders/code_wall_fade.gdshader.uid b/hoppys-adventure-arcade/scenes/runner/shaders/code_wall_fade.gdshader.uid deleted file mode 100644 index 7daa1307..00000000 --- a/hoppys-adventure-arcade/scenes/runner/shaders/code_wall_fade.gdshader.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bht4vlllasrep diff --git a/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd b/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd index 8ac5de86..c6847230 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/camera_rig.gd @@ -10,11 +10,6 @@ extends Node3D const RunnerGame = preload("res://scenes/runner/runner_game.gd") const RunConfig = preload("res://scenes/runner/data/run_config.gd") -## Contract §11.10 fallbacks, used only when no config has been supplied yet. -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, @@ -79,6 +74,47 @@ const BOSS_SHOTS := { } } +## Contract 2.7 §11.15: the run intro's shot table is frontend data. It lives in this script once it +## exists — `const SHOTS := {&"full": {cut, ret, keys}, &"short": {...}}`, rows in the BOSS_SHOTS +## layout — and is read through ResourceLoader.exists + load(), never preloaded, so the backend runs +## without it on the built-in fallback below. +const RUN_INTRO_SHOTS_PATH := "res://scenes/runner/data/run_intro_shots.gd" +## The built-in intro shots, used per mode whenever the frontend table is missing or lacks the mode. +## Unlike a boss shot the lens is placed off Hoppy's feet (`focus`): `distance` metres behind him +## along +Z, rotated `orbit` degrees around him, so orbit ±180 looks back at him from down the corridor. +## The rest pose is [8.0, 4.0, 0.0, 60.0, 2.5]: the gameplay camera at (0, 4, 8) aimed 2.5 m above the +## feet, which is the same line as the follow's look-at (0, 1, -8). Side-on keys stay within 3.4 m of +## Hoppy so the lens never enters the corridor walls (x ±4.4). `cut` 0 is a hard cut on frame one. +const RUN_INTRO_FALLBACK_SHOTS := { + &"full": { + "cut": 0.0, + "ret": 0.86, + "keys": [ + # Open 14 m down the corridor looking back at Hoppy, push in for the title card, rise over + # his shoulder side-on, then land on the rest pose behind him for the 3-2-1-GO. + [0.0, 14.0, 2.2, -180.0, 58.0, 1.2], + [0.28, 6.0, 1.6, -180.0, 50.0, 1.1], + [0.45, 5.6, 1.7, -175.0, 50.0, 1.1], + [0.55, 2.5, 5.0, -90.0, 60.0, 1.5], + [0.7, 8.0, 4.0, 0.0, 60.0, 2.5], + [1.0, 8.0, 4.0, 0.0, 60.0, 2.5] + ] + }, + &"short": { + "cut": 0.0, + "ret": 0.8, + "keys": [ + # A gentle push down onto the rest pose. + [0.0, 10.0, 5.0, 0.0, 64.0, 2.6], + [0.6, 8.0, 4.0, 0.0, 60.0, 2.5], + [1.0, 8.0, 4.0, 0.0, 60.0, 2.5] + ] + } +} +## The shot table, read once per session on first use. +static var _run_intro_shots: Dictionary = {} +static var _run_intro_shots_read := false + @onready var camera: Camera3D = $Camera @export var follow_smoothing := 8.0 @@ -98,10 +134,6 @@ var trauma_decay := 0.0 var fov_punch := 0.0 var fov_punch_decay := 0.0 var rng := RandomNumberGenerator.new() -## 0..1 through the cinematic turn; 0 outside one (contract §11.9). -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 = &"" @@ -109,17 +141,25 @@ var boss_mode: StringName = &"" 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 +## 0..1 through the run intro and its mode, &"" outside one (contract 2.7 §11.15). +var intro_progress := 0.0 +var intro_mode: StringName = &"" +## Hoppy's feet, in this rig's space, that the intro lens is placed off and aims above. +var intro_focus := Vector3.ZERO +## Set when an intro opens; the next tick snaps the interpolated camera onto its first shot. +var _intro_cut := false ## Where the rig wants the camera before shake, refreshed in _physics_process. var _rest_position := Vector3(0, 4, 8) -## The pose _aim() left behind, so the turn swing is applied on top of it and never accumulates. +## The rest rotation _aim() left behind. Every tick starts from it, so a boss shot blended on top +## never accumulates. var _base_rotation := Vector3.ZERO func setup(runner: RunnerGame) -> void: game = runner config = runner.config # The authored pose is the fallback resting rotation, so a rig without a config still keeps the - # scene's pitch instead of snapping to zero the first time the turn swing resolves. + # scene's pitch instead of snapping to zero on its first tick. if camera != null: _base_rotation = camera.rotation if config != null: @@ -135,12 +175,14 @@ func reset() -> void: trauma_decay = 0.0 fov_punch = 0.0 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 + intro_progress = 0.0 + intro_mode = &"" + intro_focus = Vector3.ZERO + _intro_cut = false _rest_position = base_position if camera != null: camera.position = base_position @@ -163,56 +205,8 @@ func _aim(target: Vector3) -> void: camera.rotation = _base_rotation camera.reset_physics_interpolation() -## The cinematic turn swing (contract §11.9). The StageManager calls this every physics tick of the -## `turn` phase and once with (0, 0) at each end of it; the swing profile is zero at both ends, so -## at p = 0 and p = 1 the camera sits exactly where the follow left it. -func set_turn(progress: float, direction: int) -> void: - turn_progress = clampf(progress, 0.0, 1.0) - turn_direction = signi(direction) - -## The ramp-hold-return swing envelope (contract §11.9): a smoothstep up over the first -## `turn_swing_in_fraction` of the bend, a hold at the peak, a smoothstep back down over the last -## `turn_swing_out_fraction`. That is what makes the corner read as a committed turn rather than as -## a wave — the camera is pointed into the corner for the whole middle of the bend. -## -## The result is exactly 0 at p = 0 and p = 1 whatever the fractions are, so the camera can never be -## left displaced: the endpoints are answered before the ramps, which also covers an authored 0/0 -## that would otherwise leave a step at each end. Fractions that together exceed the whole bend are -## scaled down in proportion, so the hold shrinks to nothing rather than the ramps overlapping. -func turn_swing_profile(progress: float) -> float: - var p := clampf(progress, 0.0, 1.0) - if p <= 0.0 or p >= 1.0: - return 0.0 - var rise := clampf(turn_swing_in_fraction(), 0.0, 1.0) - var fall := clampf(turn_swing_out_fraction(), 0.0, 1.0) - var total := rise + fall - if total > 1.0: - rise /= total - fall /= total - if rise > 0.0 and p < rise: - return smoothstep(0.0, 1.0, p / rise) - if fall > 0.0 and p > 1.0 - fall: - return smoothstep(0.0, 1.0, (1.0 - p) / fall) - return 1.0 - -func turn_camera_yaw_degrees() -> float: - return float(config.turn_camera_yaw_degrees) if config != null else DEFAULT_TURN_CAMERA_YAW - -func turn_camera_bank_degrees() -> float: - return float(config.turn_camera_bank_degrees) if config != null else DEFAULT_TURN_CAMERA_BANK - -func turn_swing_in_fraction() -> float: - if config == null or not (&"turn_swing_in_fraction" in config): - return DEFAULT_TURN_SWING_IN_FRACTION - return float(config.turn_swing_in_fraction) - -func turn_swing_out_fraction() -> float: - if config == null or not (&"turn_swing_out_fraction" in config): - 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. +## the `boss` phase, 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 @@ -226,8 +220,8 @@ func set_boss_cinematic(progress: float, mode: StringName, boss_position: Vector 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. +## BOSS_SHOTS). `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, {}) @@ -271,6 +265,69 @@ static func _shot_pose(keys: Array, progress: float) -> Array: return pose return keys[keys.size() - 1] +## The run intro shot (contract 2.7 §11.15). The StageManager calls this every physics tick of the +## `intro` phase and with (0, &"") once the intro is over. `focus` is Hoppy's global position (his +## feet). A fresh intro hard-cuts in, so the camera never slides over from wherever it was. +func set_run_intro(progress: float, mode: StringName, focus: Vector3) -> void: + if intro_mode == &"" and mode != &"": + _intro_cut = true + intro_progress = clampf(progress, 0.0, 1.0) + intro_mode = mode + intro_focus = to_local(focus) if is_inside_tree() else focus + +## Pure shot curve for the run intro: {blend, distance, height, orbit, fov, look_bias}, from the +## frontend table when it has `mode`, else from RUN_INTRO_FALLBACK_SHOTS. `blend` is exactly 0 at +## p = 1 and for an unknown or empty mode; at p = 0 it is 1 when `cut` is 0 (a hard cut) and 0 +## otherwise, ramping in over [0, cut] and out over [ret, 1] as a boss shot does. +static func run_intro_shot(p: float, mode: StringName) -> Dictionary: + var shot := {"blend": 0.0, "distance": 0.0, "height": 0.0, "orbit": 0.0, "fov": 0.0, "look_bias": 0.0} + if mode == &"": + return shot + var table: Variant = run_intro_shots().get(mode, RUN_INTRO_FALLBACK_SHOTS.get(mode, {})) + if not (table is Dictionary): + return shot + var keys: Variant = table.get("keys", []) + if not (keys is Array) or keys.is_empty(): + return shot + var progress := clampf(p, 0.0, 1.0) + 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 >= 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 frontend's SHOTS table when its script exists, else the built-in one. Read once: the script +## is loaded only for its constant map and not kept. +static func run_intro_shots() -> Dictionary: + if _run_intro_shots_read: + return _run_intro_shots + _run_intro_shots_read = true + _run_intro_shots = RUN_INTRO_FALLBACK_SHOTS + if not ResourceLoader.exists(RUN_INTRO_SHOTS_PATH): + return _run_intro_shots + var script := load(RUN_INTRO_SHOTS_PATH) as Script + if script == null: + push_warning("Run intro shots at %s are not a script; using the built-in shots" % RUN_INTRO_SHOTS_PATH) + return _run_intro_shots + var table: Variant = script.get_script_constant_map().get("SHOTS", null) + if table is Dictionary and not table.is_empty(): + _run_intro_shots = table + else: + push_warning("Run intro shots at %s have no SHOTS table; using the built-in shots" % RUN_INTRO_SHOTS_PATH) + return _run_intro_shots + func shake(strength: float, duration := 0.35) -> void: if duration <= 0.0 or strength <= 0.0: return @@ -305,10 +362,18 @@ func _physics_process(delta: float) -> void: var amount := trauma * trauma * shake_amplitude offset = Vector3(rng.randf_range(-amount, amount), rng.randf_range(-amount, amount), 0.0) camera.position = _rest_position + offset - _apply_turn_swing() + camera.rotation = _base_rotation var shot := boss_shot(boss_progress, boss_mode) var blend := float(shot["blend"]) _apply_boss_shot(shot, blend, offset) + # The run intro and a boss cinematic never overlap (different phases); the intro, when live, + # frames the camera instead. + if intro_mode != &"": + var intro := run_intro_shot(intro_progress, intro_mode) + if float(intro["blend"]) > 0.0: + shot = intro + blend = float(intro["blend"]) + _apply_shot(intro_focus, intro_focus, shot, blend, offset) if fov_punch > 0.0: fov_punch = maxf(fov_punch - fov_punch_decay * delta, 0.0) var speed_boost := 0.0 @@ -317,33 +382,26 @@ func _physics_process(delta: float) -> void: 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 -## and spends its middle held at the peak. -func _apply_turn_swing() -> void: - var swing := float(turn_direction) * turn_swing_profile(turn_progress) - if is_zero_approx(swing): - camera.rotation = _base_rotation - return - var yaw := deg_to_rad(turn_camera_yaw_degrees()) * swing - var bank := deg_to_rad(turn_camera_bank_degrees()) * swing - var pivot := Vector3.ZERO - if game != null and is_instance_valid(game.player): - pivot = game.player.position - camera.position = pivot + (camera.position - pivot).rotated(Vector3.UP, yaw) - camera.rotation = _base_rotation + Vector3(0.0, yaw, bank) + if _intro_cut: + # The intro's first frame is a cut, not a move: drop the interpolated previous pose. + _intro_cut = false + camera.reset_physics_interpolation() ## 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: + _apply_shot(boss_target, boss_focus, shot, blend, offset) + +## The shared lens placement: `distance` metres from `target` along +Z rotated `orbit` degrees about +## it, `height` metres up, aimed at `focus` plus `look_bias`, mixed with the follow pose by `blend`. +func _apply_shot(target: Vector3, focus: Vector3, 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 lens := target + Vector3(0.0, float(shot["height"]), float(shot["distance"])).rotated(Vector3.UP, orbit) + var aim := 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 diff --git a/hoppys-adventure-arcade/scenes/runner/systems/score_manager.gd b/hoppys-adventure-arcade/scenes/runner/systems/score_manager.gd index e8c03956..302443d3 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/score_manager.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/score_manager.gd @@ -93,10 +93,12 @@ func base_rate() -> float: return float(config.base_score_rate) if config != null else 0.0 ## base_score_rate x multiplier x delta, emitted only when the integer score or the multiplier -## changes (contract §4.1). +## changes (contract §4.1). Nothing accrues during the run intro (contract 2.7 §11.15). func _physics_process(delta: float) -> void: if game == null or game.state != &"running": return + if &"run_intro_mode" in game and StringName(game.get(&"run_intro_mode")) != &"": + return var value := current_multiplier() var multiplier_changed := not is_equal_approx(value, multiplier) multiplier = value diff --git a/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd b/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd index a8849994..1296a3d7 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/stage_manager.gd @@ -1,7 +1,13 @@ extends Node -## The run-phase state machine: run -> turn -> boss -> stage_clear -> run (contract §11.1, -## systems spec §10.1). It owns `phase`, the stage counters and `run_clock`, and it is the only -## node that drives the TurnRig, the camera swing and the boss encounter. +## The run-phase state machine: [intro ->] run -> (drain) -> boss -> stage_clear -> run (contract +## §11.1, systems spec §10.1). It owns `phase`, the stage counters and `run_clock`, and it is the +## only node that drives the boss encounter and the camera's boss and run-intro shots. The corridor +## never turns: the drain at the end of `run` empties it and the boss phase follows on the same tick. +## +## The run intro (contract 2.7 §11.15) opens a run in front of stage 1: the full one on a fresh run, +## the short one on a retry. A boss-checkpoint retry has none; the boss's own short intro is its +## lead-in. The intro holds the player through the same cinematic hold the boss cinematics use until +## its last (GO) beat. ## ## `state` (ready/running/caught/finished) stays the facade's; `phase` is orthogonal to it. A catch ## aborts whatever phase is live back to `idle`. @@ -20,17 +26,15 @@ const BossDefs = preload("res://scenes/runner/boss/boss_defs.gd") ## Contract §11.1 phases. const PHASE_IDLE: StringName = &"idle" +const PHASE_INTRO: StringName = &"intro" const PHASE_RUN: StringName = &"run" -const PHASE_TURN: StringName = &"turn" const PHASE_BOSS: StringName = &"boss" const PHASE_STAGE_CLEAR: StringName = &"stage_clear" +## Contract 2.7 §11.15 run intro modes; `run_intro_mode` is &"" outside an intro. +const INTRO_FULL: StringName = &"full" +const INTRO_SHORT: StringName = &"short" -## Mixed into the run seed so the turn directions and the boss's lane rolls are reproducible but -## never in step with the spawner's stream. -const STAGE_SEED_MIX := 0x5F356495 ## Contract §11.10 fallbacks, used only when no config has been supplied yet. -const DEFAULT_TURN_SECONDS := 2.4 -const DEFAULT_TURN_WARNING_SECONDS := 3.0 const DEFAULT_STAGE_DRAIN_DISTANCE := 120.0 const DEFAULT_STAGE_DRAIN_TIMEOUT := 6.0 const DEFAULT_STAGE_CLEAR_LINE_Z := 8.0 @@ -40,6 +44,14 @@ 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 +const DEFAULT_RUN_INTRO_FULL_SECONDS := 6.0 +const DEFAULT_RUN_INTRO_SHORT_SECONDS := 2.2 +const DEFAULT_RUN_INTRO_SKIP_AFTER := 0.3 +const DEFAULT_RUN_INTRO_SKIP_TAIL := 0.4 +const DEFAULT_RUN_INTRO_FULL_BEATS: Array[String] = ["title", "3", "2", "1", "GO!"] +const DEFAULT_RUN_INTRO_FULL_BEAT_TIMES: Array[float] = [1.0, 3.0, 3.8, 4.6, 5.4] +const DEFAULT_RUN_INTRO_SHORT_BEATS: Array[String] = ["READY", "GO!"] +const DEFAULT_RUN_INTRO_SHORT_BEAT_TIMES: Array[float] = [1.0, 1.6] ## Contract §11.5: the Boss node's hover height, the camera's fallback target with no boss node. const BOSS_CENTER_Y := 2.6 @@ -54,42 +66,40 @@ var stage_index := 0 var stage_name: StringName = &"" var stage_distance := 0.0 var stage_distance_target := 0.0 -## RUN-phase seconds only: frozen through turn, boss and stage_clear (contract §11.1). +## RUN-phase seconds only: frozen through boss and stage_clear (contract §11.1). var run_clock := 0.0 -## -1 left, +1 right for the turn this seam is about to take, 0 when none is pending. Set when the -## 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. +## (contract 2.6 §11.14), and from run_intro until its GO beat or skip (2.7 §11.15): manual controls +## are off and the corridor eases to boss_cinematic_speed_scale for exactly as long as this holds. var cinematic_hold := false +## Contract 2.7 §11.15, mirrored onto the facade: the intro playing (&"" outside one) and 0..1 through it. +var run_intro_mode: StringName = &"" +var run_intro_progress := 0.0 # 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. var stages_cleared := 0 -var turns_taken := 0 var bosses_defeated := 0 var bosses_escaped := 0 var tools_used := 0 var boss_damage_dealt := 0 -var stage_rng := RandomNumberGenerator.new() ## Seconds inside the current phase. In `run` it only starts once the distance target is reached, ## so it measures the drain window and nothing else. var _phase_timer := 0.0 -## -1 left, +1 right; 0 outside a turn. -var _turn_direction := 0 -## The direction of the last bend taken, which outlives the turn so the boss checkpoint can replay -## the same corner on its lead-in (contract §11.13). -var _last_turn_direction := 0 ## True between a successful boss_encounter.start() and the end of the fight. var _boss_active := false ## True once the spawner has been told to stop placing for this stage. var _draining := false -## True once turn_incoming has gone out for this seam, so the warning fires exactly once. -var _turn_announced := false +## The intro's own clock and length, in seconds; a skip jumps the clock to the tail. +var _intro_clock := 0.0 +var _intro_seconds := 0.0 +## Index of the next run_intro_beat to send; beats always go out in order. +var _intro_next_beat := 0 +var _intro_labels: Array[String] = [] +var _intro_times: Array[float] = [] +var _intro_skipped := false func setup(runner: RunnerGame) -> void: game = runner @@ -120,55 +130,54 @@ func _connect(runner: RunnerGame) -> void: ## Called from RunnerGame.start_run() alongside the other system resets; begin() follows once every ## system is back at zero. func reset(run_seed: int) -> void: - stage_rng.seed = run_seed ^ STAGE_SEED_MIX + # A restart mid-intro still owes its run_intro_ended (exactly one per run_intro). + _end_intro() stage_index = 0 stage_name = &"" stage_distance = 0.0 stage_distance_target = 0.0 run_clock = 0.0 stages_cleared = 0 - turns_taken = 0 bosses_defeated = 0 bosses_escaped = 0 tools_used = 0 boss_damage_dealt = 0 _phase_timer = 0.0 - _turn_direction = 0 - _last_turn_direction = 0 _draining = false - _turn_announced = false # 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() ## Enter stage 1. RunnerGame calls this after every system reset, so the first stage_started lands -## with the run already live. -func begin() -> void: +## with the run already live. `intro_mode` (&"full" / &"short") opens the run on that run intro first, +## when run_intro_enabled allows; &"" goes straight to `run`. +func begin(intro_mode: StringName = &"") -> void: stage_index = 1 + if _intro_allowed(intro_mode): + # Mirrors first, so the intro's listeners already read stage 1 off the facade. + stage_distance_target = StageDefs.distance_target(stage_index, config) + stage_name = StageDefs.boss_name(stage_index) + _enter_intro(intro_mode) + return _enter_run() -## Contract §11.13: open a run on the turn into the boss a checkpoint was taken at, instead of -## stage 1. RunnerGame calls this in place of begin() once every system is reset and the facade has -## restored its own half of the checkpoint. The bend is the readable lead-in; _enter_boss() then runs -## as normal and takes a fresh checkpoint, so a boss can be retried any number of times. +## Contract §11.13: open a run in front of the boss a checkpoint was taken at, instead of stage 1. +## RunnerGame calls this in place of begin() once every system is reset and the facade has restored +## its own half of the checkpoint. There is no run intro here (contract 2.7 §11.15): the boss's +## short intro cinematic is the lead-in. _enter_boss() then runs as normal and takes a fresh +## checkpoint, so a boss can be retried any number of times. func begin_at_boss(checkpoint: Dictionary) -> void: stage_index = maxi(int(checkpoint.get("stage_index", 1)), 1) - # Only this path re-seeds: the turn and lane rolls of a replayed stage depend on the stage and - # the run seed alone, never on how many draws the original run had made before it. - stage_rng.seed = int(checkpoint.get("seed", 0)) ^ STAGE_SEED_MIX ^ stage_index stage_name = StageDefs.boss_name(stage_index) stage_distance_target = StageDefs.distance_target(stage_index, config) stage_distance = stage_distance_target run_clock = maxf(float(checkpoint.get("run_clock", 0.0)), 0.0) stages_cleared = int(checkpoint.get("stages_cleared", 0)) - # The snapshot was taken after this stage's bend was counted, and the lead-in counts it again. - turns_taken = maxi(int(checkpoint.get("turns_taken", 0)) - 1, 0) bosses_defeated = int(checkpoint.get("bosses_defeated", 0)) bosses_escaped = int(checkpoint.get("bosses_escaped", 0)) tools_used = int(checkpoint.get("tools_used", 0)) @@ -192,17 +201,11 @@ func begin_at_boss(checkpoint: Dictionary) -> void: _set_spawning(false) _clear_blocking_hazards() _draining = true - _turn_announced = true - next_turn_direction = signi(int(checkpoint.get("next_turn_direction", 0))) - if next_turn_direction == 0: - next_turn_direction = stage_rng.randi_range(0, 1) * 2 - 1 _publish() - # The HUDs key the stage label and the stage-clear card off stage_started, and the turn card off - # turn_incoming; both go out once so they read this stage before the bend. There is no drain to - # count down, so the turn is announced as already here. + # The HUDs key the stage label and the stage-clear card off stage_started, so it goes out once + # here and they read this stage before the fight opens. game.stage_started.emit(stage_index, stage_name, _stage_base_speed()) - game.turn_incoming.emit(next_turn_direction, 0.0) - _enter_turn() + _enter_boss() ## The run as it stood on boss entry (contract §11.13): what begin_at_boss() and the facade's ## _restore_checkpoint() need to put a retry back here. HP is deliberately not part of it. @@ -223,12 +226,10 @@ func _snapshot() -> Dictionary: "hazards_dodged": int(game.hazards_dodged), "hazards_blasted_total": int(game.hazards_blasted_total), "stages_cleared": stages_cleared, - "turns_taken": turns_taken, "bosses_defeated": bosses_defeated, "bosses_escaped": bosses_escaped, "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. @@ -287,10 +288,10 @@ func _physics_process(delta: float) -> void: if game.paused or game.state != &"running": return match phase: + PHASE_INTRO: + _tick_intro(delta) PHASE_RUN: _tick_run(delta) - PHASE_TURN: - _tick_turn(delta) PHASE_BOSS: _tick_boss(delta) PHASE_STAGE_CLEAR: @@ -299,23 +300,26 @@ func _physics_process(delta: float) -> void: # --- phases --- +## The intro's clock runs in real time (paused with the run), sends each beat as its time is reached +## and frames the camera on the same tick; the last tick hands over to `run`, or to the boss on a +## checkpoint retry. +func _tick_intro(delta: float) -> void: + _intro_clock = minf(_intro_clock + maxf(delta, 0.0), _intro_seconds) + run_intro_progress = _intro_ratio() + _send_due_beats() + _apply_intro_camera() + if _intro_clock >= _intro_seconds: + _finish_intro() + ## Distance accrues at the authoritative speed; the spawner is switched off a drain's worth of -## metres early so the corridor is empty by the time the turn starts. +## metres early so the corridor is empty by the time the boss arrives. func _tick_run(delta: float) -> void: run_clock += delta stage_distance += float(game.speed) * delta stage_distance_target = StageDefs.distance_target(stage_index, config) if not _draining and stage_distance >= stage_distance_target - _drain_distance(): _draining = true - # The direction is drawn here rather than at the bend, so the warning below and the - # turn_started that follows it can never disagree (contract §11.4). - next_turn_direction = stage_rng.randi_range(0, 1) * 2 - 1 - _publish() _set_spawning(false) - # Announce the corner turn_warning_seconds of travel out. A drain shorter than that window is - # already inside it when it opens, so the warning lands on the same tick as the drain gate. - if _draining and not _turn_announced and stage_distance >= stage_distance_target - _turn_warning_distance(): - _announce_turn() if stage_distance < stage_distance_target: return # Past the target the drain window opens: wait for the last blocking hazard to pass the clear @@ -323,25 +327,10 @@ func _tick_run(delta: float) -> void: _phase_timer += delta if _phase_timer >= _drain_timeout(): _clear_blocking_hazards() - _enter_turn() + _enter_boss() return if not _has_blocking_hazard_ahead(): - _enter_turn() - -func _tick_turn(delta: float) -> void: - _phase_timer += delta - var seconds := _turn_seconds() - var progress := 1.0 if seconds <= 0.0 else clampf(_phase_timer / seconds, 0.0, 1.0) - _apply_turn(progress) - if progress < 1.0: - return - turns_taken += 1 - _apply_turn_reset() - # Mirrors before the signal: a listener on turn_finished must not still read the direction of - # the bend that just ended off next_turn_direction. - _publish() - game.turn_finished.emit() - _enter_boss() + _enter_boss() func _tick_boss(delta: float) -> void: var arena := _boss_encounter() @@ -363,10 +352,94 @@ func _tick_stage_clear(delta: float) -> void: # --- transitions --- +## Open the run intro: hazard-free, the player held and the corridor eased down until the GO beat. +func _enter_intro(mode: StringName) -> void: + _phase_timer = 0.0 + run_intro_mode = mode + _intro_seconds = _intro_length(mode) + _intro_clock = 0.0 + _intro_next_beat = 0 + _intro_skipped = false + _intro_labels = _intro_beat_labels(mode) + _intro_times = _intro_beat_times(mode, _intro_labels.size()) + run_intro_progress = 0.0 + _set_spawning(false) + _publish() + _set_phase(PHASE_INTRO) + _set_cinematic_hold(true, true) + # The first frame is framed on the tick the intro opens; the camera hard-cuts in. + _apply_intro_camera() + game.run_intro.emit(mode, _intro_seconds) + # A beat authored at 0 s goes out with the intro rather than a tick late. + _send_due_beats() + +## The intro has played out (or its skip tail has): close it and open stage 1's run. +func _finish_intro() -> void: + _end_intro() + _enter_run() + +## Close a live intro on every exit path (played out, skipped tail done, a catch, a new run): the hold +## and the camera go back and run_intro_ended goes out exactly once. A no-op when none is live. +func _end_intro() -> void: + if run_intro_mode == &"": + return + run_intro_mode = &"" + run_intro_progress = 0.0 + _intro_clock = 0.0 + _intro_seconds = 0.0 + _intro_next_beat = 0 + _intro_skipped = false + _set_cinematic_hold(false) + _apply_intro_camera_reset() + _publish() + if game != null: + game.run_intro_ended.emit() + +## Every beat whose time has been reached, in order. The last one is GO, which hands the player back +## before it is announced, so a listener on it already reads live controls. +func _send_due_beats() -> void: + while run_intro_mode != &"" and _intro_next_beat < _intro_labels.size() \ + and _intro_clock >= minf(_intro_times[_intro_next_beat], _intro_seconds): + _send_beat(_intro_next_beat) + +func _send_beat(index: int) -> void: + _intro_next_beat = index + 1 + if _intro_next_beat >= _intro_labels.size(): + _set_cinematic_hold(false) + _publish() + game.run_intro_beat.emit(index, _intro_labels[index]) + +## Contract 2.7 §11.15: true once the intro has played run_intro_skip_after seconds, until it is +## skipped, its GO beat has gone out, or it reaches its last run_intro_skip_tail seconds. Closing on +## GO matters: controls are live from GO, so a jump pressed on it must be a jump, not a skip. The +## facade adds the running/paused gate. +func can_skip_run_intro() -> bool: + if phase != PHASE_INTRO or run_intro_mode == &"" or _intro_skipped: + return false + if _intro_next_beat >= _intro_labels.size(): + return false + return _intro_clock >= _intro_skip_after() and _intro_clock < _intro_seconds - _intro_skip_tail() + +## Jump to the tail: the player gets control back at once, the beats jumped over are never sent, and +## the GO beat still is, right after run_intro_skipped, so a GO cue plays on every intro. +func skip_run_intro() -> bool: + if not can_skip_run_intro(): + return false + _intro_skipped = true + _intro_clock = maxf(_intro_clock, _intro_seconds - _intro_skip_tail()) + run_intro_progress = _intro_ratio() + _set_cinematic_hold(false) + _publish() + _apply_intro_camera() + game.run_intro_skipped.emit() + var last := _intro_labels.size() - 1 + if _intro_next_beat <= last: + _send_beat(last) + return true + func _enter_run() -> void: _phase_timer = 0.0 _draining = false - _turn_announced = false stage_distance = 0.0 stage_distance_target = StageDefs.distance_target(stage_index, config) stage_name = StageDefs.boss_name(stage_index) @@ -388,20 +461,6 @@ func _enter_run() -> void: _set_spawning(true) game.stage_started.emit(stage_index, stage_name, _stage_base_speed()) -## The bend takes the direction the drain drew, so it matches the turn_incoming that announced it. -## A seam that somehow reached the bend without a drain pick (a zero-length stage, a hand-driven -## test) draws one here rather than turning by 0. -func _enter_turn() -> void: - _phase_timer = 0.0 - if next_turn_direction == 0: - next_turn_direction = stage_rng.randi_range(0, 1) * 2 - 1 - _turn_direction = next_turn_direction - _last_turn_direction = _turn_direction - _publish() - _set_phase(PHASE_TURN) - _apply_turn(0.0) - game.turn_started.emit(_turn_direction, _turn_seconds()) - ## The boss phase is driven entirely through has_method guards. Without a real encounter it ends on ## the tick it starts: no boss_spawned, no boss_retreated, and the stage clears anyway. func _enter_boss() -> void: @@ -426,7 +485,7 @@ 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. + # unconditional. _set_cinematic_hold(false) _apply_boss_camera_reset() _set_boss_arena(false) @@ -442,40 +501,21 @@ func _enter_stage_clear() -> void: score.add_bonus(bonus, &"stage_clear") game.stage_cleared.emit(stage_index, bonus) -## A catch aborts any phase back to idle: the fight is dropped without bonuses and the rig unbends. +## A catch aborts any phase back to idle: the fight is dropped without bonuses. ## The boss checkpoint is deliberately left alone, so a catch mid-fight can offer Retry Boss. func _on_caught() -> void: if phase == PHASE_IDLE: return + _end_intro() _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() -## turn_incoming, once per seam (contract §11.4). `seconds` is the estimated time left to the bend -## at the corridor's current speed, which is what a frontend counts an announcement card down on. -func _announce_turn() -> void: - _turn_announced = true - var remaining := maxf(stage_distance_target - stage_distance, 0.0) - var pace := _turn_pace() - var seconds := remaining / pace if pace > 0.0 else 0.0 - game.turn_incoming.emit(next_turn_direction, seconds) - -## The speed the warning is measured at: the corridor's own, floored at the stage's base speed so a -## stalled or hand-driven facade still gets a sane lead rather than an infinite one. -func _turn_pace() -> float: - var current := float(game.speed) if game != null else 0.0 - return maxf(current, _stage_base_speed()) - -## Metres of run left when the warning goes out: turn_warning_seconds of travel at that pace. -func _turn_warning_distance() -> float: - return maxf(_turn_pace() * _turn_warning_seconds(), 0.0) - func _on_boss_defeated(_kind: StringName, _bonus: int) -> void: bosses_defeated += 1 @@ -530,29 +570,6 @@ func _corridor() -> Node: return null return corridor -func _apply_turn(progress: float) -> void: - var rig: Node = game.turn_rig - if rig != null and is_instance_valid(rig) and rig.has_method("set_turn"): - rig.set_turn(progress, _turn_direction) - var camera: Node = game.camera_rig - if camera != null and is_instance_valid(camera) and camera.has_method("set_turn"): - camera.set_turn(progress, _turn_direction) - -## Both rigs back to square, and no turn pending any more. The camera swing profile is zero at both -## ends of the bend, so p = 1 and p = 0 already agree; this is what makes that explicit on every -## exit path, including a catch mid-turn. -func _apply_turn_reset() -> void: - _turn_direction = 0 - next_turn_direction = 0 - if game == null: - return - var rig: Node = game.turn_rig - if rig != null and is_instance_valid(rig) and rig.has_method("set_turn"): - rig.set_turn(0.0, 0) - var camera: Node = game.camera_rig - if camera != null and is_instance_valid(camera) and camera.has_method("set_turn"): - camera.set_turn(0.0, 0) - func _set_spawning(on: bool) -> void: if game == null: return @@ -602,8 +619,9 @@ func _reset_boss() -> void: ## 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: +## gives both back. Idempotent, so every exit path can call it. `snap` sets the speed at once instead +## of easing it, for an intro that opens a run, which has no running speed to ease down from. +func _set_cinematic_hold(on: bool, snap := false) -> void: if cinematic_hold == on: return cinematic_hold = on @@ -613,10 +631,14 @@ func _set_cinematic_hold(on: bool) -> void: 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. + var target := _cinematic_speed_scale() if on else 1.0 + if snap: + controller.set_cinematic_scale(target, 0.0) + else: + controller.set_cinematic_scale(target) + +## The camera follows the encounter's cinematic clock on every boss tick; outside a cinematic it is +## handed (0, &""), which the shot curve never blends. func _apply_boss_camera() -> void: if game == null: return @@ -635,6 +657,29 @@ func _apply_boss_camera_reset() -> void: if camera != null and is_instance_valid(camera) and camera.has_method("set_boss_cinematic"): camera.set_boss_cinematic(0.0, &"", Vector3.ZERO) +## The run intro's shot follows its clock, aimed off Hoppy's feet (contract 2.7 §11.15). +func _apply_intro_camera() -> void: + if game == null or run_intro_mode == &"": + return + var camera: Node = game.camera_rig + if camera == null or not is_instance_valid(camera) or not camera.has_method("set_run_intro"): + return + camera.set_run_intro(run_intro_progress, run_intro_mode, _intro_focus()) + +func _apply_intro_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_run_intro"): + camera.set_run_intro(0.0, &"", Vector3.ZERO) + +## Hoppy's feet, or the lane origin with no player. +func _intro_focus() -> Vector3: + var player: Node3D = game.player as Node3D if game != null else null + if player != null and is_instance_valid(player) and player.is_inside_tree(): + return player.global_position + return 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): @@ -710,7 +755,8 @@ func _publish() -> void: game.stage_distance = stage_distance game.stage_distance_target = stage_distance_target game.run_clock = run_clock - game.next_turn_direction = next_turn_direction + game.run_intro_mode = run_intro_mode + game.run_intro_progress = run_intro_progress # Contract 2.6 §11.14 read-only mirrors, straight off the encounter's clock. var arena := _boss_encounter() var mode := _cinematic_mode(arena) @@ -733,12 +779,6 @@ func _stage_clear_bonus() -> int: var step := _int_config(&"stage_clear_bonus_per_stage", DEFAULT_STAGE_CLEAR_BONUS_PER_STAGE) return maxi(base + step * (StageDefs.normalize_index(stage_index) - 1), 0) -func _turn_seconds() -> float: - return _float_config(&"turn_seconds", DEFAULT_TURN_SECONDS) - -func _turn_warning_seconds() -> float: - return maxf(_float_config(&"turn_warning_seconds", DEFAULT_TURN_WARNING_SECONDS), 0.0) - func _drain_distance() -> float: return maxf(_float_config(&"stage_drain_distance", DEFAULT_STAGE_DRAIN_DISTANCE), 0.0) @@ -754,6 +794,57 @@ 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) +## A mode plays only when the config allows intros and it is one of the two known modes. +func _intro_allowed(mode: StringName) -> bool: + if mode != INTRO_FULL and mode != INTRO_SHORT: + return false + if config != null and &"run_intro_enabled" in config and not bool(config.get(&"run_intro_enabled")): + return false + return _intro_length(mode) > 0.0 + +func _intro_length(mode: StringName) -> float: + if mode == INTRO_FULL: + return maxf(_float_config(&"run_intro_full_seconds", DEFAULT_RUN_INTRO_FULL_SECONDS), 0.0) + return maxf(_float_config(&"run_intro_short_seconds", DEFAULT_RUN_INTRO_SHORT_SECONDS), 0.0) + +func _intro_ratio() -> float: + return clampf(_intro_clock / _intro_seconds, 0.0, 1.0) if _intro_seconds > 0.0 else 1.0 + +func _intro_skip_after() -> float: + return maxf(_float_config(&"run_intro_skip_after", DEFAULT_RUN_INTRO_SKIP_AFTER), 0.0) + +func _intro_skip_tail() -> float: + return clampf(_float_config(&"run_intro_skip_tail", DEFAULT_RUN_INTRO_SKIP_TAIL), 0.0, _intro_seconds) + +func _intro_beat_labels(mode: StringName) -> Array[String]: + var labels: Array[String] = [] + var key := &"run_intro_full_beats" if mode == INTRO_FULL else &"run_intro_short_beats" + var value: Variant = config.get(key) if config != null and key in config else null + if value is PackedStringArray or value is Array: + for label: Variant in value: + labels.append(String(label)) + else: + labels.assign(DEFAULT_RUN_INTRO_FULL_BEATS if mode == INTRO_FULL else DEFAULT_RUN_INTRO_SHORT_BEATS) + return labels + +## One time per label, never decreasing, so the beats always go out in index order; missing times fall +## at the end of the intro. +func _intro_beat_times(mode: StringName, count: int) -> Array[float]: + var key := &"run_intro_full_beat_times" if mode == INTRO_FULL else &"run_intro_short_beat_times" + var value: Variant = config.get(key) if config != null and key in config else null + var source: Array = [] + if value is PackedFloat32Array or value is PackedFloat64Array or value is Array: + source.assign(Array(value)) + else: + source.assign(DEFAULT_RUN_INTRO_FULL_BEAT_TIMES if mode == INTRO_FULL else DEFAULT_RUN_INTRO_SHORT_BEAT_TIMES) + var times: Array[float] = [] + var floor_ := 0.0 + for index in count: + var at := float(source[index]) if index < source.size() else _intro_length(mode) + floor_ = maxf(floor_, maxf(at, 0.0)) + times.append(floor_) + return times + func _cinematic_speed_scale() -> float: return clampf(_float_config(&"boss_cinematic_speed_scale", DEFAULT_BOSS_CINEMATIC_SPEED_SCALE), 0.0, 1.0) diff --git a/hoppys-adventure-arcade/scenes/runner/systems/turn_rig.gd b/hoppys-adventure-arcade/scenes/runner/systems/turn_rig.gd index 72c3e336..8a3354fd 100644 --- a/hoppys-adventure-arcade/scenes/runner/systems/turn_rig.gd +++ b/hoppys-adventure-arcade/scenes/runner/systems/turn_rig.gd @@ -1,48 +1,27 @@ extends Node3D -## World/TurnRig: the only node the cinematic turn ever rotates (contract §11.3, systems §10.5). +## World/TurnRig: the static mount the corridor dressing and the boss arena hang under (contract +## §11.3, systems §10.5). The corridor no longer turns, so the rig never rotates; it keeps its name +## because every mount path, fixture and capture tool addresses World/TurnRig. ## -## Gameplay never turns. Chunks, hazards, projectiles, tools and the player all stay in the -## unrotated frame, so every z test in the backend keeps working; only the corridor dressing and the -## boss arena hang under this rig. When the mounted frontend corridor implements `set_turn` it owns -## the bend and the rig stays at yaw 0; otherwise the rig yaws the graybox environment itself -## (contract §11.9, fallback only). +## Gameplay stays in the World frame: chunks, hazards, projectiles, tools and the player are not +## children of this rig, and RunnerGame.track_z() measures through it so a z test never assumes +## where it sits. const RunnerGame = preload("res://scenes/runner/runner_game.gd") -const RunConfig = preload("res://scenes/runner/data/run_config.gd") -## Contract §11.10 fallback, used only when no config has been supplied yet. -const DEFAULT_TURN_YAW_DEGREES := 90.0 var game: RunnerGame -var config: RunConfig -## 0 at the start of the bend, 1 when it is visually back where it began. -var turn_progress := 0.0 -## -1 left, +1 right, 0 outside a turn. -var turn_direction := 0 -## The mounted frontend corridor, when one exists; null while the graybox is in place. +## The mounted frontend corridor, when one exists; null while the graybox is in place. The +## StageManager forwards phases and the boss arena to it through this reference. var corridor: Node = null func setup(runner: RunnerGame) -> void: game = runner - config = runner.config reset() -## RunnerGame hands the mounted corridor over so the rig can offer it the bend first. +## RunnerGame hands the mounted corridor over once it exists. func set_corridor(node: Node) -> void: corridor = node +## Square and at rest: nothing ever turns this rig, so a restart only has to clear stray rotation. func reset() -> void: - set_turn(0.0, 0) - -## Called every physics tick of the `turn` phase, and once with (0, 0) at both ends of it. -func set_turn(progress: float, direction: int) -> void: - turn_progress = clampf(progress, 0.0, 1.0) - turn_direction = signi(direction) - if corridor != null and is_instance_valid(corridor) and corridor.has_method("set_turn"): - # The frontend owns the visual bend; the rig must stay square or the two would compound. - rotation.y = 0.0 - corridor.set_turn(turn_progress, turn_direction) - return - rotation.y = float(turn_direction) * deg_to_rad(turn_yaw_degrees()) * smoothstep(0.0, 1.0, turn_progress) - -func turn_yaw_degrees() -> float: - return float(config.turn_yaw_degrees) if config != null else DEFAULT_TURN_YAW_DEGREES + rotation = Vector3.ZERO diff --git a/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_a7.gd b/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_a7.gd index 82284b8d..7454f25d 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_a7.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_a7.gd @@ -75,10 +75,6 @@ func run() -> void: fixture.boss_overlay._process(3.0) fixture.boss_spawned.emit(fixture.boss_kind, 140) await create_timer(0.35).timeout - fixture.turn_incoming.emit(1, 3.0) - fixture.hud._process(0.2) - if not await shot("turn-warning"): return - fixture.turn_finished.emit() fixture.boss_overlay.hide() fixture.hud.widgets.hide() diff --git a/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_run_intro.gd b/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_run_intro.gd new file mode 100644 index 00000000..ac2eac35 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_run_intro.gd @@ -0,0 +1,187 @@ +extends SceneTree +## Graphical proof: --always-on-top, --real for the mounted game, --output=. +## Real captures advance the live facade clock, then freeze the tree for a rendered readback. +const Mock = preload("res://scenes/runner/ui/dev/mock_run_intro_game.gd") +const FULL := [0.12, 0.65, 1.20, 2.15, 3.12, 3.93, 4.73, 5.50] +const ORBIT := [2.10, 2.35, 2.60, 2.85] +const SHORT := [0.30, 1.12, 1.70, 2.10] +var folder := "" +var failures := 0 +var game: Node3D +var real_game := false +var frozen := false +var duration := 6.0 +var facade_script: GDScript +var board := "user://capture_run_intro_%d.cfg" % OS.get_process_id() +var telemetry: Array[Dictionary] = [] + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + if not root.is_node_ready(): await root.ready + await process_frame + if DisplayServer.get_name() == "headless": + printerr("RUN INTRO CAPTURE: graphical display required") + quit(1) + return + root.size = Vector2i(1280, 720) + AudioServer.set_bus_mute(0, true) + folder = ProjectSettings.globalize_path("user://run-intro-captures") + for arg in OS.get_cmdline_user_args(): + if arg == "--real": real_game = true + if arg.begins_with("--output="): folder = arg.trim_prefix("--output=") + if DirAccess.make_dir_recursive_absolute(folder) != OK: + printerr("RUN INTRO CAPTURE: cannot create output directory") + quit(1) + return + if real_game: + facade_script = load("res://scenes/runner/runner_game.gd") as GDScript + game = load("res://scenes/runner/runner_game.tscn").instantiate() + game.autostart = false + game.leaderboard_path = board + else: + game = Mock.new() + game.scenery = true + root.add_child(game) + if not game.is_node_ready(): await game.ready + var ui := _ui() + if not game.is_inside_tree() or ui == null: + printerr("RUN INTRO CAPTURE: fixture failed to mount") + quit(1) + return + if real_game: + game.paused_changed.connect(_ignore_focus_pause) + _check(game.find_child("EntryBulkhead", true, false) != null, "real corridor mounts EntryBulkhead") + await physics_frame + await capture_sequence(&"full", FULL, 4) + await capture_sequence(&"short", SHORT, 4) + await capture_sequence(&"full", ORBIT, 4, "full-orbit") + _begin(&"full") + await _advance_to(1.2) + _check(game.skip_run_intro(), "skip accepted after title") + await _advance_to(duration - 0.2) + await capture_frame("full-skip-tail") + await _advance_to(duration - 0.05) + await capture_frame("full-skip-fade") + _check(_ui().countdown.modulate.a < 0.30, "skipped GO fades before hand-back") + if real_game: + _freeze(false) + var deadline := Time.get_ticks_msec() + 2000 + while game.phase == &"intro" and Time.get_ticks_msec() < deadline: + await process_frame + _check(game.phase == &"run", "skip hands the real scene back to run") + _freeze(true) + else: + game.advance_intro(0.3) + await capture_frame("after-intro") + _freeze(false) + game.free() + if facade_script != null: facade_script.set(&"pending_intro_mode", &"full") + var audio := root.get_node_or_null("AudioService") + if audio != null and audio.has_method("shutdown"): audio.shutdown() + # Stop queues playback disposal on the audio thread, which must finish before exit. + await create_timer(0.3).timeout + await process_frame + for suffix in ["", ".tmp", ".bak"]: + if FileAccess.file_exists(board + suffix): DirAccess.remove_absolute(board + suffix) + var output := FileAccess.open(folder.path_join("telemetry.json"), FileAccess.WRITE) + if output != null: + output.store_string(JSON.stringify({"real_game": real_game, "failures": failures, "frames": telemetry}, "\t")) + else: + failures += 1 + print("RUN INTRO CAPTURE: %s (%s, real=%s)" % ["PASS" if failures == 0 else "FAIL", folder, real_game]) + quit(0 if failures == 0 else 1) + +func _check(condition: bool, label: String) -> void: + if condition: return + failures += 1 + push_error("RUN INTRO CAPTURE: " + label) + +func _ui() -> CanvasLayer: + return game.get_node_or_null("HUD/RunIntro") if real_game else game.ui + +func _begin(mode: StringName) -> void: + duration = 2.2 if mode == &"short" else 6.0 + if real_game: + _freeze(false) + facade_script.set(&"pending_intro_mode", mode) + game.start_run(4704) + _check(game.run_intro_mode == mode and game.phase == &"intro", "real facade starts " + String(mode)) + else: + game.begin_intro(mode) + +func _clock() -> float: + return float(game.run_intro_progress) * duration if real_game else float(game.elapsed) + +func _advance_to(target: float) -> void: + if not real_game: + game.advance_intro(maxf(0, target - _clock())) + _check(is_equal_approx(_clock(), target), "mock reached requested clock") + return + _freeze(false) + var deadline := Time.get_ticks_msec() + 10000 + while game.phase == &"intro" and _clock() < target and Time.get_ticks_msec() < deadline: + await process_frame + _check(game.phase == &"intro" and _clock() >= target, "real clock reached %.2fs" % target) + # The facade is PROCESS_ALWAYS, so freeze its bookkeeping as well as the pausable world. + # No pause signal is sent: these are frames of the playing cinematic, not the pause menu. + _freeze(true) + +func _freeze(value: bool) -> void: + if not real_game: return + frozen = value + game.set_physics_process(not value) + paused = value + +func _ignore_focus_pause(value: bool) -> void: + if value: + game.resume() + paused = frozen + +func capture_sequence(mode: StringName, points: Array, columns: int, label: String = "") -> void: + if label.is_empty(): label = String(mode) + _begin(mode) + var strip := Image.create(columns * 640, ceili(float(points.size()) / columns) * 360, false, Image.FORMAT_RGB8) + for i in points.size(): + var target := float(points[i]) + await _advance_to(target) + var frame := await capture_frame("%s-%02d-%.2fs" % [label, i, target]) + frame.resize(640, 360, Image.INTERPOLATE_LANCZOS) + frame.convert(Image.FORMAT_RGB8) + strip.blit_rect(frame, Rect2i(0, 0, 640, 360), Vector2i(i % columns * 640, i / columns * 360)) + if strip.save_png(folder.path_join("%s-strip.png" % label)) != OK: failures += 1 + +func capture_frame(label: String) -> Image: + var ui := _ui() + if real_game: + ui._process(0) + game.get_node("HUD/PostFX")._process(0) + var camera: Camera3D = game.camera_rig.camera + var entry := game.find_child("EntryBulkhead", true, false) as Node3D + var visual: Node = game.player.visual + _check(visual != null, "real Hoppy visual is mounted") + if label == "full-02-1.20s": + _check(visual != null and visual.get("anim_state") == &"victory", "title beat plays Hoppy victory") + _check(ui.title_group.visible, "title card is visible with victory") + if ui.count_label.text == "GO!" and ui.countdown.visible: + _check(not game.can_skip_run_intro() and not ui.skip_button.visible, "GO closes the skip window") + if label == "after-intro": + _check(not ui.root.visible, "intro overlay clears on hand-back") + telemetry.append({"frame": label, "mode": String(game.run_intro_mode), + "progress": game.run_intro_progress, "phase": String(game.phase), "beat": ui.count_label.text, + "title_visible": ui.title_group.visible, "count_visible": ui.countdown.visible, + "count_alpha": ui.countdown.modulate.a, "skip_visible": ui.skip_button.visible, + "entry_bulkhead": entry != null, "camera_position": str(camera.global_position), + "camera_rotation": str(camera.global_rotation), "player_position": str(game.player.global_position), + "visual": String(visual.get("anim_state")) if visual != null else ""}) + else: + game.update_visuals() + # Flush the skeleton and draw the frozen scene before reading the viewport. + await physics_frame + await RenderingServer.frame_post_draw + var frame := root.get_texture().get_image() + var path := folder.path_join(label + ".png") + if frame.save_png(path) != OK: failures += 1 + print("RUN INTRO FRAME: ", path) + return frame diff --git a/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_run_intro.gd.uid b/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_run_intro.gd.uid new file mode 100644 index 00000000..7f0a52b3 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/dev/capture_run_intro.gd.uid @@ -0,0 +1 @@ +uid://c6q75re0b6ncm diff --git a/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_run_intro_game.gd b/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_run_intro_game.gd new file mode 100644 index 00000000..99955fb0 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_run_intro_game.gd @@ -0,0 +1,149 @@ +extends Node3D +## Isolated presentation fixture. Tests opt into scenery only when rendered proof is needed. +const Shots = preload("res://scenes/runner/data/run_intro_shots.gd") +signal run_started(seed: int) +signal run_intro(mode: StringName, seconds: float) +signal run_intro_beat(index: int, label: String) +signal run_intro_skipped +signal run_intro_ended +signal phase_changed(phase: StringName) +signal paused_changed(paused: bool) +signal caught +signal run_finished(result: Dictionary) +signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) +signal boss_cinematic_skipped(mode: StringName) +signal boss_cinematic_ended(mode: StringName) +var run_intro_mode: StringName = &"" +var run_intro_progress := 0.0 +var boss_cinematic_progress := 0.0 +var boss_time_factor := 1.0 +var phase: StringName = &"idle" +var paused := false +var autoplay := false +var scenery := false +var seconds := 6.0 +var elapsed := 0.0 +var skipped := false +var emitted_beats := 0 +var skip_calls := 0 +var camera: Camera3D +var visual: Node3D +var ui: CanvasLayer +var post_fx: CanvasLayer + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_PAUSABLE + physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + if not scenery: return + var corridor := load("res://scenes/runner/environment/corridor.tscn").instantiate() as Node3D + add_child(corridor) + corridor.set_scroll_speed(0) + visual = load("res://scenes/runner/player/hoppy_runner_visual.tscn").instantiate() as Node3D + add_child(visual) + visual.set_anim_state(&"idle") + camera = Camera3D.new() + camera.current = true + camera.near = 0.15 + camera.far = 200 + add_child(camera) + ui = load("res://scenes/runner/ui/run_intro.tscn").instantiate() + add_child(ui) + ui.bind(self) + post_fx = load("res://scenes/runner/ui/post_fx.tscn").instantiate() + add_child(post_fx) + post_fx.bind(self) + +func begin_intro(value: StringName = &"full") -> void: + run_started.emit(4704) + run_intro_mode = value + run_intro_progress = 0 + seconds = 2.2 if value == &"short" else 6.0 + elapsed = 0 + skipped = false + emitted_beats = 0 + paused = false + phase = &"intro" + phase_changed.emit(phase) + run_intro.emit(value, seconds) + update_visuals() + +func advance_intro(delta: float) -> void: + if paused or phase != &"intro": return + elapsed = minf(seconds, elapsed + maxf(delta, 0)) + run_intro_progress = elapsed / seconds + var times := [1.0, 1.6] if run_intro_mode == &"short" else [1.0, 3.0, 3.8, 4.6, 5.4] + var labels := ["READY", "GO!"] if run_intro_mode == &"short" else ["title", "3", "2", "1", "GO!"] + while not skipped and emitted_beats < times.size() and elapsed >= float(times[emitted_beats]): + var index := emitted_beats + emitted_beats += 1 + run_intro_beat.emit(index, labels[index]) + update_visuals() + if elapsed >= seconds: + run_intro_mode = &"" + run_intro_ended.emit() + phase = &"run" + phase_changed.emit(phase) + +func can_skip_run_intro() -> bool: + var final_index := 1 if run_intro_mode == &"short" else 4 + return emitted_beats <= final_index and phase == &"intro" and not paused and not skipped and elapsed >= 0.3 and elapsed < seconds - 0.4 + +func skip_run_intro() -> bool: + if not can_skip_run_intro(): return false + skip_calls += 1 + skipped = true + elapsed = seconds - 0.4 + run_intro_progress = elapsed / seconds + run_intro_skipped.emit() + var final_index := 1 if run_intro_mode == &"short" else 4 + if emitted_beats <= final_index: + emitted_beats = final_index + 1 + run_intro_beat.emit(final_index, "GO!") + update_visuals() + return true + +func pause_intro(value: bool) -> void: + paused = value + paused_changed.emit(value) + +func _process(delta: float) -> void: + if autoplay: advance_intro(delta) + +func update_visuals() -> void: + if is_instance_valid(ui): ui._process(0) + if is_instance_valid(post_fx): post_fx._process(0) + if not is_instance_valid(camera): return + var table: Dictionary = Shots.SHOTS.get(run_intro_mode, {}) + var keys: Array = table.get("keys", []) + if keys.is_empty(): return + var p := run_intro_progress + var row: Array = keys.back() + for i in range(1, keys.size()): + if p > float(keys[i][0]): continue + var a: Array = keys[i - 1] + var b: Array = keys[i] + var t := smoothstep(float(a[0]), float(b[0]), p) + row = [p] + for column in range(1, 6): row.append(lerpf(a[column], b[column], t)) + break + var blend := 1.0 - smoothstep(float(table.ret), 1, p) + if float(table.cut) > 0: blend *= smoothstep(0, float(table.cut), p) + var lens := Vector3(0, row[2], row[1]).rotated(Vector3.UP, deg_to_rad(row[3])) + var rest := Vector3(0, 4, 8) + camera.position = rest.lerp(lens, blend) + var follow := Basis.looking_at(Vector3(0, 1, -8) - rest).get_rotation_quaternion() + var pose := Basis.looking_at(Vector3(0, row[5], 0) - lens).get_rotation_quaternion() + camera.quaternion = follow.slerp(pose, blend) + camera.fov = lerpf(60, row[4], blend) + if is_instance_valid(visual): + var player: AnimationPlayer = visual.get("animation_player") + if player != null: + var victory := player.get_animation(&"victory") + var title_age := elapsed - 1.0 + var title_active := run_intro_mode == &"full" and title_age >= 0 and title_age < victory.length and not skipped + var clip: StringName = &"victory" if title_active else &"idle" + visual.set_anim_state(clip) + # Sample the one-shot once, exactly as the real title beat's celebrate() hook. + player.play(clip, 0) + player.seek(title_age if title_active else 0.0, true) + player.pause() diff --git a/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_run_intro_game.gd.uid b/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_run_intro_game.gd.uid new file mode 100644 index 00000000..789d2c19 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_run_intro_game.gd.uid @@ -0,0 +1 @@ +uid://dvhvrmuve52dy diff --git a/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_runner_game.gd b/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_runner_game.gd index ef33ed9e..3fbcaeb8 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_runner_game.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/dev/mock_runner_game.gd @@ -8,11 +8,7 @@ const GameOver = preload("res://scenes/runner/ui/game_over.tscn") const Leaderboard = preload("res://scenes/runner/systems/runner_leaderboard.gd") signal stage_started(index: int, name: StringName, speed: float) signal stage_cleared(index: int, bonus: int) -signal turn_incoming(direction: int, seconds: float) signal boss_intro(kind: StringName, name: String, seconds: float) -var next_turn_direction := 0 -signal turn_started(direction: int, seconds: float) -signal turn_finished signal phase_changed(phase: StringName) var phase: StringName = &"idle" var stage_index := 1 @@ -212,19 +208,11 @@ func advance_demo(delta: float) -> void: health_changed.emit(hp, hp) pursuit_recovered.emit(&"timer") pursuit_alert.emit(&"clear", 0.0) - for at in [3.0, 4.0, 5.0, 6.0, 7.0, 9.0, 12.0, 14.0, 15.0, 17.4, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 30.0, 34.0, 38.4, 40.0]: + for at in [3.0, 4.0, 5.0, 6.0, 7.0, 9.0, 14.0, 17.4, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 30.0, 34.0, 38.4, 40.0]: if demo_time >= at and not _events.has(at): _events[at] = true match at: - 12.0: - next_turn_direction = 1 - turn_incoming.emit(next_turn_direction, 3.0) - 15.0: - set_demo_phase(&"turn") - turn_started.emit(next_turn_direction, 2.4) 17.4: - next_turn_direction = 0 - turn_finished.emit() set_demo_phase(&"boss") 27.0: set_demo_phase(&"stage_clear") diff --git a/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd b/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd index 137229c4..6dcc53fc 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/post_fx.gd @@ -19,6 +19,9 @@ var suspended: Dictionary = {} var signal_handlers: Dictionary = {} var signal_paused := false var grain_seed := 17 +var intro_hold := false +var intro_seconds := 1.0 +var intro_skip_tail := 0.4 var cinematic_hold := false var cinematic_released := false var cinematic_seconds := 1.0 @@ -37,6 +40,7 @@ func _init() -> void: screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(screen) signal_handlers = { + &"run_intro": _run_intro, &"run_intro_skipped": _intro_skipped, &"run_intro_ended": _intro_ended, &"run_started": _run_started, &"damage_taken": _damage, &"tool_collected": _tool, &"pursuit_started": _pursuit_started, &"pursuit_progress": _pursuit_progress, &"pursuit_recovered": _recovered, &"caught": _caught, @@ -100,7 +104,11 @@ 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): + if intro_hold and is_instance_valid(game): + var p := clampf(float(game.get("run_intro_progress")), 0, 1) + var age := p * intro_seconds + values.letterbox = smoothstep(0.0, 0.18, age) * (1.0 - smoothstep(maxf(0, intro_seconds - intro_skip_tail), intro_seconds, age)) + elif 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() @@ -150,6 +158,7 @@ func _animate(key: String, target: float, seconds: float = 0.18, hold: float = - _suspend(key, tween) func _reset() -> void: + intro_hold = false cinematic_hold = false cinematic_released = false for key: String in transitions.keys(): @@ -202,8 +211,29 @@ func _expired(kind: StringName) -> void: if kind == &"nitro": _animate("bloom_lift", 0.0, 0.35) +func _run_intro(_mode: StringName, seconds: float) -> void: + _cancel("letterbox") + intro_hold = seconds > 0 + cinematic_hold = false + intro_seconds = maxf(seconds, 0.001) + intro_skip_tail = 0.4 + if is_instance_valid(game) and "config" in game: + var config: Resource = game.get("config") + if config != null and "run_intro_skip_tail" in config: + intro_skip_tail = maxf(0.001, float(config.get("run_intro_skip_tail"))) + _process(0.0) + +func _intro_skipped(_mode: StringName = &"") -> void: + _process(0.0) + +func _intro_ended(_mode: StringName = &"") -> void: + intro_hold = false + _cancel("letterbox") + _write(0.0, "letterbox") + func _boss_cinematic(_kind: StringName, _mode: StringName, seconds: float) -> void: _cancel("letterbox") + intro_hold = false cinematic_hold = true cinematic_released = false cinematic_seconds = maxf(seconds, 0.001) @@ -228,9 +258,10 @@ func _stage_clear(_index: int, _bonus: int) -> void: _animate("letterbox", 1.0, 0.18, 2.4) func _phase(phase: StringName) -> void: + if intro_hold and phase != &"intro": _intro_ended() if phase == &"idle": _reset() - elif phase in [&"run", &"turn"]: + elif phase == &"run": _animate("letterbox", 0.0) func _finished(_result: Dictionary) -> void: diff --git a/hoppys-adventure-arcade/scenes/runner/ui/pursuit_alert.gd b/hoppys-adventure-arcade/scenes/runner/ui/pursuit_alert.gd index 0b4ffa8d..e1422a1d 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/pursuit_alert.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/pursuit_alert.gd @@ -43,8 +43,13 @@ class PortraitArt extends Control: portrait_font.changed.connect(queue_redraw) queue_redraw() _refresh_font.call_deferred() + ## One frame later, through a one-shot connection rather than an await: when the alert is freed + ## inside that frame (a scene change, a test tearing its run down) the connection goes with it, + ## so nothing resumes on a freed instance. func _refresh_font() -> void: - await get_tree().process_frame + if not is_inside_tree(): return + get_tree().process_frame.connect(_redraw_after_frame, CONNECT_ONE_SHOT) + func _redraw_after_frame() -> void: if not is_inside_tree(): return deferred_redraws += 1 queue_redraw() diff --git a/hoppys-adventure-arcade/scenes/runner/ui/run_intro.gd b/hoppys-adventure-arcade/scenes/runner/ui/run_intro.gd new file mode 100644 index 00000000..385991b7 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/run_intro.gd @@ -0,0 +1,293 @@ +extends CanvasLayer +## Clock-driven opening card and countdown. The facade owns playback, pause and skip. +const Style = preload("res://scenes/runner/ui/hud_style.gd") +const Palette = preload("res://scenes/runner/environment/runner_palette.gd") +const Prompts = preload("res://app/controller_prompts.gd") +var game: Node +var connections: Dictionary = {} +var mode: StringName = &"" +var seconds := 0.0 +var skipped := false +var beat_index := -1 +var beat_label := "" +var _beat_at := 0.0 +var root: Control +var title_group: Control +var title: Label +var eyebrow: Label +var subtitle: Label +var header: Label +var footer: Label +var lower_third: TextureRect +var countdown: Control +var count_label: Label +var count_hint: Label +var burst: BeatBurst +var flash: ColorRect +var skip_button: Button +var skip_prompt: Label +var _viewport_size := Vector2.ZERO + +class BeatBurst extends Control: + var age := 0.0 + var go := false + var tint := Color("f78949") + func _draw() -> void: + var center := size * 0.5 + var unit := minf(size.x, size.y) + var expansion := 1.0 - pow(1.0 - clampf(age / 0.50, 0, 1), 3) + var alpha := 1.0 - smoothstep(0.18, 0.58, age) + if go: + for i in 16: + var direction := Vector2.RIGHT.rotated(TAU * float(i) / 16.0) + var inner := unit * lerpf(0.18, 0.38, expansion) + var outer := inner + unit * (0.08 if i % 2 == 0 else 0.035) + draw_line(center + direction * inner, center + direction * outer, Color(tint, alpha * 0.85), 3.0, true) + draw_arc(center, unit * lerpf(0.17, 0.43, expansion), 0, TAU, 96, Color(tint, alpha * 0.55), 2.0, true) + else: + draw_circle(center, unit * 0.22, Color("08111cca")) + for i in 4: + var angle := PI * 0.25 + PI * 0.5 * i + draw_arc(center, unit * 0.24, angle, angle + PI * 0.34, 24, Color(tint, 0.7 * alpha), 3.0, true) + +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 = "LowerThird" + lower_third.mouse_filter = Control.MOUSE_FILTER_IGNORE + var gradient := Gradient.new() + gradient.offsets = PackedFloat32Array([0, 0.48, 1]) + gradient.colors = PackedColorArray([Color(Palette.NAVY_BG, 0), Color(Palette.NAVY_BG, 0.88), Color(Palette.NAVY_BG, 0.98)]) + var texture := GradientTexture2D.new() + texture.gradient = gradient + texture.fill_from = Vector2.ZERO + texture.fill_to = Vector2.DOWN + lower_third.texture = texture + root.add_child(lower_third) + header = _label(root, "CODERABBIT ARCADE / ENDLESS RUNNER", 18, Palette.ORANGE) + footer = _label(root, "RUN 001 / THE NEXT COMMIT IS YOURS", 16, Palette.CREAM) + footer.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) + eyebrow = _label(title_group, "A NEW RUN", 20, Palette.ORANGE) + title = _label(title_group, "THE CODEBASE CORRIDOR", 54, Palette.CREAM) + subtitle = _label(title_group, "KEEP MOVING. SHIP CLEAN.", 20, Palette.MINT) + countdown = Control.new() + countdown.name = "Countdown" + countdown.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(countdown) + burst = BeatBurst.new() + burst.name = "BeatBurst" + burst.mouse_filter = Control.MOUSE_FILTER_IGNORE + countdown.add_child(burst) + count_label = _label(countdown, "", 132, Palette.CREAM) + count_hint = _label(countdown, "", 18, Palette.CREAM) + flash = ColorRect.new() + flash.name = "GoFlash" + flash.color = Color(Palette.MINT, 0) + flash.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(flash) + flash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + skip_button = Button.new() + skip_button.name = "SkipIntro" + skip_button.flat = true + skip_button.focus_mode = Control.FOCUS_NONE + skip_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + skip_button.pressed.connect(_request_skip) + root.add_child(skip_button) + skip_prompt = _label(skip_button, "", 18, Palette.CREAM) + skip_prompt.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + Prompts.track(skip_prompt, func() -> String: return "SKIP [%s]" % Prompts.either("SPACE", &"jump", true)) + var font := FontVariation.new() + font.base_font = Style.theme().default_font + font.variation_embolden = 1.2 + font.spacing_glyph = 1 + for label in [title, count_label]: + label.add_theme_font_override("font", 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(18, w * 0.05) + header.position = Vector2(inset, h * 0.007) + header.size = Vector2(w - inset * 2, h * 0.05) + header.add_theme_font_size_override("font_size", int(clampf(w * 0.014, 12, 20))) + footer.position = Vector2(inset, h * 0.943) + footer.size = Vector2(w * 0.57, h * 0.05) + footer.add_theme_font_size_override("font_size", int(clampf(w * 0.0125, 10, 18))) + lower_third.position = Vector2(0, h * 0.56) + lower_third.size = Vector2(w, h * 0.38) + title_group.position = Vector2(inset, h * 0.68) + title_group.size = Vector2(w - inset * 2, h * 0.21) + title_group.pivot_offset = title_group.size * Vector2(0.5, 0.4) + eyebrow.size = Vector2(title_group.size.x, h * 0.037) + title.position = Vector2(0, h * 0.035) + title.size = Vector2(title_group.size.x, h * 0.095) + title.add_theme_font_size_override("font_size", int(clampf(w * 0.040, 18, 64))) + subtitle.position = Vector2(0, h * 0.135) + subtitle.size = Vector2(title_group.size.x, h * 0.04) + subtitle.add_theme_font_size_override("font_size", int(clampf(w * 0.016, 12, 22))) + countdown.position = Vector2(w * 0.15, h * 0.005) + countdown.size = Vector2(w * 0.70, h * 0.64) + countdown.pivot_offset = countdown.size * 0.5 + burst.size = countdown.size + count_label.size = countdown.size + count_label.add_theme_font_size_override("font_size", int(clampf(minf(w * 0.12, h * 0.21), 40, 156))) + count_hint.position = Vector2(0, countdown.size.y * 0.70) + count_hint.size = Vector2(countdown.size.x, h * 0.05) + skip_button.position = Vector2(w - inset - minf(290, w * 0.38), h * 0.934) + skip_button.size = Vector2(minf(290, w * 0.38), h * 0.065) + skip_prompt.add_theme_font_size_override("font_size", int(clampf(w * 0.014, 12, 20))) + +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 = {&"run_intro": _begin, &"run_intro_beat": _beat, + &"run_intro_skipped": _skipped, &"run_intro_ended": _ended, + &"phase_changed": _phase, &"run_started": _run_started, + &"caught": _reset, &"run_finished": _finished, &"paused_changed": _paused} + if not is_instance_valid(game): + connections.clear() + return + for event: StringName in connections.keys(): + if game.has_signal(event): game.connect(event, connections[event]) + else: connections.erase(event) + +func _begin(value: StringName, duration: float) -> void: + _reset() + if value not in [&"full", &"short"] or duration <= 0: return + mode = value + seconds = duration + footer.text = "RUN 001 / THE NEXT COMMIT IS YOURS" if mode == &"full" else "BACK IN THE CODEBASE" + root.show() + _process(0) + +func _beat(index: int, label: String) -> void: + if mode == &"" or (skipped and not label.to_upper().begins_with("GO")): return + # Beat spacing and intro length are configurable; pop from the signal arrival. + _beat_at = float(game.get("run_intro_progress")) * seconds + beat_index = index + beat_label = label + _process(0) + +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("run_intro_progress")), 0, 1) + var age := p * seconds + var out := 1.0 - smoothstep(0.93, 1, p) + # Keep the title's original timing relative to its beat, regardless of intro length. + var title_age := age - _beat_at + var title_alpha := smoothstep(0, 0.12, title_age) * (1.0 - smoothstep(1.20, 1.65, title_age)) + title_group.visible = mode == &"full" and beat_label.to_lower() == "title" and title_age >= 0 and title_age < 1.65 and not skipped + lower_third.visible = title_group.visible + title_group.modulate.a = title_alpha + lower_third.modulate.a = title_alpha + title_group.scale = Vector2.ONE * lerpf(1.10, 1.0, 1.0 - pow(1.0 - clampf(title_age / 0.28, 0, 1), 3)) + header.visible = mode == &"full" and not skipped + header.modulate.a = smoothstep(0.16, 0.40, age) * out + footer.visible = not skipped + footer.modulate.a = smoothstep(0.16, 0.40, age) * out + var is_count := beat_index >= 0 and beat_label.to_upper() in ["READY", "3", "2", "1", "GO!"] + countdown.visible = is_count and (not skipped or beat_label.to_upper().begins_with("GO")) + if is_count: + var beat_age := maxf(0, age - _beat_at) + var go := beat_label.to_upper().begins_with("GO") + count_label.text = beat_label.to_upper() + count_label.add_theme_color_override("font_color", Palette.MINT if go else Palette.CREAM) + count_hint.text = "LET'S SHIP IT" if go else ("ONE MORE RUN" if mode == &"short" else "GET READY") + countdown.scale = Vector2.ONE * lerpf(1.42 if go else 1.28, 1.0, 1.0 - pow(1.0 - clampf(beat_age / 0.20, 0, 1), 3)) + # A skipped GO has only the remaining tail to finish, which may be configured. + var go_fade_end := minf(0.60, maxf(seconds - _beat_at, 0.001)) if skipped else 0.60 + var go_fade_start := go_fade_end * (0.25 if skipped else 0.60) + countdown.modulate.a = (1.0 - smoothstep(go_fade_start, go_fade_end, beat_age)) if go else (1.0 - smoothstep(0.50, 0.74, beat_age)) + burst.age = beat_age + burst.go = go + burst.tint = Palette.MINT if go else Palette.ORANGE + burst.queue_redraw() + flash.color.a = maxf(0, 1.0 - beat_age / 0.12) * 0.20 * countdown.modulate.a if go else 0 + else: + flash.color.a = 0 + skip_button.visible = not skipped and game.has_method("can_skip_run_intro") and bool(game.call("can_skip_run_intro")) + skip_prompt.visible = skip_button.visible + skip_button.disabled = not skip_button.visible + +func _request_skip() -> void: + if is_instance_valid(game) and game.has_method("can_skip_run_intro") and bool(game.call("can_skip_run_intro")) and game.has_method("skip_run_intro"): + game.call("skip_run_intro") + +func _skipped(_mode: StringName = &"") -> void: + skipped = true + # Clear immediately even if a pause notification arrives in the same frame. + title_group.hide() + lower_third.hide() + countdown.hide() + skip_button.hide() + skip_prompt.hide() + header.hide() + footer.hide() + flash.color.a = 0 + +func _ended(_mode: StringName = &"") -> void: + _reset() + +func _phase(value: StringName) -> void: + if value != &"intro": _reset() + +func _run_started(_seed: int) -> void: + _reset() + +func _finished(_result: Dictionary) -> void: + _reset() + +func _paused(value: bool) -> void: + if value: + skip_button.hide() + skip_prompt.hide() + else: + _process(0) + +func _reset() -> void: + mode = &"" + seconds = 0 + skipped = false + beat_index = -1 + beat_label = "" + _beat_at = 0 + if root == null: return + root.hide() + title_group.hide() + lower_third.hide() + countdown.hide() + skip_button.hide() + skip_prompt.hide() + flash.color.a = 0 + +func _exit_tree() -> void: + bind(null) diff --git a/hoppys-adventure-arcade/scenes/runner/ui/run_intro.gd.uid b/hoppys-adventure-arcade/scenes/runner/ui/run_intro.gd.uid new file mode 100644 index 00000000..936cbcb2 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/run_intro.gd.uid @@ -0,0 +1 @@ +uid://cxehxflmwiamy diff --git a/hoppys-adventure-arcade/scenes/runner/ui/run_intro.tscn b/hoppys-adventure-arcade/scenes/runner/ui/run_intro.tscn new file mode 100644 index 00000000..f41eae52 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/runner/ui/run_intro.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/runner/ui/run_intro.gd" id="1"] + +[node name="RunIntro" type="CanvasLayer"] +process_mode = 1 +layer = 12 +script = ExtResource("1") diff --git a/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd b/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd index 4864bdb1..16ef21b1 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/runner_hud.gd @@ -2,8 +2,6 @@ extends CanvasLayer 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") -const Corridor = preload("res://scenes/runner/environment/corridor.gd") -const TurnFx = preload("res://scenes/runner/environment/turn_fx.gd") const Vignette = preload("res://scenes/runner/shaders/vignette.gdshader") const PowerUpDefs = preload("res://scenes/runner/powerups/power_up_defs.gd") const Burst = preload("res://scenes/runner/powerups/visuals/hifi/collect_burst.gd") @@ -11,13 +9,6 @@ const Burst = preload("res://scenes/runner/powerups/visuals/hifi/collect_burst.g ## chip never rasterises and uploads new glyphs on the pickup frame. const WARM_TEXT := "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 ×\u2014+-:/.,!'" const Prompts := preload("res://app/controller_prompts.gd") -var turn_fx: Node3D -var turn_panel: PanelContainer -var turn_label: Label -var turn_icon: Control -var warning_age := 0.0 -var warning_seconds := 0.0 -var warning_direction := 0 var game: Node var screen: Control var widgets: Control @@ -61,7 +52,9 @@ var nitro_panel: PanelContainer var ready_panel: PanelContainer var pursuit_panel: PanelContainer var toast_remaining := 0.0 -var turn_alpha := 1.0 +## True while the run intro owns the screen (contract 2.7 §11.15): the widgets and GET READY are +## hidden until the intro hands over to the run. +var _intro_hidden := false var pulse_clock := 0.0 var teaching_seen := {} var teaching_pending: Array[Dictionary] = [] @@ -168,15 +161,6 @@ func _ready() -> void: screen.add_child(hit_flash) hit_flash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) hit_flash.color = Color(1, 1, 1, 0) - turn_label = Style.label("", 34, Palette.ORANGE) - turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - turn_panel = _back(turn_label, Control.PRESET_CENTER_TOP, Vector2(-180, 536), Vector2(360, 76), Palette.DANGER, 0.94) - turn_panel.hide() - turn_icon = Style.icon(&"arrow", Palette.ORANGE) - turn_label.add_child(turn_icon) - turn_icon.position = Vector2(4, 16) - turn_icon.size = Vector2(30, 30) - turn_icon.pivot_offset = Vector2(15, 15) for kind: StringName in PowerUpDefs.kinds(): if kind != &"auto_fix": _chip_for(kind) _warm_fonts() @@ -230,15 +214,7 @@ func bind(runner: Node) -> void: if game == runner: return game = runner - turn_fx = TurnFx.new() - for node: Node in game.find_children("*", "Node3D", true, false): - if node is Corridor: - node.add_child(turn_fx) - break - if game.has_signal("turn_incoming"): game.turn_incoming.connect(_turn_incoming) - if game.has_signal("turn_started"): game.turn_started.connect(_turn_started) - if game.has_signal("turn_finished"): game.turn_finished.connect(_turn_finished) - if game.has_signal("phase_changed"): game.phase_changed.connect(_turn_phase) + if game.has_signal("phase_changed"): game.phase_changed.connect(_phase) if game.has_method("_mount_frontend"): # RunnerGame mounts the sibling alert after binding this HUD. _find_mounted_alert.call_deferred() @@ -300,9 +276,6 @@ func _process(delta: float) -> void: if toast_remaining <= 0 and not _teaching_blocked() and not teaching_pending.is_empty(): var hint: Dictionary = teaching_pending.pop_front() _toast(hint.text, 10, hint.accent, 2.4, true) - if turn_panel.visible: - warning_age += delta - _update_turn_warning() var seconds := float(game.elapsed) time_label.text = "%02d:%04.1f" % [int(seconds) / 60, fmod(seconds, 60)] for kind in chips: @@ -325,7 +298,7 @@ func _process(delta: float) -> void: get_ready.hide() toast_remaining -= delta toast_age += delta - toast_panel.modulate.a = minf(1.0, toast_age / 0.12) * turn_alpha + toast_panel.modulate.a = minf(1.0, toast_age / 0.12) if toast_remaining <= 0: toast.text = "" toast_priority = -1 @@ -401,7 +374,6 @@ func _caught() -> void: vignette_material.set_shader_parameter("strength", 1.0) func _state(value: StringName) -> void: - if value != &"running": _turn_finished() var show_ready := value == &"ready" or (value == &"running" and _ready_seen and ready_age < 2.75) get_ready.visible = show_ready ready_panel.visible = show_ready @@ -413,7 +385,7 @@ func _state(value: StringName) -> void: _gameplay_visible = true widgets.show() for child in widgets.get_children(): - if child is PanelContainer and child not in [ready_panel, toast_panel, nitro_panel, pursuit_panel, turn_panel]: + if child is PanelContainer and child not in [ready_panel, toast_panel, nitro_panel, pursuit_panel]: child.show() widgets.modulate.a = 1.0 vignette_material.set_shader_parameter("strength", 0.0) @@ -438,7 +410,7 @@ func _sync_ready_speed() -> void: if not panel.visible: panel.self_modulate.a = 0.0 func _paused(value: bool) -> void: - widgets.modulate.a = 0.35 if value else 1.0 + widgets.modulate.a = 0.0 if _intro_hidden else (0.35 if value else 1.0) if is_instance_valid(pursuit_alert) and pursuit_alert.get_parent() != widgets: pursuit_alert.modulate.a = widgets.modulate.a @@ -571,44 +543,33 @@ func _shield(active: bool) -> void: else: _expire(&"approve_shield") -func _turn_started(_direction: int, _seconds: float) -> void: - if is_instance_valid(turn_fx): turn_fx.clear() - _dim_turn(0.55) - -func _turn_finished() -> void: - turn_panel.hide() - if is_instance_valid(turn_fx): turn_fx.clear() - _dim_turn(1.0) - -func _turn_phase(value: StringName) -> void: +func _phase(value: StringName) -> void: teaching_phase = value if value in [&"boss_intro", &"stage_clear"]: _defer_teaching() - if value in [&"idle", &"run"]: _turn_finished() - if value != &"turn": _dim_turn(1.0) - -func _dim_turn(alpha: float) -> void: - turn_alpha = alpha - for widget in [time_label.get_parent(), score_label.get_parent().get_parent(), speed_label.get_parent().get_parent(), powerups, toast_panel, nitro_panel]: - widget.modulate.a = alpha - -func _turn_incoming(direction: int, seconds: float) -> void: - ready_panel.hide() - get_ready.hide() - warning_direction = direction - warning_seconds = maxf(seconds, 0.001) - warning_age = 0.0 - turn_fx.announce(direction) - turn_panel.show() - _update_turn_warning() - -func _update_turn_warning() -> void: - var tick := maxi(1, ceili(warning_seconds - warning_age)) - turn_label.text = ("TURN LEFT" if warning_direction < 0 else "TURN RIGHT") + " %d" % tick - turn_icon.scale.x = -1.0 if warning_direction < 0 else 1.0 - turn_label.modulate.a = 0.65 + 0.35 * (1.0 - fposmod(warning_age, 1.0)) - -func _exit_tree() -> void: - if is_instance_valid(turn_fx): turn_fx.queue_free() + if value == &"intro": + _set_intro_hidden(true) + elif _intro_hidden: + _set_intro_hidden(false) + # GET READY and its controls legend open with the run itself, not under the intro's cards. + if value == &"run": _open_ready() + +## The run intro's title card and countdown own the screen: the gameplay widgets fade out and GET +## READY waits; both come back when the intro ends. +func _set_intro_hidden(on: bool) -> void: + _intro_hidden = on + widgets.modulate.a = 0.0 if on else 1.0 + if on: + ready_panel.hide() + get_ready.hide() + _sync_ready_speed() + +func _open_ready() -> void: + if game == null or StringName(game.get(&"state")) != &"running": return + ready_age = 0.0 + ready_panel.modulate.a = 1.0 + ready_panel.show() + get_ready.show() + _sync_ready_speed() func _reset_teaching(_seed: int = 0) -> void: toast_teaching = false diff --git a/hoppys-adventure-arcade/scenes/runner/ui/stage_banner.gd b/hoppys-adventure-arcade/scenes/runner/ui/stage_banner.gd index e4e10d71..82668aa1 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/stage_banner.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/stage_banner.gd @@ -53,12 +53,11 @@ func _base(extent: Vector2) -> VBoxContainer: return box var pending: Dictionary = {} -var turning := false var boss_visible := false var rules: Array[ColorRect] = [] func _ready() -> void: - connections = {&"boss_intro": _intro, &"turn_incoming": _turn, &"stage_started": _stage, &"turn_started": _turn, &"turn_finished": _finish_turn, &"boss_spawned": _boss, &"phase_changed": _phase} + connections = {&"boss_intro": _intro, &"stage_started": _stage, &"boss_spawned": _boss, &"phase_changed": _phase} var box := _base(Vector2(820, 180)) title = Style.label("S T A G E 1", 30, Palette.MINT) subtitle = Style.label("", 52) @@ -74,7 +73,7 @@ func _ready() -> void: func _stage(index: int, boss_name: StringName, _speed: float) -> void: pending = {"index": index, "name": boss_name} - if not turning and not boss_visible: _show_pending() + if not boss_visible: _show_pending() func _show_pending() -> void: if pending.is_empty() or boss_visible: return @@ -86,14 +85,6 @@ func _show_pending() -> void: _process(0.0) opened.emit() -func _turn(_direction: int, _seconds: float) -> void: - turning = true - _close() - -func _finish_turn() -> void: - turning = false - _show_pending() - func _boss(_kind: StringName, _hp: int) -> void: boss_visible = true _close() @@ -103,7 +94,6 @@ func _phase(value: StringName) -> void: if value == &"run": boss_visible = false if value == &"idle": pending.clear() - turning = false boss_visible = false func _process(delta: float) -> void: diff --git a/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd b/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd index 0518434c..ba326f1d 100644 --- a/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd +++ b/hoppys-adventure-arcade/scenes/runner/warmup/gpu_warmup.gd @@ -124,10 +124,6 @@ static func run(host: Node, camera: Camera3D = null) -> bool: stage.build(batch) build_ms += (Time.get_ticks_usec() - started) / 1000.0 started = Time.get_ticks_usec() - # The corridor's turn corner, chevron and streaks only show mid-turn, the HUD's floor - # chevrons only once a turn is announced. - var turn: bool = corridor != null and batch == &"canvas" - if turn: show_turn(host, corridor, true) # The live Hoppy's auras and shield, under its own lights (restored below; a run opens bare). if hoppy != null and batch == &"characters": _dress(hoppy, true) _sync_transforms(host) @@ -135,7 +131,6 @@ static func run(host: Node, camera: Camera3D = null) -> bool: stage._light.visible = lit for i in FRAMES: RenderingServer.force_draw(false, 1.0 / 60.0) if hoppy != null and batch == &"characters": _dress(hoppy, false) - if turn: show_turn(host, corridor, false) draw_ms += (Time.get_ticks_usec() - started) / 1000.0 retain(stage) labels._copy_labels(stage._group, []) @@ -259,27 +254,12 @@ static func _dress(hoppy: Node3D, on: bool) -> void: # The orbit glyphs follow the aura from the visual's next _process; apply it now, as play would. hoppy.notification(Node.NOTIFICATION_PROCESS) -## The mounted corridor, found by its public turn hook rather than by path. +## The mounted corridor, found by its public StageManager hooks rather than by path. static func _corridor(host: Node) -> Node: for node in host.find_children("*", "Node3D", true, false): - if node.has_method(&"set_turn") and node.has_method(&"set_boss_arena"): return node + if node.has_method(&"set_phase") and node.has_method(&"set_boss_arena"): return node return null -## Mid-turn pose plus the announced floor chevrons (runner_hud.gd's turn_fx.gd, found by its -## announce/clear hooks); `on` false restores the straight corridor and hides the chevrons. -static func show_turn(host: Node, corridor: Node, on: bool) -> void: - _pose_turn(corridor, 0.5 if on else 0.0) - for node in host.find_children("*", "MultiMeshInstance3D", true, false): - if node.has_method(&"announce") and node.has_method(&"clear"): - if on: node.call(&"announce", 1) - else: node.call(&"clear") - -## set_turn() is the StageManager's public hook; one process notification applies the pose the -## way the next frame would. Progress 0 restores the straight corridor. -static func _pose_turn(corridor: Node, progress: float) -> void: - corridor.call(&"set_turn", progress, 1 if progress > 0.0 else 0) - corridor.notification(Node.NOTIFICATION_PROCESS) - ## Builds an empty stage under `host`, oriented like `camera`, with the (hidden) warm omni light. ## `focus` is where Hoppy stands; without one the near copy goes 6 m ahead of the camera. static func stage_for(host: Node, camera: Camera3D, focus := Vector3.INF) -> Node3D: @@ -596,4 +576,29 @@ func _canvas() -> void: rect.material = material rect.size = Vector2(64, 64) layer.add_child(rect) + _warm_run_intro_fonts(layer) _place(layer, "canvas") + +## Contract 2.7 §11.15: the run intro's title card and countdown draw in their own emboldened +## FontVariation at roughly 50 and 150 px, so their glyphs would otherwise be rasterised on the +## intro's first title and count frames. Copies of the mounted labels (the same font resource, size +## and outline) draw once here, invisibly, like the HUD's own font warm. A no-op without the mount. +func _warm_run_intro_fonts(layer: CanvasLayer) -> void: + var host := get_parent() + var ui: Node = host.get_node_or_null(^"HUD/RunIntro") if host != null else null + if ui == null: + return + for entry: Array in [[&"title", "THE CODEBASE CORRIDOR"], [&"count_label", "READY 3 2 1 GO!"]]: + var source: Label = ui.get(entry[0]) as Label if entry[0] in ui else null + if source == null: + continue + var copy := Label.new() + copy.text = source.text if not source.text.is_empty() else String(entry[1]) + if entry[0] == &"count_label": + copy.text = String(entry[1]) + copy.add_theme_font_override("font", source.get_theme_font("font")) + copy.add_theme_font_size_override("font_size", source.get_theme_font_size("font_size")) + copy.add_theme_constant_override("outline_size", source.get_theme_constant("outline_size")) + copy.add_theme_color_override("font_outline_color", source.get_theme_color("font_outline_color")) + copy.modulate.a = 0.0 + layer.add_child(copy) diff --git a/hoppys-adventure-arcade/tests/assets/a2_check.gd b/hoppys-adventure-arcade/tests/assets/a2_check.gd index b86fd533..8b9cbc2d 100644 --- a/hoppys-adventure-arcade/tests/assets/a2_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a2_check.gd @@ -43,6 +43,19 @@ func census(node: Node) -> Vector2i: result.y += triangles(child.draw_pass_1) * child.amount return result +func _straight_modules(corridor: Node3D) -> bool: + corridor._update_pillars() + for i in 12: + var data: Color = corridor._rack_data[i] + if not is_zero_approx(data.g) or not is_equal_approx(data.b, 1.0): + return false + if not corridor._rack_poses[i].basis.is_equal_approx(Basis.IDENTITY): + return false + for i in 24: + if not corridor._pillars.get_instance_transform(i).basis.is_equal_approx(Basis.IDENTITY): + return false + return true + func run() -> void: # Dummy rendering keeps the original get_image() snapshot after set_image(). # Exercise the full lazy path for pixel/mipmap assertions; prewarm_check covers @@ -83,40 +96,16 @@ func run() -> void: check(Textures.maps()["normal_map"] == Textures.maps()["normal_map"], "procedural map cache") var rest := census(corridor) var peak := rest - for direction in [-1, 1]: - corridor.set_turn(0.0, direction) - corridor._process(0.0) - var pose: Transform3D = corridor.transform - var racks: Transform3D = corridor._rack_poses[0] - for p in [0.15, 0.3, 0.5, 0.7, 0.85, 1.0]: - corridor.set_turn(p, direction) - corridor._process(0.0) - check(is_zero_approx(corridor._corner_material.get_shader_parameter("bend")), "B7 no shader bend on panel") - check(corridor._turn_chevron.get_parent() == corridor._corner, "B7 shared panel pose") - var relative: Vector3 = corridor._corner.to_local(corridor._turn_chevron.global_position) - check(relative.is_equal_approx(Vector3(corridor.bend * 18.0 * 18.0 - corridor._corner.position.x, 0.5, 6.0)), "B7 label follows curve center in front of wall") - var label: Label3D = corridor._turn_chevron - var width: float = label.font.get_string_size(label.text, HORIZONTAL_ALIGNMENT_LEFT, -1, label.font_size).x * label.pixel_size - var sight_clear := true - var sign_center: Vector3 = corridor._corner.position + relative - for z in range(-23, 8): - var ratio := (8.0 - float(z)) / (8.0 - sign_center.z) - var center_x: float = corridor.bend * pow(maxf(0.0, -float(z) - 6.0), 2) - for edge_x in [sign_center.x - width * 0.5, sign_center.x + width * 0.5]: - if absf(edge_x * ratio - center_x) > 3.6: sight_clear = false - check(sight_clear, "R5 TURN text and arrow sightlines clear near rack envelope") - check(width < 6.0 and label.text.contains("TURN"), "B7 complete text fits clear corridor width") - check(label.font.has_char(0x25BA) and label.font.has_char(0x25C4), "B7 font supports both chevrons") - if p == 0.5: - check(not corridor._rack_poses[5].basis.is_equal_approx(Basis.IDENTITY), "rack module tangent yaw") - var now := census(corridor) - peak = Vector2i(maxi(peak.x, now.x), maxi(peak.y, now.y)) - if p >= 0.3 and p <= 0.7: check(is_equal_approx(absf(corridor.rotation_degrees.y), 55), "55 degree hold") - check(corridor.transform == pose and corridor._rack_poses[0] == racks, "identical turn endpoints") + check(corridor.rotation == Vector3.ZERO and _straight_modules(corridor), "corridor, racks and pillars stay square") + var bulkhead := corridor.starship.get_node_or_null("EntryBulkhead") as MeshInstance3D + check(bulkhead != null and bulkhead.get_node_or_null("EntryDoorGlow") != null, "entry bulkhead closes the near end") for speed in [0.0, 12.0, 24.0, 32.0]: corridor.set_scroll_speed(speed) corridor._offset = 95.99 corridor._process(0.02) + check(corridor.rotation == Vector3.ZERO and _straight_modules(corridor), "scroll leaves the corridor square") + var scrolled := census(corridor) + peak = Vector2i(maxi(peak.x, scrolled.x), maxi(peak.y, scrolled.y)) check(corridor._offset >= 0 and corridor._offset < 96, "scroll wrap") for i in 11: var a: float = corridor._rack_poses[i].origin.z @@ -139,7 +128,7 @@ func run() -> void: paused = false corridor.set_process(false) var paths := ["res://scenes/runner/environment/hifi/rack_metal.gdshader"] - for name in ["floor_grid", "code_wall", "code_wall_fade", "far_glow", "hologram"]: + for name in ["floor_grid", "code_wall", "far_glow", "hologram"]: paths.append("res://scenes/runner/shaders/" + name + ".gdshader") var regex := RegEx.new() regex.compile("\\bTIME\\b") @@ -151,7 +140,7 @@ func run() -> void: check(texture.get_width() <= 1024 and texture.get_height() <= 1024, "texture size") # check_walls measures every map and both atlases against the 8 x 1024² area budget. check(rest.x <= 14 and peak.x <= 18 and peak.y <= 60000, "draw and triangle budgets") - print("ASSET A2 CENSUS: rest=%d turn=%d triangles=%d textures=8 (7 maps + shared wall/particle atlas)" % [rest.x, peak.x, peak.y]) + print("ASSET A2 CENSUS: rest=%d straight=%d triangles=%d textures=8 (7 maps + shared wall/particle atlas)" % [rest.x, peak.x, peak.y]) corridor.free() check(is_equal_approx(root.scaling_3d_scale, baseline), "viewport ownership restored") var camera := Camera3D.new() @@ -201,7 +190,7 @@ func check_round_two(corridor: Node3D) -> void: check(key.light_color == preload("res://scenes/runner/environment/runner_palette.gd").MINT, "B3 palette key light") check(key.omni_range < 3.5 and key.light_energy <= 0.65 and key.position.y < key.omni_range, "B3 light reaches floor but not walls") check(float(corridor.get_node("Floor").material_override.get_shader_parameter("grid_strength")) >= 0.45, "B4 readable grid strength") - for path in ["res://scenes/runner/shaders/floor_grid.gdshader", "res://scenes/runner/shaders/code_wall.gdshader", "res://scenes/runner/shaders/code_wall_fade.gdshader", "res://scenes/runner/environment/hifi/rack_metal.gdshader"]: + for path in ["res://scenes/runner/shaders/floor_grid.gdshader", "res://scenes/runner/shaders/code_wall.gdshader", "res://scenes/runner/environment/hifi/rack_metal.gdshader"]: var tabs_only := true for line in FileAccess.get_file_as_string(path).split("\n"): if line.begins_with(" "): tabs_only = false @@ -238,8 +227,17 @@ func check_round_three(corridor: Node3D) -> void: check(float(corridor.get_node("WallR").material_override.get_shader_parameter("panel_period")) == 8.0, "R3 B2 corridor opts into eight-meter panels") corridor._process(0.1) check(corridor.get_node("LaneGlow").material_override.get_shader_parameter("clock") == corridor._clock, "lane glow receives pause-safe clock") - check(not corridor._scroll_materials.has(material) and not corridor._frame_materials.has(material) and corridor._metal_bend_materials.has(material), "metal receives only supported uniforms") - check(corridor._clock_materials.size() == 8, "persistent complete clock targets") + check(not corridor._scroll_materials.has(material) and not corridor._frame_materials.has(material) and corridor._rack_materials.has(material), "metal receives only supported uniforms") + # Scroll surfaces, the far glow, the pillars, the lane, and every rack. The corner material is gone. + var clock_targets: int = corridor._scroll_materials.size() + corridor._rack_materials.size() + 3 + check(corridor._clock_materials.size() == clock_targets, "persistent complete clock targets (%d)" % corridor._clock_materials.size()) + for listed in corridor._scroll_materials: + check(corridor._clock_materials.has(listed), "clock list keeps scroll material") + for listed in corridor._rack_materials: + check(corridor._clock_materials.has(listed), "clock list keeps rack material") + check(corridor._clock_materials.has(corridor._far_material), "clock list keeps far glow") + check(corridor._clock_materials.has(corridor._pillar_material), "clock list keeps pillars") + check(corridor._clock_materials.has(corridor.get_node("LaneGlow").material_override), "clock list keeps lane glow") check(float(corridor.get_node("Ceiling").material_override.get_shader_parameter("intensity")) <= 0.15, "ceiling emission controlled") var metrics := preload("res://scenes/runner/environment/hifi/capture_metrics.gd") var test_image := Image.create(16, 16, false, Image.FORMAT_RGB8) @@ -330,7 +328,6 @@ func check_round_five(corridor: Node3D) -> void: check(float(ceiling.get_shader_parameter("baked_gain")) >= 0.12 and float(ceiling.get_shader_parameter("intensity")) >= 0.05, "R5 B2 ceiling depth cue restored") var wall: ShaderMaterial = corridor.get_node("WallR").material_override check(wall.shader.code.contains("front*1.35+middle*0.48+rear*0.24+listing"), "R5 B2 full glyph ink restored") - check(corridor._turn_chevron.pixel_size >= 0.014 and corridor._turn_chevron.position.z >= 6.0, "R5 larger forward TURN sign") func check_round_six(corridor: Node3D) -> void: var metrics := preload("res://scenes/runner/environment/hifi/capture_metrics.gd") @@ -375,7 +372,7 @@ func check_walls(corridor: Node3D) -> void: check(atlas.RAIN_LAYERS == 3, "walls three rain layers") var wall: ShaderMaterial = corridor.get_node("WallR").material_override check(wall.get_shader_parameter("wall_atlas") == texture, "walls new atlas bound") - for shader_name in ["code_wall", "code_wall_fade"]: + for shader_name in ["code_wall"]: var source := FileAccess.get_file_as_string("res://scenes/runner/shaders/" + shader_name + ".gdshader") check(source.count("= rain(") == atlas.RAIN_LAYERS and source.contains("float listing ="), "walls rain and persistent listing " + shader_name) check(Prewarm._census.wall_atlas["texture[]"] == texture, "walls new atlas prewarmed and counted") @@ -492,16 +489,6 @@ func check_round_nine(corridor: Node3D) -> void: var value: float = 0.15 + float(x) * 0.003 + (amplitude if (x + y) % 2 == 0 else -amplitude) patch.set_pixel(x, y, Color(value, value, value)) check((metrics.wall_stats(patch, region).detail >= metrics.WALL_MIN_DETAIL) == (factor > 1.0), "R9 detail component brackets configured threshold independently of exposure") - check(corridor._corner_material.get_shader_parameter("full_face") == true, "R9 end wall full face opt-in") - var corner_source: String = corridor._corner_material.shader.code - check(corner_source.contains("if (full_face) { screen = UV; variant = 0.0; }"), "R9 terminator aperture follows full quad") - var canvas := Image.create(1280, 720, false, Image.FORMAT_RGB8) - canvas.fill(Color.BLACK) - for i in metrics.END_WALL_REGIONS.size(): - check(not metrics.end_wall_stats(canvas, i)["pass"], "R9 black terminator fails sample %d" % i) - canvas.fill(Color(0.1, 0.1, 0.1)) - for i in metrics.END_WALL_REGIONS.size(): - check(metrics.end_wall_stats(canvas, i)["pass"], "R9 visible terminator passes sample %d" % i) var peak := brush_correlation_peak(Textures.maps().albedo_map.get_image()) check(peak < 0.4, "R9 generated plating has no dominant brush period") var periodic := Image.create(1024, 1024, false, Image.FORMAT_R8) @@ -525,7 +512,6 @@ func check_round_nine(corridor: Node3D) -> void: check(triangles(corridor._rack_batches[0].mesh) == triangles(racks.mesh(0)), "R9 delta layout adds no triangles") check(corridor._rack_batches.size() == 3 and census(corridor).x <= 13, "R9 one draw of rest headroom") var saved_offset: float = corridor._offset - var saved_bend: float = corridor.bend var tags: Array[float] = [] for offset in [0.0, 95.99, 96.0]: corridor._offset = offset @@ -534,6 +520,7 @@ func check_round_nine(corridor: Node3D) -> void: for i in 12: var batch_index: int = racks.BATCH_FOR_VARIANT[racks.PATTERN[i]] var data: Color = corridor._rack_data[i] # Dummy renderer does not retain MultiMesh buffer readback. + check(is_zero_approx(data.g) and is_equal_approx(data.b, 1.0), "R9 racks stay straight across wrap") check(is_equal_approx(data.a, racks.instance_tag(i)), "R9 stable per-instance wear tag across wrap") if offset == 0.0: tags.append(data.a) slot[batch_index] += 1 @@ -541,11 +528,10 @@ func check_round_nine(corridor: Node3D) -> void: # Shader inverse yaw uses reconstructed sine, never the repurposed tag channel. check(material.shader.code.contains("origin_s*p.z") and not material.shader.code.contains("INSTANCE_CUSTOM.w*p.z"), "R9 tag is not interpreted as yaw") corridor._offset = saved_offset - corridor.bend = saved_bend corridor._update_pillars() var streak: ShaderMaterial = corridor._speed_lines.draw_pass_1.material check(streak.shader.code.contains("screen_alpha = 0.035") and streak.shader.code.contains("corridor_frame"), "R9 screen-adjacent streak attenuation in corridor frame") - print("ASSET A2 R9: brush_autocorrelation_peak=%.4f (<0.4); merged_batches=%d; full_face=true" % [peak, corridor._rack_batches.size()]) + print("ASSET A2 R9: brush_autocorrelation_peak=%.4f (<0.4); merged_batches=%d; racks_straight" % [peak, corridor._rack_batches.size()]) # Guard discrimination only. No CPU image claims to predict GPU convergence. func check_round_eleven(corridor: Node3D) -> void: diff --git a/hoppys-adventure-arcade/tests/assets/a7_check.gd b/hoppys-adventure-arcade/tests/assets/a7_check.gd index 607c53f9..80ff17b6 100644 --- a/hoppys-adventure-arcade/tests/assets/a7_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a7_check.gd @@ -1,5 +1,13 @@ extends SceneTree const Mock = preload("res://scenes/runner/ui/dev/mock_runner_game.gd") + +## The facade has no encounter clock. BossHud and the title card both bind these signals. +class CinematicMock extends Mock: + signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) + signal boss_cinematic_skipped(mode: StringName) + signal boss_cinematic_ended(mode: StringName) + var boss_cinematic_progress := 0.0 + var boss_time_factor := 1.0 const Palette = preload("res://scenes/runner/environment/runner_palette.gd") const Alert = preload("res://scenes/runner/ui/pursuit_alert.gd") const Pursuer = preload("res://scenes/runner/pursuer/pursuer_visual.gd") @@ -19,6 +27,7 @@ var failures := 0 var canvas_items := 0 var mesh_triangles := 0 var draws_3d := 0 +var cinematic: CanvasLayer func _initialize() -> void: run.call_deferred() func check(ok: bool, message: String) -> void: @@ -54,9 +63,12 @@ func run() -> void: OS.add_logger(logger) create_timer(30.0).timeout.connect(func() -> void: quit(1)) root.size = Vector2i(1280, 720) - var mock := Mock.new() + var mock := CinematicMock.new() mock.autoplay = false root.add_child(mock) + cinematic = (load("res://scenes/runner/ui/boss_cinematic.tscn") as PackedScene).instantiate() + root.add_child(cinematic) + cinematic.bind(mock) mock.start_run() await frames() await check_real_startup() @@ -93,13 +105,13 @@ func run() -> void: check(not mock.hud.pursuit_alert.visible, "alert recovery closes") mock.stage_started.emit(2, &"Merge Conflict Hydra", 24.0) mock.stage_banner._process(0.5) - check(mock.stage_banner.visible, "stage banner") - mock.turn_incoming.emit(1, 1.0) - mock.turn_started.emit(1, 1.0) + check(mock.stage_banner.visible and mock.stage_banner.title.text == "S T A G E 2", "stage banner") mock.stage_started.emit(3, &"Legacy Monolith", 26.0) - check(not mock.stage_banner.visible, "turn defers banner") - mock.turn_finished.emit() - check(mock.stage_banner.visible, "turn releases banner") + check(mock.stage_banner.visible and mock.stage_banner.title.text == "S T A G E 3", "the next stage card replaces the open one") + mock.boss_intro.emit(mock.boss_kind, "Merge Conflict Hydra", 1.0) + check(not mock.stage_banner.visible, "boss intro hides the stage card") + mock.stage_started.emit(4, &"Legacy Monolith", 26.0) + check(not mock.stage_banner.visible, "a visible boss keeps the next stage card hidden") mock.boss_hp = 140 mock.boss_intro.emit(mock.boss_kind, "Merge Conflict Hydra", 1.0) mock.boss_spawned.emit(mock.boss_kind, 140) @@ -119,16 +131,9 @@ func run() -> void: mock.hud._state(&"running") mock.hud._toast("CODERABBIT TOOL: REVIEW COMMENT \u2014 auto-fires at the boss", 10, Palette.ORANGE, 2.4, true) mock.hud._process(0.01) - mock.hud.turn_label.text = "TURN RIGHT 3" - mock.hud.turn_panel.show() await frames() await check_centered_titles(mock) - check(absf(mock.hud.turn_panel.get_global_rect().get_center().x-extent.x*0.5) <= 2, "R7 centered turn plate") - var label: Label = mock.hud.turn_label - var text_width := label.get_theme_font("font").get_string_size(label.text,HORIZONTAL_ALIGNMENT_LEFT,-1,label.get_theme_font_size("font_size")).x - check(label.get_theme_font_size("font_size") >= 34, "R7 warning type minimum") - check((label.size.x-text_width)*0.5 >= mock.hud.turn_icon.position.x+mock.hud.turn_icon.size.x+6, "R7 vector arrow clears centered copy") - check(mock.hud.turn_icon.tint == mock.hud.turn_label.get_theme_color("font_color"), "R7 arrow and turn copy share hue") + _check_clear_of_removed_phase(mock.hud) # Boss cards deliberately share one slot, but must never be visible together. var panels := { "approvals": mock.hud.hp_row.get_parent().get_parent(), @@ -136,8 +141,6 @@ func run() -> void: "score": mock.hud.score_label.get_parent().get_parent(), "pursuit countdown": mock.hud.pursuit_panel, "boss plate": mock.boss_overlay.panel, - "intro card": mock.boss_overlay.intro_card, - "turn plate": mock.hud.turn_panel, "toast": mock.hud.toast_panel, "speed": mock.hud.speed_label.get_parent().get_parent(), "pursuit alert": mock.hud.pursuit_alert, @@ -145,32 +148,30 @@ func run() -> void: var scale_xy := Vector2(extent) / Vector2(1280, 720) var top_band := Rect2(Vector2.ZERO, Vector2(extent.x, 130 * scale_xy.y)) var viewing_region := Rect2(Vector2(300, 130) * scale_xy, Vector2(680, 350) * scale_xy) - # Lay out the longest authored intro while it is visible, then verify handover. - mock.boss_intro.emit(mock.boss_kind, "Merge Conflict Hydra", 3.0) - mock.boss_overlay._process(1.0) - await frames() - check(not mock.boss_overlay.visible and mock.boss_overlay.intro_card.visible, "intro replaces boss plate") - for card in [mock.boss_overlay.panel, mock.boss_overlay.intro_card]: - var rect: Rect2 = card.get_global_rect() - check(top_band.encloses(rect), "boss card inside top band at " + str(extent) + ": " + str(rect)) - check(not viewing_region.intersects(rect), "boss card clears viewing region at " + str(extent)) + var plate_rect: Rect2 = mock.boss_overlay.panel.get_global_rect() + check(top_band.encloses(plate_rect), "boss plate inside top band at " + str(extent) + ": " + str(plate_rect)) + check(not viewing_region.intersects(plate_rect), "boss plate clears viewing region at " + str(extent)) check(mock.boss_overlay.hp.size.y <= 14, "compact boss HP track") var names := panels.keys() for i in names.size(): var rect: Rect2 = panels[names[i]].get_global_rect() - if names[i] not in ["toast", "turn plate"]: check(not viewing_region.intersects(rect), "persistent panel clears centre: " + names[i]) + if names[i] != "toast": check(not viewing_region.intersects(rect), "persistent panel clears centre: " + names[i]) print("A7 HUD RECT %s %s: %s" % [extent, names[i], rect]) for j in range(i + 1, names.size()): - if names[i] == "boss plate" and names[j] == "intro card": - check(not (panels[names[i]].is_visible_in_tree() and panels[names[j]].is_visible_in_tree()), "shared boss slot is exclusive") - continue check(not rect.intersects(panels[names[j]].get_global_rect()), "%s / %s separated at %s" % [names[i], names[j], extent]) - mock.boss_overlay._process(3.0) - mock.boss_spawned.emit(mock.boss_kind, 140) + # The title card lives on the cinematic, framed by the letterbox, and the plate stays hidden until it ends. + mock.boss_cinematic_progress = 0.55 + mock.boss_cinematic.emit(mock.boss_kind, &"intro_full", 3.0) + await frames() + check(not mock.boss_overlay.visible and cinematic.title_group.is_visible_in_tree(), "intro replaces boss plate") + var bar_height := float(extent.y) * 0.065 + 1.0 + var title_rect: Rect2 = cinematic.title_group.get_global_rect() + check(title_rect.position.y > bar_height and title_rect.end.y < float(extent.y) - bar_height, "bars frame title card at " + str(extent) + ": " + str(title_rect)) + check(cinematic.review_header.get_global_rect().end.y <= bar_height, "review header stays on the top matte at " + str(extent)) + mock.boss_cinematic_ended.emit(&"intro_full") await create_timer(0.35).timeout - check(mock.boss_overlay.visible and not mock.boss_overlay.intro_card.visible, "spawned boss owns top slot after intro") - check(mock.hud.turn_panel.get_global_rect().position.y >= 530, "turn warning below hazard read zone") - check(not mock.hud.turn_panel.get_global_rect().intersects(mock.hud.speed_label.get_parent().get_parent().get_global_rect()), "turn and speed separated") + check(mock.boss_overlay.visible and not cinematic.title_group.is_visible_in_tree(), "spawned boss owns top slot after intro") + check(top_band.encloses(mock.boss_overlay.panel.get_global_rect()), "boss plate returns to the top band at " + str(extent)) root.size = Vector2i(1280,720) root.content_scale_size = Vector2i(1280,720) @@ -396,10 +397,15 @@ func check_raster_rejection() -> void: func check_overlay_fonts(mock: Node) -> void: var shared: Font = Style.theme().default_font check(shared == Style.theme().default_font, "R4 stable shared theme font") - for ui: Control in [mock.hud.screen, mock.pause_menu.get_child(0), mock.game_over.get_node("Screen"), mock.boss_overlay, mock.boss_overlay.intro_card, mock.stage_banner, mock.stage_clear, mock.hud.pursuit_alert]: + for ui: Control in [mock.hud.screen, mock.pause_menu.get_child(0), mock.game_over.get_node("Screen"), mock.boss_overlay, mock.stage_banner, mock.stage_clear, mock.hud.pursuit_alert]: check(ui.get_theme_default_font() == shared, "R4 overlay font: " + ui.name) for label: Label in ui.find_children("*", "Label", true, false): check(label.get_theme_font("font") == shared, "R4 label font: " + label.text) + check(cinematic.root.get_theme_default_font() == shared, "R4 cinematic font") + var title_font: Font = cinematic.boss_name.get_theme_font("font") + check(title_font is FontVariation and (title_font as FontVariation).base_font == shared, "R4 title variation bases on the shared font") + for label: Label in [cinematic.epithet, cinematic.chyron, cinematic.threat]: + check(label.get_theme_font("font") == shared, "R4 cinematic label font: " + label.name) func check_tally_cleanup() -> void: var label := Style.label("SCORE 12840",22) @@ -414,7 +420,7 @@ func check_tally_cleanup() -> void: func check_centered_titles(mock: Node) -> void: # Hidden containers retain stale bounds. Measure each card after visible layout. - var cards := [mock.boss_overlay.intro_card, mock.stage_banner, mock.stage_clear] + var cards := [mock.stage_banner, mock.stage_clear] var visibility: Array[bool] = [] for card: Control in cards: visibility.append(card.visible) @@ -426,9 +432,25 @@ func check_centered_titles(mock: Node) -> void: var box: StyleBoxFlat = panel.get_theme_stylebox("panel") check(is_equal_approx(box.content_margin_left,box.content_margin_right), "R5 symmetric title margins") check(absf(title.get_global_rect().get_center().x-panel.get_global_rect().get_center().x) <= 2, "R5 title horizontally centered: " + title.text) - var intro: Label = mock.boss_overlay.intro_name - check(mock.boss_overlay.intro_card.get_global_rect().encloses(intro.get_global_rect()), "intro title fits top-band card") - check(not intro.get_global_rect().intersects(intro.get_parent().get_child(0).get_global_rect()), "intro title clears warning eyebrow") + var saved_progress: float = mock.boss_cinematic_progress + mock.boss_cinematic_progress = 0.55 + cinematic._cinematic(mock.boss_kind, &"intro_full", 3.0) + cinematic._layout() + cinematic._process(0.0) + await frames() + var title_card: Control = cinematic.title_group + var intro: Label = cinematic.boss_name + check(title_card.get_global_rect().encloses(intro.get_global_rect()), "intro title fits title card") + check(absf(intro.get_global_rect().get_center().x - title_card.get_global_rect().get_center().x) <= 2.0, "intro title horizontally centered") + var epithet: Label = cinematic.epithet + check(epithet.position.y < intro.position.y, "intro epithet sits above the name") + check(title_card.get_global_rect().encloses(epithet.get_global_rect()), "intro epithet fits title card") + var bar_height := root.size.y * 0.065 + 1.0 + var title_rect := title_card.get_global_rect() + check(title_rect.position.y > bar_height and title_rect.end.y < root.size.y - bar_height, "bars frame title card") + check(cinematic.review_header.get_global_rect().end.y <= bar_height, "review header stays on the top matte") + cinematic._ended(&"intro_full") + mock.boss_cinematic_progress = saved_progress for card: Control in cards: check_panel_decorations(card) check_panel_decorations(mock.boss_overlay) for index in cards.size(): @@ -520,10 +542,9 @@ func check_teaching(mock: Node) -> void: mock.pursuit_recovered.emit(&"timer") hud.pursuit_alert._process(1.5) hud._state(&"ready") - mock.turn_incoming.emit(1, 3.0) - check(hud.turn_panel.visible and not hud.ready_panel.visible, "turn replaces GET READY") + check(hud.ready_panel.visible, "GET READY stays up with nothing to replace it") + _check_clear_of_removed_phase(hud) check_ready_separation(mock, extent) - mock.turn_finished.emit() hud._state(&"ready") hud._state(&"running") hud._process(3.0) @@ -577,13 +598,22 @@ func check_teaching(mock: Node) -> void: hud.toast_teaching = false hud.set_process(true) +func _check_clear_of_removed_phase(node: Node) -> void: + var script: Script = node.get_script() + while script != null: + for prop in script.get_script_property_list(): + var prop_name := String(prop["name"]) + check(not prop_name.contains("turn"), "HUD still exposes %s" % prop_name) + script = script.get_base_script() + for child in node.find_children("*", "Node", true, false): + check(not String(child.name).to_lower().contains("turn"), "HUD still builds %s" % child.name) + func check_ready_separation(mock: Node, extent: Vector2i) -> void: var ready: Control = mock.hud.ready_panel var panels := { "speed": mock.hud.speed_label.get_parent().get_parent(), "stage banner": mock.stage_banner.panel, "pursuit alert": mock.hud.pursuit_alert, - "turn plate": mock.hud.turn_panel, } for title in panels: var panel: Control = panels[title] diff --git a/hoppys-adventure-arcade/tests/assets/a8_check.gd b/hoppys-adventure-arcade/tests/assets/a8_check.gd index fe9c7bc7..e54aa2de 100644 --- a/hoppys-adventure-arcade/tests/assets/a8_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a8_check.gd @@ -2,6 +2,18 @@ extends SceneTree const Synth = preload("res://scenes/runner/audio/sfx_synth.gd") const Scene = preload("res://scenes/runner/audio/runner_audio.tscn") const Mock = preload("res://scenes/runner/ui/dev/mock_runner_game.gd") + +## The facade timeline has no encounter clock. These signals are what RunnerAudio binds. +class CinematicMock extends Mock: + signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) + signal boss_cinematic_skipped(mode: StringName) + signal boss_cinematic_ended(mode: StringName) + signal run_intro(mode: StringName, seconds: float) + signal run_intro_beat(index: int, label: String) + signal run_intro_skipped + signal run_intro_ended + var boss_cinematic_progress := 0.0 + var boss_time_factor := 1.0 var checks := 0 var failures := 0 var pcm_bytes := 0 @@ -65,7 +77,7 @@ func window_metrics(stream: AudioStreamWAV, start: float, end: float) -> Vector2 return Vector2(sqrt(energy / maxi(1, last - first)), peak) func run() -> void: - check(Synth.RECIPES.size() == 32, "32 recipes") + check(Synth.RECIPES.size() == 39, "39 recipes remain once the run-intro cues are in") for kind: StringName in Synth.RECIPES: audio_check(Synth.sound(kind), Synth.RECIPES[kind][0], kind) check(Synth.sound(kind) == Synth.sound(kind), "cached " + kind) @@ -103,15 +115,17 @@ func run() -> void: check(window_metrics(fire, 0.0, 0.015).y >= window_metrics(fire, 0.0, 0.18).y * 0.8, "launch rendered attack within 15 ms") check(Synth._envelope(Synth.family_for(&"jump"), 0.12, 0.22) < 0.15, "jump has a tonal decay") check(Synth._envelope(Synth.family_for(&"pursuit_start"), 0.45, 0.75) > Synth._envelope(Synth.family_for(&"pursuit_start"), 0.15, 0.75) * 2.0, "pursuit second stage rises") - for kind in [&"turn_whoosh", &"projectile_whoosh"]: - var original := Synth.sound(kind).data - var center := Synth.panned_whoosh(2, kind).data - check(original == center, "center whoosh preserves stereo channels " + kind) - var left := Synth.panned_whoosh(1, kind).data - var right := Synth.panned_whoosh(4, kind).data - for i in range(0, original.size(), 400): - check(left.decode_s16(i) == original.decode_s16(i), "left whoosh preserves near channel") - check(right.decode_s16(i + 2) == original.decode_s16(i + 2), "right whoosh preserves near channel") + for key in Synth.RECIPES: + check(not String(key).contains("turn"), "RECIPES has no seam cue " + String(key)) + var whoosh_kind := &"projectile_whoosh" + var original := Synth.sound(whoosh_kind).data + var center := Synth.panned_whoosh(2, whoosh_kind).data + check(original == center, "center whoosh preserves stereo channels") + var left := Synth.panned_whoosh(1, whoosh_kind).data + var right := Synth.panned_whoosh(4, whoosh_kind).data + for i in range(0, original.size(), 400): + check(left.decode_s16(i) == original.decode_s16(i), "left whoosh preserves near channel") + check(right.decode_s16(i + 2) == original.decode_s16(i + 2), "right whoosh preserves near channel") check(Synth._envelope(&"chime", 0.1, 0.65) > 0.5, "chime retains ring") for kind in [&"lgtm", &"recovered_chime", &"stage_clear", &"tool_pickup", &"powerup"]: check(Synth.LAYERS[Synth.family_for(kind)][3] == 0.0, "clean tonal noise weight " + kind) @@ -127,14 +141,14 @@ func run() -> void: var source := FileAccess.get_file_as_string("res://scenes/runner/audio/sfx_synth.gd") var sample_loop := source.get_slice("for i in count:", 1).get_slice("# Remove DC", 0) check(not sample_loop.contains("kind in [") and not sample_loop.contains("float(["), "no per-sample recipe arrays") - for kind in [&"turn_whoosh", &"projectile_whoosh"]: - for mask in 8: audio_check(Synth.panned_whoosh(mask, kind), Synth.RECIPES[kind][0], "%s mask %d" % [kind, mask]) + for mask in 8: + audio_check(Synth.panned_whoosh(mask, whoosh_kind), Synth.RECIPES[whoosh_kind][0], "projectile_whoosh mask %d" % mask) check(Synth.ui_click() == Synth.sound(&"telegraph_tick"), "UI tick reuse") for path in ["sfx_synth.gd", "runner_audio.gd", "hifi/bus_chain.gd"]: check(not FileAccess.get_file_as_string("res://scenes/runner/audio/" + path).contains("TIME"), "no shader TIME: " + path) var before := AudioServer.bus_count var old_send := AudioServer.get_bus_send(AudioServer.get_bus_index(&"SFX")) - var mock := Mock.new() + var mock := CinematicMock.new() mock.autoplay = false root.add_child(mock) var audio := Scene.instantiate() @@ -170,7 +184,7 @@ func run() -> void: audio.play_ui_click() var heard := {} audio.sound_played.connect(func(kind: StringName, _stream: AudioStream) -> void: heard[kind] = true) - # The mock timeline exercises all 32 signal-triggered recipes. Run it until it finishes, so the + # The mock timeline exercises the signal-triggered recipes. Run it until it finishes, so the # Game Over sting still fires when config.caught_sequence_seconds changes (#61 made it 3.0 s). var ticks := 0 while mock.state != &"finished" and ticks < 600: @@ -184,6 +198,39 @@ func run() -> void: audio._process(0.1) check(audio.active_voice_count() <= 10, "voice cap") check(mock.state == &"finished", "demo timeline reaches Game Over (%d ticks)" % ticks) + # Cinematic cues follow the encounter clock. Drive each mode through the audio handlers. + mock.state_changed.emit(&"running") + for mode in [&"intro_full", &"intro_short", &"defeat_full"]: + mock.boss_cinematic_progress = 0.0 + mock.boss_time_factor = 1.0 + mock.boss_cinematic.emit(&"merge_conflict_hydra", mode, 5.0) + for step 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(step) / 20.0 + audio._process(0.15) + check(audio.active_voice_count() <= 10, "voice cap") + if mode == &"intro_short": + mock.boss_cinematic_skipped.emit(mode) + mock.boss_cinematic_ended.emit(mode) + audio._process(0.15) + # Contract 2.7 §11.15: intro_whoosh, intro_title, intro_count and intro_go through the handlers. + audio.one_shots.stop() + audio.one_shots.play() + audio._playback = audio.one_shots.get_stream_playback() + audio._voices.clear() + audio._voice_kinds.clear() + mock.run_intro.emit(&"full", 6.0) + mock.run_intro_beat.emit(0, "title") + mock.run_intro_beat.emit(1, "3") + mock.run_intro_beat.emit(2, "READY") + mock.run_intro_beat.emit(4, "GO!") + mock.run_intro_skipped.emit() + mock.run_intro_ended.emit() + audio._process(0.15) for kind: StringName in Synth.RECIPES: check(heard.has(kind), "trigger exercised: " + kind) audio._stop_all() audio.one_shots.play() @@ -202,7 +249,7 @@ func run() -> void: await create_timer(0.3).timeout check(AudioServer.bus_count == before, "bus teardown") check(AudioServer.get_bus_send(AudioServer.get_bus_index(&"SFX")) == old_send, "send restored") - print("A8 AUDIO: 32 recipes; peak=%.6f; checked PCM bytes=%d; draw census=%d; triangles=%d; textures=0" % [maximum, pcm_bytes, budget.x, budget.y]) + print("A8 AUDIO: 39 recipes; peak=%.6f; checked PCM bytes=%d; draw census=%d; triangles=%d; textures=0" % [maximum, pcm_bytes, budget.x, budget.y]) print("ASSET A8: %s (%d checks)" % ["PASS" if failures == 0 else "FAIL", checks]) for player in root.get_node("AudioService").get_children(): if player is AudioStreamPlayer: player.stop() diff --git a/hoppys-adventure-arcade/tests/assets/a9_check.gd b/hoppys-adventure-arcade/tests/assets/a9_check.gd index 6b94e39d..114f94ed 100644 --- a/hoppys-adventure-arcade/tests/assets/a9_check.gd +++ b/hoppys-adventure-arcade/tests/assets/a9_check.gd @@ -3,6 +3,27 @@ const Metrics = preload("res://scenes/runner/ui/dev/post_fx_capture_metrics.gd") const PostFX = preload("res://scenes/runner/ui/post_fx.tscn") const Preview = preload("res://scenes/runner/ui/dev/post_fx_preview.tscn") const PreviewScript = preload("res://scenes/runner/ui/dev/post_fx_preview.gd") + +## The preview stub predates the cinematic clock. Post FX reads that clock for the letterbox. +class CinematicGame extends PreviewScript.StubGame: + signal boss_cinematic(kind: StringName, mode: StringName, seconds: float) + signal boss_cinematic_skipped(mode: StringName) + signal boss_cinematic_ended(mode: StringName) + var boss_cinematic_progress := 0.0 + var boss_time_factor := 1.0 + signal run_intro(mode: StringName, seconds: float) + signal run_intro_beat(index: int, label: String) + signal run_intro_skipped + signal run_intro_ended + var run_intro_mode: StringName = &"" + var run_intro_progress := 0.0 + +## Title layout reads the encounter clock off its game node. +class TitleFixture extends Node: + var boss_cinematic_progress := 1.0 + var stage_index := 1 + var paused := false + var checks := 0 var failures := 0 @@ -32,7 +53,7 @@ func inspect(node: Node) -> void: func run() -> void: await check_ui_layers() - var game := PreviewScript.StubGame.new() + var game := CinematicGame.new() root.add_child(game) var fx := PostFX.instantiate() fx.bind(game) # Binding before ready must work, too. @@ -131,17 +152,19 @@ func run() -> void: game.power_up_expired.emit(&"nitro") step(fx, 0.4) check(value(fx, "bloom_lift") == 0.0, "Nitro expires") - game.boss_intro.emit(&"legacy_monolith", "Legacy Monolith", 3.0) + game.boss_cinematic_progress = 1.0 + game.boss_cinematic.emit(&"legacy_monolith", &"intro_full", 3.0) step(fx, 0.2) check(value(fx, "letterbox") == 1.0, "boss bars open") - step(fx, 3.1) - check(value(fx, "letterbox") == 0.0, "boss bars expire") + game.boss_cinematic_ended.emit(&"intro_full") + step(fx, 0.3) + check(value(fx, "letterbox") == 0.0, "boss bars close") game.stage_cleared.emit(1, 500) step(fx, 0.2) check(value(fx, "letterbox") == 1.0, "clear card bars open") step(fx, 3.0) check(value(fx, "letterbox") == 0.0, "clear card bars close") - for phase in [&"run", &"turn", &"idle"]: + for phase in [&"run", &"idle"]: game.stage_cleared.emit(1, 500) step(fx, 0.2) game.phase_changed.emit(phase) @@ -293,6 +316,12 @@ func check_ui_layers() -> void: for parent_path in ["HUD", "Overlays"]: for path: String in mounts[parent_path]: if path == "res://scenes/runner/ui/post_fx.tscn": continue + # Contract 2.7 §11.15: run_intro.tscn is an optional mount. S5 may not have built it, + # and a missing scene must not abort the layer checks for the panels that do exist. + if not ResourceLoader.exists(path): + check(path == "res://scenes/runner/ui/run_intro.tscn", + "only the optional run intro mount may be absent, missing " + path) + continue var ui := (load(path) as PackedScene).instantiate() skeleton.get_node(parent_path).add_child(ui) check(effective_layer(ui) > post_layer, "production UI above post: " + path) @@ -308,7 +337,12 @@ func check_ui_layers() -> void: var clear := (load("res://scenes/runner/ui/stage_clear.tscn") as PackedScene).instantiate() hud.add_child(boss) hud.add_child(clear) - boss._intro(&"legacy_monolith", "Legacy Monolith", 3.0) + var cinematic := (load("res://scenes/runner/ui/boss_cinematic.tscn") as PackedScene).instantiate() + viewport.add_child(cinematic) + var fixture := TitleFixture.new() + viewport.add_child(fixture) + cinematic.game = fixture + cinematic._cinematic(&"legacy_monolith", &"intro_full", 3.0) clear.show() clear.title.text = "STAGE 1 CLEAR" clear.subtitle.text = "LEGACY MONOLITH / FIXED" @@ -319,7 +353,7 @@ func check_ui_layers() -> void: var shader := load("res://scenes/runner/shaders/post_grade.gdshader") as Shader check(shader.code.contains("letterbox * 0.065"), "tested letterbox height matches shader") var bar_height := 720.0 * 0.065 + 1.0 - for card: Control in [boss.intro_card, clear.panel]: + for card: Control in [cinematic.title_group, clear.panel]: var rect := card.get_global_rect() check(effective_layer(card) > post_layer, "card stays above letterbox: " + card.name) check(rect.position.y > bar_height and rect.end.y < 720.0 - bar_height, "bars frame card bounds: " + card.name) diff --git a/hoppys-adventure-arcade/tests/assets/prewarm_check.gd b/hoppys-adventure-arcade/tests/assets/prewarm_check.gd index d56bcca7..bb5c7af1 100644 --- a/hoppys-adventure-arcade/tests/assets/prewarm_check.gd +++ b/hoppys-adventure-arcade/tests/assets/prewarm_check.gd @@ -87,13 +87,15 @@ func run() -> void: check(Prewarm.generated_on_workers, "image generation ran on workers") check(Prewarm.finalized_on_main, "ImageTexture finalization ran on main") # Cues are synthesised on workers; RunnerAudio.bind() finishes them before a run can start. - for kind in [&"boss_intro", &"turn_warning", &"turn_whoosh"]: - check(Synth._cache.has(kind), "critical cue warm before gameplay: " + kind) + check(Synth._cache.has(&"boss_intro"), "critical cue warm before gameplay: boss_intro") + for key in Synth.RECIPES: + check(not String(key).contains("turn"), "RECIPES has no seam cue " + String(key)) for kind in Synth.PERCUSSIVE: check(Synth._cache.has(kind), "percussive ready: " + kind) - for kind in [&"projectile_whoosh", &"turn_whoosh"]: - for mask in 8: - check(Synth._cache.has(StringName("%s_pan_%d" % [kind, mask])), "panned cue ready") + for mask in 8: + check(Synth._cache.has(StringName("projectile_whoosh_pan_%d" % mask)), "panned cue ready") + for key in Synth._cache: + check(not String(key).contains("turn"), "synth cache has no seam cue " + String(key)) check(identities(BossForge.maps) == fallback_ids, "early boss fallback cache survives worker completion") check(BossForge.maps.albedo.get_image().get_data() == fallback_pixels, "worker completion preserves fallback pixels") check(identities(EnvironmentForge.cache) == environment_ids, "environment bindings retain texture identities") @@ -118,12 +120,16 @@ func run() -> void: var expected := {"environment": 9, "materials": 11, "hazards": 4, "hazard_meshes": 16, "pickups": 4, "pickup_templates": 3, "boss": 4, "boss_meshes": 42, "boss_fx": 2, "pursuer": 3, - "ui": 1, "audio": 32, "footsteps": 6, "whooshes": 16} + "ui": 1, "audio": 39, "footsteps": 6, "whooshes": 8} for package in expected: check(census.has(package), package + " present in census") var count := identities(census.get(package, {})).size() check(count >= expected[package], "%s census populated (%d)" % [package, count]) print("PREWARM census %s: %d resources" % [package, count]) + for key in census.get("whooshes", {}): + var whoosh_key := String(key) + check(whoosh_key.contains("projectile_whoosh"), "whoosh census is the projectile pan: " + whoosh_key) + check(not whoosh_key.contains("turn"), "whoosh census has no seam cue " + whoosh_key) var before := cache_ids() var census_ids := identities(census) var started := Time.get_ticks_usec() diff --git a/hoppys-adventure-arcade/tests/assets/starship_check.gd b/hoppys-adventure-arcade/tests/assets/starship_check.gd index a7b635b7..c1910258 100644 --- a/hoppys-adventure-arcade/tests/assets/starship_check.gd +++ b/hoppys-adventure-arcade/tests/assets/starship_check.gd @@ -35,7 +35,7 @@ func run() -> void: for vertex: Vector3 in mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]: if absf(vertex.x) < 3.1 and vertex.y < 3.0: lane_clear = false check(lane_clear, "architectural dressing stays outside playable lane clearance") - for node in [corridor.starship.get_node("ArmouredDeck"),corridor.starship.get_node("FarBulkhead")] + corridor.starship.reactor_dock.get_children(): + for node in [corridor.starship.get_node("ArmouredDeck"), corridor.starship.get_node("FarBulkhead"), corridor.starship.get_node("EntryBulkhead"), corridor.starship.get_node("EntryBulkhead/EntryDoorGlow")] + corridor.starship.reactor_dock.get_children(): var arrays: Array = node.mesh.surface_get_arrays(0) var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] if arrays[Mesh.ARRAY_INDEX] != null else PackedInt32Array() extra_triangles += (indices.size() if not indices.is_empty() else arrays[Mesh.ARRAY_VERTEX].size()) / 3 @@ -48,12 +48,39 @@ func run() -> void: corridor.set_scroll_speed(32) corridor._process(3.0) check(is_zero_approx(corridor._offset), "maximum-speed 96m wrap has no drift") - for direction in [-1,1]: - corridor.set_turn(0.5,direction) - corridor._process(0.0) - check(is_equal_approx(corridor.bend,0.01*direction), "retained turn profile") - for material in corridor.starship.materials: - check(is_equal_approx(material.get_shader_parameter("bend"),corridor.bend), "all starship surfaces share bend") + check(corridor.rotation == Vector3.ZERO, "the corridor stays square") + var bulkhead: MeshInstance3D = corridor.starship.get_node("EntryBulkhead") + var hull: ShaderMaterial = bulkhead.material_override + var glow := bulkhead.get_node_or_null("EntryDoorGlow") as MeshInstance3D + check(glow != null, "entry door has its glow child") + check(is_zero_approx(bulkhead.extra_cull_margin), + "entry bulkhead cull margin is 0, got %.2f" % bulkhead.extra_cull_margin) + check(glow != null and is_zero_approx(glow.extra_cull_margin), + "entry door glow cull margin is 0, got %.2f" % (glow.extra_cull_margin if glow != null else -1.0)) + if glow != null: + var box: AABB = bulkhead.global_transform * bulkhead.get_aabb() + check(box.position.z > 8.0 and box.end.z > 8.0, + "entry bulkhead sits behind the gameplay camera at z 8 (z %.2f..%.2f)" % [box.position.z, box.end.z]) + check(is_equal_approx(float(hull.get_shader_parameter("metallic")), 0.68) + and not bool(hull.get_shader_parameter("window_mode")) + and is_zero_approx(float(hull.get_shader_parameter("glow"))) + and not hull.shader.code.contains("blend_"), + "entry bulkhead is opaque hull") + var beyond := false + var sleeve := false + var z_min := 1000.0 + var z_max := -1000.0 + for vertex in bulkhead.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]: + z_min = minf(z_min, vertex.z) + z_max = maxf(z_max, vertex.z) + beyond = beyond or vertex.z > 24.0 + sleeve = sleeve or (vertex.z < 24.0 and vertex.z >= 11.0) + check(beyond and z_max > 24.0, "entry door sits beyond z +24 (max %.2f)" % z_max) + check(sleeve and z_min > 10.0 and z_min < 12.5, "entry sleeve reaches back over the near modules (min %.2f)" % z_min) + for material in corridor.starship.materials: + var bend_value: Variant = material.get_shader_parameter("bend") + # An unset uniform keeps the shader default, which is square. + check(bend_value == null or is_zero_approx(float(bend_value)), "starship surfaces stay unbent") corridor.set_boss_arena(true) corridor._process(2.0) check(corridor.starship.reactor_dock.visible, "boss reactor_dock appears") @@ -75,7 +102,7 @@ func run() -> void: check(is_equal_approx(root.scaling_3d_scale,0.85), "newest viewport owner retains quality scale") await Cleanup.release(self,[second]) check(is_equal_approx(root.scaling_3d_scale,original_scale), "viewport quality restores after exit") - print("STARSHIP TOTAL: %d triangles including deck, far bulkhead and reactor dock" % (triangles + extra_triangles)) + print("STARSHIP TOTAL: %d triangles including deck, far bulkhead, entry bulkhead and reactor dock" % (triangles + extra_triangles)) print("STARSHIP GEOMETRY: repeated modules=%d triangles; 6 batches; 1 shared 256x256 texture" % triangles) print("STARSHIP CHECK: %s (%d checks, %d failures)" % ["PASS" if failures == 0 else "FAIL",checks,failures]) quit(0 if failures == 0 else 1) diff --git a/hoppys-adventure-arcade/tests/beatemup_knockout.gd b/hoppys-adventure-arcade/tests/beatemup_knockout.gd index 822a9174..281f61ab 100644 --- a/hoppys-adventure-arcade/tests/beatemup_knockout.gd +++ b/hoppys-adventure-arcade/tests/beatemup_knockout.gd @@ -3,7 +3,22 @@ extends SceneTree ## stepped with _physics_process, the same way as stage_flow.gd. The live-path checks ## kill Hoppy through a bullet, a ground warning, an enemy and the boss. const DT := 1.0/60.0 +## Frames to wait for the last autoload before giving up, so a stuck start fails instead of hanging. +const READY_FRAMES := 600 const LENGTH := 2.2 +## Wall-time contract: .12 s at full speed, .63 s of pose clock at .55x, the rest at full speed, +## then the card for .8 s. 0.12+0.63/0.55+(2.2-0.75)+0.8 = 3.515 s, under the 4 s budget. +const SLOWMO_FROM := .12 +const SLOWMO_TO := .75 +const SLOWMO_SCALE := .55 +const CARD_HOLD := .8 +const WALL_TOTAL := .12+(.75-.12)/.55+(2.2-.75)+.8 +const WALL_BUDGET := 4.0 +const SLACK := .05 +const ZOOM := 2.0 +const BARS := 90.0 +## Rendered frames go here, not into docs/previews, until the preview owner adopts them. +const CAPTURE_DIR := "/tmp/s3-captures" const DISTANCE := 140.0 var failures := 0 var passes := 0 @@ -23,9 +38,15 @@ func step() -> void: game._physics_process(DT) func advance_to(seconds: float) -> void: var guard := 0 - while game.state=="knockout" and game.ko.clock=600: check(false, tag("advance_to(%.2f) reaches its clock within 600 ticks" % seconds)) +## A polling loop that ran out of ticks leaves the knockout running: record it once per loop. +func ko_stuck(label: String) -> bool: + if game.state!="knockout": return false + check(false, tag("%s: the knockout ends within the tick cap" % label)) + return true func freeze() -> void: game.set_physics_process(false) if is_instance_valid(game.player): @@ -155,6 +176,18 @@ func accept_event() -> InputEventAction: event.pressed = true return event func run() -> void: + # Rendered, SaveService's window-mode call pumps the loop inside its _ready and fires the deferred + # run() before the autoloads finish, so wait for the last one. + var waited := 0 + while not (root.has_node("ControllerService") and root.get_node("ControllerService").is_node_ready()) and waited < READY_FRAMES: + await process_frame + waited += 1 + if not (root.has_node("ControllerService") and root.get_node("ControllerService").is_node_ready()): + check(false, "ControllerService is ready within %d frames" % READY_FRAMES) + quit(1) + return + await process_frame + await settle_window() await run_mode("res://scenes/beatem_up/code_city_brawl.tscn", false) await run_mode("res://scenes/beatem_up/reinvent/heroes_vs_villains.tscn", true) print("Knockout passes: ", passes, " failures: ", failures) @@ -162,20 +195,34 @@ func run() -> void: root.get_node("AudioService").shutdown() await create_timer(.4).timeout quit(1 if failures else 0) -func boot(path: String) -> void: +## Takes the scene just changed to as `game` once it has built. Without it the suite cannot go +## on: records a failure, quits 1 and returns false. +func adopt_scene() -> bool: + var loading := 0 + while (current_scene==null or current_scene.scene_file_path!=scene_path or current_scene.get("player")==null) and loading bool: scene_path = path change_scene_to_file(path) await process_frame await process_frame - game = current_scene + if not await adopt_scene(): return false game.test_mode = true # A covered window can focus-pause before test_mode is set. Physics is re-enabled at ready. paused = false if game.state=="paused": game.resume() freeze() + return true func run_mode(path: String, is_heroes: bool) -> void: heroes = is_heroes - await boot(path) + if not await boot(path): return print("--- ", mode(), " ---") check_timeline() check_arc(true) @@ -188,9 +235,18 @@ func run_mode(path: String, is_heroes: bool) -> void: check_double() check_flow() check_live() + check_cinematic() + check_card() + check_camera() + check_no_camera() + check_hud_proxy() + check_hud_real() + check_done_once() + check_retry_cleanup() await check_menu() await check_cancel() await check_preview() + await check_card_preview() func check_timeline() -> void: ready_combat() var tune: Dictionary = game.ko.tuning @@ -208,25 +264,36 @@ func check_timeline() -> void: var at := {} var early := false var leaked := false + var last_wall := 0.0 + var wall_total := 0.0 note_phase(order, at) var guard := 0 - while game.state=="knockout" and guard<400: - if game.ko.clock>=LENGTH: early = true + while game.state=="knockout" and guard<600: + if game.ko.clock>=LENGTH and game.ko.phase!="card": early = true if game.overlay.visible or game.playing: leaked = true step() note_phase(order, at) + last_wall = wall() guard += 1 - var want: Array[String] = ["hit_stop", "launch", "land", "out_cold", "game_over"] + wall_total = last_wall + var want: Array[String] = ["hit_stop", "launch", "land", "out_cold", "card", "game_over"] check(order==want, tag("phases run in order, got %s" % str(order))) - check(near_at(at, "launch", .12), tag("launch starts at 0.12 s (clock %s)" % str(at.get("launch", "?")))) - check(near_at(at, "land", .75), tag("land starts at 0.75 s (clock %s)" % str(at.get("land", "?")))) - check(near_at(at, "out_cold", 1.0), tag("out_cold starts at 1.0 s (clock %s)" % str(at.get("out_cold", "?")))) - var ended: float = float(at.get("game_over", -1.0)) - var on_time := ended>=LENGTH-0.001 and ended<=LENGTH+DT+0.001 - check(on_time and not early, tag("game over is first reached at 2.2 s ± one tick (clock %.4f)" % ended)) + # The pose clock still marks the phases. Slow-mo only changes how fast it runs in wall time, + # so a tick that crosses a boundary may overshoot it by up to one (slowed) tick. + check(near_at(at, "launch", .12), tag("launch starts at pose clock 0.12 s (clock %s)" % str(at.get("launch", "?")))) + check(near_at(at, "land", .75), tag("land starts at pose clock 0.75 s (clock %s)" % str(at.get("land", "?")))) + check(near_at(at, "out_cold", 1.0), tag("out_cold starts at pose clock 1.0 s (clock %s)" % str(at.get("out_cold", "?")))) + var carded: float = float(at.get("card", -1.0)) + check(carded>=LENGTH-0.001 and carded<=LENGTH+DT+0.001 and not early, tag("the card starts when the pose clock reaches 2.2 s (clock %.4f)" % carded)) + check(wall_ok(wall_total), tag("game over lands at about 3.515 s of wall time, within 4 s (%.4f)" % wall_total)) check(not leaked, tag("play stays stopped and the menu stays hidden through the knockout")) var focused = game.state=="game_over" and game.overlay.visible and game.primary.has_focus() check(focused, tag("game over shows the overlay with Retry focused")) +## Wall seconds since the hit. The knockout adds `elapsed` beside the pose `clock`. +func wall() -> float: + return float(game.ko.elapsed) +func wall_ok(seconds: float) -> bool: + return absf(seconds-WALL_TOTAL)<=DT*2.0+SLACK and seconds<=WALL_BUDGET func note_phase(order: Array[String], at: Dictionary) -> void: var name = "game_over" if game.state=="game_over" else game.ko.phase if order.is_empty() or order.back()!=name: @@ -304,7 +371,7 @@ func check_poses() -> void: var frame_bad := "" var saw_frame := false var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: var c: float = game.ko.clock var anim := String(p.body.animation) var want_face := direction if c>=turn else -direction @@ -321,6 +388,7 @@ func check_poses() -> void: elif frame_bad=="": frame_bad = "clock %.3f frame %s" % [c, str(p.body.frame)] step() guard += 1 + ko_stuck("poses") check(face_bad=="", tag("he faces the hit, then the throw (%s)" % face_bad)) if heroes: check(pose_bad=="", tag("hurt before 0.75 s, then defeat (%s)" % pose_bad)) else: @@ -345,7 +413,7 @@ func check_weapon() -> void: var saw_air := false var saw_carry := false var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: var c: float = game.ko.clock if heroes and c<0.75: saw_air = true @@ -359,6 +427,7 @@ func check_weapon() -> void: elif p.held.visible and hidden_bad=="": hidden_bad = "clock %.3f" % c step() guard += 1 + ko_stuck("weapon") if heroes: check(saw_air and air_bad=="", tag("the stamp is hidden and the body is unarmed while airborne (%s)" % air_bad)) check(saw_carry and carry_bad=="", tag("the stamp and its carry return after touchdown (%s)" % carry_bad)) @@ -377,7 +446,7 @@ func check_effects() -> void: var duck_ok := false var duck_at := 0.0 var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: var c: float = game.ko.clock if c+0.001>=0.75 and c<0.9 and has_pose(dust): saw_dust = true if c>=1.05 and c<=1.7: @@ -388,6 +457,7 @@ func check_effects() -> void: duck_at = game.flow.music_db step() guard += 1 + ko_stuck("effects") check(saw_dust, tag("a dust sprite is in actors right after touchdown")) check(stars_ok, tag("stars are visible during out_cold")) check(duck_ok, tag("music ducks during out_cold (db %.2f)" % duck_at)) @@ -424,13 +494,13 @@ func check_pause() -> void: game.resume() var guard := 0 var early := false - while game.state=="knockout" and guard<400: - if game.ko.clock>=LENGTH: early = true + while game.state=="knockout" and guard<600: + if game.ko.clock>=LENGTH and game.ko.phase!="card": early = true step() guard += 1 - var ended: float = game.ko.clock - var on_time = game.state=="game_over" and not early and ended>=LENGTH-0.001 and ended<=LENGTH+DT+0.001 - check(on_time, tag("game over still lands at 2.2 s of unpaused time (clock %.4f)" % ended)) + var ended: float = wall() + var on_time = game.state=="game_over" and not early and wall_ok(ended) + check(on_time, tag("game over still lands at about 3.515 s of unpaused wall time (%.4f)" % ended)) func same_knockout(clock: float, phase: String, direction: float, landing: Vector2) -> bool: return is_equal_approx(game.ko.clock, clock) and game.ko.phase==phase and is_equal_approx(float(game.ko.direction), direction) and game.ko.landing.is_equal_approx(landing) func check_double() -> void: @@ -452,7 +522,7 @@ func check_double() -> void: game.knockout(p.position+Vector2(-200, 10)) check(same_knockout(clock, phase, direction, landing), tag("knockout() during a knockout leaves clock, phase, direction and landing")) var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: step() guard += 1 game.knockout(Vector2.ZERO) @@ -485,12 +555,13 @@ func check_flow() -> void: var moved := false var chatter := false var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: step() if game.flow.active() or game.room!=room or game.stage_index!=stage: moved = true if game.toast_label.visible or game.held_toast!="" or game.prompt.text!="": chatter = true if game.flow.banner_panel.visible: chatter = true guard += 1 + ko_stuck("flow") check(not moved and game.room==room, tag("the flow stays idle and the room does not change past the gate")) check(not chatter, tag("toasts, the prompt and the stage banner stay hidden")) func check_menu() -> void: @@ -501,9 +572,10 @@ func check_menu() -> void: check(false, tag("a fatal hit is accepted for the menu")) return var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: step() guard += 1 + if ko_stuck("menu"): return check(game.state=="game_over" and game.overlay.visible and game.primary.has_focus(), tag("game over focuses Retry")) await process_frame if heroes: @@ -569,11 +641,11 @@ func reload_scene() -> void: check(false, tag("AppRouter finishes routing within 5 s")) return await process_frame - check(current_scene.name=="BeatemMenu", tag("leave still routes to the Beat'em Up menu")) + check(current_scene!=null and current_scene.name=="BeatemMenu", tag("leave still routes to the Beat'em Up menu")) change_scene_to_file(scene_path) await process_frame await process_frame - game = current_scene + if not await adopt_scene(): return game.test_mode = true paused = false if game.state=="paused": game.resume() @@ -779,12 +851,12 @@ func defy_victory() -> void: check(ignored, tag("boss_defeated during the knockout does not start a finisher or a victory")) var guard := 0 var won := false - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: step() if game.state=="finisher" or game.state=="boss_victory" or game.state=="won": won = true guard += 1 - var ended: float = game.ko.clock - var over = game.state=="game_over" and not won and ended>=LENGTH-0.001 and ended<=LENGTH+DT+0.001 + var ended: float = wall() + var over = game.state=="game_over" and not won and wall_ok(ended) check(over, tag("the knockout still ends in game over (state %s, clock %.4f)" % [game.state, ended])) ## The bullet connects on the step an ally is due. Threats run first, so no ally and no toast. func check_fatal_frame() -> void: @@ -807,15 +879,15 @@ func check_fatal_frame() -> void: check(true, tag("the fatal bullet knocks Hoppy out with no ally and no toast")) var leaked := false var guard := 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: step() if not toast_hidden() or not game.allies.is_empty(): leaked = true guard += 1 if not toast_hidden() or not game.allies.is_empty(): leaked = true check(not leaked, tag("the toast stays hidden, and no ally appears, until game over")) - var ended: float = game.ko.clock - var over = game.state=="game_over" and ended>=LENGTH-0.001 and ended<=LENGTH+DT+0.001 - check(over, tag("that knockout still ends in game over (clock %.4f)" % ended)) + var ended: float = wall() + var over = game.state=="game_over" and wall_ok(ended) + check(over, tag("that knockout still ends in game over (wall %.4f)" % ended)) ## Rendered only: one out-cold frame per mode. Headless, or with the render loop off (where ## frame_post_draw never fires), it returns before any check. func check_preview() -> void: @@ -840,13 +912,11 @@ func check_preview() -> void: check(false, tag("a hit from the right is accepted for the preview")) return var guard := 0 - while game.state=="knockout" and game.ko.clock<1.6 and guard<400: + while game.state=="knockout" and game.ko.clock<1.6 and guard<600: step() guard += 1 # Hand steps land between engine ticks. Reset interpolation or the shot draws the previous pose. - game.camera.position = Vector2(game.room_start()+640.0, 360.0) - game.camera.offset = Vector2.ZERO - game.camera.zoom = Vector2.ONE + # The knockout's own camera push stays: the capture shows the zoomed frame as played. game.shake = 0.0 game.reset_physics_interpolation() await RenderingServer.frame_post_draw @@ -865,7 +935,8 @@ func check_preview() -> void: var stars = p.get_node_or_null("KnockoutStars") var orbit: Node2D = stars if stars else p var screen: Vector2 = orbit.get_global_transform_with_canvas().origin - var star_box := Rect2(screen-Vector2(64, 40), Vector2(128, 80)) + var zoom := float(game.camera.zoom.x) if game.camera!=null else 1.0 + var star_box := Rect2(screen-Vector2(64, 40)*zoom, Vector2(128, 80)*zoom) var callout_xform: Transform2D = game.ko.callout.get_global_transform_with_canvas() var callout_rect := Rect2(callout_xform.origin, game.ko.callout.size*callout_xform.get_scale()) var star_color: Color = game.ko.tuning.star_color @@ -888,15 +959,20 @@ func check_preview() -> void: # The rendered run refreshes the committed preview, as the smoke suites do, but only with a # frame that passed every check above. if failures==failed_before: - var folder := "heroes-villains" if heroes else "code-city" - DirAccess.make_dir_recursive_absolute("res://../docs/previews/"+folder) - var saved = image.save_png("res://../docs/previews/"+folder+"/knockout.png")==OK - check(saved, tag("knockout preview saved")) + check(bars_are_black(image), tag("the out-cold frame has pure black %d px bars top and bottom" % int(BARS))) + check(hoppy_pixels(image)>=HOPPY_MIN, tag("Hoppy is drawn large under the %.1fx zoom (%d orange px)" % [ZOOM, hoppy_pixels(image)])) + if game.has_method("knockout_hud_nodes"): + var shown := 0 + for n in game.knockout_hud_nodes(): + if is_instance_valid(n) and n.is_visible_in_tree(): shown += 1 + check(shown==0, tag("the HUD and minimap nodes are hidden in the out-cold frame (%d visible)" % shown)) + check(edges_are_stage(image), tag("the out-cold frame shows stage, not the clear color, along the left and right edges")) + check(save_capture(image, "knockout_out_cold"), tag("out-cold capture saved under %s" % CAPTURE_DIR)) else: print(tag("knockout preview not saved: its frame failed a check")) game.ko.callout.show() if is_instance_valid(game.ko.stars): game.ko.stars.show() guard = 0 - while game.state=="knockout" and guard<400: + while game.state=="knockout" and guard<600: step() guard += 1 check(game.state=="game_over", tag("the preview knockout still ends in game over")) @@ -915,3 +991,442 @@ func count_near(image: Image, rect: Rect2, target: Color, tol: float) -> int: var db := px.b-target.b if dr*dr+dg*dg+db*db<=tol2: hits += 1 return hits +## Rendered only: let the window-mode change settle, then pin the viewport to 1280x720 so every +## capture has the same size. Gives up after 120 process frames and the captures refuse a wrong size. +const FRAME := Vector2i(1280, 720) +func settle_window() -> void: + if DisplayServer.get_name()=="headless" or not RenderingServer.render_loop_enabled: return + await create_timer(.5).timeout + DisplayServer.window_set_size(FRAME) + root.size = FRAME + var frames := 0 + while Vector2i(root.get_texture().get_size())!=FRAME and frames<120: + await process_frame + frames += 1 + print("window settled at ", root.get_texture().get_size(), " after ", frames, " frames") + if Vector2i(root.get_texture().get_size())!=FRAME: check(false, "the window settles to 1280x720 within 120 frames") +func frame_ok(image: Image) -> bool: + return Vector2i(image.get_width(), image.get_height())==FRAME +func save_capture(image: Image, name: String) -> bool: + if not frame_ok(image): + print(tag("capture %s refused: frame is %dx%d, not 1280x720" % [name, image.get_width(), image.get_height()])) + return false + var folder := "heroes-villains" if heroes else "code-city" + var dir := "%s/%s" % [CAPTURE_DIR, folder] + DirAccess.make_dir_recursive_absolute(dir) + return image.save_png("%s/%s.png" % [dir, name])==OK +## The first tick, wall-measured: full speed before .12, warped at .55x between .12 and .75, +## full speed after, with Engine.time_scale untouched throughout. +func check_cinematic() -> void: + ready_combat() + game.player.position = Vector2(game.room_start()+640.0, 580.0) + if not strike(true): + check(false, tag("a fatal hit is accepted for the slow-mo check")) + return + var tune: Dictionary = game.ko.tuning + var keys := is_equal_approx(float(tune.slowmo_from), SLOWMO_FROM) and is_equal_approx(float(tune.slowmo_to), SLOWMO_TO) and is_equal_approx(float(tune.slowmo_scale), SLOWMO_SCALE) + check(keys, tag("slowmo_from .12, slowmo_to .75 and slowmo_scale .55 are the defaults")) + check(is_equal_approx(float(tune.card_hold), CARD_HOLD) and str(tune.card_title)=="REVIEW FAILED", tag("card_hold .8 and card_title REVIEW FAILED are the defaults")) + check(is_equal_approx(float(tune.zoom), ZOOM) and is_equal_approx(float(tune.zoom_in), .45) and is_equal_approx(float(tune.zoom_out), .25) and is_equal_approx(float(tune.bars), BARS), tag("zoom 2.0, zoom_in .45, zoom_out .25 and bars 90 are the defaults")) + var scale_bad := "" + var time_scale_bad := false + var guard := 0 + while game.state=="knockout" and guard<600: + var before_clock: float = game.ko.clock + var before_wall := wall() + step() + if not is_equal_approx(Engine.time_scale, 1.0): time_scale_bad = true + var dc: float = game.ko.clock-before_clock + var dw := wall()-before_wall + if dw>0.0 and game.ko.phase!="card" and before_clock>=SLOWMO_FROM+DT and game.ko.clock<=SLOWMO_TO-DT: + var ratio := dc/dw + if absf(ratio-SLOWMO_SCALE)>.02 and scale_bad=="": scale_bad = "clock %.3f ratio %.3f" % [before_clock, ratio] + elif dw>0.0 and game.ko.phase=="out_cold" and before_clock>=1.0: + if absf(dc/dw-1.0)>.02 and scale_bad=="": scale_bad = "after slow-mo, clock %.3f ratio %.3f" % [before_clock, dc/dw] + elif dw>0.0 and before_clock.02 and scale_bad=="": scale_bad = "before slow-mo, clock %.3f ratio %.3f" % [before_clock, dc/dw] + guard += 1 + ko_stuck("cinematic") + check(scale_bad=="", tag("the pose clock runs .55x in wall time between .12 and .75 and 1x outside it (%s)" % scale_bad)) + check(not time_scale_bad and is_equal_approx(Engine.time_scale, 1.0), tag("Engine.time_scale is never touched")) +## The card is `ko.card` (a Control) with `card_title` and `top_bar`/`bottom_bar` letterbox bars. +func card_visible() -> bool: + return is_instance_valid(game.ko.card) and game.ko.card.is_visible_in_tree() +func card_labels() -> Array[String]: + var found: Array[String] = [] + if not is_instance_valid(game.ko.root): return found + var stack: Array = [game.ko.root] + while not stack.is_empty(): + var node: Node = stack.pop_back() + for child in node.get_children(): stack.append(child) + if node is Label and node.is_visible_in_tree() and node.text!="": found.append(node.text) + return found +func check_card() -> void: + ready_combat() + game.player.position = Vector2(game.room_start()+640.0, 580.0) + if not strike(true): + check(false, tag("a fatal hit is accepted for the card check")) + return + var title := str(game.ko.tuning.card_title) + var seen_early := false + var card_start := -1.0 + var card_end := -1.0 + var saw_title := false + var guard := 0 + while game.state=="knockout" and guard<600: + var shown := title in card_labels() + if game.ko.phase!="card" and shown: seen_early = true + if game.ko.phase=="card": + if card_start<0.0: card_start = wall() + if shown: saw_title = true + if game.overlay.visible: seen_early = true + step() + guard += 1 + card_end = wall() + check(not seen_early, tag("the failure card and the menu stay hidden until the pose clock ends")) + check(saw_title and card_start>0.0, tag("the card shows %s during the card phase" % title)) + check(str(game.ko.card_title.text)==title, tag("card_title carries the configured text")) + var held := card_end-card_start + check(absf(held-CARD_HOLD)<=DT*2.0+SLACK, tag("the card holds for .8 s of wall time (%.3f)" % held)) + check(game.state=="game_over" and game.overlay.visible and not card_visible() and not game.ko.top_bar.is_visible_in_tree(), tag("game over hides the card, the bars and shows the menu")) +func check_camera() -> void: + ready_combat() + var cam: Camera2D = game.camera + if cam==null: + check(false, tag("the game has a camera for the camera check")) + return + var zoom0 := Vector2(1.1, 1.1) + var pos0 := Vector2(game.room_start()+600.0, 340.0) + cam.zoom = zoom0 + cam.position = pos0 + game.player.position = Vector2(game.room_start()+640.0, 580.0) + if not strike(true): + check(false, tag("a fatal hit is accepted for the camera check")) + return + var peak := 0.0 + var guard := 0 + var moved := false + while game.state=="knockout" and guard<600: + step() + peak = maxf(peak, float(cam.zoom.x)) + if game.state=="knockout" and game.ko.phase=="card" and absf(cam.zoom.x-zoom0.x*ZOOM)<.01: moved = true + guard += 1 + ko_stuck("camera") + check(peak>zoom0.x+.05 and peak<=zoom0.x*ZOOM+.01, tag("the camera pushes in during the knockout, to at most ZOOMx its snapshot zoom (peak %.3f)" % peak)) + check(moved, tag("the camera holds the full zoom of its snapshot through the card")) + check(cam.zoom.is_equal_approx(zoom0) and cam.position.is_equal_approx(pos0), tag("camera position and zoom are restored to their snapshot on completion")) + ready_combat() + cam.zoom = zoom0 + cam.position = pos0 + game.player.position = Vector2(game.room_start()+640.0, 580.0) + if not strike(true): + check(false, tag("a fatal hit is accepted for the camera cancel")) + return + advance_to(1.5) + check(not cam.zoom.is_equal_approx(zoom0), tag("the camera is mid-push before cancel (%.3f)" % cam.zoom.x)) + game.ko.cancel() + check(cam.zoom.is_equal_approx(zoom0) and cam.position.is_equal_approx(pos0), tag("cancel() restores the camera position and zoom at once")) + cam.position = pos0+Vector2(33, 0) + game.ko.cancel() + check(cam.position.is_equal_approx(pos0+Vector2(33, 0)), tag("a second cancel() does not re-apply the snapshot")) + cam.position = pos0 + game.state = "combat" +## Driven straight through ko.play/tick: done fires once, never after cancel, and replay works. +func check_done_once() -> void: + ready_combat() + var p = game.player + p.position = Vector2(game.room_start()+640.0, 580.0) + var counter := [0] + var done := func(): counter[0] += 1 + game.ko.play(game, p.position+Vector2(-80, 0), done) + var wall_seen := 0.0 + for _i in 600: + game.ko.tick(DT) + wall_seen = float(game.ko.elapsed) + if counter[0]>0: break + check(counter[0]==1 and not game.ko.active(), tag("done runs once when the card ends")) + check(wall_ok(wall_seen), tag("done runs at about 3.515 s of wall time (%.3f)" % wall_seen)) + for _i in 60: game.ko.tick(DT) + check(counter[0]==1, tag("ticking an ended knockout never calls done again")) + # A coarse step carries through every phase without skipping done or calling it twice. + counter[0] = 0 + p.position = Vector2(game.room_start()+640.0, 580.0) + game.ko.play(game, p.position+Vector2(80, 0), done) + for _i in 20: game.ko.tick(.5) + check(counter[0]==1 and not game.ko.active(), tag("half-second steps still call done exactly once")) + # cancel() in each phase suppresses done. + for cut in [.05, .4, 1.2, 2.0, 3.0]: + counter[0] = 0 + p.position = Vector2(game.room_start()+640.0, 580.0) + game.ko.play(game, p.position+Vector2(-80, 0), done) + var guard := 0 + while float(game.ko.elapsed) void: + ready_combat() + var cam: Camera2D = game.camera + if cam==null: + check(false, tag("the game has a camera for the retry cleanup check")) + return + var zoom0: Vector2 = cam.zoom + game.player.position = Vector2(game.room_start()+640.0, 580.0) + if not strike(true): + check(false, tag("a fatal hit is accepted for the retry cleanup")) + return + var guard := 0 + while game.state=="knockout" and game.ko.phase!="card" and guard<600: + step() + guard += 1 + check(game.ko.phase=="card", tag("the cleanup sample is inside the card")) + var w := wall() + do_pause() + game._physics_process(2.0) + check(game.state=="paused" and is_equal_approx(wall(), w), tag("a pause inside the card freezes the wall clock")) + game.resume() + game.start_stage(0) + freeze() + var clean: bool = not game.ko.active() and not stars_visible() and not (str(game.ko.tuning.card_title) in card_labels()) + check(clean and cam.zoom.is_equal_approx(zoom0) and loose_sprites()==0, tag("start_stage during the card clears the card, the stars, the sprites and the zoom")) + guard = 0 + while game.state!="combat" and guard<240: + step() + guard += 1 + freeze() + check(game.state=="combat" and game.player.hp==3 and not game.ko.active(), tag("the retried stage reaches combat with the knockout inactive")) +## Rendered only: the failure card frame. +func check_card_preview() -> void: + if DisplayServer.get_name()=="headless" or not RenderingServer.render_loop_enabled: return + ready_combat() + sweep() + var p = game.player + game.toast_left = 0.0 + game.held_toast = "" + game.toast_label.hide() + game.prompt.text = "" + game.flow.banner("", "", 0) + p.position = Vector2(game.room_start()+560.0, 580.0) + if not strike(false): + check(false, tag("a hit is accepted for the card preview")) + return + var guard := 0 + while game.state=="knockout" and game.ko.phase!="card" and guard<600: + step() + guard += 1 + var target := wall()+CARD_HOLD*.5 + while game.state=="knockout" and wall() bool: + var clear := RenderingServer.get_default_clear_color() + var total := 0 + var bad := 0 + for x in list_edge_columns(image.get_width()): + for y in range(340, mini(image.get_height()-60, 690), 2): + total += 1 + var px := image.get_pixel(x, y) + if Vector3(px.r-clear.r, px.g-clear.g, px.b-clear.b).length()<.03: bad += 1 + print(tag("edge strips: %d of %d samples match the clear color" % [bad, total])) + return total>0 and float(bad)/float(total)<=.03 +func list_edge_columns(width: int) -> Array[int]: + var columns: Array[int] = [] + for x in 12: + columns.append(x) + columns.append(width-1-x) + return columns +## The camera is optional: with the owner's camera unset, play, tick and cancel still run. +func check_no_camera() -> void: + ready_combat() + var saved = game.camera + var p = game.player + p.position = Vector2(game.room_start()+640.0, 580.0) + var counter := [0] + game.camera = null + game.ko.play(game, p.position+Vector2(-80, 0), func(): counter[0] += 1) + for _i in 600: + game.ko.tick(DT) + if not game.ko.active(): break + var finished: bool = counter[0]==1 and not game.ko.active() + game.ko.play(game, p.position+Vector2(-80, 0), func(): counter[0] += 1) + for _i in 30: game.ko.tick(DT) + game.ko.cancel() + var cancelled: bool = counter[0]==1 and not game.ko.active() + game.camera = saved + check(finished, tag("a knockout without a camera runs to done once")) + check(cancelled, tag("cancelling a knockout without a camera is quiet")) + check(is_instance_valid(game.camera) and game.camera==saved, tag("the owner's camera is back for the game's own ticks")) +## Hoppy's orange (1, .33, 0) pixels; 2x zoom roughly quadruples the 1x count. +const HOPPY_MIN := 1500 +func hoppy_pixels(image: Image) -> int: + var count := 0 + for y in range(0, image.get_height(), 2): + for x in range(0, image.get_width(), 2): + var px := image.get_pixel(x, y) + if Vector3(px.r-1.0, px.g-.33, px.b).length()<=.18: count += 4 + return count +## Both bars are solid black from the window edge in, with no HUD text or controls over them. +func bars_are_black(image: Image) -> bool: + var h := image.get_height() + var bad := 0 + var total := 0 + for x in range(0, image.get_width(), 4): + for y in range(2, int(BARS)-4, 3): + total += 2 + var top := image.get_pixel(x, y) + var bottom := image.get_pixel(x, h-1-y) + if top.r+top.g+top.b>.06: bad += 1 + if bottom.r+bottom.g+bottom.b>.06: bad += 1 + print(tag("bar strips: %d of %d samples are not black" % [bad, total])) + return total>0 and bad==0 +## Stands in for the game with the optional hook, so the HUD contract is tested in both modes +## even before a game implements it. Forwards everything else to the real game. +class Proxy extends Node: + var target + var nodes: Array[CanvasItem] = [] + func _get(property: StringName): + return target.get(property) + func _set(property: StringName, value) -> bool: + if property in ["target", "nodes"]: return false + target.set(property, value) + return true + func knockout_hud_nodes() -> Array[CanvasItem]: return nodes + func sound(key: String, at: Vector2 = Vector2.ZERO) -> void: target.sound(key, at) + func clamp_ground(at: Vector2) -> Vector2: return target.clamp_ground(at) + func room_start() -> float: return target.room_start() +func make_proxy(): + var proxy := Proxy.new() + proxy.target = game + var extra := Control.new() + game.ui_layer.add_child(extra) + var hidden := Control.new() + hidden.visible = false + game.ui_layer.add_child(hidden) + proxy.nodes = [game.hud, extra, hidden] + return proxy +func free_proxy(proxy) -> void: + for n in proxy.nodes: + if is_instance_valid(n) and n!=game.hud: n.free() + proxy.free() +func visible_states(proxy) -> Array: + var states := [] + for n in proxy.nodes: states.append(n.visible) + return states +func check_hud_proxy() -> void: + ready_combat() + var proxy = make_proxy() + var hud_before: bool = game.hud.visible + var p = game.player + p.position = Vector2(game.room_start()+640.0, 580.0) + var counter := [0] + game.ko.play(proxy, p.position+Vector2(-80, 0), func(): counter[0] += 1) + check(visible_states(proxy)==[false, false, false], tag("every hook node is hidden from the first frame of the knockout")) + var leaked := false + var guard := 0 + while game.ko.active() and guard<600: + # The owner refreshes its HUD before every tick, as the minimap does. + proxy.nodes[1].visible = true + game.hud.visible = true + game.ko.tick(DT) + if game.ko.active() and visible_states(proxy)!=[false, false, false]: leaked = true + guard += 1 + check(not leaked, tag("the HUD stays hidden through the throw and the card even when the owner re-shows it")) + check(counter[0]==1 and visible_states(proxy)==[true, true, false], tag("completion restores each node's original visibility and keeps the prior-hidden one hidden (%s)" % str(visible_states(proxy)))) + # cancel() restores the same way. + game.hud.visible = hud_before + proxy.nodes[1].visible = true + game.ko.play(proxy, p.position+Vector2(80, 0), func(): counter[0] += 1) + for _i in 100: game.ko.tick(DT) + check(visible_states(proxy)==[false, false, false], tag("the HUD is hidden mid-knockout")) + game.ko.cancel() + check(visible_states(proxy)==[true, true, false] and counter[0]==1, tag("cancel() restores visibility and does not call done")) + game.ko.cancel() + check(visible_states(proxy)==[true, true, false], tag("a second cancel() leaves visibility alone")) + # A replay over a running knockout captures the restored state, not the hidden one. + game.ko.play(proxy, p.position+Vector2(80, 0), func(): pass) + for _i in 30: game.ko.tick(DT) + game.ko.play(proxy, p.position+Vector2(80, 0), func(): pass) + game.ko.cancel() + check(visible_states(proxy)==[true, true, false], tag("replaying captures the original visibility, so cancel restores it")) + # A freed node mid-knockout is skipped. + var gone := Control.new() + game.ui_layer.add_child(gone) + proxy.nodes.append(gone) + game.ko.play(proxy, p.position+Vector2(80, 0), func(): pass) + gone.free() + proxy.nodes.pop_back() + for _i in 10: game.ko.tick(DT) + game.ko.cancel() + check(visible_states(proxy)==[true, true, false], tag("a hook node freed during the knockout does not break the restore")) + free_proxy(proxy) +## The real game's hook, once it exists: hidden during the knockout, the pause overlay still works. +func check_hud_real() -> void: + if not game.has_method("knockout_hud_nodes"): + print(tag("no knockout_hud_nodes() hook yet: real-game HUD checks skipped")) + return + ready_combat() + var nodes: Array = game.knockout_hud_nodes() + check(nodes.size()>0 and game.hud in nodes, tag("the game's hook lists its HUD")) + var minimap_listed: bool = game.minimap!=null and (game.minimap in nodes or game.minimap.get_parent() in nodes) + check(minimap_listed, tag("the game's hook lists its minimap")) + var before := [] + for n in nodes: before.append(n.visible) + game.player.position = Vector2(game.room_start()+640.0, 580.0) + if not strike(true): + check(false, tag("a fatal hit is accepted for the real HUD check")) + return + var leaked := false + var guard := 0 + while game.state=="knockout" and game.ko.phase!="card" and guard<600: + step() + for n in nodes: + if n.is_visible_in_tree(): leaked = true + guard += 1 + if game.state!="knockout" or game.ko.phase!="card": + check(false, tag("the real HUD check reaches the card (state %s, phase %s)" % [game.state, str(game.ko.phase)])) + return + check(not leaked, tag("no HUD or minimap node is visible from the throw to the card")) + do_pause() + check(game.state=="paused" and game.overlay.visible, tag("pausing during the card still shows the pause overlay")) + game.resume() + check(game.state=="knockout", tag("resume returns to the knockout")) + for n in nodes: + if n.is_visible_in_tree(): leaked = true + check(not leaked, tag("resuming keeps the HUD hidden")) + guard = 0 + while game.state=="knockout" and guard<600: + step() + guard += 1 + var after := [] + for n in nodes: after.append(n.visible) + check(game.state=="game_over" and after==before, tag("the game over menu follows with each HUD node's visibility restored")) diff --git a/hoppys-adventure-arcade/tests/beatemup_story.gd b/hoppys-adventure-arcade/tests/beatemup_story.gd index c6d3b469..6213f198 100644 --- a/hoppys-adventure-arcade/tests/beatemup_story.gd +++ b/hoppys-adventure-arcade/tests/beatemup_story.gd @@ -749,10 +749,12 @@ func run_city_ending_hands_free() -> void: city_to_finisher() check(game.state == "finisher", "The boss's last hit starts the Force Merge finisher") var guard := 0 - while game.state == "finisher" and guard < 600: + var celebrated := false + while game.state in ["finisher", "celebration"] and guard < 1200: game._physics_process(DT) + if game.state == "celebration": celebrated = true guard += 1 - check(game.state == "ending" and game.story_pages == "ENDING" and game.intro_page == 0 and game.story.visible, "The finisher hands off to the ending after ~4s (%d ticks)" % guard) + check(celebrated and game.state == "ending" and game.story_pages == "ENDING" and game.intro_page == 0 and game.story.visible, "The finisher hands off through the celebration to the ending (%d ticks)" % guard) check(game.completion_count == 0 and not game.minimap.visible and not game.victory.visible, "The ending is not yet a win, and has no minimap or results") check(game.story.kicker.text == str(City.ENDING[0]["kicker"]) and game.intro_next.text == "Next →" and game.intro_skip.text == "Skip" and not game.intro_return.visible, "Ending page 1: Next, Skip, and no Return") var per := ticks_to_cross(float(City.FLOW.ending_hold)) @@ -794,7 +796,7 @@ func check_city_ending_controls() -> void: # Next, and the Return button hidden, then Skip. city_to_finisher() var guard := 0 - while game.state == "finisher" and guard < 600: + while game.state in ["finisher", "celebration"] and guard < 1200: game._physics_process(DT) guard += 1 check(game.state == "ending" and not game.intro_return.visible, "The ending starts with Return hidden") @@ -830,7 +832,7 @@ func check_city_ending_controls() -> void: # Skip. city_to_finisher() guard = 0 - while game.state == "finisher" and guard < 600: + while game.state in ["finisher", "celebration"] and guard < 1200: game._physics_process(DT) guard += 1 var skip := action_button("Skip") diff --git a/hoppys-adventure-arcade/tests/capture_code_city_bosses.gd b/hoppys-adventure-arcade/tests/capture_code_city_bosses.gd new file mode 100644 index 00000000..e0b97c42 --- /dev/null +++ b/hoppys-adventure-arcade/tests/capture_code_city_bosses.gd @@ -0,0 +1,181 @@ +extends SceneTree +## Captures the Code City district-boss and finale stills docs/code-city-brawl.md shows (graphical run only): +## Godot --always-on-top --path hoppys-adventure-arcade --script res://tests/capture_code_city_bosses.gd +## Steps the real game by hand into each district boss and writes docs/previews/code-city/ +## mini_boss_{1,2}.png (a slam winding up onto Hoppy), district_boss_down.png (the victory hop), +## district_boss_runoff.png (the exit run) and celebration.png (mid-party, after the Force Merge). +const OUT_DIR := "res://../docs/previews/code-city/" +const DT := 1.0/60.0 +var game +var failures := 0 + +func _initialize() -> void: + call_deferred("run") + +func freeze() -> void: + game.set_physics_process(false) + game.player.set_physics_process(false) + game.player.controlled = false + for e in game.enemies: + if is_instance_valid(e): e.set_physics_process(false) + if is_instance_valid(game.boss): game.boss.set_physics_process(false) + +func tick(n := 1) -> void: + for i in n: game._physics_process(DT) + +## Draws the state just stepped (not the last engine tick) and saves it. +func capture(name: String) -> void: + game.world.reset_physics_interpolation() + game.camera.reset_physics_interpolation() + await process_frame + await RenderingServer.frame_post_draw + var path := OUT_DIR+name+".png" + var image := root.get_texture().get_image() + var ok: bool = image.get_size() == Vector2i(1280, 720) and image.save_png(path) == OK + print("CODE CITY BOSS CAPTURE: ", path if ok else "failed to write "+path) + if not ok: failures += 1 + +## Records a miss and returns false, so the caller skips what depended on it. +func expect(ok: bool, message: String) -> bool: + if not ok: + print("CODE CITY BOSS CAPTURE: ", message) + failures += 1 + return ok + +## Hit bursts and slam dust fade on tweens; clear them so a still shows the pose, not the fx. +func clear_fx() -> void: + for child in game.actors.get_children(): + if child is AnimatedSprite2D and child != game.merge_button: child.queue_free() + +## Room 3 of `stage` with its wave gone, Hoppy left of centre and the camera on the room. False +## (with a recorded miss) when the stage never reaches combat. +func boss_room(stage: int) -> bool: + game.flow.cancel() + game.start_stage(stage) + freeze() + var guard := 0 + while game.state != "combat" and guard < 400: + tick() + guard += 1 + if not expect(game.state == "combat", "district %d never reached combat (state %s)" % [stage+1, game.state]): return false + game.flow.banner("", "", 0) + game.enter_room(2) + for e in game.enemies: + if is_instance_valid(e): e.queue_free() + game.enemies.clear() + freeze() + game.hazard_clock = 30.0 + game.ally_clock = 30.0 + game.camera.position = Vector2(game.room_start()+640, 360) + game.player.position = Vector2(game.room_start()+430, 590) + return true + +func district(stage: int) -> void: + if not boss_room(stage): return + tick() # the cleared wave calls in the district boss + var boss = game.boss + if not expect(game.has_mini_boss(), "no district boss in district %d" % (stage+1)): return + boss.set_physics_process(false) + var guard := 0 + while boss.state == "intro" and guard < 200: + boss.tick(DT) + tick() + guard += 1 + if not expect(boss.state != "intro", "%s never finished its drop-in" % boss.title): return + boss.cycle = 0 + boss.telegraph() # cycle 1 is the slam, planted on Hoppy + for i in 24: + boss.tick(DT) + tick() + game.hide_toast() + await capture("mini_boss_%d" % (stage+1)) + if stage != 0: return + # The last hit lands through a real swing, the way play reaches the victory hop. + game.clear_threats() + boss.state = "chase" + boss.hp = 1.0 + var p = game.player + p.position = boss.position-Vector2(80, 0) + p.facing = 1.0 + p.start_attack() + guard = 0 + while game.state != "boss_victory" and guard < 60: + p.tick(DT) + guard += 1 + if not expect(game.state == "boss_victory", "the swing did not defeat %s (state %s)" % [boss.title, game.state]): return + guard = 0 + while game.state == "boss_victory" and game.victory_clock < .7 and guard < 200: + tick() + guard += 1 + if not expect(game.state == "boss_victory" and game.victory_clock >= .7, "the victory hop never reached its peak (state %s, clock %.2f)" % [game.state, game.victory_clock]): return + clear_fx() + await capture("district_boss_down") + guard = 0 + while not (game.state == "stage_exit" and game.flow.phase == "exit_run" and game.flow.clock >= .35) and guard < 400: + tick() + guard += 1 + if not expect(game.state == "stage_exit" and game.flow.phase == "exit_run" and game.flow.clock >= .35, "the run-off never got under way (state %s, phase %s)" % [game.state, game.flow.phase]): return + await capture("district_boss_runoff") + +func finale() -> void: + if not boss_room(2): return + var boss = game.boss + if not expect(is_instance_valid(boss) and not game.has_mini_boss(), "district 3 has no Grand Merge Conflict"): return + boss.set_physics_process(false) + for damage in [12, 6, 6]: + boss.vulnerable = true + boss.take_hit(damage, 0, Vector2.ZERO, true) + freeze() + if not expect(game.state == "finisher", "the Grand Merge Conflict's defeat did not start the finisher (state %s)" % game.state): return + var guard := 0 + while game.state == "finisher" and guard < 600: + tick() + guard += 1 + if not expect(game.state == "celebration", "the Force Merge did not reach the celebration (state %s)" % game.state): return + guard = 0 + while game.state == "celebration" and game.party.clock < 2.6 and guard < 600: + tick() + guard += 1 + if not expect(game.state == "celebration" and game.party.clock >= 2.6, "the celebration never reached mid-party (state %s, clock %.2f)" % [game.state, game.party.clock]): return + clear_fx() + await capture("celebration") + +func run() -> void: + if DisplayServer.get_name() == "headless": + print("CODE CITY BOSS CAPTURE: requires a graphical display; no images written") + quit(1) + return + # A graphical start is still adding the autoloads' nodes to root for its first frames, and the + # maximized window can open taller than 720 px; settle, then size it like the other stills. + await create_timer(.5).timeout + root.size = Vector2i(1280, 720) + DisplayServer.window_set_size(Vector2i(1280, 720)) + var sizing := 0 + while Vector2i(root.get_texture().get_size()) != Vector2i(1280, 720) and sizing < 120: + await process_frame + sizing += 1 + if Vector2i(root.get_texture().get_size()) != Vector2i(1280, 720): + print("CODE CITY BOSS CAPTURE: the window stayed %s, not 1280x720; no images written" % root.get_texture().get_size()) + quit(1) + return + change_scene_to_file("res://scenes/beatem_up/code_city_brawl.tscn") + var loading := 0 + while (current_scene == null or current_scene.get("player") == null) and loading < 600: + await process_frame + loading += 1 + if current_scene == null or current_scene.get("player") == null: + print("CODE CITY BOSS CAPTURE: Code City never loaded; no images written") + quit(1) + return + game = current_scene + game.test_mode = true + paused = false + if game.state == "paused": game.resume() + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR)) + await district(0) + await district(1) + await finale() + game.queue_free() + root.get_node("AudioService").shutdown() + await create_timer(.4).timeout + quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/capture_code_city_bosses.gd.uid b/hoppys-adventure-arcade/tests/capture_code_city_bosses.gd.uid new file mode 100644 index 00000000..03c555da --- /dev/null +++ b/hoppys-adventure-arcade/tests/capture_code_city_bosses.gd.uid @@ -0,0 +1 @@ +uid://bl1ubee2lrug7 diff --git a/hoppys-adventure-arcade/tests/capture_runner_menu_previews.gd b/hoppys-adventure-arcade/tests/capture_runner_menu_previews.gd index 5736aeb4..3fe980d1 100644 --- a/hoppys-adventure-arcade/tests/capture_runner_menu_previews.gd +++ b/hoppys-adventure-arcade/tests/capture_runner_menu_previews.gd @@ -1,13 +1,16 @@ extends SceneTree ## Captures the Endless Runner stills the main menu showcases (graphical run only): ## Godot --always-on-top --path hoppys-adventure-arcade --script res://tests/capture_runner_menu_previews.gd -## Plays a real autopilot run with every 2D layer hidden, fast-forwards the stage to its corner -## (which is followed by the boss) and writes res://Assets/runner/menu_previews/runner_{gameplay,powerup,turn,boss}.png. +## Plays a real autopilot run with every 2D layer hidden, catches the bot jumping a blocking hazard, +## fast-forwards the stage to its boss and writes +## res://Assets/runner/menu_previews/runner_{gameplay,powerup,dodge,boss}.png. const OUT_DIR := "res://Assets/runner/menu_previews/" const RUN_SEED := 4242 const DEADLINE_SECONDS := 240.0 var game: Node -var wanted := {"powerup": false, "turn": false, "boss": false} +var wanted := {"powerup": false, "dodge": false, "boss": false} +## A jump only counts as a dodge when a blocking hazard is this close ahead (track z). +const DODGE_REACH := 9.0 var pending: Array[String] = [] func _initialize() -> void: @@ -24,8 +27,10 @@ func run() -> void: game.autostart = false game.leaderboard_path = OS.get_temp_dir().path_join("runner_menu_capture_leaderboard.cfg") root.add_child(game) - game.turn_started.connect(func(_direction, _seconds): _want("turn")) + game.player_action.connect(_on_action) game.boss_spawned.connect(func(_kind, _hp): _want("boss")) + # The stills are gameplay: no run intro in front of them. + game.pending_intro_mode = &"" game.start_run(RUN_SEED) game.set_autopilot(true) _hide_hud_panels() @@ -35,6 +40,7 @@ func run() -> void: var deadline := Time.get_ticks_msec() + int(DEADLINE_SECONDS * 1000) while wanted.values().has(false) and Time.get_ticks_msec() < deadline: if game.state != &"running": + game.pending_intro_mode = &"" game.start_run(RUN_SEED + Time.get_ticks_msec() % 997) game.set_autopilot(true) # A pickup glowing a few metres ahead reads better than the frame after a grab. @@ -43,13 +49,14 @@ func run() -> void: if pickup.is_visible_in_tree() and pickup.global_position.z > -11.0 and pickup.global_position.z < -7.0: _want("powerup") break - if wanted.powerup and not wanted.turn and game.phase == &"run": + if wanted.powerup and wanted.dodge and not wanted.boss and game.phase == &"run": var stages = game.stage_manager stages.stage_distance = maxf(stages.stage_distance, stages.stage_distance_target - 40.0) if not pending.is_empty(): var shot: String = pending.pop_front() - # Let the moment develop: the turn is mid-swing and the boss has closed in after its intro. - await create_timer({"powerup": 0.0, "turn": 0.6, "boss": 4.5}[shot]).timeout + # Let the moment develop: Hoppy is at the top of the jump and the boss has closed in after + # its intro. + await create_timer({"powerup": 0.0, "dodge": 0.15, "boss": 4.5}[shot]).timeout _hide_hud_panels() failures += await _capture(shot) wanted[shot] = true @@ -63,6 +70,18 @@ func run() -> void: await create_timer(0.3).timeout quit(failures) +## The bot jumped: keep it as the dodge still only when a blocking hazard is right in front of it. +func _on_action(action: StringName) -> void: + if action != &"jump" or wanted.dodge: + return + for hazard: Node3D in get_nodes_in_group(&"runner_hazards"): + if not hazard.has_method("is_blocking") or not hazard.is_blocking(): + continue + var z: float = game.track_z(hazard) + if z < 0.0 and z > -DODGE_REACH: + _want("dodge") + return + func _want(shot: String) -> void: if not wanted.get(shot, true) and not pending.has(shot): pending.append(shot) diff --git a/hoppys-adventure-arcade/tests/code_city_assets.gd b/hoppys-adventure-arcade/tests/code_city_assets.gd index 7216c1df..999cc3f3 100644 --- a/hoppys-adventure-arcade/tests/code_city_assets.gd +++ b/hoppys-adventure-arcade/tests/code_city_assets.gd @@ -4,6 +4,20 @@ const BASE = "res://Assets/code-city-brawl-assets-only/" var manifest: Dictionary var stage: Node2D var failures := 0 +## Mini-bosses face left and stand about 1.6x a linter_enforcer. +const MINI_BOSSES := ["stack_overflow", "zero_day"] +## clip name: [frames, fps, loop]. Each mini-boss has 16 populated cells in a 256 square cell. +const MINI_BOSS_CLIPS := { + "idle": [[0, 1], 3, true], + "walk": [[2, 3, 4, 5], 6, true], + "slam_warning": [[6, 7], 5, false], + "slam": [[8, 9], 10, false], + "dash_warning": [[10], 1, false], + "dash": [[11, 12], 10, false], + "recover": [[13], 1, false], + "hurt": [[14], 1, false], + "defeat": [[15], 1, false], +} func check(condition: bool, context: String) -> void: if not condition: @@ -39,16 +53,22 @@ func run() -> void: check(image != null and image.get_used_rect().has_area(), clip.name + " nonempty") check(image.detect_alpha() != Image.ALPHA_NONE, clip.name + " alpha") clip_count += 1 - frame_count += spec.frames.size() + if not MINI_BOSSES.has(key): + var counted: Variant = spec.get("frames", []) + if counted is Array: + frame_count += counted.size() + else: + check(false, key + " publishes a frame list") for bg in manifest.backgrounds.values(): var texture := load(BASE + "code_city/" + bg.file) as Texture2D check(texture != null and texture.get_size() == Vector2(1280,720), bg.file) - check(frame_count == 200, "200 source frames") - check(manifest.atlases.size() == 19 and manifest.backgrounds.size() == 3, "resource coverage") + check(frame_count == 200, "200 source frames outside the mini-boss atlases") + check_mini_bosses() + check(manifest.atlases.size() == 19 + MINI_BOSSES.size() and manifest.backgrounds.size() == 3, "resource coverage") if failures > 0: await finish(1) return - print("PASS: Code City Brawl — ", frame_count, " frames, ", clip_count, " states, 19 SpriteFrames resources, 3 panoramas.") + print("PASS: Code City Brawl — ", frame_count, " frames, ", clip_count, " states, 21 SpriteFrames resources, 3 panoramas.") if DisplayServer.get_name() != "headless": root.size = Vector2i(1280,720) for theme in ["district", "security", "hq"]: @@ -63,6 +83,98 @@ func run() -> void: await process_frame await finish(0 if failures == 0 else 1) +func standing_height(key: String, clip_name: String) -> float: + if not manifest is Dictionary: + return 0.0 + var atlases: Variant = manifest.get("atlases", {}) + if not atlases is Dictionary or not atlases.get(key) is Dictionary: + return 0.0 + var spec: Dictionary = atlases[key] + var clips: Variant = spec.get("clips", []) + var frames: Variant = spec.get("frames", []) + if not clips is Array or not frames is Array: + return 0.0 + for clip in clips: + if not clip is Dictionary or str(clip.get("name", "")) != clip_name: + continue + var listed: Variant = clip.get("frames", []) + if not listed is Array or listed.is_empty(): + return 0.0 + var index := int(listed[0]) + if index < 0 or index >= frames.size() or not frames[index] is Dictionary: + return 0.0 + var bounds: Variant = frames[index].get("visible_bounds", []) + if not bounds is Array or bounds.size() < 4: + return 0.0 + return float(bounds[3]) - float(bounds[1]) + return 0.0 + +func check_mini_bosses() -> void: + var reference := standing_height("linter_enforcer", "armored_idle") + check(reference > 0.0, "linter_enforcer reference height measured") + var atlases: Variant = manifest.get("atlases", {}) if manifest is Dictionary else {} + for key in MINI_BOSSES: + var present: bool = atlases is Dictionary and atlases.has(key) + check(present, key + " atlas is in the manifest") + if not present or not atlases[key] is Dictionary: + if present: + check(false, key + " atlas entry is an object") + continue + var spec: Dictionary = atlases[key] + check(spec.get("facing", "") == "left", key + " faces left") + var cell: Variant = spec.get("cell", []) + var pivot: Variant = spec.get("pivot", []) + var cell_ok: bool = cell is Array and cell.size() == 2 + var pivot_ok: bool = pivot is Array and pivot.size() == 2 + check(cell_ok and pivot_ok and Vector2i(int(cell[0]), int(cell[1])) == Vector2i(256, 256) and Vector2i(int(pivot[0]), int(pivot[1])) == Vector2i(128, 230), key + " keeps the 256 square cell and (128, 230) pivot") + var frames: Variant = spec.get("frames", []) + check(frames is Array and frames.size() == 16, key + " has 16 populated cells") + var clips: Variant = spec.get("clips", []) + if not clips is Array: + check(false, key + " clips are exactly " + ", ".join(MINI_BOSS_CLIPS.keys()) + " (got none)") + continue + if not cell_ok or not pivot_ok or not frames is Array: + continue + var names := [] + for clip in clips: + names.append(str(clip.get("name", "")) if clip is Dictionary else "") + var wanted: Array = MINI_BOSS_CLIPS.keys() + wanted.sort() + var got := names.duplicate() + got.sort() + check(got == wanted, key + " clips are exactly " + ", ".join(MINI_BOSS_CLIPS.keys()) + " (got " + ", ".join(names) + ")") + for clip in clips: + if not clip is Dictionary or not MINI_BOSS_CLIPS.has(str(clip.get("name", ""))): + continue + var clip_name := str(clip.name) + var contract: Array = MINI_BOSS_CLIPS[clip_name] + var listed_frames: Variant = clip.get("frames", []) + if not listed_frames is Array: + check(false, key + "/" + clip_name + " frames [] should be " + str(contract[0])) + continue + var listed := [] + for index in listed_frames: + listed.append(int(index)) + check(listed == contract[0], key + "/" + clip_name + " frames " + str(listed) + " should be " + str(contract[0])) + var fps_value: Variant = clip.get("fps", null) + check((fps_value is float or fps_value is int) and is_equal_approx(float(fps_value), float(contract[1])), key + "/" + clip_name + " fps") + check(clip.get("loop") == contract[2], key + "/" + clip_name + " loop flag") + for index in listed_frames: + var frame_index := int(index) + if frame_index < 0 or frame_index >= frames.size() or not frames[frame_index] is Dictionary: + check(false, key + "/" + clip_name + " frame " + str(frame_index) + " is missing") + continue + var b: Variant = frames[frame_index].get("visible_bounds", []) + if not b is Array or b.size() < 4: + check(false, key + "/" + clip_name + " publishes visible bounds") + continue + check(int(b[0]) >= 0 and int(b[1]) >= 0 and int(b[2]) <= int(cell[0]) and int(b[3]) <= int(cell[1]), key + "/" + clip_name + " art fits its cell") + check(int(b[3]) <= int(pivot[1]) + 2, key + "/" + clip_name + " does not sink below the foot pivot") + var stand := standing_height(key, "idle") + check(stand >= 85.0 and stand <= 110.0, key + " idle stands about 98 px (got %.0f)" % stand) + var ratio := standing_height(key, "idle") / maxf(reference, 1.0) + check(ratio >= 1.35 and ratio <= 1.85, key + " idle stands about 1.6x a linter_enforcer (ratio %.2f)" % ratio) + func build_preview(theme: String) -> void: var bg := Sprite2D.new() bg.texture = load(BASE + "code_city/" + theme + "_background.png") diff --git a/hoppys-adventure-arcade/tests/code_city_celebration.gd b/hoppys-adventure-arcade/tests/code_city_celebration.gd new file mode 100644 index 00000000..ba35fdf2 --- /dev/null +++ b/hoppys-adventure-arcade/tests/code_city_celebration.gd @@ -0,0 +1,859 @@ +extends SceneTree +## The Code City victory party (modes/beatem_up/celebration.gd) on a stub owner, so it needs +## none of the game's finisher hooks. Covers the clock, the victory pose, the boss-music duck, +## the single jingle, the loop hand-off to the flow, cancel and replay, coarse steps, an owner +## without a camera, and loop-resource immutability. Rendered runs also save captures. +const Celebration = preload("res://modes/beatem_up/celebration.gd") +const StageFlow = preload("res://modes/beatem_up/stage_flow.gd") +const COMBAT := "res://Assets/code-city-brawl-player/combat.tres" +const MANIFEST := "res://Assets/code-city-brawl-player/manifest.json" +const JINGLE := "res://Assets/Audio/Beat'em Up/code_city_victory_jingle.wav" +const LOOP := "res://Assets/Audio/Beat'em Up/code_city_victory_loop.wav" +const BOSS_MUSIC := "res://Assets/Audio/Beat'em Up/Boss Fight 2.mp3" +const CAPTURE_DIR := "/tmp/s3-captures/celebration" +const DT := 1.0/60.0 +## Frames to wait for the last autoload before giving up, so a stuck start fails instead of hanging. +const READY_FRAMES := 600 +## Contract times, seconds on the party clock. +const JINGLE_AT := .3 +const LOOP_AT := 3.8 +const LENGTH := 5.2 +const WALL_MAX := 6.0 +const HUD_COLOR := Color(1.0, 0.0, 1.0) +const MENU_COLOR := Color(0.0, 1.0, 1.0) + +class StubPlayer extends Node2D: + var body := AnimatedSprite2D.new() + var facing := 1.0 + var poses: Array[String] = [] + func _init() -> void: + body.sprite_frames = load("res://Assets/code-city-brawl-player/combat.tres") + body.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + add_child(body) + func show_pose(pose: String, _base := false) -> void: + poses.append(pose) + if body.sprite_frames.has_animation(pose): body.play(pose) + +class StubOwner extends Node2D: + var player: StubPlayer + var ui_layer := CanvasLayer.new() + var camera: Camera2D = Camera2D.new() + var flow + var music := AudioStreamPlayer.new() + var shake := 0.0 + func sound(_key: String, _at: Vector2 = Vector2.ZERO) -> void: pass + +## An owner with the optional knockout_hud_nodes() hook, as the real games have. +class StubHudOwner extends StubOwner: + var hud_nodes: Array[CanvasItem] = [] + func knockout_hud_nodes() -> Array[CanvasItem]: return hud_nodes + +class StubNoCamera extends Node2D: + var player: StubPlayer + var ui_layer := CanvasLayer.new() + var flow + var music := AudioStreamPlayer.new() + var shake := 0.0 + func sound(_key: String, _at: Vector2 = Vector2.ZERO) -> void: pass + +var failures := 0 +var passes := 0 +var party +var stage +var calls := 0 +var boss_stream: AudioStream + +func _initialize() -> void: call_deferred("run") +func check(ok: bool, message: String) -> void: + print("PASS: " if ok else "FAIL: ", message) + if ok: passes += 1 + else: failures += 1 + +func done() -> void: calls += 1 + +## A fresh stub owner in the tree with its flow and boss music playing, like the finisher's start. +func make_owner(with_camera := true, hud := false): + var owner = StubHudOwner.new() if hud else StubOwner.new() if with_camera else StubNoCamera.new() + owner.player = StubPlayer.new() + owner.add_child(owner.player) + owner.add_child(owner.ui_layer) + owner.add_child(owner.music) + if with_camera: + owner.add_child(owner.camera) + owner.camera.position = Vector2(640, 360) + owner.camera.zoom = Vector2.ONE + var flow = StageFlow.new() + owner.add_child(flow) + flow.setup(owner.ui_layer, owner.music, {}) + owner.flow = flow + if hud: add_hud_fixtures(owner) + root.add_child(owner) + boss_stream = load(BOSS_MUSIC) + flow.play_music(boss_stream) + if stage != null and is_instance_valid(stage): stage.free() + stage = owner + party = Celebration.new() + calls = 0 + return owner + +## HUD chrome in loud magenta so a leak is unmistakable in a capture: a top strip with the score, +## a bottom controls strip, a boss-name plate and a minimap, plus a node that was already hidden. +## `menu` is the pause/menu overlay: it is NOT in the hook's list and must always stay visible. +func add_hud_fixtures(owner) -> void: + var make := func(name: String, rect: Rect2, text: String) -> ColorRect: + var panel := ColorRect.new() + panel.name = name + panel.color = HUD_COLOR + panel.position = rect.position + panel.size = rect.size + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 22) + label.position = Vector2(14, 8) + panel.add_child(label) + owner.ui_layer.add_child(panel) + return panel + var top: ColorRect = make.call("HudTop", Rect2(0, 0, 1280, 60), "HOPPY ♥ ♥ ♥ SCORE 01234 CLI / IDE DISTRICT · 3/3") + var bottom: ColorRect = make.call("HudControls", Rect2(0, 668, 1280, 44), "WASD · Move Space · Jump J, X · Combo E · Pickup Esc · Pause") + var boss: ColorRect = make.call("HudBoss", Rect2(380, 70, 520, 34), "GRAND MERGE CONFLICT") + var minimap: ColorRect = make.call("HudMinimap", Rect2(1020, 320, 240, 90), "MINIMAP") + var prior: ColorRect = make.call("HudPriorHidden", Rect2(0, 600, 200, 40), "HIDDEN") + prior.visible = false + var menu := ColorRect.new() + menu.name = "PauseMenu" + menu.color = MENU_COLOR + menu.position = Vector2(20, 560) + menu.size = Vector2(160, 60) + owner.ui_layer.add_child(menu) + owner.hud_nodes.assign([top, bottom, boss, minimap, prior]) + owner.set_meta("menu", menu) +func hud_states(owner) -> Array: + var states := [] + for n in owner.hud_nodes: states.append(is_instance_valid(n) and n.visible) + return states +## What a game does before each party tick: its minimap, toast and HUD refresh show themselves. +func owner_refresh(owner) -> void: + for n in owner.hud_nodes: + if is_instance_valid(n): n.visible = true +func run_for(seconds: float, dt := DT) -> void: + var left := seconds + while left>1e-6 and party.active(): + var step := minf(dt, left) + party.tick(step) + left -= step + +## Players the party made itself: every AudioStreamPlayer under the owner that isn't the flow's. +func jingles(owner) -> Array[AudioStreamPlayer]: + var found: Array[AudioStreamPlayer] = [] + for child in owner.get_children(): + if child is AudioStreamPlayer and child!=owner.music and not is_queued_for_free(child): found.append(child) + return found +func is_queued_for_free(node: Node) -> bool: return node.is_queued_for_deletion() +func playing_jingles(owner) -> int: + var count := 0 + for j in jingles(owner): + if j.playing: count += 1 + return count +## Visible Control or Node2D chrome the party added to the UI layer. +func visible_ui(owner) -> int: + var count := 0 + for child in owner.ui_layer.get_children(): + if child is CanvasItem and child.visible and not child.is_queued_for_deletion() and child.name!="StageFlow": count += 1 + return count +func pose_has_victory(owner) -> bool: + return "victory" in owner.player.poses + +func run() -> void: + # Rendered, SaveService's window-mode call pumps the loop inside its _ready and fires the deferred + # run() early, so wait for the last autoload. + var waited := 0 + while not (root.has_node("ControllerService") and root.get_node("ControllerService").is_node_ready()) and waited < READY_FRAMES: + await process_frame + waited += 1 + if not (root.has_node("ControllerService") and root.get_node("ControllerService").is_node_ready()): + check(false, "ControllerService is ready within %d frames" % READY_FRAMES) + quit(1) + return + await process_frame + await settle_window() + check_asset_clip() + check_music_assets() + check_clock_and_poses() + check_music_flow() + check_camera() + check_no_camera() + check_cancel_phases() + check_cancel_ownership() + check_replay_and_callbacks() + check_coarse_steps() + check_loop_immutable() + check_pause_clock() + check_hud_hidden() + check_hud_restore() + check_hud_edge_cases() + await check_capture() + await check_real_game() + print("Celebration passes: ", passes, " failures: ", failures) + if is_instance_valid(stage): stage.free() + root.get_node("AudioService").shutdown() + await create_timer(.4).timeout + quit(1 if failures else 0) + +## The new victory clip: three frames, the same 96x96 cell and 48,86 pivot as every clip. +func check_asset_clip() -> void: + var frames: SpriteFrames = load(COMBAT) + var has := frames!=null and frames.has_animation("victory") + check(has, "combat.tres has a victory clip") + if has: + check(frames.get_frame_count("victory")==3, "the victory clip has 3 frames (%d)" % frames.get_frame_count("victory")) + check(frames.get_animation_loop("victory"), "the victory clip loops") + var cells_ok := true + for i in frames.get_frame_count("victory"): + var tex := frames.get_frame_texture("victory", i) + if not (tex is AtlasTexture) or tex.region.size!=Vector2(96, 96): cells_ok = false + check(cells_ok, "every victory frame is a 96x96 cell") + check(frames.get_animation_speed("victory")>0.0, "the victory clip has a positive speed") + var data = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST)) + var manifest_ok: bool = data is Dictionary and Vector2(data.cell[0], data.cell[1])==Vector2(96, 96) and Vector2(data.pivot[0], data.pivot[1])==Vector2(48, 86) + check(manifest_ok, "the manifest keeps the 96x96 cell and the 48,86 pivot") + var clip = data.clips.get("victory") if data is Dictionary and data.has("clips") else null + check(clip is Array and clip[0].size()==3 and bool(clip[2]), "the manifest lists victory as 3 looping frames") + +func check_music_assets() -> void: + var jingle = load(JINGLE) if ResourceLoader.exists(JINGLE) else null + var loop = load(LOOP) if ResourceLoader.exists(LOOP) else null + check(jingle is AudioStream, "the jingle asset loads") + check(loop is AudioStream, "the loop asset loads") + if jingle is AudioStream: + var length: float = jingle.get_length() + check(length>3.2 and length<=LOOP_AT-JINGLE_AT+.05, "the jingle is about 3.5 s so it ends as the loop begins (%.2f)" % length) + if loop is AudioStream: + check(loop.get_length()>2.0, "the loop is long enough to carry the ending pages (%.2f s)" % loop.get_length()) + for asset in [jingle, loop]: + if asset is AudioStreamWAV: + var name := "jingle" if asset==jingle else "loop" + check(asset.format==AudioStreamWAV.FORMAT_16_BITS, "the %s imports as raw 16-bit PCM (format %d)" % [name, asset.format]) + var bytes: PackedByteArray = asset.data + var channels := 2 if asset.stereo else 1 + if asset.format==AudioStreamWAV.FORMAT_16_BITS and bytes.size()>=2*channels: + var ends := true + for c in channels: + if bytes.decode_s16(c*2)!=0 or bytes.decode_s16(bytes.size()-(channels-c)*2)!=0: ends = false + check(ends, "the %s's first and last samples are exactly zero, so the seam can't click" % name) + if jingle is AudioStream and "loop_mode" in jingle: + check(jingle.loop_mode==AudioStreamWAV.LOOP_DISABLED, "the jingle does not loop") + +func check_clock_and_poses() -> void: + var owner = make_owner() + check(not party.active(), "a new party is idle") + party.play(owner, done) + check(party.active() and calls==0, "play() starts the party without calling done") + check(pose_has_victory(owner), "Hoppy takes the victory pose at once") + check(visible_ui(owner)>0, "the banner and confetti layer appears in the UI layer") + run_for(LENGTH-DT*3.0) + check(party.active() and calls==0, "the party is still running just before 5.2 s") + run_for(DT*6.0) + check(not party.active() and calls==1, "the party ends and calls done once at 5.2 s") + check(visible_ui(owner)==0, "the UI is cleared once the party ends") + check(owner.player.body.position.is_equal_approx(Vector2.ZERO) and is_zero_approx(owner.player.body.rotation), "Hoppy's body rests on the shadow afterwards") + run_for(2.0) + party.tick(1.0) + check(calls==1, "ticking after the end never calls done again") + party.cancel() + check(calls==1 and not party.active(), "cancel() after the end is quiet") + +func check_music_flow() -> void: + var owner = make_owner() + var flow = owner.flow + party.play(owner, done) + var boss_playing: bool = flow.music.playing + run_for(.1) + check(flow.music_db<0.0 and boss_playing, "the boss track ducks as the party starts (%.1f dB)" % flow.music_db) + check(flow.music.stream==boss_stream, "the boss track is only ducked, not replaced, before the loop") + check(playing_jingles(owner)==0, "the jingle has not started before 0.3 s") + run_for(JINGLE_AT+.1) + check(playing_jingles(owner)==1, "one jingle player plays after 0.3 s on its own child player") + check(jingles(owner).size()==1 and jingles(owner)[0]!=flow.music, "the jingle is not the flow's music player") + check(flow.music.stream==boss_stream and flow.music_db<0.0, "the boss track stays ducked under the jingle") + run_for(LOOP_AT-JINGLE_AT-.3) + check(playing_jingles(owner)<=1 and jingles(owner).size()==1, "the jingle is never doubled") + check(flow.music.stream==boss_stream, "the boss track is still the flow's stream just before the loop is due (%.2f s)" % party.clock) + # Timed on the party's own clock, so the switch time can't drift from a local counter. + var seen_loop := false + var swapped_at := -1.0 + while party.active() and party.clockLOOP_AT+.2: break + check(seen_loop and absf(swapped_at-LOOP_AT)<=.25, "the flow's music switches to the loop at about 3.8 s (%.2f)" % swapped_at) + check(is_equal_approx(flow.music_db, 0.0), "the duck is lifted once the loop starts") + check(flow.music.playing and flow.music_track!=boss_stream, "the loop is playing on the flow's player") + run_for(10.0) + check(calls==1 and not party.active(), "the party completes once") + check(flow.music.playing and flow.music.stream!=boss_stream, "the loop is flow-owned and keeps playing after completion") + var looped: AudioStream = flow.music.stream + var loops: bool = looped!=null and (("loop_mode" in looped and looped.loop_mode!=AudioStreamWAV.LOOP_DISABLED) or ("loop" in looped and looped.loop)) + check(loops, "the stream the flow plays loops (without editing the shared asset)") + if looped is AudioStreamWAV: + var pcm: PackedByteArray = looped.data + var tail_ok: bool = looped.format==AudioStreamWAV.FORMAT_16_BITS and pcm.size()>=2 and pcm.decode_s16(0)==0 and pcm.decode_s16(pcm.size()-(4 if looped.stereo else 2))==0 + check(tail_ok, "the runtime loop is 16-bit PCM with a zero first and last sample") + check(playing_jingles(owner)==0, "the jingle player is stopped at completion") + check(is_equal_approx(flow.music_db, 0.0), "the duck stays lifted after completion") + var held: AudioStream = flow.music.stream + party.cancel() + check(not flow.music.playing or flow.music.stream!=held, "cancel() after completion stops the party's own loop") + +func check_camera() -> void: + var owner = make_owner() + var pos := Vector2(900, 300) + var zoom := Vector2(1.05, 1.05) + owner.camera.position = pos + owner.camera.zoom = zoom + party.play(owner, done) + var peak := 0.0 + var moved := false + for _i in int(LENGTH/DT)+30: + if not party.active(): break + party.tick(DT) + peak = maxf(peak, owner.camera.zoom.x) + if not owner.camera.position.is_equal_approx(pos): moved = true + check(peak>zoom.x+.03, "the camera pushes in during the party (peak %.3f)" % peak) + check(calls==1 and owner.camera.position.is_equal_approx(pos) and owner.camera.zoom.is_equal_approx(zoom), "completion restores the camera position and zoom (%s, %s)" % [str(owner.camera.position), str(owner.camera.zoom)]) + owner.camera.position = pos + owner.camera.zoom = zoom + party.play(owner, done) + run_for(2.0) + check(not owner.camera.zoom.is_equal_approx(zoom), "the camera is mid-push before cancel") + party.cancel() + check(owner.camera.position.is_equal_approx(pos) and owner.camera.zoom.is_equal_approx(zoom), "cancel() restores the camera position and zoom") + +func check_no_camera() -> void: + var owner = make_owner(false) + check(not ("camera" in owner), "the stub owner has no camera") + party.play(owner, done) + run_for(LENGTH+1.0) + check(calls==1 and not party.active(), "a party without a camera still runs to done once") + check(pose_has_victory(owner), "the camera-less party still poses Hoppy") + party.play(owner, done) + run_for(1.0) + party.cancel() + check(calls==1 and visible_ui(owner)==0, "cancelling a camera-less party clears its UI") + +func check_cancel_phases() -> void: + for cut in [.05, 1.0, 3.0, 4.5]: + var owner = make_owner() + var flow = owner.flow + party.play(owner, done) + run_for(cut) + party.cancel() + var label := "cancel() at %.2f s" % cut + check(not party.active() and calls==0, label+" ends the party without done") + check(visible_ui(owner)==0, label+" clears the UI") + check(playing_jingles(owner)==0, label+" stops the jingle") + check(is_equal_approx(flow.music_db, 0.0), label+" lifts the duck") + check(owner.camera.position.is_equal_approx(Vector2(640, 360)) and owner.camera.zoom.is_equal_approx(Vector2.ONE), label+" restores the camera") + check(owner.player.body.position.is_equal_approx(Vector2.ZERO) and is_zero_approx(owner.player.body.rotation), label+" rests the body") + party.tick(1.0) + run_for(LENGTH+1.0) + check(calls==0, label+" never calls done afterwards") + party.cancel() + check(calls==0, label+" is idempotent") + +## Cancel stops only the track the party started; music something else started later is left alone. +func check_cancel_ownership() -> void: + var owner = make_owner() + var flow = owner.flow + party.play(owner, done) + run_for(2.0) + party.cancel() + check(flow.music.stream==boss_stream and flow.music.playing, "cancel() before the switch leaves the boss track playing") + owner = make_owner() + flow = owner.flow + party.play(owner, done) + run_for(LENGTH+.5) + var next: AudioStream = load(BOSS_MUSIC) + flow.play_music(next) + party.cancel() + check(flow.music.playing and flow.music.stream==next, "cancel() leaves music started after the party playing") + owner = make_owner() + flow = owner.flow + party.play(owner, done) + run_for(LOOP_AT+.5) + party.cancel() + check(not flow.music.playing or flow.music.stream==boss_stream, "cancel() after the switch stops the party's loop") + +func check_replay_and_callbacks() -> void: + var owner = make_owner() + var first := [0] + var second := [0] + party.play(owner, func(): first[0] += 1) + run_for(1.5) + party.play(owner, func(): second[0] += 1) + check(party.active() and visible_ui(owner)>0, "replaying restarts the party") + check(jingles(owner).size()<=1, "replay leaves at most one jingle player") + run_for(LENGTH+1.0) + check(first[0]==0 and second[0]==1, "replay calls only the latest done, once") + check(visible_ui(owner)==0 and playing_jingles(owner)==0, "replay leaves no stray UI or jingle") + # done may start another party (the next stage's) without being re-entered or double counted. + var chained := [0] + var again := func(): + chained[0] += 1 + if chained[0]==1: party.play(owner, func(): chained[0] += 10) + party.play(owner, again) + run_for(LENGTH+.5) + check(chained[0]==1 and party.active(), "a done that starts a new party is not cancelled by the old one") + run_for(LENGTH+.5) + check(chained[0]==11, "the chained party completes on its own") + # done may cancel: it must not recurse or double fire. + var cancelling := [0] + party.play(owner, func(): + cancelling[0] += 1 + party.cancel()) + run_for(LENGTH+.5) + check(cancelling[0]==1, "a done that calls cancel() fires once") + +func check_coarse_steps() -> void: + for step in [.25, .5, 1.0, 2.5, 10.0]: + var owner = make_owner() + party.play(owner, done) + var guard := 0 + while party.active() and guard<64: + party.tick(step) + guard += 1 + check(calls==1 and not party.active(), "dt %.2f finishes with done once" % step) + check(owner.flow.music.stream!=boss_stream and is_equal_approx(owner.flow.music_db, 0.0), "dt %.2f still reaches the loop with the duck lifted" % step) + check(visible_ui(owner)==0 and playing_jingles(owner)==0, "dt %.2f leaves nothing behind" % step) + check(owner.camera.zoom.is_equal_approx(Vector2.ONE), "dt %.2f restores the camera" % step) + var owner = make_owner() + party.play(owner, done) + party.tick(0.0) + party.tick(-1.0) + check(party.active() and calls==0, "a zero or negative dt does not advance or end the party") + var wall := 0.0 + while party.active() and wall void: + if not ResourceLoader.exists(LOOP) or not ResourceLoader.exists(JINGLE): return + var loop: AudioStream = load(LOOP) + var jingle: AudioStream = load(JINGLE) + if loop==null or jingle==null: + check(false, "the loop and jingle load as audio streams for the immutability check") + return + var before := snapshot(loop) + var before_jingle := snapshot(jingle) + var owner = make_owner() + party.play(owner, done) + run_for(LENGTH+.5) + check(snapshot(loop)==before, "the loop resource's settings are unchanged after a full party") + check(snapshot(jingle)==before_jingle, "the jingle resource's settings are unchanged after a full party") + party.cancel() + check(snapshot(loop)==before and load(LOOP)==loop, "cancel() leaves the cached loop resource untouched") +func snapshot(stream: AudioStream) -> Array: + var state: Array = [stream.resource_path, stream.get_length()] + if stream is AudioStreamWAV: + state.append_array([stream.loop_mode, stream.loop_begin, stream.loop_end, stream.mix_rate, stream.format, stream.stereo, stream.data.size()]) + return state + +## Rendered only: the banner and confetti at three moments, saved as PNGs for review. +func check_capture() -> void: + if DisplayServer.get_name()=="headless" or not RenderingServer.render_loop_enabled: return + var owner = make_owner(true, true) + var backdrop := ColorRect.new() + backdrop.color = Color(.16, .2, .3) + backdrop.size = Vector2(1280, 720) + backdrop.z_index = -10 + owner.add_child(backdrop) + owner.player.position = Vector2(640, 520) + owner.player.body.scale = Vector2(3, 3) + owner.player.body.offset = Vector2(0, -38) + DirAccess.make_dir_recursive_absolute(CAPTURE_DIR) + await process_frame + await RenderingServer.frame_post_draw + # Before the party the HUD is on screen: the magenta fixtures prove the capture can see a leak. + var before: Image = root.get_texture().get_image() + check(image_ok_save(before, "celebration_hud_before"), "the pre-party capture with HUD chrome is saved") + var hud_before := count_color(before, HUD_COLOR, .12) + check(hud_before>20000, "the HUD fixtures are plainly visible before the party (%d magenta px)" % hud_before) + check(count_color(before, MENU_COLOR, .12)>1000, "the menu fixture is visible before the party") + for n in owner.hud_nodes: n.hide() + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + var plain: Image = root.get_texture().get_image() + owner_refresh(owner) + owner.hud_nodes[4].hide() + party.play(owner, done) + var shots := {1.0:"celebration_start", 2.5:"celebration_mid", 4.6:"celebration_end"} + var clock := 0.0 + for at in shots: + while clock2000, "capture %s differs from the bare stage (%d px)" % [shots[at], changed_pixels(plain, image)]) + # A gray or empty frame must fail: Hoppy's orange, the gold title and the dark banner strip must all be drawn. + check(count_color(image, Color(1.0, .33, .0), .18)>1200, "capture %s draws Hoppy (%d orange px)" % [shots[at], count_color(image, Color(1.0, .33, .0), .18)]) + check(count_color(image, Color("ffd36b"), .12)>400, "capture %s draws the gold MERGE SUCCESSFUL title (%d px)" % [shots[at], count_color(image, Color("ffd36b"), .12)]) + check(not is_flat(image), "capture %s is not a single flat color" % shots[at]) + var leak := count_color(image, HUD_COLOR, .12) + check(leak==0, "capture %s shows none of the HUD chrome: no top strip, controls, boss plate or minimap (%d magenta px)" % [shots[at], leak]) + check(count_color(image, MENU_COLOR, .12)>1000, "capture %s keeps the excluded menu overlay visible" % shots[at]) + run_for(2.0) + check(calls==1, "the rendered party also completes once") + check(hud_states(owner)==[true, true, true, true, false], "the HUD is back after the rendered party (%s)" % str(hud_states(owner))) + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + check(count_color(root.get_texture().get_image(), HUD_COLOR, .12)>20000, "the HUD fixtures are drawn again after completion") + backdrop.queue_free() +## Rendered only: let the window-mode change settle, then pin the viewport to 1280x720 so every +## capture has the same size. Gives up after 120 process frames and the captures refuse a wrong size. +const FRAME := Vector2i(1280, 720) +func settle_window() -> void: + if DisplayServer.get_name()=="headless" or not RenderingServer.render_loop_enabled: return + await create_timer(.5).timeout + DisplayServer.window_set_size(FRAME) + root.size = FRAME + var frames := 0 + while Vector2i(root.get_texture().get_size())!=FRAME and frames<120: + await process_frame + frames += 1 + print("window settled at ", root.get_texture().get_size(), " after ", frames, " frames") + if Vector2i(root.get_texture().get_size())!=FRAME: check(false, "the window settles to 1280x720 within 120 frames") +func frame_ok(image: Image) -> bool: + return Vector2i(image.get_width(), image.get_height())==FRAME +func image_ok_save(image: Image, name: String) -> bool: + if not frame_ok(image): return false + return image.save_png("%s/%s.png" % [CAPTURE_DIR, name])==OK +func changed_pixels(a: Image, b: Image) -> int: + var count := 0 + var w := mini(a.get_width(), b.get_width()) + var h := mini(a.get_height(), b.get_height()) + for y in range(0, h, 4): + for x in range(0, w, 4): + var pa := a.get_pixel(x, y) + var pb := b.get_pixel(x, y) + if absf(pa.r-pb.r)+absf(pa.g-pb.g)+absf(pa.b-pb.b)>.12: count += 16 + return count +func count_color(image: Image, target: Color, tol: float) -> int: + var count := 0 + for y in range(0, image.get_height(), 2): + for x in range(0, image.get_width(), 2): + var px := image.get_pixel(x, y) + if Vector3(px.r-target.r, px.g-target.g, px.b-target.b).length()<=tol: count += 4 + return count +func is_flat(image: Image) -> bool: + var first := image.get_pixel(0, 0) + for y in range(0, image.get_height(), 8): + for x in range(0, image.get_width(), 8): + if image.get_pixel(x, y)!=first: return false + return true + +## Pause freezes the party clock, the confetti and the jingle's progress; only unpaused ticks count. +func check_pause_clock() -> void: + var owner = make_owner() + party.play(owner, done) + run_for(1.0) + var clock: float = party.clock + check(clock>.9 and clock<1.1, "the party clock is about 1 s after 1 s of ticks (%.3f)" % clock) + paused = true + for _i in 180: party.tick(DT) + party.tick(5.0) + var frozen: bool = is_equal_approx(party.clock, clock) and party.active() and calls==0 + paused = false + check(frozen, "a paused tree freezes the party clock, even for a huge dt, and calls no done (clock %.3f)" % party.clock) + run_for(.5) + check(party.clock>clock+.45 and party.clock