diff --git a/README.md b/README.md index daac46bc..60c0f08c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ A Godot 4 arcade collection starring Hoppy, the CodeRabbit mascot. It has a reac | Move | A/D or arrow keys | Left stick / D-pad | | Jump | Space | A / Cross | | Attack | J / X | X / Square | -| Pause | Escape | Start | +| Pause | Escape or P | Start | + +Each game opens with a short **How to Play** card: a few animated pages showing the goal, these controls for keyboard and controller, and tips. Jump, attack or confirm turns the page, ←/→ flip back and forth, and pause skips straight to play. Pages turn by themselves every 7 seconds, and the pause menu's **How to Play** button shows the card again. See [main-menu.md](docs/main-menu.md#how-to-play-cards). In Orbital Ops' zero-gravity bay, hold Jump or Up to rise, hold Down to descend, and release to brake. diff --git a/docs/bay-area-blitz.md b/docs/bay-area-blitz.md index 8f511c6a..c7bbe137 100644 --- a/docs/bay-area-blitz.md +++ b/docs/bay-area-blitz.md @@ -8,8 +8,8 @@ Run the project normally: **Platformer Game → Level Select → Play Bay Area B - Move: A/D, left/right arrows, controller left stick or D-pad. - Jump: Space/W/up arrow, controller A/Cross. Release early for a shorter jump. -- Swipe: J/X, controller X/Square. Landing on an enemy also deals damage and bounces Hoppy. -- Pause/resume: Escape or controller Start. Focus loss pauses the game. +- Swipe: J/X, controller X/Square. Swiping is the only way to hurt a bug; there is no stomping, and touching an enemy from any side, including landing on it, costs a heart. +- Pause/resume: Escape, P or controller Start. Focus loss pauses the game. Entering the level from the menus opens its [how-to-play card](main-menu.md#how-to-play-cards); the pause menu's **How to Play** shows it again. - Defeat the Merge Conflict Monster, then swipe or land on the MERGE button to finish. - Pause and results provide fresh retry, platformer menu, and arcade return. Finishing a level (any of the three) plays a short stage-clear stinger (`Assets/Audio/Platformer/stinger_stage_clear.ogg`) over the MERGE SUCCESSFUL results while the paused level music stays silent; a knockout keeps its ducked level music instead. @@ -17,7 +17,7 @@ Run the project normally: **Platformer Game → Level Select → Play Bay Area B A 5,300-pixel authored city route with two jumpable gaps, optional raised token routes, two moving cable-car platforms, parallax buildings/fog, supplied Golden Gate scenery, animated Hoppy, glitch critters, Request Changes Reviewers, broken pipelines, a moving conflict zone, and a three-headed boss. The safe lower route does not require waiting for a cable car. -Three-heart health, damage knockback, post-hit protection, variable jump height, coyote time, jump buffering, swipe hit deduplication, stomping, per-run score, boss-gated completion, pause/focus handling, death/retry, level selection, and the Bay Area music track are connected. The existing menu audio/background paths were repaired to match the reorganized Assets folder. Other levels remain explicitly unavailable. +Three-heart health, damage knockback, post-hit protection, variable jump height, coyote time, jump buffering, swipe hit deduplication, per-run score, boss-gated completion, pause/focus handling, death/retry, level selection, and the Bay Area music track are connected. The existing menu audio/background paths were repaired to match the reorganized Assets folder. Other levels remain explicitly unavailable. All art comes from `Assets/bay-area-blitz-assets-only`; Hoppy uses that pack's existing platformer SpriteFrames. Sound uses existing project assets. No assets were generated or downloaded. diff --git a/docs/builds.md b/docs/builds.md index 931368b7..7f6457e5 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -34,6 +34,7 @@ open -a "Hoppy’s Adventure Arcade.app" --args -- --booth --route=heroes_vs_vil - **Fullscreen.** The game starts fullscreen without changing the saved settings. - **Booth QR code.** A scannable, branded CodeRabbit link stays in the top-right corner on every route in booth mode. The provided code points to `https://coderabbit.link/ato26-ad?qr=1`, which identifies QR scans for attribution. To change the destination, regenerate `hoppys-adventure-arcade/Assets/booth/booth_qr.png` from the repository root with `swift hoppys-adventure-arcade/tools/booth/generate_qr.swift` and pass the output path and URL as optional arguments, then rebuild the game. +- **How-to-play cards.** Each visitor's first entry into a game opens its how-to-play card. Its pages turn by themselves every 7 seconds (28 seconds at most), so it starts play on its own and never holds off the idle reset. A new visitor starts whenever the attract route opens. See [main-menu.md](main-menu.md#how-to-play-cards). - **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the attract route. That is the hub, whose rotating previews act as the attract screen, unless `--attract=` names `beatem_up` or `runner`, the other menus that start their own music. - **Game over.** A lost game doesn't offer unlimited retries. A player gets `--retries=` retries (default 0); after that, the game shows no retry screen. Instead a **GAME OVER** card thanks the player, shows their score and counts down six seconds, then the attract route opens for the next player. The count starts again whenever the attract route opens. Every game takes part: Heroes vs. Villains, Code City Brawl, the platformer levels and the Endless Runner. Without `--booth`, every game keeps its own retry screen. - **No quitting from the menu.** The menu's **Quit Game** does nothing in booth mode; staff close the window (Cmd+Q, Alt+F4). diff --git a/docs/code-city-brawl.md b/docs/code-city-brawl.md index 1fabc7c0..a9d46a42 100644 --- a/docs/code-city-brawl.md +++ b/docs/code-city-brawl.md @@ -12,7 +12,9 @@ Launch Godot 4.7.1 and choose **Beat'em Up → Play Code City Brawl** from the a | Jump | Space | A / Cross | | Attack; chain jab → swing → spinning kick | J or X | X / Square | | Pick up nearby weapon | E or K | Y / Triangle | -| Pause / resume | Escape | Start | +| Pause / resume | Escape or P | Start | + +The first entry from the menus opens the game's [how-to-play card](main-menu.md#how-to-play-cards) before the opening story scenes; the pause menu's **How to Play** shows it again. 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. diff --git a/docs/enchanted-repo.md b/docs/enchanted-repo.md index b982b83c..69aa65b3 100644 --- a/docs/enchanted-repo.md +++ b/docs/enchanted-repo.md @@ -6,7 +6,7 @@ Implemented in Godot 4.7.1 from the current shared GDD's Platformer section, the Choose **Platformer Game → Level Select → Play Enchanted Repo**. Direct scene: `res://scenes/platformer/enchanted_repo.tscn`. -Move with A/D or arrows, jump with Space/W/up, swipe with J/X, pause with Escape. Existing gamepad mappings remain stick/D-pad movement, A/Cross jump, X/Square attack, and Start pause. Release Jump early for a short hop. Descending onto an enemy bounces Hoppy; the Monolith takes damage only in its recovery windows. +Move with A/D or arrows, jump with Space/W/up, swipe with J/X, pause with Escape or P. Existing gamepad mappings remain stick/D-pad movement, A/Cross jump, X/Square attack, and Start pause. Release Jump early for a short hop. Descending onto an enemy bounces Hoppy; the Monolith takes damage only in its recovery windows. Entering the level from the menus opens its [how-to-play card](main-menu.md#how-to-play-cards); the pause menu's **How to Play** shows it again. Cross the forest and ruined castle, defeat the Legacy Monolith, then land on or swipe the unlocked MERGE button. Standing beside it or rising through it does not finish the level. Retry starts a completely fresh Enchanted Repo run. diff --git a/docs/endless-runner.md b/docs/endless-runner.md index 719d7e88..cfdf19ef 100644 --- a/docs/endless-runner.md +++ b/docs/endless-runner.md @@ -47,7 +47,7 @@ The hub routes to `RunnerMenu`, a Node3D with Backdrop and Interface children. T The menu calls the AppRouter singleton directly: Play -> `go("runner_game")`, Return/cancel -> `go("hub")`, Quit -> `quit_game()`. Local singleton references allow standalone `--check-only`. In gameplay, overlays call only facade `pause()`, `resume()`, `retry()`, `go_to_menu()`, and `submit_name(name)`. Backend owns physics, collisions, recovery, score, persistence, routes and shared audio. The frontend milestones changed no backend files or project settings. The backend contract PR added the runner [input], [layer_names] and physics_interpolation entries to project.godot. -The teaching layer uses a mint shield for power-ups, an orange wrench for CodeRabbit tools, and a striped DANGER triangle for hazards. GET READY carries the full grab/avoid legend; HOW TO PLAY repeats the three symbols before Play. First power-up collection, tool collection, and unblocked damage each queue one full-copy, kind-bordered 2.4-second hint per run. Boss intros and stage-clear cards defer these hints, including any active hint. The legend closes with GET READY, and no persistent gameplay panel is added. Coordinator capture views `get-ready` and `toast-grab` are available; no graphical capture was run. +The teaching layer uses a mint shield for power-ups, an orange wrench for CodeRabbit tools, and a striped DANGER triangle for hazards. Before the run intro, a visitor's first run opens the runner's [how-to-play card](main-menu.md#how-to-play-cards) (goal and tips over its menu screenshots, then lane change, jump, roll and pause), and the pause menu's **How to Play** button shows it again. GET READY carries the full grab/avoid legend; HOW TO PLAY repeats the three symbols before Play. First power-up collection, tool collection, and unblocked damage each queue one full-copy, kind-bordered 2.4-second hint per run. Boss intros and stage-clear cards defer these hints, including any active hint. The legend closes with GET READY, and no persistent gameplay panel is added. Coordinator capture views `get-ready` and `toast-grab` are available; no graphical capture was run. 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. diff --git a/docs/heroes-vs-villains.md b/docs/heroes-vs-villains.md index 94b716f8..5c733a0c 100644 --- a/docs/heroes-vs-villains.md +++ b/docs/heroes-vs-villains.md @@ -13,11 +13,13 @@ This mode uses the armored Hoppy and the [Mode 2 asset pack](heroes-vs-villains- | Move along the ground plane | WASD / arrows | Left stick / D-pad | | Jump | Space | A | | Three-hit combo / weapon | J or X | X | -| Pick up weapon | E | Y | +| Pick up weapon | E or K | Y | | Charge REVIEW after unlock | Hold Q | Hold LB | -| Pause | Escape | Start | +| Pause | Escape or P | Start | | Skip presentation card | Enter / keypad Enter | View / Select | +The first entry from the menus opens the game's [how-to-play card](main-menu.md#how-to-play-cards) before the opening cards; the pause menu's **How to Play** shows it again. + The armored Hoppy uses eight-frame side, away and toward-camera locomotion cycles at 14 fps, with cadence scaled to analog movement and speed effects. Base travel speed is 264 pixels/second (10% above Code City Brawl). Hoppy has three health points, jump avoidance, hit recovery and one shield on stage entry after REVIEW is unlocked. Attack presses can buffer the next combo strike. Clear ordinary rooms, then walk through the right gate. Boss encounters end with a short Hoppy victory hop while the defeated villain collapses, followed by the reward cards. After the last reward card, Hoppy runs out of the stage on his own under a CAPABILITY DEPLOYED banner, faster than his normal run when he starts far from the right edge, so he always leaves the screen before it fades. The screen dips to black while the next stage loads, then he runs in under its title banner and play resumes; the sequence takes about 2–3 seconds and needs no input. Messages raised while the title banner fades into combat, such as the encounter counter, wait until it clears. The opening cards lead into the same run-in, and so does a retry. Boss music dips, swaps tracks at the bottom and comes back up over 0.6 seconds. The final boss instead leads to the final victory cards and results. Break props for supplies; consumables collect on contact, while weapons require pickup input. Losing the last health point knocks Hoppy out: @@ -26,6 +28,8 @@ Hoppy has three health points, jump avoidance, hit recovery and one shield on st - He lands flat on his front with a burst 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 of the current stage with its entry score and capabilities, fresh health and cleared temporary effects. Pause and focus loss stop the run, including during cards and stage transitions; return-to-menu is available through pause and results. +REVIEW never hurts Hoppy. Holding Q / LB for under half a second fires a mint burst on the floor around him that stuns nearby threats; stunned enemies turn blue. For 0.85 seconds after it fires, hits can't land and Hoppy glows mint, without the blink that means he was hit. It recharges in 8 seconds. + Capabilities persist within the run, not across application launches. REVIEW unlocks after AI SLOP; TRIAGE and CHANGE STACK unlock after defeating both the fused army commander and the PR; SECURITY completes the final victory. The three opening cards are painted story scenes: diff --git a/docs/main-menu.md b/docs/main-menu.md index fbf1ccb8..2be6492e 100644 --- a/docs/main-menu.md +++ b/docs/main-menu.md @@ -32,6 +32,19 @@ The **Credits** button, after Settings and Quit Game, opens `scenes/credits/cred - **Asset credits** are generated. `tools/credits/build_credits.py` writes `scenes/credits/asset_credits.json` from `ASSETS_LICENSES.md`. After changing that table, run `python3 tools/credits/build_credits.py`; CI fails if the two differ. - **Hand-written credits** live in `credits.json`: a list of sections, each with a `title`, optional `lines` and optional `tracks` (`{"title": …, "used_by": …}`). Original music goes in the "Original music" section. To credit something else, add a track there or add a section. +## How-to-play cards + +The first time a visitor enters a game, `AppRouter.go()` opens that game's how-to-play card (`app/how_to_play.gd`) over it. A visitor starts whenever the hub or the booth's attract route opens, so retries and returns from a game's own menu skip the card. `--route=` launches show it too. + +- **Pages.** Each card is three or four pages: **Your goal**, **Controls**, **Tips**, and for Orbital Ops a **Zero gravity** page. Dots in the corner show where you are. +- **Scenes.** Each page except Controls plays a short looping scene with a line of text under it (`app/how_to_play_demo.gd`). The scenes reuse the game's own sprite sheets: Hoppy swiping the level boss down and landing on MERGE, swiping a glitch critter, rising and sinking in the zero-G bay, crossing a laser while it's off, hopping across crumbling branches, combos against Code City soldiers and AI Slop sprites, and a REVIEW burst. The runner is 3D, so its pages pan across its menu screenshots. Captions on the scene repeat the key moment ("Off: go!", "Hold Q / LB: REVIEW"). A scene is one looping Tween bound to the page, so it runs while the game is paused and stops when the page changes. Each scene is a scaled-down window onto the game's own screen: the floor line sits where the game puts it (y 600), actors keep their in-game scale and are anchored at the feet by the same pivots the game uses, the platformer scenes draw the level's sky and ground tiles, and the beat 'em up scenes draw the room panorama so actors stand on its painted floor. Bosses stand where the game places them (y 565 in the beat 'em ups). +- **Controls.** `app/how_to_play_data.gd` holds one entry per gameplay route: title, pages, control rows and `poses`. Each row names its input actions, the keys it shows and the pad inputs it shows, plus an `icon`: a small Hoppy acting out the move (running, jumping, swiping, rolling, rising, sinking). The keyboard text ("Space / W / ↑", "WASD / Arrows") and controller text are built from those. With a controller connected, buttons use that pad's names (Cross, A or B for the bottom button); without one they read "A / Cross". Face buttons also get a four-dot glyph with the one to press filled in. Orbital Ops adds its zero-G rise and sink rows; Heroes vs. Villains adds REVIEW and Skip card. +- **Holding the game.** The card pauses the tree and is the root's last child, so its `_input` sees every press first and swallows it. It passes each press to `AppRouter.note_input()` (booth idle timer) and `ControllerService.observe()` (player one's pad). +- **Paging and closing.** Presses in the first 0.5 s are ignored, so the press that picked the game can't skip a page. After that, a confirm, jump or attack press, a left click or a touch turns to the next page, and on the last page starts the game. Left/right (arrows, A/D, D-pad, one flip per stick push) go back and forth without closing. Pause (Esc, P, Start) skips straight to the game. Each page turns by itself after 7 s while its bar drains, so a whole card is at most 28 s. The game unpauses two physics frames after the card closes, so the closing press never also jumps or swipes. The countdown waits while the window is unfocused. If the game paused itself under the card (focus loss), it stays in its pause menu: games answer `is_player_paused()`. +- **Booth.** The 90 s idle timer keeps counting under the card, and a route change removes it. +- **Pause menus.** The platformer levels, the runner and both beat 'em ups have a **How to Play** button in their pause menu. It opens the same card without a countdown; its pages turn the same way, Back or Pause closes it from any page, the game stays paused and focus returns to the button. +- **Tests.** `tests/how_to_play.gd` checks that every gameplay route has a card with a controls page, that every key and pad input a card shows is bound to its row's actions, that every sprite clip and image a page or icon uses exists, that every page builds and plays, the label text, the once-per-visitor rule, paging and closing, auto-advance, booth idle, focus loss and the pause-menu buttons. Suites run with `--script` and get no cards unless they set `AppRouter.how_to_play_enabled`. + ## Art and replacement points As of 2026-09-30, `hoppy.gd` uses the supplied `Assets/Character/Hoppy/2D/hoppy-sprite-pack/hoppy` SpriteFrames: idle by default, one jump on Platformer selection, and one jab then the default idle on Beat’em Up selection. Endless Runner hover or keyboard/gamepad focus crossfades to the existing `hoppy_runner_visual.tscn` rig running in place, rendered through a transparent, isolated 512×512 SubViewport. Leaving that selection restores 2D immediately through a 0.22-second fade; idle background cycling does not switch the character. Hidden 3D rendering and rig processing are disabled. Both versions face toward the buttons and share a ground anchor. `sprite_scale` and `transition_seconds` are exposed on the Hoppy node for iteration. The title uses a system monospace fallback; no final pixel font or logo was supplied. diff --git a/docs/orbital-ops.md b/docs/orbital-ops.md index ed76cdd4..b0ea08be 100644 --- a/docs/orbital-ops.md +++ b/docs/orbital-ops.md @@ -10,7 +10,7 @@ Launch the project, choose **Platformer Game → Level Select → Play Orbital O - Jump: Space/W/up, gamepad A/Cross. Release early for a shorter normal-gravity jump. - Swipe: J/X, gamepad X/Square. Descending onto an enemy also damages it and bounces Hoppy. - Zero-gravity bay: hold Jump/Up to rise, Down/S or stick/D-pad down to descend, release vertical input to brake. Normal gravity resumes outside the cyan boundaries. -- Pause: Escape/Start or the Pause button. Focus loss also pauses. +- Pause: Escape/P/Start or the Pause button. Focus loss also pauses. Entering the level from the menus opens its [how-to-play card](main-menu.md#how-to-play-cards); the pause menu's **How to Play** shows it again. Its card adds the zero-G rise and sink rows and a Zero gravity page showing Hoppy rise, brake and sink. - Defeat the Deprecated API, then land on or swipe the green MERGE button. Retry always starts a fresh Orbital Ops run; return buttons lead to the platformer menu or arcade. ## Level and combat diff --git a/docs/previews/how-to-play/bay_area_blitz_0.png b/docs/previews/how-to-play/bay_area_blitz_0.png new file mode 100644 index 00000000..37e34025 Binary files /dev/null and b/docs/previews/how-to-play/bay_area_blitz_0.png differ diff --git a/docs/previews/how-to-play/bay_area_blitz_1.png b/docs/previews/how-to-play/bay_area_blitz_1.png new file mode 100644 index 00000000..213daa4a Binary files /dev/null and b/docs/previews/how-to-play/bay_area_blitz_1.png differ diff --git a/docs/previews/how-to-play/code_city_brawl_0.png b/docs/previews/how-to-play/code_city_brawl_0.png new file mode 100644 index 00000000..586c1847 Binary files /dev/null and b/docs/previews/how-to-play/code_city_brawl_0.png differ diff --git a/docs/previews/how-to-play/enchanted_repo_0.png b/docs/previews/how-to-play/enchanted_repo_0.png new file mode 100644 index 00000000..2e096d6a Binary files /dev/null and b/docs/previews/how-to-play/enchanted_repo_0.png differ diff --git a/docs/previews/how-to-play/heroes_vs_villains_2.png b/docs/previews/how-to-play/heroes_vs_villains_2.png new file mode 100644 index 00000000..feda60f4 Binary files /dev/null and b/docs/previews/how-to-play/heroes_vs_villains_2.png differ diff --git a/docs/previews/how-to-play/orbital_ops_2.png b/docs/previews/how-to-play/orbital_ops_2.png new file mode 100644 index 00000000..85148e2b Binary files /dev/null and b/docs/previews/how-to-play/orbital_ops_2.png differ diff --git a/docs/previews/how-to-play/runner_game_0.png b/docs/previews/how-to-play/runner_game_0.png new file mode 100644 index 00000000..74a26cb5 Binary files /dev/null and b/docs/previews/how-to-play/runner_game_0.png differ diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index 226ed1e7..fe505bce 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -4,6 +4,8 @@ const BoothHeartbeat := preload("res://app/booth_heartbeat.gd") const NextPlayerCard := preload("res://app/next_player_card.gd") const BoothSoak := preload("res://app/booth_soak.gd") const BoothQrOverlay := preload("res://app/booth_qr_overlay.gd") +const HowToPlay := preload("res://app/how_to_play.gd") +const HowToPlayData := preload("res://app/how_to_play_data.gd") const ROUTES := { "heroes_vs_villains": "res://scenes/beatem_up/reinvent/heroes_vs_villains.tscn", "code_city_brawl": "res://scenes/beatem_up/code_city_brawl.tscn", @@ -67,6 +69,15 @@ var next_player_left := 0.0 ## The soak test (app/booth_soak.gd), when the game was launched with `--soak=`. var soak: BoothSoak var booth_qr: Control +## How-to-play cards (app/how_to_play.gd): the first time a visitor enters a game, its card opens +## over it. A visitor starts when the hub or the booth's attract route opens, so retries and +## returns from a game's own menu skip the card. Test suites run with --script and get no cards +## unless they set this; soaks get none either. +var how_to_play_enabled := not ("--script" in OS.get_cmdline_args()) +## Game routes whose card this visitor has seen. +var how_to_play_seen := {} +## The card on screen, if any. +var how_to_play: Node func _ready() -> void: # The veil tween must keep running while a microgame has the tree paused. process_mode = Node.PROCESS_MODE_ALWAYS @@ -119,6 +130,9 @@ func go(route: String, reason := "menu") -> bool: busy = true veil.mouse_filter = Control.MOUSE_FILTER_STOP AudioService.play_ui(2) + if is_instance_valid(how_to_play): + how_to_play.discard() + how_to_play = null await create_tween().tween_property(veil, "modulate:a", 1.0, 0.25).finished var error := get_tree().change_scene_to_file(ROUTES[route]) if error != OK: @@ -130,6 +144,11 @@ func go(route: String, reason := "menu") -> bool: session_log.route_changed(route, reason) if route == attract: game_overs = 0 + if route == attract or route == "hub": + how_to_play_seen.clear() + if wants_how_to_play(route): + how_to_play_seen[route] = true + _open_how_to_play(route) if next_player: next_player.hide() if route == "hub": @@ -259,7 +278,28 @@ func set_booth(enabled: bool) -> void: DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) print("AppRouter: booth mode on (idle return after %d s)" % int(idle_limit)) +## True when entering `route` now should open its how-to-play card. +func wants_how_to_play(route: String) -> bool: + return how_to_play_enabled and soak == null and HowToPlayData.has_game(route) \ + and not how_to_play_seen.has(route) + +## Opens the card once the new scene is in the tree (the change lands later this frame), so the card +## is the root's last child and sees input first. +func _open_how_to_play(route: String) -> void: + await get_tree().scene_changed + var scene := get_tree().current_scene + if scene == null or scene.scene_file_path != ROUTES[route]: + return + how_to_play = HowToPlay.open(get_tree(), route, HowToPlay.Mode.START) + func _input(event: InputEvent) -> void: + note_input(event) + +## Resets the booth idle timer for a player's press or stick push. The how-to-play card swallows +## input, so it calls this itself. +func note_input(event: InputEvent) -> void: + if not booth: + return if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton \ or event is InputEventScreenTouch \ or (event is InputEventJoypadMotion and absf(event.axis_value) > STICK_DEADZONE): diff --git a/hoppys-adventure-arcade/app/controller_service.gd b/hoppys-adventure-arcade/app/controller_service.gd index f3ecbf63..b8d87f4b 100644 --- a/hoppys-adventure-arcade/app/controller_service.gd +++ b/hoppys-adventure-arcade/app/controller_service.gd @@ -80,6 +80,12 @@ func is_paused_for_controller() -> bool: func _input(event: InputEvent) -> void: + observe(event) + + +## Lets a pad's press make it player one. _input calls this; so does the how-to-play card, which +## swallows input before this autoload would see it. +func observe(event: InputEvent) -> void: var device := _gameplay_device(event) if device < 0 or roster.role_of(device) == PLAYER: return diff --git a/hoppys-adventure-arcade/app/how_to_play.gd b/hoppys-adventure-arcade/app/how_to_play.gd new file mode 100644 index 00000000..49ff27bb --- /dev/null +++ b/hoppys-adventure-arcade/app/how_to_play.gd @@ -0,0 +1,501 @@ +extends CanvasLayer +## The how-to-play card: a few pages per game (the goal, the controls with keyboard and controller +## side by side, tips), most acted out by a little looping scene (app/how_to_play_demo.gd). Its +## text comes from app/how_to_play_data.gd. +## +## Paging: a confirm, jump or attack press, a click or a touch turns to the next page, and on the +## last page closes the card; left/right flip back and forth without closing; pause (Esc / Start) +## skips straight to the game. +## +## Two ways in: +## - AppRouter opens it in START mode when a visitor first enters a game (see AppRouter.go()). It +## pauses the game and ignores presses for its first ARM_SECONDS. Each page turns by itself after +## PAGE_SECONDS and the last one closes the card, so an unattended booth never waits on it. +## Closing unpauses the game unless the game paused itself meanwhile (focus loss shows its own +## pause menu; see `is_player_paused()` on the game scenes). +## - A game's pause menu opens it in PAUSE mode: no countdown, Back closes it too, the game stays +## paused and focus returns to the button that opened it. +## +## The card is added as the root's last child, so its _input sees every event before the game +## does. While it is up it swallows every press (the game would otherwise toggle pause or skip its +## intro underneath), after handing the press to AppRouter (booth idle timer) and ControllerService +## (which pad is player one), which would otherwise never see it. + +signal closed + +const Data := preload("res://app/how_to_play_data.gd") +const Style := preload("res://scenes/main_menu/menu_style.gd") +const Prompts := preload("res://app/controller_prompts.gd") +const Labels := preload("res://addons/cr_kit/input.gd").Labels +const Demo := preload("res://app/how_to_play_demo.gd") + +enum Mode { START, PAUSE } + +## Above every game's pause menu, below ControllerService's disconnect banner (90) and the +## router's fade (100). +const LAYER := 89 +const ARM_SECONDS := 0.5 +## How long a START card stays on each page before turning it. +const PAGE_SECONDS := 7.0 +## Room for the tallest page, so the panel doesn't jump between pages. +const BODY_HEIGHT := 330.0 +const DIM := Color(0.01, 0.02, 0.04, 0.86) +const PANEL := Color("142735") +const MUTED := Color("afbdc9") +## Turn to the next page, or close the card on the last one. +const NEXT_ACTIONS: Array[StringName] = [ + &"ui_accept", &"jump", &"attack", &"brawl_jump", &"brawl_attack", &"heroes_popup_advance"] +## Close the card from any page. +const SKIP_ACTIONS: Array[StringName] = [&"pause"] +const BACK_ACTIONS: Array[StringName] = [&"ui_left", &"move_left"] +const FORWARD_ACTIONS: Array[StringName] = [&"ui_right", &"move_right"] +## Each family's face-button names when no controller is connected: Xbox first, then PlayStation. +const ANY_PAD := ["xbox", "playstation"] +const KEY_CAPS := { + KEY_LEFT: "←", KEY_RIGHT: "→", KEY_UP: "↑", KEY_DOWN: "↓", KEY_ESCAPE: "Esc", KEY_ENTER: "Enter", +} +const STICK_TEXT := {Data.STICK: "Left stick", Data.STICK_UP: "Stick ↑", Data.STICK_DOWN: "Stick ↓"} + +var route := "" +var mode := Mode.START +var age := 0.0 +var page := 0 +var countdown := PAGE_SECONDS +var return_focus: Control +var _focus_lost := false +var _closing := false +## Still swallowing input for the few frames a START card takes to hand the game back. +var _closing_input := false +var _controls: GridContainer +var _heading: Label +var _body: VBoxContainer +var _dots: Label +var _footer: Label +## The stick's last left/right push (-1, 0, 1), so holding it flips one page, not one per event. +var _stick := 0 +var _bar: ColorRect +var _bar_track: Control + + +## Shows the card for `route` over the current scene and returns it, or null when the route has no +## card. START pauses the tree; PAUSE expects the game to be paused already. +static func open(tree: SceneTree, game_route: String, card_mode: Mode, focus_after: Control = null) -> Node: + if not Data.has_game(game_route): + return null + var card: Node = load("res://app/how_to_play.gd").new() + card.route = game_route + card.mode = card_mode + card.return_focus = focus_after + tree.root.add_child(card) + if card_mode == Mode.START: + tree.paused = true + return card + + +func _ready() -> void: + name = "HowToPlay" + layer = LAYER + process_mode = Node.PROCESS_MODE_ALWAYS + _build() + var controllers := Prompts.service() + if controllers: + controllers.family_changed.connect(_on_family_changed) + + +func _exit_tree() -> void: + var controllers := Prompts.service() + if controllers and controllers.family_changed.is_connected(_on_family_changed): + controllers.family_changed.disconnect(_on_family_changed) + + +func _process(delta: float) -> void: + if _closing: + return + age += delta + if mode != Mode.START: + return + # The countdown waits while the window is in the background; nobody can be reading it. + if not _focus_lost: + countdown -= delta + _bar.size.x = _bar_track.size.x * clampf(countdown / PAGE_SECONDS, 0.0, 1.0) + if countdown <= 0.0: + next_page() + + +func _input(event: InputEvent) -> void: + if not _is_press_or_motion(event) or (_closing and not _closing_input): + return + _share(event) + get_viewport().set_input_as_handled() + if _closing or age < ARM_SECONDS: + _track_stick(event) + return + var flip := _flip_of(event) + if flip != 0: + show_page(page + flip) + elif is_dismiss(event): + close() + elif is_next(event): + next_page() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_APPLICATION_FOCUS_OUT: + _focus_lost = true + elif what == NOTIFICATION_APPLICATION_FOCUS_IN: + _focus_lost = false + + +## True for a press that closes the card from any page: pause, or Back on a reopened card. +func is_dismiss(event: InputEvent) -> bool: + if _pressed(event, SKIP_ACTIONS): + return true + return mode == Mode.PAUSE and _pressed(event, [&"ui_cancel"]) + + +## True for a press that turns to the next page (or closes the card on the last one). +func is_next(event: InputEvent) -> bool: + if event.is_echo(): + return false + # Only a left click or a real touch: no wheel or right button, and no mouse click Godot emulates + # from a touch (on by default), so one tap turns one page. + if event is InputEventMouseButton: + return event.pressed and event.button_index == MOUSE_BUTTON_LEFT \ + and event.device != InputEvent.DEVICE_ID_EMULATION + if event is InputEventScreenTouch: + return event.pressed and event.device != InputEvent.DEVICE_ID_EMULATION + return _pressed(event, NEXT_ACTIONS) + + +func _pressed(event: InputEvent, actions: Array[StringName]) -> bool: + if event.is_echo() or event is InputEventJoypadMotion: + return false + for action in actions: + if InputMap.has_action(action) and event.is_action_pressed(action): + return true + return false + + +## -1 or 1 for a press that flips back or forward a page, else 0. The stick flips once per push. +func _flip_of(event: InputEvent) -> int: + if event is InputEventJoypadMotion: + var before := _stick + _track_stick(event) + return _stick if event.axis == JOY_AXIS_LEFT_X and _stick != before else 0 + if _pressed(event, BACK_ACTIONS): + return -1 + if _pressed(event, FORWARD_ACTIONS): + return 1 + return 0 + + +func _track_stick(event: InputEvent) -> void: + if event is InputEventJoypadMotion and event.axis == JOY_AXIS_LEFT_X: + _stick = 0 if absf(event.axis_value) < 0.5 else signi(int(signf(event.axis_value))) + + +func page_count() -> int: + return Data.pages(route).size() + + +func is_last_page() -> bool: + return page >= page_count() - 1 + + +## Turns to the next page, or closes the card from the last one. +func next_page() -> void: + if is_last_page(): + close() + else: + show_page(page + 1) + + +## Shows page `index` (clamped) and restarts its countdown. +func show_page(index: int) -> void: + page = clampi(index, 0, page_count() - 1) + countdown = PAGE_SECONDS + var entry: Dictionary = Data.pages(route)[page] + _heading.text = str(entry.heading).to_upper() + for child in _body.get_children(): + _body.remove_child(child) + child.queue_free() + _controls = null + if entry.get("controls", false): + _controls = GridContainer.new() + _controls.columns = 4 + _controls.add_theme_constant_override("h_separation", 22) + _controls.add_theme_constant_override("v_separation", 4) + _body.add_child(_controls) + _fill_controls() + else: + _body.add_child(Demo.build(entry.scene)) + var text := Style.label(str(entry.text), 20, Style.CREAM) + text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + text.custom_minimum_size.x = Demo.SIZE.x + _body.add_child(text) + var dots: PackedStringArray = [] + for each in page_count(): + dots.append("●" if each == page else "○") + _dots.text = " ".join(dots) + _update_footer() + + +## Hides the card and hands the game back: in START mode it unpauses the tree unless the game has +## since paused itself; in PAUSE mode focus returns to the pause menu. +func close() -> void: + if _closing: + return + _closing = true + hide() + var tree := get_tree() + if mode == Mode.START: + # The press that closed the card is still "just pressed" this frame: unpausing now would let + # it jump or swipe in the game too. Swallow input until it has gone stale. + _closing_input = true + var scene := tree.current_scene + for frame in 2: + await tree.physics_frame + await tree.process_frame + var router := tree.root.get_node_or_null(^"AppRouter") + var leaving: bool = router != null and router.busy + var self_paused: bool = scene != null and scene.has_method("is_player_paused") and scene.is_player_paused() + if tree.current_scene == scene and not leaving and not self_paused: + tree.paused = false + elif is_instance_valid(return_focus) and return_focus.is_visible_in_tree(): + return_focus.grab_focus() + closed.emit() + queue_free() + + +## Frees the card without touching pause, for a route change that is replacing the game anyway. +func discard() -> void: + hide() + if _closing_input: + return # close() is mid-handover; it sees the route change, leaves pause alone and frees the card. + _closing = true + queue_free() + + +## The keyboard column's text for a row: "Space / W / ↑", "WASD / Arrows". +static func keys_text(keys: Array) -> String: + var parts: PackedStringArray = [] + var wasd := [KEY_W, KEY_A, KEY_S, KEY_D].all(func(key: int) -> bool: return key in keys) + var arrows := [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN].filter(func(key: int) -> bool: return key in keys) + var arrows_done := false + for key: int in keys: + if wasd and key in [KEY_W, KEY_A, KEY_S, KEY_D]: + if key == KEY_W: + parts.append("WASD") + continue + if key in [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]: + if not arrows_done: + arrows_done = true + parts.append("Arrows" if arrows.size() == 4 else " ".join(arrows.map(func(arrow: int) -> String: return KEY_CAPS[arrow]))) + continue + parts.append(KEY_CAPS.get(key, OS.get_keycode_string(key))) + return " / ".join(parts) + + +## The controller column's text for a row, for `family` ("" = no controller: name each button for +## Xbox and PlayStation, "A / Cross"). D-pad directions fold into one "D-pad" when the row also +## uses the stick that way. +static func pad_text(pads: Array, family: String) -> String: + var parts: PackedStringArray = [] + var dpad := pads.filter(func(pad: int) -> bool: return Labels.DPAD.has(pad)) + var dpad_done := false + for pad: int in pads: + if STICK_TEXT.has(pad): + parts.append(STICK_TEXT[pad]) + elif Labels.DPAD.has(pad): + if dpad_done: + continue + dpad_done = true + parts.append("D-pad" if dpad.size() > 1 else str(Labels.DPAD[pad]).replace(" up", " ↑").replace(" down", " ↓").replace(" left", " ←").replace(" right", " →")) + else: + parts.append(button_text(pad, family)) + return " / ".join(parts) + + +static func button_text(pad: int, family: String) -> String: + if not family.is_empty(): + return Labels.button(pad, family) + if pad == JOY_BUTTON_START: + return "Start" + var names: PackedStringArray = [] + for each in ANY_PAD: + var label := Labels.button(pad, each) + if label not in names: + names.append(label) + return " / ".join(names) + + +## The face button (JOY_BUTTON_A..Y) a row's controller column leads with, or -1. +static func face_button(pads: Array) -> int: + for pad: int in pads: + if pad in [JOY_BUTTON_A, JOY_BUTTON_B, JOY_BUTTON_X, JOY_BUTTON_Y]: + return pad + return -1 + + +func _family() -> String: + var controllers := Prompts.service() + return controllers.family if controllers else "" + + +func _build() -> void: + var game := Data.game(route) + var ui := Control.new() + ui.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + ui.theme = Style.theme() + ui.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(ui) + var dim := ColorRect.new() + dim.color = DIM + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_IGNORE + ui.add_child(dim) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + ui.add_child(center) + var panel := PanelContainer.new() + var box := StyleBoxFlat.new() + box.bg_color = PANEL + box.border_color = Style.MINT + box.set_border_width_all(2) + box.content_margin_left = 34 + box.content_margin_right = 34 + box.content_margin_top = 22 + box.content_margin_bottom = 20 + panel.add_theme_stylebox_override("panel", box) + center.add_child(panel) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 10) + column.custom_minimum_size.x = 820 + panel.add_child(column) + var top := HBoxContainer.new() + column.add_child(top) + top.add_child(Style.label("HOW TO PLAY", 16, Style.MINT)) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(spacer) + _dots = Style.label("", 16, Style.MINT) + top.add_child(_dots) + var heading := HBoxContainer.new() + heading.add_theme_constant_override("separation", 18) + column.add_child(heading) + heading.add_child(Style.label(str(game.title), 36)) + _heading = Style.label("", 20, Style.MINT) + _heading.size_flags_vertical = Control.SIZE_SHRINK_END + heading.add_child(_heading) + _body = VBoxContainer.new() + _body.add_theme_constant_override("separation", 10) + _body.custom_minimum_size.y = BODY_HEIGHT + column.add_child(_body) + _footer = Style.label("", 18, Style.MINT) + _footer.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + column.add_child(_footer) + _bar_track = ColorRect.new() + (_bar_track as ColorRect).color = Color("244447") + _bar_track.custom_minimum_size = Vector2(0, 4) + column.add_child(_bar_track) + _bar = ColorRect.new() + _bar.color = Style.MINT + _bar.size = Vector2(0, 4) + _bar_track.add_child(_bar) + _bar_track.visible = mode == Mode.START + show_page(0) + + +func _fill_controls() -> void: + if _controls == null: + return + for child in _controls.get_children(): + _controls.remove_child(child) + child.queue_free() + var family := _family() + var poses: Dictionary = Data.game(route).get("poses", {}) + for heading in ["", "", "KEYBOARD", "CONTROLLER"]: + _controls.add_child(Style.label(heading, 14, Style.MINT)) + for row: Dictionary in Data.game(route).rows: + if poses.has(row.get("icon", "")): + _controls.add_child(Demo.icon(poses[row.icon])) + else: + var blank := Control.new() + blank.custom_minimum_size = Demo.ICON_SIZE + _controls.add_child(blank) + var action := HBoxContainer.new() + action.add_theme_constant_override("separation", 10) + action.add_child(Style.label(str(row.label), 20)) + if row.has("hint"): + var hint := Style.label("(%s)" % row.hint, 15, MUTED) + hint.size_flags_vertical = Control.SIZE_SHRINK_CENTER + action.add_child(hint) + _controls.add_child(action) + _controls.add_child(Style.label(keys_text(row.keys), 20)) + var pad := HBoxContainer.new() + pad.add_theme_constant_override("separation", 8) + var face := face_button(row.pad) + if face >= 0: + var glyph := FaceGlyph.new() + glyph.button = face + pad.add_child(glyph) + pad.add_child(Style.label(pad_text(row.pad, family), 20)) + _controls.add_child(pad) + + +func _update_footer() -> void: + var family := _family() + var next := "Space / %s" % button_text(JOY_BUTTON_A, family) + if is_last_page(): + _footer.text = ("%s to play" if mode == Mode.START else "%s to go back") % next + elif mode == Mode.START: + _footer.text = "%s next ← → pages Esc / %s play now" % [next, button_text(JOY_BUTTON_START, family)] + else: + _footer.text = "%s next ← → pages Esc / %s back" % [next, button_text(JOY_BUTTON_B, family)] + + +func _on_family_changed(_family_name: String) -> void: + _fill_controls() + _update_footer() + + +func _is_press_or_motion(event: InputEvent) -> bool: + return event is InputEventKey or event is InputEventJoypadButton or event is InputEventJoypadMotion \ + or event is InputEventMouseButton or event is InputEventScreenTouch or event is InputEventScreenDrag \ + or event is InputEventAction + + +## The card swallows input, so the autoloads that watch every press get it from here instead. +func _share(event: InputEvent) -> void: + var root := get_tree().root + var router := root.get_node_or_null(^"AppRouter") + if router and router.has_method("note_input"): + router.note_input(event) + var controllers := root.get_node_or_null(^"ControllerService") + if controllers and controllers.has_method("observe"): + controllers.observe(event) + + +## Four face buttons in a diamond with the one to press filled in: readable on any pad, whatever +## its letters say. +class FaceGlyph extends Control: + const SPOTS := { + JOY_BUTTON_Y: Vector2(0, -1), JOY_BUTTON_A: Vector2(0, 1), + JOY_BUTTON_X: Vector2(-1, 0), JOY_BUTTON_B: Vector2(1, 0), + } + var button := JOY_BUTTON_A + + func _init() -> void: + custom_minimum_size = Vector2(26, 26) + size_flags_vertical = Control.SIZE_SHRINK_CENTER + mouse_filter = Control.MOUSE_FILTER_IGNORE + + func _draw() -> void: + var middle := size / 2.0 + for spot: int in SPOTS: + var at: Vector2 = middle + SPOTS[spot] * 8.0 + if spot == button: + draw_circle(at, 4.5, Color("79e0c4")) + else: + draw_circle(at, 3.5, Color("5b6d7c"), false, 1.5, true) diff --git a/hoppys-adventure-arcade/app/how_to_play.gd.uid b/hoppys-adventure-arcade/app/how_to_play.gd.uid new file mode 100644 index 00000000..45bc9588 --- /dev/null +++ b/hoppys-adventure-arcade/app/how_to_play.gd.uid @@ -0,0 +1 @@ +uid://dxn7ae7i64aum diff --git a/hoppys-adventure-arcade/app/how_to_play_data.gd b/hoppys-adventure-arcade/app/how_to_play_data.gd new file mode 100644 index 00000000..5e1b4f78 --- /dev/null +++ b/hoppys-adventure-arcade/app/how_to_play_data.gd @@ -0,0 +1,186 @@ +extends RefCounted +## What each game's how-to-play card (app/how_to_play.gd) says: one entry per gameplay route +## (ControllerService.GAMEPLAY_ROUTES). The card is read standing at a booth, so each page's text is +## a line or two. +## +## A card is a few pages. A page is either the controls table (`"controls": true`) or a line of text +## over a short looping scene that app/how_to_play_demo.gd plays from the game's own sprites +## (`"scene": {"id": ...}`; the demo lists the ids and what each takes). +## +## Each control row names the input actions it describes, the keys it shows and the pad inputs it +## shows. tests/how_to_play.gd fails when a shown key or pad input is not bound to one of the row's +## actions, so the card can't drift from project.godot or a mode's install_input(). A row doesn't +## have to list every binding, only true ones. A row's `icon` names one of the game's `poses`: the +## sprite sheet, clip and optional motion ("slide", "rise", "sink", "spin", "pulse") of the little +## Hoppy acting it out beside the row. +## +## Pad entries are JoyButton values or one of the stick constants below. + +## The left stick, any direction the row's actions use. +const STICK := -1 +const STICK_UP := -2 +const STICK_DOWN := -3 + +const BAY := "res://Assets/bay-area-blitz-assets-only/bay_area/" +const ORBIT := "res://Assets/orbital-ops-assets-only/orbital_ops/" +const FOREST := "res://Assets/enchanted-repo-assets-only/enchanted_repo/" +const CITY := "res://Assets/code-city-brawl-assets-only/code_city/" +const HEROES := "res://Assets/heroes-vs-villains-assets-only/heroes_villains/" +const RUNNER := "res://Assets/runner/menu_previews/" +const PLATFORMER_HOPPY := BAY + "hoppy/platformer.tres" +const BRAWL_HOPPY := "res://Assets/code-city-brawl-player/combat.tres" + +const _PAUSE := {"label": "Pause", "actions": ["pause"], + "keys": [KEY_ESCAPE, KEY_P], "pad": [JOY_BUTTON_START]} + +const _PLATFORMER_ROWS := [ + {"label": "Move", "icon": "move", "actions": ["move_left", "move_right"], + "keys": [KEY_A, KEY_D, KEY_LEFT, KEY_RIGHT], "pad": [STICK, JOY_BUTTON_DPAD_LEFT, JOY_BUTTON_DPAD_RIGHT]}, + {"label": "Jump", "hint": "hold for higher", "icon": "jump", "actions": ["jump"], + "keys": [KEY_SPACE, KEY_W, KEY_UP], "pad": [JOY_BUTTON_A]}, + {"label": "Swipe", "icon": "attack", "actions": ["attack"], "keys": [KEY_J, KEY_X], "pad": [JOY_BUTTON_X]}, + _PAUSE, +] + +const _PLATFORMER_POSES := { + "move": [PLATFORMER_HOPPY, "run"], + "jump": [PLATFORMER_HOPPY, "jump"], + "attack": [PLATFORMER_HOPPY, "attack"], + "rise": [PLATFORMER_HOPPY, "jump_rise", "rise"], + "sink": [PLATFORMER_HOPPY, "jump_fall", "sink"], +} + +const _BRAWL_ROWS := [ + {"label": "Move", "hint": "up/down = lane", "icon": "move", "actions": ["move_left", "move_right", "move_up", "move_down"], + "keys": [KEY_W, KEY_A, KEY_S, KEY_D, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN], + "pad": [STICK, JOY_BUTTON_DPAD_LEFT, JOY_BUTTON_DPAD_RIGHT, JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN]}, + {"label": "Jump", "icon": "jump", "actions": ["brawl_jump"], "keys": [KEY_SPACE], "pad": [JOY_BUTTON_A]}, + {"label": "Attack", "hint": "3-hit combo", "icon": "attack", "actions": ["brawl_attack"], + "keys": [KEY_J, KEY_X], "pad": [JOY_BUTTON_X]}, + {"label": "Pick up weapon", "icon": "pickup", "actions": ["brawl_pickup"], "keys": [KEY_E, KEY_K], "pad": [JOY_BUTTON_Y]}, +] + +const _CONTROLS := {"heading": "Controls", "controls": true} + +const GAMES := { + "bay_area_blitz": { + "title": "Bay Area Blitz", + "pages": [ + {"heading": "Your goal", "text": "Run across the city, defeat the Merge Conflict Monster, then land on or swipe the MERGE button.", + "scene": {"id": "platformer_goal", "world": "bay", "boss": BAY + "merge_monster.tres"}}, + _CONTROLS, + {"heading": "Tips", "text": "Swipe bugs to clear them; you can't stomp them. You have three hearts; grab tokens for points.", + "scene": {"id": "blitz_tips", "world": "bay"}}, + ], + "rows": _PLATFORMER_ROWS, + "poses": _PLATFORMER_POSES, + }, + "orbital_ops": { + "title": "Orbital Ops", + "pages": [ + {"heading": "Your goal", "text": "Cross the space station, defeat the Deprecated API, then land on or swipe the MERGE button.", + "scene": {"id": "platformer_goal", "world": "orbital", "boss": ORBIT + "deprecated_api.tres"}}, + _CONTROLS, + {"heading": "Zero gravity", "text": "In the cyan zero-gravity bay you float. Hold Jump or Up to rise, Down to sink; let go to brake.", + "scene": {"id": "zero_g", "world": "orbital"}}, + {"heading": "Tips", "text": "Amber lasers and vents are a warning: cross while they're off.", + "scene": {"id": "orbital_tips", "world": "orbital"}}, + ], + "rows": _PLATFORMER_ROWS + [ + {"label": "Zero-G rise", "hint": "hold", "icon": "rise", "actions": ["jump", "move_up"], + "keys": [KEY_SPACE, KEY_W, KEY_UP], "pad": [JOY_BUTTON_A, STICK_UP]}, + {"label": "Zero-G sink", "hint": "hold", "icon": "sink", "actions": ["move_down"], + "keys": [KEY_S, KEY_DOWN], "pad": [STICK_DOWN, JOY_BUTTON_DPAD_DOWN]}, + ], + "poses": _PLATFORMER_POSES, + }, + "enchanted_repo": { + "title": "The Enchanted Repo", + "pages": [ + {"heading": "Your goal", "text": "Cross the forest and castle, defeat the Legacy Monolith, then land on or swipe the MERGE button.", + "scene": {"id": "platformer_goal", "world": "forest", "boss": FOREST + "legacy_monolith.tres", "boss_scale": 1.35}}, + _CONTROLS, + {"heading": "Tips", "text": "Branches crumble under you, so keep moving. Hit the Monolith while it recovers.", + "scene": {"id": "enchanted_tips", "world": "forest"}}, + ], + "rows": _PLATFORMER_ROWS, + "poses": _PLATFORMER_POSES, + }, + "runner_game": { + "title": "Endless Runner", + "pages": [ + {"heading": "Your goal", "text": "Outrun the backlog through the Codebase Corridor. Survive as long as you can for a high score.", + "scene": {"id": "slideshow", "images": [RUNNER + "runner_gameplay.png", RUNNER + "runner_boss.png"]}}, + _CONTROLS, + {"heading": "Tips", "text": "Dodge hazards and grab power-ups and tools. Two hits without recovering and the backlog catches you.", + "scene": {"id": "slideshow", "images": [RUNNER + "runner_dodge.png", RUNNER + "runner_powerup.png"]}}, + ], + "rows": [ + {"label": "Change lane", "icon": "lane", "actions": ["move_left", "move_right"], + "keys": [KEY_A, KEY_D, KEY_LEFT, KEY_RIGHT], "pad": [STICK, JOY_BUTTON_DPAD_LEFT, JOY_BUTTON_DPAD_RIGHT]}, + {"label": "Jump", "icon": "jump", "actions": ["jump", "move_up"], + "keys": [KEY_SPACE, KEY_W, KEY_UP], "pad": [JOY_BUTTON_A, JOY_BUTTON_DPAD_UP]}, + {"label": "Roll", "icon": "roll", "actions": ["roll", "move_down"], + "keys": [KEY_S, KEY_CTRL, KEY_DOWN], "pad": [JOY_BUTTON_B, JOY_BUTTON_DPAD_DOWN]}, + _PAUSE, + ], + "poses": { + "lane": [PLATFORMER_HOPPY, "run", "slide"], + "jump": [PLATFORMER_HOPPY, "jump"], + "roll": [PLATFORMER_HOPPY, "jump_apex", "spin"], + }, + }, + "code_city_brawl": { + "title": "Code City Brawl", + "pages": [ + {"heading": "Your goal", "text": "Fight through three districts of bugs and beat the Grand Merge Conflict.", + "scene": {"id": "brawl_goal", "world": "city"}}, + _CONTROLS, + {"heading": "Tips", "text": "Clear every enemy, then walk right. Jump or change lanes to dodge attacks. Coffee heals.", + "scene": {"id": "brawl_tips", "world": "city"}}, + ], + "rows": _BRAWL_ROWS + [_PAUSE], + "poses": { + "move": [PLATFORMER_HOPPY, "run"], + "jump": [BRAWL_HOPPY, "jump"], + "attack": [BRAWL_HOPPY, "jab"], + "pickup": [BRAWL_HOPPY, "pickup"], + }, + }, + "heroes_vs_villains": { + "title": "Heroes vs. Villains", + "pages": [ + {"heading": "Your goal", "text": "Defeat four villains and unlock CodeRabbit's capabilities to save the expo.", + "scene": {"id": "heroes_goal", "world": "heroes"}}, + _CONTROLS, + {"heading": "Tips", "text": "Clear each room, then walk through the right gate. Beating AI Slop unlocks REVIEW: hold Q / LB to stun nearby threats.", + "scene": {"id": "heroes_review", "world": "heroes"}}, + ], + "rows": _BRAWL_ROWS + [ + {"label": "REVIEW", "hint": "hold, once unlocked", "icon": "review", "actions": ["review_burst"], + "keys": [KEY_Q], "pad": [JOY_BUTTON_LEFT_SHOULDER]}, + {"label": "Skip card", "actions": ["heroes_popup_advance"], + "keys": [KEY_ENTER], "pad": [JOY_BUTTON_BACK]}, + _PAUSE, + ], + "poses": { + "move": [HEROES + "hoppy_locomotion.tres", "walk_down"], + "jump": [HEROES + "hoppy.tres", "jump"], + "attack": [HEROES + "hoppy.tres", "jab_action"], + "pickup": [HEROES + "hoppy.tres", "pickup"], + "review": [HEROES + "capabilities_fx.tres", "review_burst", "pulse"], + }, + }, +} + + +static func has_game(route: String) -> bool: + return GAMES.has(route) + + +static func game(route: String) -> Dictionary: + return GAMES.get(route, {}) + + +static func pages(route: String) -> Array: + return game(route).get("pages", []) diff --git a/hoppys-adventure-arcade/app/how_to_play_data.gd.uid b/hoppys-adventure-arcade/app/how_to_play_data.gd.uid new file mode 100644 index 00000000..9b7e82ae --- /dev/null +++ b/hoppys-adventure-arcade/app/how_to_play_data.gd.uid @@ -0,0 +1 @@ +uid://bav5ynow4sbaw diff --git a/hoppys-adventure-arcade/app/how_to_play_demo.gd b/hoppys-adventure-arcade/app/how_to_play_demo.gd new file mode 100644 index 00000000..40d36f5f --- /dev/null +++ b/hoppys-adventure-arcade/app/how_to_play_demo.gd @@ -0,0 +1,768 @@ +extends Control +## The little looping scenes on how-to-play pages (app/how_to_play.gd), acted out with each game's +## own sprite sheets: Hoppy beating the boss and pressing MERGE, swiping a bug, floating in zero-G. +## Each scene is one looping Tween bound to this node, so it runs while the game under the card is +## paused and stops when the page is freed. +## +## A scene is a scaled-down window onto the game's own screen, so it reads like the game: `world` +## takes game coordinates (the floor at y = FLOOR_Y, as in every level and arena), each actor keeps +## its game scale and its sheet's foot pivot, and the backdrop sits where the game draws it. The +## platformers get their sky, mid layer and ground tiles; the beat 'em ups get their room panorama, +## whose painted floor is where the actors walk. WORLDS holds each game's layers and how far the +## window zooms out (`zoom`). +## +## A page's `scene` dictionary picks the scene by `id` and the game by `world`: +## - platformer_goal: world (bay, orbital, forest), boss (SpriteFrames with idle and defeat clips) +## and boss_scale (its in-game scale) +## - blitz_tips (bay), zero_g and orbital_tips (orbital), enchanted_tips (forest), brawl_goal and +## brawl_tips (city), heroes_goal and heroes_review (heroes) +## - slideshow: images (screenshots, panned and cross-faded; the runner is 3D, so it has no sheets) +## +## `icon()` makes the small animated Hoppy beside a control row from a game's `poses` entry. + +const Data := preload("res://app/how_to_play_data.gd") +const OrbitalArt := preload("res://modes/platformer/orbital_art.gd") +const ForestArt := preload("res://modes/platformer/enchanted_art.gd") + +const SIZE := Vector2(820, 260) +## Where the game's floor line lands on the stage. +const GROUND := 236.0 +## The floor line in game coordinates: platformer ground tops and the beat 'em ups' front lane. +const FLOOR_Y := 600.0 +const SCENES := ["platformer_goal", "blitz_tips", "zero_g", "orbital_tips", "enchanted_tips", + "slideshow", "brawl_goal", "brawl_tips", "heroes_goal", "heroes_review"] +const ICON_SIZE := Vector2(52, 40) +const CAPTION := Color("fff4d6") +const REVIEW_TINT := preload("res://modes/beatem_up/reinvent/player.gd").REVIEW_TINT +const STUNNED := preload("res://modes/beatem_up/reinvent/enemy.gd").STUNNED_TINT +const HOPPY_GLOW := preload("res://modes/beatem_up/reinvent/player.gd").REVIEW_GLOW +## Each game's backdrop, as its level script draws it (scenes/platformer/*.gd, the beat 'em ups' +## room setup). `zoom` is stage pixels per game pixel. +const WORLDS := { + "bay": {"zoom": 0.8, "sky": Data.BAY + "backdrop.png", "shade": Color(0.03, 0.09, 0.15, 0.24), + "fill": Color("223c48"), "slab": "sidewalk"}, + "orbital": {"zoom": 0.8, "sky": Data.ORBIT + "starfield.png", "shade": Color(0.02, 0.035, 0.09, 0.3), + "fill": Color("101d35"), "slab": "floor_slab"}, + "forest": {"zoom": 0.8, "sky": Data.FOREST + "sky.png", "shade": Color(0.025, 0.05, 0.06, 0.35), + "fill": Color("252f35"), "slab": "ground_slab"}, + "city": {"zoom": 0.45, "room": Data.CITY + "district_background.png"}, + "heroes": {"zoom": 0.55, "room": Data.HEROES + "generation_floor.png"}, +} +## Foot offsets the games set by hand rather than from a sheet manifest. +const FOOT := { + "platformer.tres": -40.0, "combat.tres": -38.0, "hazards.tres:orbital": -54.0, "interactives.tres": -51.0, +} + +var spec: Dictionary +var world: Node2D +var caption: Label +var loop: Tween +var zoom := 1.0 +static var manifests := {} + + +## A scene for a page's `scene` entry. +static func build(scene: Dictionary) -> Control: + var demo: Control = load("res://app/how_to_play_demo.gd").new() + demo.spec = scene + return demo + + +## A row's little animated Hoppy for a `poses` entry: [sheet, clip, motion?]. +static func icon(entry: Array) -> Control: + var box := Control.new() + box.custom_minimum_size = ICON_SIZE + box.size_flags_vertical = Control.SIZE_SHRINK_CENTER + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + var frames: SpriteFrames = load(str(entry[0])) + var sprite := AnimatedSprite2D.new() + sprite.sprite_frames = frames + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + var clip := StringName(entry[1]) + var height := frames.get_frame_texture(clip, 0).get_size().y + # Sheet frames carry padding around the figure, so fill a bit past the box. + var zoom := ICON_SIZE.y * 1.35 / height + if entry[0].ends_with("capabilities_fx.tres"): + zoom = ICON_SIZE.y * 1.1 / height + sprite.scale = Vector2.ONE * zoom + sprite.position = ICON_SIZE / 2.0 + box.add_child(sprite) + _replay(sprite, clip) + var motion := str(entry[2]) if entry.size() > 2 else "" + if not motion.is_empty(): + # Tweens need the tree; the row isn't in it yet. + sprite.tree_entered.connect(_move.bind(sprite, motion, zoom), CONNECT_ONE_SHOT) + return box + + +static func _move(sprite: AnimatedSprite2D, motion: String, zoom: float) -> void: + var tween := sprite.create_tween().set_loops() + var home := sprite.position + match motion: + "slide": + tween.tween_property(sprite, "position:x", home.x - 10.0, 0.35).set_trans(Tween.TRANS_SINE) + tween.tween_property(sprite, "position:x", home.x + 10.0, 0.7).set_trans(Tween.TRANS_SINE) + tween.tween_property(sprite, "position:x", home.x, 0.35).set_trans(Tween.TRANS_SINE) + "rise", "sink": + var step := -7.0 if motion == "rise" else 7.0 + sprite.position.y = home.y - step + tween.tween_property(sprite, "position:y", home.y + step, 0.9).set_trans(Tween.TRANS_SINE) + tween.tween_property(sprite, "modulate:a", 0.0, 0.15) + tween.tween_callback(func() -> void: sprite.position.y = home.y - step) + tween.tween_property(sprite, "modulate:a", 1.0, 0.15) + "spin": + tween.tween_property(sprite, "rotation", TAU, 0.6).from(0.0) + tween.tween_interval(0.5) + "pulse": + tween.tween_property(sprite, "scale", Vector2.ONE * zoom * 0.7, 0.45).set_trans(Tween.TRANS_SINE) + tween.tween_property(sprite, "scale", Vector2.ONE * zoom, 0.45).set_trans(Tween.TRANS_SINE) + + +## Plays `clip`, and plays it again after a beat when the sheet doesn't loop it. +static func _replay(sprite: AnimatedSprite2D, clip: StringName) -> void: + sprite.play(clip) + if sprite.sprite_frames.get_animation_loop(clip): + return + sprite.animation_finished.connect(func() -> void: + var tween := sprite.create_tween() + tween.tween_interval(0.35) + tween.tween_callback(func() -> void: sprite.play(clip))) + + +func _ready() -> void: + custom_minimum_size = SIZE + clip_contents = true + mouse_filter = Control.MOUSE_FILTER_IGNORE + var images: Array = spec.get("images", []) + if not images.is_empty(): + world = Node2D.new() + add_child(world) + _slideshow(images) + return + var setting: Dictionary = WORLDS[str(spec.world)] + zoom = setting.zoom + # Centre the game's 1280-wide screen on the stage, floor line on GROUND. + var origin := Vector2((SIZE.x - 1280.0 * zoom) / 2.0, GROUND - FLOOR_Y * zoom) + if setting.has("room"): + _room(str(setting.room), origin) + else: + _sky(setting, origin) + world = Node2D.new() + world.position = origin + world.scale = Vector2.ONE * zoom + add_child(world) + caption = Label.new() + caption.add_theme_font_size_override("font_size", 17) + caption.add_theme_color_override("font_color", CAPTION) + caption.add_theme_color_override("font_outline_color", Color(0.02, 0.04, 0.07)) + caption.add_theme_constant_override("outline_size", 6) + caption.position = Vector2(16, 10) + add_child(caption) + loop = create_tween().set_loops() + call(StringName("_" + str(spec.id))) + + +## A platformer's screen-fixed backdrop: the sky plate covering the 1280x720 screen and its shade. +func _sky(setting: Dictionary, origin: Vector2) -> void: + var plate := TextureRect.new() + plate.texture = load(str(setting.sky)) + plate.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + plate.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + plate.position = origin + plate.size = Vector2(1280, 720) * zoom + plate.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(plate) + var shade := ColorRect.new() + shade.color = setting.shade + shade.size = SIZE + shade.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(shade) + + +## A beat 'em up room: its panorama at 1:1 game scale, repeated the way the arena lays rooms side +## by side. Its painted floor band (y 455-650) is where the actors walk. +func _room(path: String, origin: Vector2) -> void: + var texture: Texture2D = load(path) + for index in range(-1, 2): + var room := Sprite2D.new() + room.texture = texture + room.centered = false + room.position = origin + Vector2(index * 1280.0 * zoom, 0) + room.scale = Vector2.ONE * zoom + add_child(room) + + +## A stretch of the level's ground from `from_x` to `to_x`: the dark fill plus the top tiles, the +## way each platformer's platform() draws it. +func ground(from_x: float, to_x: float) -> void: + var setting: Dictionary = WORLDS[str(spec.world)] + var fill := Polygon2D.new() + fill.polygon = PackedVector2Array([Vector2(from_x, FLOOR_Y), Vector2(to_x, FLOOR_Y), Vector2(to_x, FLOOR_Y + 160), Vector2(from_x, FLOOR_Y + 160)]) + fill.color = setting.fill + world.add_child(fill) + var slab := str(setting.slab) + var tile: Texture2D + var tile_size: Vector2 + match str(spec.world): + "bay": + var whole := load("res://Assets/bay-area-blitz-assets-only/individual/street_kit/" + slab + ".png") as Texture2D + var crop := AtlasTexture.new() + crop.atlas = whole + crop.region = whole.get_image().get_used_rect() + tile = crop + tile_size = Vector2(96, 32) + "orbital": + tile = OrbitalArt.crop("station_kit", slab) + tile_size = Vector2(128, 40) + _: + tile = ForestArt.crop("forest_kit", slab) + tile_size = Vector2(128, 68) + var x := from_x + while x < to_x: + var piece := Sprite2D.new() + piece.texture = tile + piece.centered = false + piece.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + piece.position = Vector2(x, FLOOR_Y) + var width := minf(tile_size.x, to_x - x) + piece.scale = Vector2(width, tile_size.y) / tile.get_size() + world.add_child(piece) + x += tile_size.x + + +## A sprite at game position `at` and game scale `size`, anchored at its feet the way the game +## anchors it (FOOT, else the sheet manifest's pivot, else 40% of the frame above centre). Sheets +## face the way they're drawn: Hoppy right, every enemy and boss left (the games flip an enemy only +## to face right); `mirror` flips it. +func actor(sheet: String, clip: String, at: Vector2, size := 1.0, mirror := false) -> AnimatedSprite2D: + var sprite := AnimatedSprite2D.new() + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sprite.scale = Vector2.ONE * size + sprite.flip_h = mirror + sprite.position = at + world.add_child(sprite) + _wear(sprite, sheet, clip) + return sprite + + +func _wear(sprite: AnimatedSprite2D, sheet: String, clip: String) -> void: + sprite.sprite_frames = load(sheet) + sprite.offset = foot(sheet, sprite.sprite_frames.get_frame_texture(clip, 0).get_size()) + sprite.play(clip) + + +## The offset that puts a frame's feet on the sprite's position. +static func foot(sheet: String, frame: Vector2) -> Vector2: + var file := sheet.get_file() + var key := file + (":orbital" if sheet.contains("orbital") else "") + if FOOT.has(key): + return Vector2(0, FOOT[key]) + var folder := sheet.get_base_dir() + "/manifest.json" + if not manifests.has(folder): + manifests[folder] = JSON.parse_string(FileAccess.get_file_as_string(folder)) if FileAccess.file_exists(folder) else {} + var atlases: Dictionary = manifests[folder].get("atlases", {}) if manifests[folder] is Dictionary else {} + var entry: Dictionary = atlases.get(file.get_basename(), {}) + if entry.has("pivot"): + var cell: Array = entry.get("cell", [frame.x, frame.y]) + return Vector2(float(cell[0]) / 2.0 - float(entry.pivot[0]), float(cell[1]) / 2.0 - float(entry.pivot[1])) + return Vector2(0, -frame.y * 0.4) + + +# -- Steps on the scene's loop --------------------------------------------------------------------- + +func play(sprite: AnimatedSprite2D, clip: String) -> void: + loop.tween_callback(func() -> void: sprite.play(clip)) + + +func pose(sprite: AnimatedSprite2D, sheet: String, clip: String) -> void: + loop.tween_callback(_wear.bind(sprite, sheet, clip)) + + +func move(sprite: Node2D, to: Vector2, seconds: float) -> PropertyTweener: + return loop.tween_property(sprite, "position", to, seconds) + + +func with(sprite: Node2D, to: Vector2, seconds: float) -> void: + loop.parallel().tween_property(sprite, "position", to, seconds) + + +func hop(sprite: Node2D, from: Vector2, to: Vector2, seconds: float, height: float) -> void: + loop.tween_method(func(t: float) -> void: + sprite.position = from.lerp(to, t) + Vector2(0, -height * 4.0 * t * (1.0 - t)), 0.0, 1.0, seconds) + + +func pause_for(seconds: float) -> void: + loop.tween_interval(seconds) + + +func fade(sprite: CanvasItem, to: float, seconds: float) -> void: + loop.tween_property(sprite, "modulate:a", to, seconds) + + +func fade_with(sprite: CanvasItem, to: float, seconds: float) -> void: + loop.parallel().tween_property(sprite, "modulate:a", to, seconds) + + +## A white flash on a hit. +func flash(sprite: CanvasItem) -> void: + loop.tween_callback(func() -> void: + sprite.modulate = Color(2.4, 2.4, 2.4, sprite.modulate.a) + sprite.create_tween().tween_property(sprite, "modulate", Color(1, 1, 1, sprite.modulate.a), 0.22)) + + +## A pickup popping: it swells and fades. +func pop(sprite: Node2D) -> void: + loop.tween_callback(func() -> void: + var base := sprite.scale + var tween := sprite.create_tween() + tween.tween_property(sprite, "scale", base * 1.6, 0.2) + tween.parallel().tween_property(sprite, "modulate:a", 0.0, 0.2) + tween.tween_callback(func() -> void: sprite.scale = base)) + + +func say(text: String) -> void: + loop.tween_callback(func() -> void: caption.text = text) + + +func reset(each: Callable) -> void: + loop.tween_callback(each) + + +## A bob that runs on its own, for pickups hanging in the air. +func bob(sprite: Node2D) -> void: + var tween := sprite.create_tween().set_loops() + var y := sprite.position.y + tween.tween_property(sprite, "position:y", y - 6.0, 0.5).set_trans(Tween.TRANS_SINE) + tween.tween_property(sprite, "position:y", y, 0.5).set_trans(Tween.TRANS_SINE) + + +# -- Scenes (game coordinates; the floor is FLOOR_Y) ------------------------------------------------ + +## Hoppy runs in and swipes the boss down; the dim MERGE button lights up and he lands on it. +func _platformer_goal() -> void: + ground(-200, 1500) + var start := Vector2(220, FLOOR_Y) + var hoppy := actor(Data.PLATFORMER_HOPPY, "run", start) + var boss := actor(str(spec.boss), "idle", Vector2(830, FLOOR_Y), float(spec.get("boss_scale", 1.0))) + var merge := actor(Data.BAY + "interactives.tres", "exit_ready", Vector2(1070, FLOOR_Y)) + var swipe_at := Vector2(700, FLOOR_Y) + var button_top := Vector2(1070, 540.0) # level.gd EXIT_TOP + var locked := Color(0.45, 0.45, 0.45) + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + hoppy.play("run") + boss.modulate = Color.WHITE + boss.play("idle") + merge.modulate = locked + merge.play("exit_ready")) + say("Reach the boss") + move(hoppy, swipe_at, 1.4) + say("Swipe it down") + for hit in 3: + play(hoppy, "attack") + flash(boss) + pause_for(0.45) + play(boss, "defeat") + play(hoppy, "idle") + pause_for(0.5) + fade(boss, 0.0, 0.5) + say("Then land on MERGE") + loop.tween_property(merge, "modulate", Color.WHITE, 0.3) + play(hoppy, "run") + move(hoppy, Vector2(930, FLOOR_Y), 0.5) + play(hoppy, "jump") + hop(hoppy, Vector2(930, FLOOR_Y), button_top, 0.55, 110.0) + play(merge, "exit_pressed") + play(hoppy, "idle") + pause_for(0.35) + play(merge, "exit_complete") + say("Level complete!") + pause_for(1.4) + fade(hoppy, 0.0, 0.3) + + +## Hoppy grabs a token, swipes a glitch critter (Bay Area Blitz has no stomping), grabs another. +func _blitz_tips() -> void: + ground(-200, 1500) + var hearts := Node2D.new() + hearts.position = Vector2(SIZE.x - 120, 26) + add_child(hearts) + for index in 3: + var heart := Sprite2D.new() + heart.texture = load("res://Assets/bay-area-blitz-assets-only/individual/hud/heart_full.png") + heart.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + heart.position = Vector2(index * 36, 0) + hearts.add_child(heart) + var start := Vector2(220, FLOOR_Y) + var hoppy := actor(Data.PLATFORMER_HOPPY, "run", start) + var critter := actor(Data.BAY + "glitch_critter.tres", "scuttle", Vector2(1150, FLOOR_Y)) + var first := actor(Data.BAY + "pickups.tres", "commit_token", Vector2(440, 470)) + var second := actor(Data.BAY + "pickups.tres", "commit_token", Vector2(960, 530)) + bob(first) + bob(second) + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + hoppy.play("run") + critter.position = Vector2(1150, FLOOR_Y) + critter.modulate = Color.WHITE + critter.play("scuttle") + for token in [first, second]: + token.modulate = Color.WHITE) + say("Grab tokens") + move(hoppy, Vector2(330, FLOOR_Y), 0.6) + with(critter, Vector2(900, FLOOR_Y), 0.6) + play(hoppy, "jump") + hop(hoppy, Vector2(330, FLOOR_Y), Vector2(540, FLOOR_Y), 0.6, 150.0) + with(critter, Vector2(700, FLOOR_Y), 0.6) + pop(first) + play(hoppy, "idle") + say("Swipe bugs") + move(critter, Vector2(620, FLOOR_Y), 0.4) + play(critter, "idle") + for hit in 2: + play(hoppy, "attack") + flash(critter) + pause_for(0.4) + play(critter, "defeat") + fade(critter, 0.0, 0.5) + play(hoppy, "run") + move(hoppy, Vector2(960, FLOOR_Y), 0.8) + pop(second) + play(hoppy, "idle") + pause_for(1.3) + fade(hoppy, 0.0, 0.3) + + +## Hoppy floats in the zero-gravity bay: rise, let go to brake, sink. +func _zero_g() -> void: + ground(-200, 1500) + var bay := Bay.new() + bay.area = Rect2(390, 140, 500, 460) + world.add_child(bay) + actor(Data.ORBIT + "hazards.tres", "zero_g_beacon", Vector2(400, FLOOR_Y)) + actor(Data.ORBIT + "hazards.tres", "zero_g_beacon", Vector2(880, FLOOR_Y)) + var low := Vector2(640, FLOOR_Y) + var high := Vector2(640, 410) + var hoppy := actor(Data.PLATFORMER_HOPPY, "jump_apex", low) + reset(func() -> void: + hoppy.position = low + hoppy.play("jump_apex")) + say("Hold Space / A to rise") + play(hoppy, "jump_rise") + move(hoppy, high, 1.3).set_trans(Tween.TRANS_SINE) + say("Let go to brake") + play(hoppy, "jump_apex") + move(hoppy, high + Vector2(0, -10), 0.9).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) + say("Hold S / Stick ↓ to sink") + play(hoppy, "jump_fall") + move(hoppy, low, 1.3).set_trans(Tween.TRANS_SINE) + say("Let go to brake") + play(hoppy, "jump_apex") + pause_for(0.9) + + +## A laser cycles; Hoppy waits for it to switch off, crosses, then hops a vent before it vents. +func _orbital_tips() -> void: + ground(-200, 1500) + # The game draws the beam itself (orbital_hazard.gd); the sheet only has the emitter. + var beam := Beam.new() + beam.position = Vector2(700, FLOOR_Y) + world.add_child(beam) + var laser := actor(Data.ORBIT + "hazards.tres", "laser_active", Vector2(700, FLOOR_Y)) + var vent := actor(Data.ORBIT + "hazards.tres", "vent_closed", Vector2(960, FLOOR_Y)) + var start := Vector2(250, FLOOR_Y) + var hoppy := actor(Data.PLATFORMER_HOPPY, "idle", start) + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + hoppy.play("idle") + laser.play("laser_active") + beam.show_state("active") + vent.play("vent_closed")) + say("Laser on: wait") + pause_for(1.2) + play(laser, "laser_cooldown") + loop.tween_callback(beam.show_state.bind("cooldown")) + pause_for(0.35) + play(laser, "laser_off") + loop.tween_callback(beam.show_state.bind("off")) + say("Off: go!") + play(hoppy, "run") + move(hoppy, Vector2(830, FLOOR_Y), 0.9) + play(laser, "laser_warning") + loop.tween_callback(beam.show_state.bind("warning")) + play(vent, "vent_warning") + say("Amber = about to fire") + play(hoppy, "jump") + hop(hoppy, Vector2(830, FLOOR_Y), Vector2(1100, FLOOR_Y), 0.6, 140.0) + play(vent, "vent_active") + play(laser, "laser_active") + loop.tween_callback(beam.show_state.bind("active")) + play(hoppy, "idle") + pause_for(1.2) + fade(hoppy, 0.0, 0.3) + + +## Hoppy hops across branches over a gap; each cracks under him and falls once he leaves. +func _enchanted_tips() -> void: + ground(-200, 400) + ground(900, 1500) + var spots := [Vector2(520, 520), Vector2(760, 460)] + var branches: Array[Sprite2D] = [] + for spot: Vector2 in spots: + var branch := Sprite2D.new() + branch.centered = false + branch.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + world.add_child(branch) + branches.append(branch) + # crumbling_branch.gd: a 180x48 crop whose top edge is the surface Hoppy stands on. + var show := func(branch: Sprite2D, spot: Vector2, clip: String) -> void: + branch.texture = ForestArt.crop("hazards", clip) + branch.position = spot - Vector2(90, 0) + branch.scale = Vector2(180, 48) / branch.texture.get_size() + var start := Vector2(200, FLOOR_Y) + var hoppy := actor(Data.PLATFORMER_HOPPY, "run", start) + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + hoppy.play("run") + for index in branches.size(): + show.call(branches[index], spots[index], "branch_intact") + branches[index].modulate = Color.WHITE) + say("Branches crumble under you") + move(hoppy, Vector2(340, FLOOR_Y), 0.4) + var from := Vector2(340, FLOOR_Y) + for index in branches.size(): + play(hoppy, "jump") + hop(hoppy, from, spots[index], 0.5, 80.0) + play(hoppy, "idle") + loop.tween_callback(show.bind(branches[index], spots[index], "branch_warning")) + if index > 0: + loop.tween_callback(show.bind(branches[index - 1], spots[index - 1], "branch_fall")) + loop.tween_property(branches[index - 1], "position:y", 900.0, 0.5).set_ease(Tween.EASE_IN) + else: + pause_for(0.35) + from = spots[index] + say("Keep moving!") + play(hoppy, "jump") + hop(hoppy, from, Vector2(1040, FLOOR_Y), 0.55, 70.0) + loop.tween_callback(show.bind(branches[-1], spots[-1], "branch_fall")) + loop.tween_property(branches[-1], "position:y", 900.0, 0.5).set_ease(Tween.EASE_IN) + play(hoppy, "idle") + pause_for(1.2) + fade(hoppy, 0.0, 0.3) + + +## Hoppy walks in, combos a soldier down, and the Grand Merge Conflict looms at the room's end. +func _brawl_goal() -> void: + var start := Vector2(160, FLOOR_Y) + # Walking sideways, the game plays the base sheet's run (beatem_up/player.gd); walk_up and + # walk_down are only for changing lanes. + var hoppy := actor(Data.PLATFORMER_HOPPY, "run", start, 1.55) + var soldier := actor(Data.CITY + "merge_conflict_soldier.tres", "walk", Vector2(1250, FLOOR_Y), 1.7) + # beatem_up/game.gd: the boss stands at room x 965, y 565, at 2.6x. + var boss := actor(Data.CITY + "conflict_spawner.tres", "idle", Vector2(1080, 565), 2.6) + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + _wear(hoppy, Data.PLATFORMER_HOPPY, "run") + soldier.position = Vector2(1250, FLOOR_Y) + soldier.modulate = Color.WHITE + soldier.play("walk") + boss.modulate.a = 0.0) + say("Beat every bug") + move(hoppy, Vector2(470, FLOOR_Y), 1.2) + with(soldier, Vector2(610, FLOOR_Y), 1.2) + play(soldier, "idle") + say("Jab, jab, kick!") + for clip in ["jab", "jab", "kick"]: + pose(hoppy, Data.BRAWL_HOPPY, clip) + flash(soldier) + pause_for(0.32) + play(soldier, "defeat") + loop.tween_property(soldier, "position:x", 760.0, 0.35) + fade_with(soldier, 0.0, 0.5) + pose(hoppy, Data.BRAWL_HOPPY, "idle") + say("Then the Grand Merge Conflict") + fade(boss, 1.0, 0.5) + pause_for(1.6) + fade(hoppy, 0.0, 0.3) + fade_with(boss, 0.0, 0.3) + + +## Change lanes to dodge a sword, drink a coffee, pick up a weapon. +func _brawl_tips() -> void: + var back := 520.0 + var start := Vector2(330, back) + var soldier := actor(Data.CITY + "merge_conflict_soldier.tres", "idle", Vector2(780, back), 1.7) + # beatem_up/game.gd draws pickups at 0.65x, centred on their spot. + var coffee := actor(Data.CITY + "items_allies.tres", "coffee", Vector2(620, FLOOR_Y - 30), 0.65) + var stick := actor(Data.CITY + "weapons.tres", "debug_stick", Vector2(980, FLOOR_Y - 30), 0.65) + var hoppy := actor(Data.BRAWL_HOPPY, "idle", start, 1.55) + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + _wear(hoppy, Data.BRAWL_HOPPY, "idle") + soldier.play("idle") + for item in [coffee, stick]: + item.modulate = Color.WHITE) + say("Sword coming…") + play(soldier, "sword_warning") + pause_for(0.6) + say("Change lanes to dodge") + play(hoppy, "walk_down") + move(hoppy, Vector2(330, FLOOR_Y), 0.35) + play(soldier, "sword") + pause_for(0.5) + play(soldier, "idle") + say("Coffee heals") + pose(hoppy, Data.PLATFORMER_HOPPY, "run") + move(hoppy, Vector2(620, FLOOR_Y), 0.6) + pop(coffee) + flash(hoppy) + say("Pick up weapons") + move(hoppy, Vector2(960, FLOOR_Y), 0.6) + pose(hoppy, Data.BRAWL_HOPPY, "pickup") + pop(stick) + pause_for(1.3) + fade(hoppy, 0.0, 0.3) + + +## Hoppy runs in and combos two AI Slop sprites while the boss watches. +func _heroes_goal() -> void: + var start := Vector2(150, FLOOR_Y) + var hoppy := actor(Data.HEROES + "hoppy_locomotion.tres", "run", start, 1.65) + # reinvent/game.gd: AI Slop stands at room x 945, y 565, at 1.6x; its minions at 1.7x. + var boss := actor(Data.HEROES + "ai_slop.tres", "idle", Vector2(1080, 565), 1.6) + var slops: Array[AnimatedSprite2D] = [ + actor(Data.HEROES + "slop_sprite.tres", "idle", Vector2(640, FLOOR_Y), 1.7), + actor(Data.HEROES + "slop_sprite.tres", "idle", Vector2(820, FLOOR_Y), 1.7)] + reset(func() -> void: + hoppy.position = start + hoppy.modulate = Color.WHITE + _wear(hoppy, Data.HEROES + "hoppy_locomotion.tres", "run") + boss.play("idle") + for slop in slops: + slop.modulate = Color.WHITE + slop.play("idle")) + say("Fight through each room") + var at := start + for index in slops.size(): + var stop := slops[index].position - Vector2(130, 0) + pose(hoppy, Data.HEROES + "hoppy_locomotion.tres", "run") + move(hoppy, stop, 0.4 + (stop.x - at.x) / 1100.0) + at = stop + for clip in ["jab_action", "kick_action"]: + pose(hoppy, Data.HEROES + "hoppy.tres", clip) + flash(slops[index]) + pause_for(0.35) + play(slops[index], "defeat") + fade(slops[index], 0.0, 0.3) + say("Then take on the villain") + pose(hoppy, Data.HEROES + "hoppy.tres", "victory") + play(boss, "hurt") + flash(boss) + pause_for(1.5) + fade(hoppy, 0.0, 0.3) + + +## Bugs close in; Hoppy holds REVIEW and the burst stuns them all. +func _heroes_review() -> void: + var center := Vector2(640, FLOOR_Y) + var hoppy := actor(Data.HEROES + "hoppy.tres", "idle", center, 1.65) + var outer := [140.0, 310.0, 970.0, 1140.0] + var inner := [400.0, 480.0, 800.0, 880.0] + var slops: Array[AnimatedSprite2D] = [] + for index in outer.size(): + slops.append(actor(Data.HEROES + "slop_sprite.tres", "idle", Vector2(outer[index], FLOOR_Y), 1.7, outer[index] < center.x)) + # reinvent/game.gd review_burst: the effect at 2x on Hoppy. + var burst := actor(Data.HEROES + "capabilities_fx.tres", "review_burst", center + Vector2(0, -60), 0.4) + # REVIEW's colour (reinvent/game.gd REVIEW_TINT): a mint burst behind everyone, so it reads as + # Hoppy's power and not a hit on him. + burst.self_modulate = REVIEW_TINT + world.move_child(burst, 0) + reset(func() -> void: + hoppy.play("idle") + hoppy.modulate = Color.WHITE + burst.modulate.a = 0.0 + burst.scale = Vector2.ONE * 0.4 + for index in slops.size(): + slops[index].position.x = outer[index] + slops[index].modulate = Color.WHITE + slops[index].play("idle")) + say("Bugs close in…") + for index in slops.size(): + loop.parallel().tween_property(slops[index], "position:x", inner[index], 1.1) + for slop in slops: + play(slop, "attack_warning") + say("Hold Q / LB: REVIEW") + play(hoppy, "scanner") + loop.tween_property(hoppy, "modulate", HOPPY_GLOW, 0.2) + pause_for(0.3) + loop.tween_property(burst, "modulate:a", 1.0, 0.1) + loop.parallel().tween_property(burst, "scale", Vector2.ONE * 2.0, 0.5) + for index in slops.size(): + var away := 90.0 if inner[index] > center.x else -90.0 + loop.parallel().tween_property(slops[index], "position:x", inner[index] + away, 0.4) + loop.parallel().tween_property(slops[index], "modulate", STUNNED, 0.3) + for slop in slops: + play(slop, "idle") + fade(burst, 0.0, 0.3) + loop.parallel().tween_property(hoppy, "modulate", Color.WHITE, 0.3) + say("Stunned!") + play(hoppy, "victory") + pause_for(1.4) + + +## Screenshots, each slowly zooming, cross-faded in turn. +func _slideshow(images: Array) -> void: + var frames: Array[TextureRect] = [] + for path in images: + var image := TextureRect.new() + image.texture = load(str(path)) + image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + image.size = SIZE + image.mouse_filter = Control.MOUSE_FILTER_IGNORE + image.modulate.a = 0.0 + add_child(image) + image.pivot_offset = SIZE / 2.0 + frames.append(image) + loop = create_tween().set_loops() + for image in frames: + loop.tween_callback(func() -> void: image.scale = Vector2.ONE) + loop.tween_property(image, "modulate:a", 1.0, 0.4) + loop.parallel().tween_property(image, "scale", Vector2.ONE * 1.08, 3.2) + loop.tween_property(image, "modulate:a", 0.0, 0.4) + + +## A laser beam drawn the way orbital_hazard.gd draws it: solid red when on, an amber dashed line +## as the warning, nothing when off. +class Beam extends Node2D: + var state := "active" + + func show_state(next: String) -> void: + state = next + queue_redraw() + + func _draw() -> void: + if state == "active": + draw_line(Vector2(0, -260), Vector2(0, -28), Color(1, .17, .3, .25), 28) + draw_line(Vector2(0, -260), Vector2(0, -28), Color("ff5369"), 14) + draw_line(Vector2(0, -260), Vector2(0, -28), Color("fff2cf"), 4) + elif state == "warning": + for y in range(-260, -28, 24): + draw_line(Vector2(0, y), Vector2(0, y + 12), Color("ffc769"), 3) + + +## The zero-gravity bay as orbital_ops.gd draws it: a faint cyan fill with dashed edges. +class Bay extends Node2D: + var area := Rect2() + + func _draw() -> void: + draw_rect(area, Color(.1, .6, .8, .065)) + for x in [area.position.x, area.end.x]: + for y in range(int(area.position.y) + 10, int(area.end.y), 28): + draw_line(Vector2(x, y), Vector2(x, y + 14), Color(.3, .9, 1, .6), 3) diff --git a/hoppys-adventure-arcade/app/how_to_play_demo.gd.uid b/hoppys-adventure-arcade/app/how_to_play_demo.gd.uid new file mode 100644 index 00000000..79a4365b --- /dev/null +++ b/hoppys-adventure-arcade/app/how_to_play_demo.gd.uid @@ -0,0 +1 @@ +uid://dlvyhtdd2eet5 diff --git a/hoppys-adventure-arcade/modes/beatem_up/game.gd b/hoppys-adventure-arcade/modes/beatem_up/game.gd index f98756de..1c8fa5a5 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/game.gd @@ -12,6 +12,7 @@ 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 HowToPlay = preload("res://app/how_to_play.gd") 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","boss_victory","finisher","knockout"] @@ -903,6 +904,11 @@ func show_overlay(title: String, detail: String, action: String, callback: Calla primary = Style.button(action,58) panel.add_child(primary) primary.pressed.connect(callback) + if state == "paused": + var how_to := Style.button("How to Play",54) + panel.add_child(how_to) + how_to.pressed.connect(func() -> void: + if state == "paused": HowToPlay.open(get_tree(), "code_city_brawl", HowToPlay.Mode.PAUSE, how_to)) if state != "won": var back := Style.button("Return to Beat'em Up menu",54) panel.add_child(back) @@ -984,6 +990,11 @@ func _unhandled_input(event: InputEvent) -> void: elif state=="paused": resume() get_viewport().set_input_as_handled() +## True while the player has the game paused. The how-to-play card checks this before unpausing, +## so focus loss under the card leaves the pause menu working. +func is_player_paused() -> bool: + return state == "paused" + func resume() -> void: if state!="paused": return get_tree().paused = false diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/enemy.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/enemy.gd index 02e023ba..eef112fd 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/enemy.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/enemy.gd @@ -1,6 +1,8 @@ extends CharacterBody2D const Art = preload("res://modes/beatem_up/reinvent/art.gd") const Catalog = preload("res://modes/beatem_up/reinvent/catalog.gd") +## Enemies stunned by REVIEW: a cool blue, so the burst visibly lands on them. +const STUNNED_TINT := Color(0.6, 0.8, 1.3) var arena: Node2D var kind := "slop_sprite" var hp := 1.0 @@ -122,6 +124,7 @@ func update_visual() -> void: # Hidden threats retain faint silhouettes and bright committed attack warnings. body.modulate = Color(1,1,1,.16 if hidden_enemy and not revealed and state not in ["tell","attack","hurt","stunned"] else 1.0) if state=="tell": body.modulate=Color(1.4,1.05,.65,1) + if state=="stunned": body.modulate=STUNNED_TINT queue_redraw() func take_hit(damage: float, force: float, from: Vector2, finisher := false) -> bool: diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd index 7ecdf18c..4f713a45 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd @@ -7,6 +7,7 @@ const Boss = preload("res://modes/beatem_up/reinvent/boss.gd") const StageFlow = preload("res://modes/beatem_up/stage_flow.gd") const Knockout = preload("res://modes/beatem_up/knockout.gd") const Style = preload("res://scenes/main_menu/menu_style.gd") +const HowToPlay = preload("res://app/how_to_play.gd") const VictoryScreen = preload("res://modes/beatem_up/victory_screen.gd") const Minimap = preload("res://modes/beatem_up/minimap.gd") ## The minimap is up while the CodeRabbit is in a room, and hidden under cards and end screens. @@ -822,6 +823,11 @@ func show_overlay(title: String, detail: String, action: String, callback: Calla primary = Style.button(action,58) panel.add_child(primary) primary.pressed.connect(callback) + if state == "paused": + var how_to := Style.button("How to Play",54) + panel.add_child(how_to) + how_to.pressed.connect(func() -> void: + if state == "paused": HowToPlay.open(get_tree(), "heroes_vs_villains", HowToPlay.Mode.PAUSE, how_to)) if state != "won": var back := Style.button("Return to Beat'em Up menu",54) panel.add_child(back) @@ -856,6 +862,11 @@ func _unhandled_input(event: InputEvent) -> void: if card_clock>.25: next_card() get_viewport().set_input_as_handled() +## True while the player has the game paused. The how-to-play card checks this before unpausing, +## so focus loss under the card leaves the pause menu working. +func is_player_paused() -> bool: + return state == "paused" + func resume() -> void: if state!="paused": return get_tree().paused=false @@ -942,7 +953,17 @@ func clear_owned_threats(owner_node: Node) -> void: warnings[i].node.queue_free();warnings.remove_at(i) func review_burst(pos: Vector2) -> void: - fx("capabilities_fx","review_burst",pos,2.0,.65) + # Mint (REVIEW_TINT) and under every actor, so it reads as Hoppy's power reaching out, not a hit + # on him; stunned enemies turn blue (enemy.gd STUNNED_TINT). + var burst := Art.sprite("capabilities_fx","review_burst",.8,false) + burst.position = pos + burst.self_modulate = Player.REVIEW_TINT + # On the floor layer: over the room panorama, under every actor. + ground.add_child(burst) + var grow := burst.create_tween() + grow.tween_property(burst,"scale",Vector2.ONE*2.2,.3).set_ease(Tween.EASE_OUT) + grow.parallel().tween_property(burst,"modulate:a",0.0,.65) + grow.tween_callback(burst.queue_free) sound("unlock",pos) for enemy in enemies+bosses: if is_instance_valid(enemy) and not enemy.dead and enemy.position.distance_to(pos)<245: enemy.stun(1.5 if not enemy.is_boss else .7) diff --git a/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd b/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd index 9edc4118..beb6bc70 100644 --- a/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd +++ b/hoppys-adventure-arcade/modes/beatem_up/reinvent/player.gd @@ -13,6 +13,10 @@ var body: AnimatedSprite2D var held: Node2D var visual_weapon := "" var block_time := 0.0 +## REVIEW's colour: the burst (reinvent/game.gd review_burst) and the card's REVIEW scene use it. +const REVIEW_TINT := Color("79e0c4") +## Hoppy while REVIEW protects him. +const REVIEW_GLOW := Color(0.8, 1.25, 1.1) 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 @@ -40,6 +44,9 @@ var knock := Vector2.ZERO var controlled := true var review_charge := 0.0 var review_cooldown := 0.0 +## REVIEW's own protection after it fires: hits can't land, and unlike the post-hit `invulnerable` +## window Hoppy doesn't blink (the blink means "you were hit"); he glows REVIEW_GLOW instead. +var review_guard := 0.0 var pose_dt := 0.0 var pose_move := Vector2.ZERO ## Drawn unarmed with no carry (knockout.gd sets it while he is airborne); the weapon is kept. @@ -71,6 +78,7 @@ func tick(dt: float) -> void: else: review_charge = 0.0 block_time = maxf(0,block_time-dt) invulnerable = maxf(0,invulnerable-dt) + review_guard = maxf(0,review_guard-dt) stagger = maxf(0,stagger-dt) input_lock = maxf(0,input_lock-dt) pickup_time = maxf(0,pickup_time-dt) @@ -118,7 +126,9 @@ func tick(dt: float) -> void: rise -= 1150*dt if height <= 0: rise = 0 body.position.y = -height - body.modulate.a = .4 if invulnerable > 0 and int(invulnerable*14)%2==0 else 1.0 + body.modulate = REVIEW_GLOW if review_guard > 0 else Color.WHITE + # REVIEW's glow wins over a leftover post-hit blink: the blink means "you were hit". + body.modulate.a = .4 if invulnerable > 0 and review_guard <= 0 and int(invulnerable*14)%2==0 else 1.0 var pose := "idle" var base := false if stagger > 0: pose = "hurt" @@ -215,7 +225,7 @@ func activate_review() -> bool: if not arena.capabilities.review or review_cooldown>0 or hp<=0: return false review_cooldown = 8.0 review_charge = 0.0 - invulnerable = maxf(invulnerable,.85) + review_guard = .85 block_time = .85 arena.review_burst(position) return true @@ -280,7 +290,7 @@ func apply_effect(kind: String) -> void: effects[kind] = {"speed_surge":8.0,"confused":3.0,"strength_down":5.0,"overwhelmed":5.0}.get(kind,4.0) func take_hit(source: Vector2, status := "", force := 180.0) -> bool: - if hp <= 0 or invulnerable > 0 or height > 38: return false + if hp <= 0 or invulnerable > 0 or review_guard > 0 or height > 38: return false invulnerable = 1.25 if shield: shield = false diff --git a/hoppys-adventure-arcade/modes/platformer/enemy.gd b/hoppys-adventure-arcade/modes/platformer/enemy.gd index b3fa9e09..12bf3021 100644 --- a/hoppys-adventure-arcade/modes/platformer/enemy.gd +++ b/hoppys-adventure-arcade/modes/platformer/enemy.gd @@ -46,12 +46,9 @@ func _physics_process(dt: float) -> void: position.x = origin.x + sin(phase * (0.8 if kind == "request_reviewer" else 1.3)) * radius sprite.flip_h = cos(phase * (0.8 if kind == "request_reviewer" else 1.3)) > 0 sprite.play("hurt" if hit_flash > 0 else ("patrol" if kind == "request_reviewer" else "scuttle")) + # Bay Area Blitz has no stomping: only a swipe hurts a bug; touching one, from any side, hurts Hoppy. if hurtbox.overlaps_body(player): - if player.velocity.y > 120 and player.position.y < position.y - (90 if kind == "merge_monster" else 40): - take_hit() - player.velocity.y = -440 - else: - player.damage(position.x) + player.damage(position.x) func take_hit() -> void: if dead or hit_flash > 0: return diff --git a/hoppys-adventure-arcade/modes/platformer/level.gd b/hoppys-adventure-arcade/modes/platformer/level.gd index cfa67f18..04c8e993 100644 --- a/hoppys-adventure-arcade/modes/platformer/level.gd +++ b/hoppys-adventure-arcade/modes/platformer/level.gd @@ -5,6 +5,7 @@ const Knockout = preload("res://modes/platformer/knockout.gd") const Player = preload("res://modes/platformer/player.gd") const Enemy = preload("res://modes/platformer/enemy.gd") const Style = preload("res://scenes/main_menu/menu_style.gd") +const HowToPlay = preload("res://app/how_to_play.gd") # Preloaded so nothing is read from disk mid-run (a freed one-shot player used to drop its stream, # so every jump reloaded the WAV). const SFX := { @@ -51,10 +52,15 @@ var overlay: ColorRect var overlay_title: Label var overlay_detail: Label var resume_button: Button +## Pause-only: reopens the level's how-to-play card over the pause menu. +var how_to_play_button: Button +var retry_button: Button var overlay_card: Control var results_reveal: Tween var exit_sprite: AnimatedSprite2D var music: AudioStreamPlayer +## The hub track's process mode, restored when the level exits. +var hub_music_mode := Node.PROCESS_MODE_INHERIT var stage_clear: AudioStreamPlayer var sfx_players := {} var encounter := "approach" @@ -221,9 +227,13 @@ func _hud() -> void: resume_button = Style.button("Resume") col.add_child(resume_button) resume_button.pressed.connect(_resume) + how_to_play_button = Style.button("How to Play") + col.add_child(how_to_play_button) + how_to_play_button.pressed.connect(_show_how_to_play) for entry in [["Retry " + level_title, level_route], ["Return to Platformer Menu", "platformer"], ["Return to Arcade", "hub"]]: var button := Style.button(entry[0]) col.add_child(button) + if entry[1] == level_route: retry_button = button button.pressed.connect(func(): # Leaving the results unpauses the level music; end the stinger before they overlap. stage_clear.stop() @@ -243,11 +253,19 @@ func _pause() -> void: overlay_title.text = "TAKE A BREATHER" overlay_detail.text = "Your commits can wait." resume_button.show() + how_to_play_button.show() overlay.color = PAUSE_SHADE overlay.modulate.a = 1.0 overlay_card.position.y = 0 overlay.show() resume_button.grab_focus() +func _show_how_to_play() -> void: + if get_tree().paused and not finished: + HowToPlay.open(get_tree(), level_route, HowToPlay.Mode.PAUSE, how_to_play_button) +## True while the player has the level paused (its pause menu is up). The how-to-play card checks +## this before unpausing, so focus loss under the card leaves the pause menu working. +func is_player_paused() -> bool: + return get_tree().paused and overlay.visible and resume_button.visible and not finished func _resume() -> void: player.input_lock = 0.15 player.buffer = 0 @@ -270,6 +288,7 @@ func _finish(won: bool) -> void: overlay_title.text = "MERGE SUCCESSFUL!" if won else "BUILD FAILED" overlay_detail.text = "%06d POINTS / %02d:%02d\n%s" % [score, int(elapsed) / 60, int(elapsed) % 60, level_title + " complete." if won else "Fresh branch. Another try."] resume_button.hide() + how_to_play_button.hide() if won: stage_clear.play() _reveal_results(0.35) @@ -313,7 +332,7 @@ func _reveal_results(delay: float) -> void: results_reveal.tween_callback(overlay.show) results_reveal.tween_property(overlay, "modulate:a", 1.0, RESULTS_FADE).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT) results_reveal.parallel().tween_property(overlay_card, "position:y", 0.0, RESULTS_FADE).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) - results_reveal.tween_callback(resume_button.get_parent().get_child(3).grab_focus) + results_reveal.tween_callback(retry_button.grab_focus) func _input(event: InputEvent) -> void: # Once a route change has started, the level is leaving: never re-pause it under the veil. if AppRouter.busy: @@ -336,6 +355,7 @@ func _exit_tree() -> void: _cancel_knockout() if results_reveal: results_reveal.kill() get_tree().paused = false + AudioService.music.process_mode = hub_music_mode AudioService.music.stream_paused = false func _sound(clip: String) -> void: @@ -496,6 +516,10 @@ func _setup_session() -> void: player.hurt.connect(func(): _sound("hurt")) player.swiped.connect(func(): AudioService.play_ui(2)) _update_hud() + # Hold the hub track paused under the level. Pausing the tree (pause menu, how-to-play card) and + # unpausing it un-pauses a pausable player, so the hub music came back on top of the level's. + hub_music_mode = AudioService.music.process_mode + AudioService.music.process_mode = Node.PROCESS_MODE_ALWAYS AudioService.music.stream_paused = true music = AudioStreamPlayer.new() music.bus = "Music" diff --git a/hoppys-adventure-arcade/modes/platformer/merge_monster.gd b/hoppys-adventure-arcade/modes/platformer/merge_monster.gd index afc65cff..ebd56502 100644 --- a/hoppys-adventure-arcade/modes/platformer/merge_monster.gd +++ b/hoppys-adventure-arcade/modes/platformer/merge_monster.gd @@ -43,12 +43,9 @@ func _physics_process(dt: float) -> void: for angle in [-0.24, 0.0, 0.24]: fired.emit(position + Vector2(0, -90), aim.rotated(angle)) queue_redraw() + # Bay Area Blitz has no stomping: only a swipe hurts a bug; touching one, from any side, hurts Hoppy. if hurtbox.overlaps_body(player): - if player.velocity.y > 120 and player.position.y < position.y - 90: - take_hit() - player.velocity.y = -440 - else: - player.damage(position.x) + player.damage(position.x) func take_hit() -> void: if active: super.take_hit() diff --git a/hoppys-adventure-arcade/scenes/platformer/bay_area_blitz.gd b/hoppys-adventure-arcade/scenes/platformer/bay_area_blitz.gd index 0245ec19..68eec813 100644 --- a/hoppys-adventure-arcade/scenes/platformer/bay_area_blitz.gd +++ b/hoppys-adventure-arcade/scenes/platformer/bay_area_blitz.gd @@ -3,6 +3,8 @@ const Boss = preload("res://modes/platformer/merge_monster.gd") const Projectile = preload("res://modes/platformer/branch_projectile.gd") const LEVEL_MUSIC = preload("res://Assets/Audio/Platformer/Bay Area Blitz/bay_area_blitz_level.ogg") const MUSIC = preload("res://Assets/Audio/Platformer/Bay Area Blitz/bay_area_blitz_boss.ogg") +## Copies drawn on each side of a repeating background strip. +const REPEATS := 4 var terrain_crops := {} func _init() -> void: @@ -82,6 +84,9 @@ func _background() -> void: var near := Parallax2D.new() near.scroll_scale = Vector2(0.65, 0) near.repeat_size = Vector2(900, 0) + # One copy per side leaves the screen's right edge bare for a moment, so a house popped in from + # nowhere. Four copies per side cover the widest screen the expand aspect allows. + near.repeat_times = REPEATS near.z_index = -1 add_child(near) for x in [100, 480, 780]: @@ -93,6 +98,7 @@ func _background() -> void: fog.scroll_scale = Vector2(0.85, 0) fog.autoscroll = Vector2(15, 0) fog.repeat_size = Vector2(800, 0) + fog.repeat_times = REPEATS fog.z_index = 3 add_child(fog) var curl := Art.sprite("street_kit", "fog", Vector2(400, 615)) diff --git a/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd b/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd index 64e3e9a6..9cd3d4e8 100644 --- a/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd +++ b/hoppys-adventure-arcade/scenes/runner/audio/runner_audio.gd @@ -172,7 +172,9 @@ func _process(delta: float) -> void: _pitch = 1.0 if _clear_remaining > 0.0 else lerpf(_pitch, target, 1.0 - exp(-5.0 * delta)) if _audio_service != null and _audio_service.has_method("set_music_pitch"): _audio_service.set_music_pitch(_pitch) - if _running and _grounded and not _rolling: + # This node processes while the tree is paused (the how-to-play card pauses it), but Hoppy isn't + # running then, so no footsteps. + if _running and _grounded and not _rolling and not get_tree().paused: _step_time += delta if _step_time >= lerpf(0.42, 0.20, clampf(_normalized, 0.0, 1.0)): _step_time = 0.0 diff --git a/hoppys-adventure-arcade/scenes/runner/runner_game.gd b/hoppys-adventure-arcade/scenes/runner/runner_game.gd index 9154d454..f9f20b46 100644 --- a/hoppys-adventure-arcade/scenes/runner/runner_game.gd +++ b/hoppys-adventure-arcade/scenes/runner/runner_game.gd @@ -389,6 +389,10 @@ func resume() -> void: player.sync_input_state() paused_changed.emit(false) +## True while the player has the run paused. The how-to-play card checks this before unpausing. +func is_player_paused() -> bool: + return paused + func toggle_pause() -> void: if paused: resume() diff --git a/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd b/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd index 0d1f5713..9cd5c9c6 100644 --- a/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd +++ b/hoppys-adventure-arcade/scenes/runner/ui/pause_menu.gd @@ -4,9 +4,11 @@ extends CanvasLayer const Style = preload("res://scenes/runner/ui/hud_style.gd") const Prompts := preload("res://app/controller_prompts.gd") +const HowToPlay := preload("res://app/how_to_play.gd") var game: Node var resume_button: Button +var how_to_play_button: Button func _ready() -> void: var ui := Control.new() @@ -38,6 +40,10 @@ func _ready() -> void: column.add_child(resume_button) buttons.append(resume_button) resume_button.pressed.connect(func() -> void: _call_game(&"resume")) + how_to_play_button = Style.button("How to Play", 52) + column.add_child(how_to_play_button) + buttons.append(how_to_play_button) + how_to_play_button.pressed.connect(_show_how_to_play) var restart := Style.button("Restart", 52) column.add_child(restart) buttons.append(restart) @@ -66,6 +72,11 @@ func _on_paused_changed(is_paused: bool) -> void: if is_paused: resume_button.grab_focus() +## Opens the runner's how-to-play card over this menu; the run stays paused under it. +func _show_how_to_play() -> void: + if visible: + HowToPlay.open(get_tree(), "runner_game", HowToPlay.Mode.PAUSE, how_to_play_button) + func _call_game(method: StringName) -> void: if game != null and game.has_method(method): game.call(method) diff --git a/hoppys-adventure-arcade/tests/heroes_villains_logic.gd b/hoppys-adventure-arcade/tests/heroes_villains_logic.gd index 5c435208..026ee75a 100644 --- a/hoppys-adventure-arcade/tests/heroes_villains_logic.gd +++ b/hoppys-adventure-arcade/tests/heroes_villains_logic.gd @@ -160,7 +160,28 @@ func run() -> void: game.capabilities.review=true var stunned=enemy("review_blocker",p.position+Vector2(80,0)) check(p.activate_review() and stunned.state=="stunned" and not p.activate_review(),"REVIEW burst stuns and cannot bypass cooldown") - check(p.invulnerable>=.8,"REVIEW burst grants brief invulnerability") + var hp_before:int=p.hp + check(p.review_guard>=.8 and p.invulnerable==0.0,"REVIEW protects Hoppy without the post-hit blink") + check(p.hp==hp_before and p.stagger<=0,"Firing REVIEW never costs Hoppy HP or staggers him") + check(not p.take_hit(p.position+Vector2(40,0)) and p.hp==hp_before,"Hits can't land while REVIEW protects Hoppy") + p.tick(0.016) + stunned.update_visual() + check(p.body.modulate.r<1.0 and p.body.modulate.g>1.0 and is_equal_approx(p.body.modulate.a,1.0),"Hoppy glows REVIEW mint, never blinks, while protected") + check(stunned.body.modulate.b>1.0,"Stunned enemies turn blue") + var burst:AnimatedSprite2D=null + for child in game.ground.get_children(): + if child is AnimatedSprite2D and child.animation=="review_burst": burst=child + check(burst!=null and burst.self_modulate==p.REVIEW_TINT,"The REVIEW burst is mint and drawn under the actors") + # Firing REVIEW inside a post-hit window: the glow shows and the blink doesn't. + p.review_guard=0;p.review_cooldown=0;p.review_charge=0 + p.invulnerable=1.0 + check(p.activate_review(),"REVIEW fires during the post-hit window") + var blinked:=false + for i in 20: + p.tick(0.016) + if p.body.modulate.a<1.0: blinked=true + check(not blinked and p.body.modulate.g>1.0,"REVIEW's glow hides a leftover post-hit blink") + p.review_guard=0;p.invulnerable=0 p.review_cooldown=0;p.review_charge=0 clear_enemies() p.position=Vector2(400,550);p.facing=1 diff --git a/hoppys-adventure-arcade/tests/how_to_play.gd b/hoppys-adventure-arcade/tests/how_to_play.gd new file mode 100644 index 00000000..d9afe075 --- /dev/null +++ b/hoppys-adventure-arcade/tests/how_to_play.gd @@ -0,0 +1,539 @@ +extends SceneTree +## How-to-play cards (app/how_to_play.gd, app/how_to_play_data.gd, app/how_to_play_demo.gd): every +## game has one, a few pages long; every key and pad input a card shows is bound to the row's +## actions; every sprite clip and image a page or row icon uses exists, and every page builds; the +## keyboard and controller text reads right for each controller family; AppRouter opens the card +## once per visitor, paused; confirm/jump/attack/click turn the pages and the last one starts the +## game, left/right flip pages, pause skips, a stray early press does nothing, and each page turns +## by itself; the press that closes it never reaches the game; booth idle still counts and still returns to the +## attract route; focus loss under the card leaves the game's own pause menu working; and each +## game's pause menu reopens the card without unpausing. Run headless with +## --script res://tests/how_to_play.gd. +const HowToPlay := preload("res://app/how_to_play.gd") +const Data := preload("res://app/how_to_play_data.gd") +const HeroesCatalog := preload("res://modes/beatem_up/reinvent/catalog.gd") +const Demo := preload("res://app/how_to_play_demo.gd") +const RUNNER_SCORES := "user://test_how_to_play_leaderboard.cfg" +var failures := 0 +var router: Node + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + print("PASS: " if ok else "FAIL: ", message) + if not ok: + failures += 1 + +func wait(seconds: float) -> void: + await create_timer(seconds, true).timeout + +func settle_route() -> void: + for i in 300: + if not router.busy: + break + await process_frame + for i in 3: + await process_frame + +func visit(route: String) -> void: + router.go(route) + await settle_route() + +func on(route: String) -> bool: + return current_scene != null and current_scene.scene_file_path == router.ROUTES[route] + +## The card on screen, START or PAUSE, or null. +func card() -> Node: + for i in range(root.get_child_count() - 1, -1, -1): + var child := root.get_child(i) + if child.get_script() == HowToPlay and not child.is_queued_for_deletion() and child.visible: + return child + return null + +func key(code: Key, pressed := true) -> void: + var event := InputEventKey.new() + event.physical_keycode = code + event.keycode = code + event.pressed = pressed + Input.parse_input_event(event) + Input.flush_buffered_events() + +## Sends a pointer press and then its release, so no button or touch stays held into later checks. +func press_and_release(event: InputEvent) -> void: + event.pressed = true + Input.parse_input_event(event) + var release: InputEvent = event.duplicate() + release.pressed = false + Input.parse_input_event(release) + Input.flush_buffered_events() + +func tap(code: Key) -> void: + key(code) + key(code, false) + +## Waits for the card to finish handing the game back. +func handed_back() -> void: + for i in 10: + await physics_frame + await process_frame + +func run() -> void: + router = root.get_node("AppRouter") + HeroesCatalog.install_input() + every_game_has_a_card() + cards_match_the_input_map() + art_exists() + await every_page_builds() + text_reads_right() + check(not router.how_to_play_enabled, "Test suites (--script) get no cards unless they ask") + router.how_to_play_enabled = true + await opens_once_per_visitor() + await pages_turn() + await closes_on_purpose_only() + await closes_by_itself() + await booth_idle_still_counts() + await focus_loss_keeps_the_pause_menu() + await every_route_opens_its_card() + await runner_card_is_quiet() + await pause_menus_reopen_it() + router.how_to_play_enabled = false + await visit("hub") + for suffix in ["", ".tmp", ".bak"]: + if FileAccess.file_exists(RUNNER_SCORES + suffix): + DirAccess.remove_absolute(RUNNER_SCORES + suffix) + await root.get_node("AudioService").settle() + print("How to play failures: ", failures) + quit(1 if failures else 0) + +func every_game_has_a_card() -> void: + var gameplay: Array = root.get_node("ControllerService").GAMEPLAY_ROUTES + for route: String in gameplay: + check(Data.has_game(route), "Gameplay route %s has a how-to-play card" % route) + for route: String in Data.GAMES: + check(route in gameplay, "Card %s is for a gameplay route" % route) + var game := Data.game(route) + var pages := Data.pages(route) + check(not str(game.title).is_empty() and not game.rows.is_empty() and pages.size() >= 3, + "Card %s has a title, controls and at least three pages" % route) + check(pages.filter(func(page: Dictionary) -> bool: return page.get("controls", false)).size() == 1, + "Card %s has one controls page" % route) + for page: Dictionary in pages: + if page.get("controls", false): + continue + check(not str(page.get("text", "")).is_empty() and str(page.scene.id) in Demo.SCENES, + "Card %s page %s has text and a known scene" % [route, page.heading]) + var labels: Array = game.rows.map(func(row: Dictionary) -> String: return row.label) + check("Pause" in labels and ("Jump" in labels), "Card %s shows Jump and Pause" % route) + var orbital: Array = Data.game("orbital_ops").rows.map(func(row: Dictionary) -> String: return row.label) + check("Zero-G rise" in orbital and "Zero-G sink" in orbital, "Orbital Ops shows the zero-G controls") + +## True when `action` has a binding for pad entry `pad` (a JoyButton or a Data.STICK* value). +func pad_bound(action: String, pad: int) -> bool: + for event in InputMap.action_get_events(action): + if pad >= 0 and event is InputEventJoypadButton and event.button_index == pad: + return true + if pad < 0 and event is InputEventJoypadMotion: + var vertical: bool = event.axis == JOY_AXIS_LEFT_Y + if pad == Data.STICK and event.axis in [JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y]: + return true + if pad == Data.STICK_UP and vertical and event.axis_value < 0: + return true + if pad == Data.STICK_DOWN and vertical and event.axis_value > 0: + return true + return false + +func key_bound(action: String, code: int) -> bool: + for event in InputMap.action_get_events(action): + if event is InputEventKey and (event.physical_keycode == code or event.keycode == code): + return true + return false + +func cards_match_the_input_map() -> void: + for route: String in Data.GAMES: + for row: Dictionary in Data.game(route).rows: + var where := "%s / %s" % [route, row.label] + for action: String in row.actions: + check(InputMap.has_action(action), "%s: action %s exists" % [where, action]) + for code: int in row.keys: + check(row.actions.any(func(action: String) -> bool: return InputMap.has_action(action) and key_bound(action, code)), + "%s: key %s is bound to %s" % [where, OS.get_keycode_string(code), row.actions]) + for pad: int in row.pad: + check(row.actions.any(func(action: String) -> bool: return InputMap.has_action(action) and pad_bound(action, pad)), + "%s: pad input %d is bound to %s" % [where, pad, row.actions]) + # The card closes on these, so each must exist in some game. + for action in [&"ui_accept", &"jump", &"attack", &"pause", &"brawl_jump", &"brawl_attack"]: + check(InputMap.has_action(action), "Dismiss action %s exists" % action) + +func clip_exists(sheet: String, clip: String) -> bool: + var frames := load(sheet) as SpriteFrames + return frames != null and frames.has_animation(clip) + +## Every sheet, clip and image the pages and row icons name is there. +func art_exists() -> void: + for name: String in Demo.WORLDS: + var setting: Dictionary = Demo.WORLDS[name] + var image: String = setting.get("room", setting.get("sky", "")) + check(load(image) is Texture2D, "World %s backdrop %s loads" % [name, image]) + for route: String in Data.GAMES: + var poses: Dictionary = Data.game(route).get("poses", {}) + for row: Dictionary in Data.game(route).rows: + if row.has("icon"): + check(poses.has(row.icon), "%s / %s: icon %s has a pose" % [route, row.label, row.icon]) + for name: String in poses: + check(clip_exists(poses[name][0], poses[name][1]), "%s: pose %s's clip %s exists" % [route, name, poses[name][1]]) + for page: Dictionary in Data.pages(route): + var scene: Dictionary = page.get("scene", {}) + if scene.has("world"): + check(Demo.WORLDS.has(scene.world), "%s: scene world %s is known" % [route, scene.world]) + for image in scene.get("images", []): + check(load(image) is Texture2D, "%s: image %s loads" % [route, image]) + if scene.has("boss"): + check(clip_exists(scene.boss, "idle") and clip_exists(scene.boss, "defeat"), "%s: boss %s has idle and defeat" % [route, scene.boss]) + +## Opens every card on every page outside any game and checks it has something on it; scenes +## that name a missing clip print an error here. +func every_page_builds() -> void: + for route: String in Data.GAMES: + var shown: Node = HowToPlay.open(self, route, HowToPlay.Mode.PAUSE) + for index in Data.pages(route).size(): + shown.show_page(index) + for i in 20: + await process_frame + var body: Node = shown._body + var filled: bool = body.get_child_count() > 0 + if Data.pages(route)[index].get("controls", false): + filled = shown._controls != null and shown._controls.get_child_count() >= 4 * (Data.game(route).rows.size() + 1) + else: + var demo: Node = body.get_child(0) + filled = demo is Control and demo.loop != null and demo.loop.is_running() and \ + (demo.world.get_child_count() > 0 or str(demo.spec.id) == "slideshow") + check(filled, "%s page %d builds and plays" % [route, index + 1]) + if filled and not Data.pages(route)[index].get("controls", false) and str(shown._body.get_child(0).spec.id) != "slideshow": + enemies_face_hoppy(shown._body.get_child(0), "%s page %d" % [route, index + 1]) + shown.close() + await process_frame + +## Enemy and boss sheets are drawn facing left, Hoppy's facing right (the games flip an enemy only to +## face right): on the stage, every enemy faces Hoppy. +func enemies_face_hoppy(demo: Node, where: String) -> void: + var hoppy: AnimatedSprite2D = null + var others: Array[AnimatedSprite2D] = [] + for child in demo.world.get_children(): + if not child is AnimatedSprite2D: + continue + var file: String = child.sprite_frames.resource_path.get_file() + if file in ["platformer.tres", "combat.tres", "hoppy.tres", "hoppy_locomotion.tres"]: + hoppy = child + elif not file in ["pickups.tres", "interactives.tres", "hazards.tres", "items_allies.tres", "weapons.tres", "capabilities_fx.tres"]: + others.append(child) + if hoppy == null: + return + for enemy in others: + var toward_left: bool = enemy.position.x > hoppy.position.x + check(enemy.flip_h != toward_left, "%s: %s faces Hoppy" % [where, enemy.sprite_frames.resource_path.get_file()]) + +func text_reads_right() -> void: + check(HowToPlay.keys_text([KEY_A, KEY_D, KEY_LEFT, KEY_RIGHT]) == "A / D / ← →", "Platformer move keys read A / D / ← →") + check(HowToPlay.keys_text([KEY_W, KEY_A, KEY_S, KEY_D, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]) == "WASD / Arrows", "Brawler move keys read WASD / Arrows") + check(HowToPlay.keys_text([KEY_SPACE, KEY_W, KEY_UP]) == "Space / W / ↑", "Jump keys read Space / W / ↑") + check(HowToPlay.keys_text([KEY_ESCAPE, KEY_P]) == "Esc / P", "Pause keys read Esc / P") + check(HowToPlay.keys_text([KEY_S, KEY_CTRL, KEY_DOWN]) == "S / Ctrl / ↓", "Roll keys read S / Ctrl / ↓") + check(HowToPlay.pad_text([JOY_BUTTON_A], "") == "A / Cross", "No controller: A reads A / Cross") + check(HowToPlay.pad_text([JOY_BUTTON_X], "playstation") == "Square", "PlayStation: X reads Square") + check(HowToPlay.pad_text([JOY_BUTTON_A], "nintendo") == "B", "Nintendo: the bottom button reads B") + check(HowToPlay.pad_text([JOY_BUTTON_START], "") == "Start", "No controller: Start reads Start") + check(HowToPlay.pad_text([JOY_BUTTON_LEFT_SHOULDER], "") == "LB / L1", "No controller: LB reads LB / L1") + check(HowToPlay.pad_text([Data.STICK, JOY_BUTTON_DPAD_LEFT, JOY_BUTTON_DPAD_RIGHT], "xbox") == "Left stick / D-pad", + "Movement reads Left stick / D-pad") + check(HowToPlay.pad_text([Data.STICK_DOWN, JOY_BUTTON_DPAD_DOWN], "xbox") == "Stick ↓ / D-pad ↓", + "Zero-G sink reads Stick ↓ / D-pad ↓") + check(HowToPlay.face_button([Data.STICK, JOY_BUTTON_Y]) == JOY_BUTTON_Y and HowToPlay.face_button([JOY_BUTTON_START]) == -1, + "Rows with a face button get its glyph") + +func opens_once_per_visitor() -> void: + await visit("hub") + check(card() == null, "The hub has no card") + await visit("bay_area_blitz") + var shown := card() + check(shown != null and shown.route == "bay_area_blitz" and shown.mode == HowToPlay.Mode.START, + "Entering Bay Area Blitz opens its card") + check(paused, "The card holds the game paused") + check(shown != null and shown.get_index() == root.get_child_count() - 1, "The card is the root's last child (first to see input)") + await close_with(KEY_ESCAPE) + check(card() == null and not paused, "Esc skips the card and the game plays") + await visit("bay_area_blitz") + check(card() == null, "A retry skips the card") + await visit("platformer") + await visit("bay_area_blitz") + check(card() == null, "Coming back from the platformer menu skips the card") + await visit("hub") + await visit("bay_area_blitz") + check(card() != null, "A new visitor (the hub opened) sees the card again") + await close_with(KEY_ESCAPE) + +## Wait past the arming delay, then press `code` (Esc skips the card). +func close_with(code: Key) -> void: + var shown := card() + if shown: + shown.age = HowToPlay.ARM_SECONDS + tap(code) + await handed_back() + +func pages_turn() -> void: + await visit("hub") + await visit("orbital_ops") + var shown := card() + var pages := Data.pages("orbital_ops").size() + check(shown != null and shown.page == 0 and shown.page_count() == pages, "The card opens on its first page") + shown.age = HowToPlay.ARM_SECONDS + tap(KEY_SPACE) + check(shown.page == 1 and shown._controls != null, "Space turns to the next page (controls)") + tap(KEY_RIGHT) + check(shown.page == 2, "→ turns forward") + tap(KEY_A) + check(shown.page == 1, "A (move left) turns back") + tap(KEY_LEFT) + tap(KEY_LEFT) + check(shown.page == 0 and card() == shown, "← stops at the first page") + for i in pages + 2: + tap(KEY_RIGHT) + check(shown.page == pages - 1 and card() == shown and shown.is_last_page(), "→ stops at the last page, never closes") + check(shown._footer.text.ends_with("to play"), "The last page says how to start: %s" % shown._footer.text) + var stick := InputEventJoypadMotion.new() + stick.axis = JOY_AXIS_LEFT_X + for value in [-0.9, -1.0, -0.95]: + stick.axis_value = value + Input.parse_input_event(stick) + Input.flush_buffered_events() + check(shown.page == pages - 2, "Holding the stick left flips one page") + stick.axis_value = 0.0 + Input.parse_input_event(stick) + Input.flush_buffered_events() + tap(KEY_SPACE) + check(shown.page == pages - 1 and card() == shown, "Space on the next-to-last page turns, not closes") + var level := current_scene + var start_y: float = level.player.global_position.y + tap(KEY_SPACE) + await handed_back() + check(card() == null and not paused, "Space on the last page starts the game") + await wait(0.3) + check(level.player.global_position.y >= start_y - 2.0, "Hoppy didn't jump on the Space that started the game") + # The same, with Space still held down after the game is handed back. + await visit("hub") + await visit("orbital_ops") + shown = card() + shown.age = HowToPlay.ARM_SECONDS + shown.show_page(shown.page_count() - 1) + level = current_scene + start_y = level.player.global_position.y + key(KEY_SPACE) + await handed_back() + check(card() == null and not paused, "A held Space on the last page starts the game") + await wait(0.3) + check(level.player.global_position.y >= start_y - 2.0, "Holding the Space that started the game doesn't make Hoppy jump") + key(KEY_SPACE, false) + await wait(0.2) + +func closes_on_purpose_only() -> void: + await visit("hub") + await visit("bay_area_blitz") + var level := current_scene + var start_y: float = level.player.global_position.y + tap(KEY_SPACE) + await handed_back() + check(card() != null, "A press in the first half second (left over from the menu) is ignored") + card().age = HowToPlay.ARM_SECONDS + tap(KEY_D) + tap(KEY_RIGHT) + tap(KEY_RIGHT) + tap(KEY_RIGHT) + await handed_back() + check(card() != null, "Movement keys don't close the card") + tap(KEY_ESCAPE) + await handed_back() + check(card() == null and not paused, "Esc closes the card and the game plays") + check(not level.overlay.visible, "The Esc that closed the card never reached the level's pause toggle") + await wait(0.3) + check(level.player.global_position.y >= start_y - 2.0, "Hoppy didn't jump on the closing press") + await visit("hub") + await visit("orbital_ops") + var shown := card() + shown.age = HowToPlay.ARM_SECONDS + tap(KEY_J) + await handed_back() + check(card() == shown and shown.page == 1, "Attack (J) turns the page") + shown.show_page(shown.page_count() - 1) + tap(KEY_J) + await handed_back() + check(card() == null and not paused, "Attack (J) on the last page starts the game") + await visit("hub") + await visit("enchanted_repo") + shown = card() + shown.age = HowToPlay.ARM_SECONDS + shown.show_page(shown.page_count() - 1) + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.pressed = true + click.position = Vector2(640, 360) + press_and_release(click) + await handed_back() + check(card() == null and not paused, "A click on the last page starts the game") + await visit("hub") + await visit("enchanted_repo") + shown = card() + shown.age = HowToPlay.ARM_SECONDS + for spec in [[MOUSE_BUTTON_WHEEL_DOWN, 0], [MOUSE_BUTTON_RIGHT, 0], [MOUSE_BUTTON_LEFT, InputEvent.DEVICE_ID_EMULATION]]: + var other := InputEventMouseButton.new() + other.button_index = spec[0] + other.device = spec[1] + other.pressed = true + other.position = Vector2(640, 360) + press_and_release(other) + check(card() == shown and shown.page == 0, "The wheel, right-click and touch-emulated clicks don't turn pages") + var touch := InputEventScreenTouch.new() + touch.pressed = true + touch.position = Vector2(640, 360) + press_and_release(touch) + check(shown.page == 1, "One tap turns one page") + await close_with(KEY_ESCAPE) + +func closes_by_itself() -> void: + await visit("hub") + await visit("bay_area_blitz") + var shown := card() + check(shown != null and shown.countdown > HowToPlay.PAGE_SECONDS - 1.0, + "Each page counts down from %d s" % int(HowToPlay.PAGE_SECONDS)) + shown.countdown = 0.2 + await wait(0.5) + check(card() == shown and shown.page == 1 and shown.countdown > HowToPlay.PAGE_SECONDS - 1.0, + "A page turns by itself and the next one starts its own countdown") + check(shown.PAGE_SECONDS * shown.page_count() < router.IDLE_SECONDS / 2.0, "A whole card is well inside the booth idle reset") + shown.show_page(shown.page_count() - 1) + shown.countdown = 0.2 + await wait(0.5) + await handed_back() + check(card() == null and not paused, "The last page closes by itself and the game plays") + +func booth_idle_still_counts() -> void: + router.set_booth(true) + await visit("hub") + await visit("orbital_ops") + check(card() != null, "Booth mode opens the card too") + router.idle_seconds = 50.0 + tap(KEY_D) + check(router.idle_seconds < 1.0, "A press the card swallows still resets the booth idle timer") + router.idle_limit = 0.4 + await wait(0.8) + await settle_route() + check(on("hub") and card() == null and not paused, "Booth idle returns to the attract route from under the card") + router.idle_limit = router.IDLE_SECONDS + router.set_booth(false) + +func focus_loss_keeps_the_pause_menu() -> void: + await visit("hub") + await visit("bay_area_blitz") + var level := current_scene + root.propagate_notification(NOTIFICATION_APPLICATION_FOCUS_OUT) + check(level.is_player_paused(), "Focus loss under the card opens the level's pause menu") + var shown := card() + var before: float = shown.countdown + await wait(0.3) + check(is_equal_approx(shown.countdown, before), "The countdown waits while the window is in the background") + root.propagate_notification(NOTIFICATION_APPLICATION_FOCUS_IN) + await wait(0.3) + check(shown.countdown < before - 0.1, "The countdown resumes once the window has focus again") + await close_with(KEY_ESCAPE) + check(card() == null and paused and level.is_player_paused(), "Closing the card leaves the game in its pause menu") + level._resume() + check(not paused, "The pause menu still resumes the level") + +func every_route_opens_its_card() -> void: + for route: String in ["bay_area_blitz", "orbital_ops", "enchanted_repo", "runner_game", "code_city_brawl", "heroes_vs_villains"]: + await visit("hub") + await visit(route) + var shown := card() + check(on(route) and shown != null and shown.route == route and paused, "%s opens its card, paused" % route) + if shown: + shown.age = HowToPlay.ARM_SECONDS + tap(KEY_ESCAPE) + await handed_back() + check(card() == null and not paused, "%s plays once its card closes" % route) + +## The runner's audio processes through the card's pause; Hoppy isn't moving, so no footsteps. +func runner_card_is_quiet() -> void: + await visit("hub") + await visit("runner_game") + var audio := current_scene.find_child("RunnerAudio", true, false) + check(card() != null and audio != null, "runner_game shows its card") + if audio == null: + return + var steps: int = audio._tread + await wait(1.5) + check(card() != null and audio._tread == steps, "No footsteps play under the runner's card (%d)" % (audio._tread - steps)) + +func reopen(button: Button, route: String) -> void: + check(button != null and button.visible, "%s's pause menu has a How to Play button" % route) + if button == null: + return + button.grab_focus() + button.pressed.emit() + var shown := card() + check(shown != null and shown.mode == HowToPlay.Mode.PAUSE and shown.route == route, "%s: How to Play reopens the card" % route) + if shown == null: + return + check(not shown._bar_track.visible, "%s: the reopened card has no countdown" % route) + shown.age = HowToPlay.ARM_SECONDS + shown.countdown = -1.0 + await process_frame + check(card() == shown and shown.page == 0, "%s: the reopened card never turns by itself" % route) + tap(KEY_SPACE) + check(shown.page == 1 and card() == shown, "%s: Space turns its pages" % route) + tap(KEY_ESCAPE) + await handed_back() + check(card() == null and paused, "%s: Esc closes it and the game stays paused" % route) + check(button.has_focus(), "%s: focus returns to How to Play" % route) + +func find_button(node: Node, text: String) -> Button: + if node is Button and node.text == text: + return node + for child in node.get_children(): + var found := find_button(child, text) + if found: + return found + return null + +func pause_menus_reopen_it() -> void: + router.how_to_play_enabled = false + await visit("bay_area_blitz") + var level := current_scene + level._pause() + await reopen(level.how_to_play_button, "bay_area_blitz") + level._resume() + await visit("code_city_brawl") + current_scene.pause("test") + await process_frame + await reopen(find_button(current_scene.panel, "How to Play"), "code_city_brawl") + check(current_scene.is_player_paused(), "code_city_brawl: still in its pause menu") + await visit("heroes_vs_villains") + current_scene.state = "combat" + tap(KEY_ESCAPE) + await process_frame + await reopen(find_button(current_scene.panel, "How to Play"), "heroes_vs_villains") + check(current_scene.is_player_paused(), "heroes_vs_villains: still in its pause menu") + current_scene.resume() + await visit("runner_game") + var runner := current_scene + runner.leaderboard_path = RUNNER_SCORES + runner.skip_run_intro() + for i in 600: + if runner.state == &"running" and not runner.can_skip_run_intro(): + break + await process_frame + await wait(0.2) + runner.pause() + await process_frame + await reopen(find_button(root, "How to Play"), "runner_game") + check(runner.is_player_paused(), "runner_game: still paused") + runner.resume() + router.how_to_play_enabled = true diff --git a/hoppys-adventure-arcade/tests/how_to_play.gd.uid b/hoppys-adventure-arcade/tests/how_to_play.gd.uid new file mode 100644 index 00000000..cb98ce79 --- /dev/null +++ b/hoppys-adventure-arcade/tests/how_to_play.gd.uid @@ -0,0 +1 @@ +uid://bec8u5exdf8ow diff --git a/hoppys-adventure-arcade/tests/menu_return_layout.gd b/hoppys-adventure-arcade/tests/menu_return_layout.gd index 5ba08927..f206dce5 100644 --- a/hoppys-adventure-arcade/tests/menu_return_layout.gd +++ b/hoppys-adventure-arcade/tests/menu_return_layout.gd @@ -25,8 +25,12 @@ func open_level() -> Node: level._resume() await wait(0.2) return level +## The pause/results column's children by their old positions (title, detail, Resume, Retry, +## Platformer Menu, Arcade), skipping the pause-only How to Play button. func overlay_button(level: Node, index: int) -> Button: - return level.resume_button.get_parent().get_child(index) + var children: Array = level.resume_button.get_parent().get_children().filter( + func(child: Node) -> bool: return child != level.how_to_play_button) + return children[index] func check_menu(label: String) -> void: var menu = current_scene check(menu.name == "MainMenu", label + ": back on the main menu") diff --git a/hoppys-adventure-arcade/tests/platformer_boss.gd b/hoppys-adventure-arcade/tests/platformer_boss.gd index d3f78cf7..69e0a8c1 100644 --- a/hoppys-adventure-arcade/tests/platformer_boss.gd +++ b/hoppys-adventure-arcade/tests/platformer_boss.gd @@ -115,7 +115,7 @@ func run() -> void: await process_frame await process_frame check(level.overlay.visible and is_equal_approx(level.overlay.modulate.a, 1.0), "Jump skips the results fade") - check(level.resume_button.get_parent().get_child(3).has_focus() and not level.results_reveal.is_running(), "Skipped results still focus Retry") + check(level.retry_button.has_focus() and not level.results_reveal.is_running(), "Skipped results still focus Retry") check(current_scene == level, "Skip press does not also trigger Retry") await capture("/tmp/hoppy-boss-merge-result.png") paused = false diff --git a/hoppys-adventure-arcade/tests/platformer_knockout.gd b/hoppys-adventure-arcade/tests/platformer_knockout.gd index 80eda9a0..15635a84 100644 --- a/hoppys-adventure-arcade/tests/platformer_knockout.gd +++ b/hoppys-adventure-arcade/tests/platformer_knockout.gd @@ -87,7 +87,7 @@ func run() -> void: check(completions[0] == 1 and not game.knockout.active, label + ": exactly one completion") await create_timer(0.75).timeout check(game.overlay.visible and is_equal_approx(game.overlay.modulate.a, 1.0), label + ": results fade completes") - var retry: Button = game.resume_button.get_parent().get_child(3) + var retry: Button = game.retry_button check(retry.has_focus(), label + ": Retry focused") if not fell: await capture(route + "-results") var old_cutaway: WeakRef = weakref(game.knockout) diff --git a/hoppys-adventure-arcade/tests/platformer_smoke.gd b/hoppys-adventure-arcade/tests/platformer_smoke.gd index 6f897efd..4163d9d4 100644 --- a/hoppys-adventure-arcade/tests/platformer_smoke.gd +++ b/hoppys-adventure-arcade/tests/platformer_smoke.gd @@ -86,6 +86,10 @@ func run() -> void: Input.parse_input_event(esc) await frames(2) check(not paused, "Pause input resumes while tree paused") + check(root.get_node("AudioService").music.stream_paused, "The hub music stays silent after a pause and resume") + for strip in level.find_children("*", "Parallax2D", true, false): + # A 21:9 screen at 720 high is 1680 wide; every copy has to reach past it, or art pops in. + check(strip.repeat_size.x * strip.repeat_times >= 1680 + strip.repeat_size.x, "Background strip covers a wide screen (%s)" % strip.repeat_times) player.boost = 0 player.position = Vector2(1650, 500) player.velocity = Vector2.ZERO @@ -115,6 +119,24 @@ func run() -> void: Input.action_release("attack") check(reviewer.dead, "Second swipe defeats reviewer") check(level.score >= 100, "Enemy defeat awards score") + # Bay Area Blitz has no stomping: dropping onto a bug costs a heart and leaves the bug standing. + var critter = level._enemy(1900, "glitch_critter") + critter.radius = 0 + await frames(2) + player.invulnerable = 0 + player.boost = 0 + player.shield = false + var hearts: int = player.hp + # Hold Hoppy falling through the top of the bug's hurtbox, where a stomp used to land. + for i in 6: + player.position = Vector2(critter.position.x, critter.position.y - 48) + player.velocity = Vector2(0, 450) + await frames(1) + await frames(10) + check(not critter.dead and critter.hp == 1, "Landing on a bug doesn't hurt it") + check(player.hp == hearts - 1, "Landing on a bug costs a heart (%d -> %d)" % [hearts, player.hp]) + critter.queue_free() + player.invulnerable = 3 player.position = Vector2(4400, 600) player.velocity = Vector2.ZERO level.camera.reset_smoothing() @@ -169,7 +191,7 @@ func run() -> void: await create_timer(3.0).timeout await capture("/tmp/hoppy-death-results-fading.png") await create_timer(0.6).timeout - var retry: Button = level.resume_button.get_parent().get_child(3) + var retry: Button = level.retry_button check(level.overlay.visible and is_equal_approx(level.overlay.modulate.a, 1.0), "Results menu fades fully in") check(level.overlay_card.position.y == 0 and retry.has_focus(), "Results menu settles with Retry focused") await capture("/tmp/hoppy-death-results.png") @@ -180,6 +202,7 @@ func run() -> void: await frames(3) await capture("/tmp/hoppy-level-select.png") check(not root.get_node("AudioService").music.stream_paused, "Menu music restored") + check(root.get_node("AudioService").music.process_mode == Node.PROCESS_MODE_INHERIT, "The hub music pauses with the tree again outside the level") print("PLATFORMER SMOKE: ", "PASS" if failures == 0 else "FAIL", " (", failures, " failures)") await frames(35) await root.get_node("AudioService").settle()