From 096487473ebb10175f849dfccc719835f130331c Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 16:28:52 -0700 Subject: [PATCH 1/3] feat(menu): add a credits screen generated from ASSETS_LICENSES.md [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Closes #51. Players need a credits screen, and #51 asks for one built from ASSETS_LICENSES.md so the two can't drift. - tools/credits/build_credits.py turns the Third-party table into scenes/credits/asset_credits.json: one credit per asset in use, grouped into Music, Sound effects and Art, with the table's attribution line, license, and source and license links. --check runs in CI; CONTRIBUTING says to rerun it after editing the table. - scenes/credits/credits.json holds hand-written sections shown first, each a title with optional lines and tracks. It opens with "Original music: Music by Erik Thorelli" and an empty tracks list, so the soundtrack's track list can be added as data. - scenes/credits/credits.tscn (route credits, a menu route for the controller service) scrolls by itself, pauses at the end and after manual scrolling, scrolls with Up/Down or the wheel, and returns to the hub with Esc, B or its button. It touches no audio. - The hub gains a Credits button after Settings and Quit Game, appended last so buttons[3] and [4] are unchanged; the three utility buttons are 160 px with a 28 px gap so the focus arrow clears its neighbour. - Tests: tests/credits_screen.gd and tools/credits/test_build_credits.py. --- .github/workflows/ci.yml | 5 + CONTRIBUTING.md | 2 +- docs/main-menu.md | 7 + hoppys-adventure-arcade/app/app_router.gd | 3 +- .../app/controller_service.gd | 2 +- .../scenes/credits/asset_credits.json | 83 +++++++++ .../scenes/credits/credits.gd | 169 ++++++++++++++++++ .../scenes/credits/credits.gd.uid | 1 + .../scenes/credits/credits.json | 12 ++ .../scenes/credits/credits.tscn | 10 ++ .../scenes/main_menu/main_menu.gd | 13 +- .../tests/credits_screen.gd | 132 ++++++++++++++ .../tests/credits_screen.gd.uid | 1 + tools/credits/build_credits.py | 134 ++++++++++++++ tools/credits/test_build_credits.py | 112 ++++++++++++ 15 files changed, 681 insertions(+), 5 deletions(-) create mode 100644 hoppys-adventure-arcade/scenes/credits/asset_credits.json create mode 100644 hoppys-adventure-arcade/scenes/credits/credits.gd create mode 100644 hoppys-adventure-arcade/scenes/credits/credits.gd.uid create mode 100644 hoppys-adventure-arcade/scenes/credits/credits.json create mode 100644 hoppys-adventure-arcade/scenes/credits/credits.tscn create mode 100644 hoppys-adventure-arcade/tests/credits_screen.gd create mode 100644 hoppys-adventure-arcade/tests/credits_screen.gd.uid create mode 100644 tools/credits/build_credits.py create mode 100644 tools/credits/test_build_credits.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6a752e3..e8558786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,11 @@ jobs: run: | command -v shellcheck >/dev/null || sudo apt-get install -y shellcheck shellcheck tools/booth/watchdog.sh tools/booth/macos/setup.sh + # The credits screen shows tools/credits/build_credits.py's output; it must match the table. + - name: Credits match ASSETS_LICENSES.md + run: python tools/credits/build_credits.py --check + - name: Credits unit tests + run: python -m unittest discover -s tools/credits -v gitleaks: name: gitleaks diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 101b8f3d..c98ee44d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ Every new asset must be redistributable in a public repository. - Accepted: third-party assets under **CC0** or **CC BY 4.0**. - Original art, audio or models made for the project: please **open an issue first**. The license for project-original assets hasn't been finalized yet (see [`ASSETS_LICENSES.md`](ASSETS_LICENSES.md#project-original)), so we can't merge original-art contributions until it is. Procedural assets generated in GDScript count as code and fall under the MIT license. - Not accepted: "free for use but no redistribution" packs, which covers most free marketplace packs. Also not accepted: NC/ND licenses, and assets of unknown origin. -- Add a row to [`ASSETS_LICENSES.md`](ASSETS_LICENSES.md) in the same PR, and include the upstream license file next to the asset. +- Add a row to [`ASSETS_LICENSES.md`](ASSETS_LICENSES.md) in the same PR, and include the upstream license file next to the asset. Then run `python3 tools/credits/build_credits.py`, which regenerates the in-game credits from that table; CI fails if they differ. - AI-generated assets: record the tool and prompts, following the existing `PROMPTS.md` / `generation.json` convention. Don't record local machine paths. ## Reporting bugs and security issues diff --git a/docs/main-menu.md b/docs/main-menu.md index 3b8874ed..20d7a28d 100644 --- a/docs/main-menu.md +++ b/docs/main-menu.md @@ -25,6 +25,13 @@ Arrow keys, Tab, D-pad, or left stick navigate; Enter / gamepad A activates; Esc - Quit asks for confirmation and fades to black for 1 second. - At exit, however the app quits, AudioService stops every audio player, the current scene's included, and waits up to 0.5 s for two audio mixes (about 25 ms with a sound card). Godot frees a stopped voice only after the mixer has run, and reports any voice it still holds at shutdown as "ObjectDB instances were leaked" / "resources still in use". `tools/checks/exit_leak.py` covers this in CI. +## Credits + +The **Credits** button, after Settings and Quit Game, opens `scenes/credits/credits.tscn` (route `credits`). It shows the hand-written sections in `scenes/credits/credits.json` first. Then come the asset credits from the Third-party table of `ASSETS_LICENSES.md`, grouped into Music, Sound effects and Art, each with its license and source link. Assets marked "Not referenced" are left out. The list scrolls by itself. Up and Down or the mouse wheel scroll it by hand, and Esc, B or **Return to Main Menu** go back to the hub. + +- **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. + ## 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/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index 1093262e..0d08792a 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -13,7 +13,8 @@ const ROUTES := { "platformer": "res://scenes/platformer/platformer_menu.tscn", "runner": "res://scenes/runner/runner_menu.tscn", "runner_game": "res://scenes/runner/runner_game.tscn", - "beatem_up": "res://scenes/beatem_up/beatem_menu.tscn" + "beatem_up": "res://scenes/beatem_up/beatem_menu.tscn", + "credits": "res://scenes/credits/credits.tscn" } ## Packaged-build check (` --headless -- --smoke-routes`): load every route, then quit ## nonzero on the first failure. tools/build/export_game.py runs it against each export. diff --git a/hoppys-adventure-arcade/app/controller_service.gd b/hoppys-adventure-arcade/app/controller_service.gd index 2eb1948f..f3ecbf63 100644 --- a/hoppys-adventure-arcade/app/controller_service.gd +++ b/hoppys-adventure-arcade/app/controller_service.gd @@ -20,7 +20,7 @@ const PLAYER := &"p1" ## tests/controller_service.gd fails when a route is in neither list. const GAMEPLAY_ROUTES: Array[String] = [ "heroes_vs_villains", "code_city_brawl", "enchanted_repo", "orbital_ops", "bay_area_blitz", "runner_game"] -const MENU_ROUTES: Array[String] = ["hub", "platformer", "runner", "beatem_up"] +const MENU_ROUTES: Array[String] = ["hub", "platformer", "runner", "beatem_up", "credits"] ## Godot ships ui_accept and ui_cancel without pad buttons, so a pad could not confirm or go back ## in menus. These match the prompts: the bottom face button confirms, the right one goes back. const MENU_BINDINGS := { diff --git a/hoppys-adventure-arcade/scenes/credits/asset_credits.json b/hoppys-adventure-arcade/scenes/credits/asset_credits.json new file mode 100644 index 00000000..1f3edb3d --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/asset_credits.json @@ -0,0 +1,83 @@ +{ + "generated_by": "tools/credits/build_credits.py from ASSETS_LICENSES.md; do not edit by hand", + "sections": [ + { + "title": "Music", + "credits": [ + { + "asset": "MERGE SUCCESSFUL jingle and victory loop", + "credit": "\"MERGE SUCCESSFUL jingle and victory loop\" by Original project compositions and square-wave synthesis; no third-party samples", + "creator": "Original project compositions and square-wave synthesis; no third-party samples", + "license": "CC0 1.0", + "license_url": "https://creativecommons.org/publicdomain/zero/1.0/", + "source_url": "", + "used_by": "Code City Brawl: celebration, ending pages and results" + } + ] + }, + { + "title": "Sound effects", + "credits": [ + { + "asset": "The Essential Retro Video Game Sound Effects Collection", + "credit": "\"The Essential Retro Video Game Sound Effects Collection\" by Juhani Junkala", + "creator": "Juhani Junkala", + "license": "CC0", + "license_url": "", + "source_url": "", + "used_by": "SFX" + }, + { + "asset": "Sunny Land forest (art, demo SFX)", + "credit": "\"Sunny Land forest (art, demo SFX)\" by Luis Zuno (@ansimuz); music by Pascal Belisle", + "creator": "Luis Zuno (@ansimuz); music by Pascal Belisle", + "license": "Art: public domain. Music: free with credit", + "license_url": "", + "source_url": "", + "used_by": "Platformer jump/hurt SFX" + } + ] + }, + { + "title": "Art", + "credits": [ + { + "asset": "Parallax forest", + "credit": "\"Parallax forest\" by Luis Zuno (@ansimuz)", + "creator": "Luis Zuno (@ansimuz)", + "license": "CC0", + "license_url": "", + "source_url": "", + "used_by": "Main menu preview" + }, + { + "asset": "Space background", + "credit": "\"Space background\" by Luis Zuno (@ansimuz)", + "creator": "Luis Zuno (@ansimuz)", + "license": "CC0", + "license_url": "", + "source_url": "", + "used_by": "Main menu preview" + }, + { + "asset": "Cyberpunk Street (layers + BGM)", + "credit": "\"Cyberpunk Street (layers + BGM)\" by Luis Zuno (ansimuz.com); music composer not credited", + "creator": "Luis Zuno (ansimuz.com); music composer not credited", + "license": "CC0", + "license_url": "", + "source_url": "https://ansimuz.itch.io/cyberpunk-street-environment", + "used_by": "Main menu preview, runner BGM" + }, + { + "asset": "Parallax City Night", + "credit": "\"Parallax City Night\" by FisherG (@TheFish523); \"Ghost (Vibrant)\" palette by PixelCod", + "creator": "FisherG (@TheFish523); \"Ghost (Vibrant)\" palette by PixelCod", + "license": "CC0", + "license_url": "", + "source_url": "https://opengameart.org/content/parallax-city-night-4-colors", + "used_by": "Main menu preview" + } + ] + } + ] +} diff --git a/hoppys-adventure-arcade/scenes/credits/credits.gd b/hoppys-adventure-arcade/scenes/credits/credits.gd new file mode 100644 index 00000000..9f179f31 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/credits.gd @@ -0,0 +1,169 @@ +extends Control +## The credits screen. It shows the hand-written sections in credits.json first (original music +## and anything else people add there as data), then the asset credits that +## tools/credits/build_credits.py generates from ASSETS_LICENSES.md. The list scrolls by itself; +## Up and Down (or the mouse wheel) scroll it by hand, and Back or Esc / B return to the hub. +const Style = preload("res://scenes/main_menu/menu_style.gd") +const Prompts := preload("res://app/controller_prompts.gd") +## Hand-written sections: {"title", "lines": [String], "tracks": [{"title", "used_by"}]}. +const CREDITS := "res://scenes/credits/credits.json" +## Generated; do not edit. Sections of credits: +## {"credit", "license", "used_by", "source_url", "license_url"}. +const ASSET_CREDITS := "res://scenes/credits/asset_credits.json" +const SCROLL_SPEED := 36.0 +## After a manual scroll, the list waits this long before scrolling by itself again. +const MANUAL_HOLD := 4.0 +## At the bottom, the list waits this long, then starts again from the top. +const END_HOLD := 3.0 +const MUTED := Color("afbdc9") +var scroll: ScrollContainer +var list: VBoxContainer +var back: Button +var offset := 0.0 +var hold := 1.5 +var leaving := false + +func _ready() -> void: + theme = Style.theme() + var background := ColorRect.new() + background.color = Color("101d2d") + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(background) + var margin := MarginContainer.new() + margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + for side in ["left", "right"]: + margin.add_theme_constant_override("margin_" + side, 76) + for side in ["top", "bottom"]: + margin.add_theme_constant_override("margin_" + side, 36) + add_child(margin) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 14) + margin.add_child(column) + column.add_child(Style.label("HOPPY'S ADVENTURE ARCADE", 16, Color("ff9a43"))) + column.add_child(Style.label("CREDITS", 38)) + scroll = ScrollContainer.new() + scroll.name = "Scroll" + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_SHOW_NEVER + column.add_child(scroll) + list = VBoxContainer.new() + list.name = "List" + list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list.add_theme_constant_override("separation", 6) + scroll.add_child(list) + for section in sections(): + _add_section(section) + _add_gap(18) + _add_text("Source code: MIT License. CodeRabbit and the Hoppy mascot are trademarks of" + + " CodeRabbit, Inc.", 15, MUTED) + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 24) + column.add_child(footer) + back = Style.button("Return to Main Menu", 48) + back.name = "BackButton" + footer.add_child(back) + back.pressed.connect(leave) + var hint := Style.label("", 14, MUTED) + hint.size_flags_vertical = Control.SIZE_SHRINK_CENTER + Prompts.track(hint, func() -> String: + return "%s: scroll %s: back" % [Prompts.keys_or_pad("↑ ↓", Prompts.if_pad("D-pad")), + Prompts.either("Esc", &"ui_cancel")]) + footer.add_child(hint) + back.grab_focus() + +## Every section in display order: the hand-written ones, then the generated asset credits. +static func sections() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + result.append_array(read_sections(CREDITS)) + result.append_array(read_sections(ASSET_CREDITS)) + return result + +## The "sections" array of a credits file, keeping only dictionaries with a title. +static func read_sections(path: String) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var data: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + if not (data is Dictionary and data.get("sections") is Array): + push_error("Credits: %s is missing or has no sections array" % path) + return result + for section: Variant in data.sections: + if section is Dictionary and str(section.get("title", "")) != "": + result.append(section) + return result + +func _add_section(section: Dictionary) -> void: + _add_gap(18) + _add_text(str(section.title).to_upper(), 22, Style.MINT) + for line: Variant in section.get("lines", []): + _add_text(str(line), 19) + for track: Variant in section.get("tracks", []): + if track is Dictionary: + var used_by := str(track.get("used_by", "")) + _add_text(str(track.get("title", "")) + (" · " + used_by if used_by != "" else ""), 17) + for entry: Variant in section.get("credits", []): + if not entry is Dictionary: + continue + _add_gap(4) + _add_text(str(entry.get("credit", "")), 18) + var details := PackedStringArray() + for key in ["license", "used_by"]: + if str(entry.get(key, "")) != "": + details.append(str(entry[key])) + _add_text(" · ".join(details), 14, MUTED) + var links := PackedStringArray() + for key in ["source_url", "license_url"]: + if str(entry.get(key, "")) != "": + links.append(str(entry[key])) + if not links.is_empty(): + _add_text(" ".join(links), 13, MUTED) + +func _add_text(text: String, font_size: int, color: Color = Style.CREAM) -> void: + var label := Style.label(text, font_size, color) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list.add_child(label) + +func _add_gap(height: float) -> void: + var gap := Control.new() + gap.custom_minimum_size.y = height + list.add_child(gap) + +func _process(delta: float) -> void: + # A scroll that didn't come from here (the mouse wheel) counts as a manual one. + if scroll.scroll_vertical != int(offset): + offset = scroll.scroll_vertical + hold = MANUAL_HOLD + if hold > 0.0: + hold -= delta + return + var bottom := maxf(0.0, list.size.y - scroll.size.y) + if offset >= bottom: + offset = 0.0 + hold = END_HOLD + else: + offset = minf(bottom, offset + SCROLL_SPEED * delta) + if offset >= bottom: + hold = END_HOLD + scroll.scroll_vertical = int(offset) + +func _input(event: InputEvent) -> void: + for action in [&"ui_up", &"ui_down"]: + if event.is_action_pressed(action, true): + offset = clampf(offset + (90.0 if action == &"ui_down" else -90.0), 0.0, + maxf(0.0, list.size.y - scroll.size.y)) + scroll.scroll_vertical = int(offset) + hold = MANUAL_HOLD + get_viewport().set_input_as_handled() + return + if event.is_action_pressed("ui_cancel"): + leave() + get_viewport().set_input_as_handled() + +## AppRouter ignores go() mid-transition, so a press then is dropped rather than latched. The +## autoload is looked up by path: suites preload this script before autoloads exist. +func leave() -> void: + var router := get_node(^"/root/AppRouter") + if leaving or router.busy: + return + leaving = true + router.go("hub") diff --git a/hoppys-adventure-arcade/scenes/credits/credits.gd.uid b/hoppys-adventure-arcade/scenes/credits/credits.gd.uid new file mode 100644 index 00000000..65c4fffd --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/credits.gd.uid @@ -0,0 +1 @@ +uid://cydku6vi25d8k diff --git a/hoppys-adventure-arcade/scenes/credits/credits.json b/hoppys-adventure-arcade/scenes/credits/credits.json new file mode 100644 index 00000000..4c850734 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/credits.json @@ -0,0 +1,12 @@ +{ + "about": "Hand-written credits, shown before the asset credits that tools/credits/build_credits.py generates from ASSETS_LICENSES.md (asset_credits.json). Each section has a title, optional lines, and optional tracks: {\"title\": ..., \"used_by\": ...}. Add a section, or add tracks to one.", + "sections": [ + { + "title": "Original music", + "lines": [ + "Music by Erik Thorelli" + ], + "tracks": [] + } + ] +} diff --git a/hoppys-adventure-arcade/scenes/credits/credits.tscn b/hoppys-adventure-arcade/scenes/credits/credits.tscn new file mode 100644 index 00000000..f52bbb05 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/credits.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://scenes/credits/credits.gd" id="1"] +[node name="Credits" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") diff --git a/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd b/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd index 201d91e0..e3790f43 100644 --- a/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd +++ b/hoppys-adventure-arcade/scenes/main_menu/main_menu.gd @@ -78,15 +78,24 @@ func _ready() -> void: button.pressed.connect(_launch.bind(routes[i])) var utilities := HBoxContainer.new() utilities.position = Vector2(76, 568) - utilities.add_theme_constant_override("separation", 16) + # Wider than the focus arrow's 25 px reach, so it never sits on the neighbouring button. + utilities.add_theme_constant_override("separation", 28) ui.add_child(utilities) for i in 2: var button := Style.button("Settings" if i == 0 else "Quit Game", 46) - button.custom_minimum_size.x = 202 + button.custom_minimum_size.x = 160 utilities.add_child(button) buttons.append(button) _connect_button(button, -1) button.pressed.connect(_open_settings.bind(button) if i == 0 else _open_quit.bind(button)) + # Last in `buttons`, so buttons[3] and buttons[4] stay Settings and Quit Game. + var credits := Style.button("Credits", 46) + credits.name = "CreditsButton" + credits.custom_minimum_size.x = 160 + utilities.add_child(credits) + buttons.append(credits) + _connect_button(credits, -1) + credits.pressed.connect(_launch.bind("credits")) for i in buttons.size(): buttons[i].focus_neighbor_top = buttons[i].get_path_to(buttons[(i - 1 + buttons.size()) % buttons.size()]) buttons[i].focus_neighbor_bottom = buttons[i].get_path_to(buttons[(i + 1) % buttons.size()]) diff --git a/hoppys-adventure-arcade/tests/credits_screen.gd b/hoppys-adventure-arcade/tests/credits_screen.gd new file mode 100644 index 00000000..d625b386 --- /dev/null +++ b/hoppys-adventure-arcade/tests/credits_screen.gd @@ -0,0 +1,132 @@ +extends SceneTree +## Credits screen (scenes/credits/): the hub's Credits button opens it; it shows the hand-written +## sections (original music) and every asset credit generated from ASSETS_LICENSES.md, with each +## license; a track added to credits.json shows up; it scrolls by itself and by hand; Esc and the +## Back button return to the hub. Run headless with --script res://tests/credits_screen.gd. +const Credits := preload("res://scenes/credits/credits.gd") +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 + await process_frame + +func texts(screen: Node) -> PackedStringArray: + var result := PackedStringArray() + for child in screen.list.get_children(): + if child is Label: + result.append(child.text) + return result + +func press(action: StringName) -> void: + var event := InputEventAction.new() + event.action = action + event.pressed = true + Input.parse_input_event(event) + Input.flush_buffered_events() + await process_frame + event = event.duplicate() + event.pressed = false + Input.parse_input_event(event) + Input.flush_buffered_events() + await process_frame + +func run() -> void: + router = root.get_node("AppRouter") + var written := Credits.read_sections(Credits.CREDITS) + var generated := Credits.read_sections(Credits.ASSET_CREDITS) + check(not written.is_empty() and written[0].title == "Original music" + and "Music by Erik Thorelli" in written[0].lines and written[0].tracks is Array, + "credits.json opens with Original music, by Erik Thorelli, and a tracks list to extend") + var all_credits: Array = [] + for section in generated: + all_credits.append_array(section.credits) + check(all_credits.size() > 0, "The generated asset credits are loaded (%d)" % all_credits.size()) + + change_scene_to_file("res://scenes/main_menu/main_menu.tscn") + await wait(0.4) + var menu := current_scene + var credits_button: Button = menu.buttons[menu.buttons.size() - 1] + check(menu.buttons.size() == 6 and credits_button.text == "Credits" + and menu.buttons[3].text == "Settings" and menu.buttons[4].text == "Quit Game", + "The hub's Credits button follows Settings and Quit Game") + credits_button.pressed.emit() + await settle_route() + var screen := current_scene + check(screen != null and screen.scene_file_path == router.ROUTES["credits"], + "The Credits button opens the credits screen") + await wait(0.2) + + var shown := texts(screen) + check("ORIGINAL MUSIC" in shown and "Music by Erik Thorelli" in shown, + "The screen credits the original music to Erik Thorelli") + var missing: Array = [] + var unlicensed: Array = [] + for entry: Dictionary in all_credits: + var at := shown.find(entry.credit) + if at < 0: + missing.append(entry.credit) + elif at + 1 >= shown.size() or not shown[at + 1].begins_with(entry.license): + unlicensed.append(entry.credit) + check(missing.is_empty(), "Every asset credit is on screen (missing %s)" % [missing]) + check(unlicensed.is_empty(), + "Each credit is followed by its license (wrong for %s)" % [unlicensed]) + var attributed := 0 + var unattributed := [] + for entry: Dictionary in all_credits: + if String(entry.license).contains("CC BY"): + attributed += 1 + if not (entry.credit in shown and String(entry.credit).contains(" by ")): + unattributed.append(entry.asset) + check(unattributed.is_empty(), "Every CC BY credit (%d) is shown with its attribution" + % attributed + " (missing %s)" % [unattributed]) + + screen._add_section({"title": "Theme", "tracks": [{"title": "A New Track", "used_by": "Hub"}]}) + check("A New Track · Hub" in texts(screen), "A track added to a section's list is shown") + + # Pad the list so scrolling has room, however few assets the table holds today. + var filler: Array[String] = [] + for i in 80: + filler.append("Test line %d" % i) + screen._add_section({"title": "Scroll test", "lines": filler}) + screen.offset = 0.0 + screen.scroll.scroll_vertical = 0 + screen.hold = 1.0 + await process_frame + await process_frame + check(screen.list.size.y > screen.scroll.size.y, "The padded credits are longer than the screen") + await wait(screen.hold + 1.2) + check(screen.scroll.scroll_vertical > 0, "The credits scroll by themselves") + var before: int = screen.scroll.scroll_vertical + await press(&"ui_down") + check(screen.scroll.scroll_vertical >= before + 80, "Down scrolls by hand") + await wait(0.5) + check(screen.scroll.scroll_vertical == before + 90 or screen.hold > 0.0, + "A manual scroll pauses the automatic one") + + await press(&"ui_cancel") + await settle_route() + check(current_scene.name == "MainMenu", "Esc returns to the hub") + router.go("credits") + await settle_route() + current_scene.back.pressed.emit() + await settle_route() + check(current_scene.name == "MainMenu", "Return to Main Menu returns to the hub") + + await root.get_node("AudioService").settle() + print("Credits screen failures: ", failures) + quit(1 if failures else 0) diff --git a/hoppys-adventure-arcade/tests/credits_screen.gd.uid b/hoppys-adventure-arcade/tests/credits_screen.gd.uid new file mode 100644 index 00000000..56a5ea1a --- /dev/null +++ b/hoppys-adventure-arcade/tests/credits_screen.gd.uid @@ -0,0 +1 @@ +uid://bnapusoqhmj8r diff --git a/tools/credits/build_credits.py b/tools/credits/build_credits.py new file mode 100644 index 00000000..04646be6 --- /dev/null +++ b/tools/credits/build_credits.py @@ -0,0 +1,134 @@ +"""Generate the in-game asset credits from ASSETS_LICENSES.md, so the two can't drift. + +Usage: + python3 tools/credits/build_credits.py # rewrite the credits data + python3 tools/credits/build_credits.py --check # exit 1 if it is out of date (CI) + +Every row of the "Third-party" table becomes one credit, except assets the game doesn't use +("Not referenced"). A credit carries the asset's title, creator and license, a source and license +link, and the attribution line the table asks for ("Credit: ..."), or "" by <creator>. +Rows are grouped into Music, Sound effects and Art. The credits screen +(hoppys-adventure-arcade/scenes/credits/) shows these after the hand-written sections in +credits.json. Standard library only. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LICENSES = ROOT / "ASSETS_LICENSES.md" +OUTPUT = ROOT / "hoppys-adventure-arcade" / "scenes" / "credits" / "asset_credits.json" +SECTIONS = ("Music", "Sound effects", "Art") + +LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)") +PARENTHESES = re.compile(r"\s*\([^()]*\)") +LABELLED = re.compile(r"^[A-Z][A-Za-z ]*: ") +CREDIT = re.compile(r"Credit: (.+?)(?:;|\.(?:\s|$))") + + +def plain(text: str) -> str: + """Markdown cell text without links, code spans or bold.""" + return LINK.sub(r"\1", text).replace("`", "").replace("**", "").strip() + + +def table(markdown: str, heading: str) -> list[dict[str, str]]: + """The rows of the first table under `## <heading>`, keyed by column name (raw cell text).""" + lines = markdown.splitlines() + try: + start = lines.index(f"## {heading}") + except ValueError: + raise ValueError(f"no '## {heading}' section") from None + rows: list[list[str]] = [] + for line in lines[start + 1:]: + if line.startswith("## "): + break + if line.startswith("|"): + rows.append([cell.strip() for cell in line.strip().strip("|").split("|")]) + elif rows: + break + if len(rows) < 2: + raise ValueError(f"no table under '## {heading}'") + header, body = rows[0], rows[2:] + return [dict(zip(header, row)) for row in body if len(row) == len(header)] + + +def license_name(cell: str) -> str: + """The license in a License cell: its first sentence, without parenthetical notes. Sentences + shaped "Label: value" stay together, as in "Art: public domain. Music: free with credit".""" + sentences = PARENTHESES.sub("", plain(cell)).split(". ") + name = sentences[0] + if LABELLED.match(name): + for sentence in sentences[1:]: + if not LABELLED.match(sentence): + break + name += ". " + sentence + return name.rstrip(".").strip() + + +def credit(row: dict[str, str]) -> dict[str, str]: + asset, creator, cell = plain(row["Asset"]), plain(row["Creator"]), row["License"] + links = [url for _, url in LINK.findall(cell)] + license_url = next((url for url in links if "creativecommons.org" in url), "") + source_url = next((url for url in links if "creativecommons.org" not in url), "") + if not source_url: + source_url = next((url for _, url in LINK.findall(row["Creator"])), "") + stated = CREDIT.search(plain(cell)) + return { + "asset": asset, + "credit": stated.group(1).strip() if stated else f"\"{asset}\" by {creator}", + "creator": creator, + "license": license_name(cell), + "license_url": license_url, + "source_url": source_url, + "used_by": plain(row["Used by"]), + } + + +def section(row: dict[str, str]) -> str: + path, used_by = plain(row["Path"]), plain(row["Used by"]) + if "SFX" in used_by.split() or used_by.endswith("SFX"): + return "Sound effects" + return "Music" if path.startswith("Audio/") else "Art" + + +def build(markdown: str) -> dict: + grouped: dict[str, list[dict[str, str]]] = {name: [] for name in SECTIONS} + for row in table(markdown, "Third-party"): + if plain(row["Used by"]).lower() == "not referenced": + continue + grouped[section(row)].append(credit(row)) + return { + "generated_by": "tools/credits/build_credits.py from ASSETS_LICENSES.md; do not edit by hand", + "sections": [{"title": name, "credits": grouped[name]} for name in SECTIONS if grouped[name]], + } + + +def render(data: dict) -> str: + return json.dumps(data, indent=1, ensure_ascii=False) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true", help="fail if the credits data is out of date") + args = parser.parse_args(argv) + expected = render(build(LICENSES.read_text(encoding="utf-8"))) + current = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else "" + if args.check: + if current != expected: + print(f"{OUTPUT.relative_to(ROOT)} is out of date with ASSETS_LICENSES.md; " + "run python3 tools/credits/build_credits.py", file=sys.stderr) + return 1 + print("Credits match ASSETS_LICENSES.md.") + return 0 + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(expected, encoding="utf-8") + print(f"Wrote {OUTPUT.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/credits/test_build_credits.py b/tools/credits/test_build_credits.py new file mode 100644 index 00000000..3dd68a8e --- /dev/null +++ b/tools/credits/test_build_credits.py @@ -0,0 +1,112 @@ +"""Unit tests for build_credits.py. Run: python3 -m unittest discover -s tools/credits -v""" +from __future__ import annotations + +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import build_credits # noqa: E402 + +SAMPLE = """# Asset licenses + +## Third-party + +| Asset | Path | Creator | License | Used by | Status | +|---|---|---|---|---|---| +| Retro SFX | `Audio/SFX pack/` | Juhani Junkala | CC0 (`INFO.txt`) | SFX | ✅ | +| Forest | `Backgrounds/forest/` | Luis Zuno (@ansimuz); music by Pascal Belisle | Art: public domain. Music: free with credit (`license.txt`). The bundled demo is MIT. | Platformer jump/hurt SFX | ✅ | +| Skyline | `Backgrounds/skyline/` | Fábio | CC0 (`Read.txt`) | Not referenced | ✅ | +| Street | `Backgrounds/street/` | Luis Zuno ([ansimuz.com](https://ansimuz.itch.io/street)) | CC0 (bundled `license.pdf`). Older listing says CC BY 3.0. | Main menu preview | ✅ | +| Valor of Heroes | `Audio/Orbital/Valor of Heroes.mp3` | Trevor Lentz | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) ([OpenGameArt](https://opengameart.org/content/valor-of-heroes), byte-identical). Credit: "Valor of Heroes" by Trevor Lentz. Preserve ShareAlike terms. | Orbital Ops: boss | ⚠️ | +| Frivolous Hero | `Audio/HvV/frivolous_hero.ogg` | Alexandr Zhelanov | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([OpenGameArt](https://opengameart.org/content/frivolous-hero)). Credit: "Frivolous Hero" by Alexandr Zhelanov; retain the creator link. | Heroes vs. Villains | ✅ | + +### Notes + +Not a table. + +## Project-original + +| Asset | Path | Notes | Status | +|---|---|---|---| +| Hoppy | `Character/` | Made here | ⚠️ | +""" + + +class BuildCreditsTest(unittest.TestCase): + def setUp(self) -> None: + self.data = build_credits.build(SAMPLE) + self.by_asset = {c["asset"]: c for s in self.data["sections"] for c in s["credits"]} + + def section_of(self, asset: str) -> str: + return next(s["title"] for s in self.data["sections"] if any(c["asset"] == asset for c in s["credits"])) + + def test_groups_rows_and_skips_unused_assets(self) -> None: + self.assertEqual([s["title"] for s in self.data["sections"]], ["Music", "Sound effects", "Art"]) + self.assertNotIn("Skyline", self.by_asset) + self.assertNotIn("Hoppy", self.by_asset, "only the Third-party table is read") + self.assertEqual(self.section_of("Retro SFX"), "Sound effects") + self.assertEqual(self.section_of("Forest"), "Sound effects") + self.assertEqual(self.section_of("Valor of Heroes"), "Music") + self.assertEqual(self.section_of("Street"), "Art") + + def test_uses_the_stated_credit_line_and_links(self) -> None: + valor = self.by_asset["Valor of Heroes"] + self.assertEqual(valor["credit"], '"Valor of Heroes" by Trevor Lentz') + self.assertEqual(valor["license"], "CC BY-SA 3.0") + self.assertEqual(valor["license_url"], "https://creativecommons.org/licenses/by-sa/3.0/") + self.assertEqual(valor["source_url"], "https://opengameart.org/content/valor-of-heroes") + self.assertEqual(self.by_asset["Frivolous Hero"]["credit"], '"Frivolous Hero" by Alexandr Zhelanov') + + def test_composes_a_credit_and_a_short_license_otherwise(self) -> None: + sfx = self.by_asset["Retro SFX"] + self.assertEqual(sfx["credit"], '"Retro SFX" by Juhani Junkala') + self.assertEqual(sfx["license"], "CC0") + self.assertEqual(self.by_asset["Forest"]["license"], "Art: public domain. Music: free with credit") + street = self.by_asset["Street"] + self.assertEqual(street["license"], "CC0") + self.assertEqual(street["creator"], "Luis Zuno (ansimuz.com)") + self.assertEqual(street["source_url"], "https://ansimuz.itch.io/street", "falls back to the creator's link") + + def test_missing_section_or_table_is_an_error(self) -> None: + with self.assertRaisesRegex(ValueError, "no '## Third-party' section"): + build_credits.build("# Asset licenses\n") + with self.assertRaisesRegex(ValueError, "no table"): + build_credits.build("## Third-party\n\nNothing yet.\n") + + def test_the_real_file_credits_every_cc_by_track(self) -> None: + markdown = build_credits.LICENSES.read_text(encoding="utf-8") + credits = {c["asset"]: c for s in build_credits.build(markdown)["sections"] for c in s["credits"]} + attributed = [row for row in build_credits.table(markdown, "Third-party") + if "CC BY" in build_credits.license_name(row["License"]) + and build_credits.plain(row["Used by"]).lower() != "not referenced"] + for row in attributed: + asset = build_credits.plain(row["Asset"]) + self.assertIn(asset, credits, "every CC BY asset in use is credited") + self.assertIn("CC BY", credits[asset]["license"], asset) + self.assertIn(" by ", credits[asset]["credit"], asset) + + def test_committed_data_is_current(self) -> None: + with redirect_stdout(io.StringIO()): + self.assertEqual(build_credits.main(["--check"]), 0, "run python3 tools/credits/build_credits.py") + + def test_check_reports_stale_data(self) -> None: + with tempfile.TemporaryDirectory() as folder: + stale = Path(folder) / "asset_credits.json" + stale.write_text("{}\n", encoding="utf-8") + with mock.patch.object(build_credits, "OUTPUT", stale), mock.patch.object(build_credits, "ROOT", Path(folder)), \ + redirect_stderr(io.StringIO()) as err: + self.assertEqual(build_credits.main(["--check"]), 1) + self.assertIn("out of date", err.getvalue()) + with mock.patch.object(build_credits, "OUTPUT", stale), mock.patch.object(build_credits, "ROOT", Path(folder)), \ + redirect_stdout(io.StringIO()): + self.assertEqual(build_credits.main([]), 0) + self.assertEqual(build_credits.main(["--check"]), 0) + + +if __name__ == "__main__": + unittest.main() From 7ad9629de0d3c4c6f9f6b587d676e62657c2394d Mon Sep 17 00:00:00 2001 From: Erik Thorelli <ethorelli@gmail.com> Date: Thu, 15 Oct 2026 20:11:46 -0700 Subject: [PATCH 2/3] fix(menu): harden the credits parser and screen [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review 5542532934 on #85: - build_credits.py splits cells on unescaped pipes only (\| stays in the cell) and fails on a row with the wrong number of cells instead of dropping it. A credit line ends at a semicolon or a sentence's period, not after an initial or a common abbreviation. A link on the asset's name is its source when the License cell has none. An art pack whose music is also used (Cyberpunk Street, the runner BGM) is credited under Art and Music. - The credits screen keeps scrolling while Up or Down is held (polled, since held D-pad buttons send no repeats), and a route change back to the hub that fails lets the player try again. - Tests: an escaped-pipe row, a malformed row, initials, an asset-name link, the mixed section, and a row count taken from the raw table lines rather than the parser. The screen suite waits on a wall-clock deadline, checks the pause by position and covers held scrolling. - docs/main-menu.md says links appear where the table gives them. --- docs/main-menu.md | 2 +- .../scenes/credits/asset_credits.json | 9 +++++ .../scenes/credits/credits.gd | 31 ++++++++++++---- .../tests/credits_screen.gd | 19 +++++++--- tools/credits/build_credits.py | 37 ++++++++++++++----- tools/credits/test_build_credits.py | 24 ++++++++++++ 6 files changed, 98 insertions(+), 24 deletions(-) diff --git a/docs/main-menu.md b/docs/main-menu.md index 20d7a28d..93a9a1cf 100644 --- a/docs/main-menu.md +++ b/docs/main-menu.md @@ -27,7 +27,7 @@ Arrow keys, Tab, D-pad, or left stick navigate; Enter / gamepad A activates; Esc ## Credits -The **Credits** button, after Settings and Quit Game, opens `scenes/credits/credits.tscn` (route `credits`). It shows the hand-written sections in `scenes/credits/credits.json` first. Then come the asset credits from the Third-party table of `ASSETS_LICENSES.md`, grouped into Music, Sound effects and Art, each with its license and source link. Assets marked "Not referenced" are left out. The list scrolls by itself. Up and Down or the mouse wheel scroll it by hand, and Esc, B or **Return to Main Menu** go back to the hub. +The **Credits** button, after Settings and Quit Game, opens `scenes/credits/credits.tscn` (route `credits`). It shows the hand-written sections in `scenes/credits/credits.json` first. Then come the asset credits from the Third-party table of `ASSETS_LICENSES.md`, grouped into Music, Sound effects and Art, each with its license and, where the table gives them, its source and license links. An art pack whose music the game also uses appears under Art and Music; assets marked "Not referenced" are left out. The list scrolls by itself. Up and Down (held, for continuous scrolling) or the mouse wheel scroll it by hand, and Esc, B or **Return to Main Menu** go back to the hub. - **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. diff --git a/hoppys-adventure-arcade/scenes/credits/asset_credits.json b/hoppys-adventure-arcade/scenes/credits/asset_credits.json index 1f3edb3d..9312a004 100644 --- a/hoppys-adventure-arcade/scenes/credits/asset_credits.json +++ b/hoppys-adventure-arcade/scenes/credits/asset_credits.json @@ -4,6 +4,15 @@ { "title": "Music", "credits": [ + { + "asset": "Cyberpunk Street (layers + BGM)", + "credit": "\"Cyberpunk Street (layers + BGM)\" by Luis Zuno (ansimuz.com); music composer not credited", + "creator": "Luis Zuno (ansimuz.com); music composer not credited", + "license": "CC0", + "license_url": "", + "source_url": "https://ansimuz.itch.io/cyberpunk-street-environment", + "used_by": "Main menu preview, runner BGM" + }, { "asset": "MERGE SUCCESSFUL jingle and victory loop", "credit": "\"MERGE SUCCESSFUL jingle and victory loop\" by Original project compositions and square-wave synthesis; no third-party samples", diff --git a/hoppys-adventure-arcade/scenes/credits/credits.gd b/hoppys-adventure-arcade/scenes/credits/credits.gd index 9f179f31..c8fa1a0c 100644 --- a/hoppys-adventure-arcade/scenes/credits/credits.gd +++ b/hoppys-adventure-arcade/scenes/credits/credits.gd @@ -13,6 +13,11 @@ const ASSET_CREDITS := "res://scenes/credits/asset_credits.json" const SCROLL_SPEED := 36.0 ## After a manual scroll, the list waits this long before scrolling by itself again. const MANUAL_HOLD := 4.0 +## One press of Up or Down moves the list this far; holding it scrolls this fast. Held D-pad +## buttons send no repeat events, so holding is polled each frame. +const STEP := 90.0 +const HELD_SPEED := 540.0 +const HELD_AFTER := 0.3 ## At the bottom, the list waits this long, then starts again from the top. const END_HOLD := 3.0 const MUTED := Color("afbdc9") @@ -22,6 +27,8 @@ var back: Button var offset := 0.0 var hold := 1.5 var leaving := false +## How long Up or Down has been held, to start continuous scrolling after HELD_AFTER. +var held := 0.0 func _ready() -> void: theme = Style.theme() @@ -133,6 +140,11 @@ func _process(delta: float) -> void: if scroll.scroll_vertical != int(offset): offset = scroll.scroll_vertical hold = MANUAL_HOLD + var direction := Input.get_axis(&"ui_up", &"ui_down") + held = held + delta if absf(direction) > 0.5 else 0.0 + if held > HELD_AFTER: + _scroll_by(signf(direction) * HELD_SPEED * delta) + return if hold > 0.0: hold -= delta return @@ -149,21 +161,26 @@ func _process(delta: float) -> void: func _input(event: InputEvent) -> void: for action in [&"ui_up", &"ui_down"]: if event.is_action_pressed(action, true): - offset = clampf(offset + (90.0 if action == &"ui_down" else -90.0), 0.0, - maxf(0.0, list.size.y - scroll.size.y)) - scroll.scroll_vertical = int(offset) - hold = MANUAL_HOLD + _scroll_by(STEP if action == &"ui_down" else -STEP) get_viewport().set_input_as_handled() return if event.is_action_pressed("ui_cancel"): leave() get_viewport().set_input_as_handled() -## AppRouter ignores go() mid-transition, so a press then is dropped rather than latched. The -## autoload is looked up by path: suites preload this script before autoloads exist. +## Move the list by `pixels` within its bounds; a manual scroll pauses the automatic one. +func _scroll_by(pixels: float) -> void: + offset = clampf(offset + pixels, 0.0, maxf(0.0, list.size.y - scroll.size.y)) + scroll.scroll_vertical = int(offset) + hold = MANUAL_HOLD + +## AppRouter ignores go() mid-transition, so a press then is dropped rather than latched, and a +## route change that fails lets the player try again. The autoload is looked up by path: suites +## preload this script before autoloads exist. func leave() -> void: var router := get_node(^"/root/AppRouter") if leaving or router.busy: return leaving = true - router.go("hub") + if not await router.go("hub"): + leaving = false diff --git a/hoppys-adventure-arcade/tests/credits_screen.gd b/hoppys-adventure-arcade/tests/credits_screen.gd index d625b386..b553bc5e 100644 --- a/hoppys-adventure-arcade/tests/credits_screen.gd +++ b/hoppys-adventure-arcade/tests/credits_screen.gd @@ -18,11 +18,13 @@ func check(ok: bool, message: String) -> void: func wait(seconds: float) -> void: await create_timer(seconds, true).timeout +## Wait for the route change to finish, up to five seconds of wall time; a change that doesn't +## finish is a failed check. func settle_route() -> void: - for i in 300: - if not router.busy: - break + var deadline := Time.get_ticks_msec() + 5000 + while router.busy and Time.get_ticks_msec() < deadline: await process_frame + check(not router.busy, "The route change finishes") await process_frame func texts(screen: Node) -> PackedStringArray: @@ -113,10 +115,15 @@ func run() -> void: check(screen.scroll.scroll_vertical > 0, "The credits scroll by themselves") var before: int = screen.scroll.scroll_vertical await press(&"ui_down") - check(screen.scroll.scroll_vertical >= before + 80, "Down scrolls by hand") + var stepped: int = screen.scroll.scroll_vertical + check(stepped >= before + 80, "Down scrolls by hand") await wait(0.5) - check(screen.scroll.scroll_vertical == before + 90 or screen.hold > 0.0, - "A manual scroll pauses the automatic one") + check(screen.scroll.scroll_vertical == stepped, "A manual scroll pauses the automatic one") + Input.action_press(&"ui_down") + await wait(0.8) + var while_held: int = screen.scroll.scroll_vertical + Input.action_release(&"ui_down") + check(while_held > stepped + screen.STEP, "Holding Down keeps scrolling") await press(&"ui_cancel") await settle_route() diff --git a/tools/credits/build_credits.py b/tools/credits/build_credits.py index 04646be6..d744bb4a 100644 --- a/tools/credits/build_credits.py +++ b/tools/credits/build_credits.py @@ -7,7 +7,8 @@ Every row of the "Third-party" table becomes one credit, except assets the game doesn't use ("Not referenced"). A credit carries the asset's title, creator and license, a source and license link, and the attribution line the table asks for ("Credit: ..."), or "<title>" by <creator>. -Rows are grouped into Music, Sound effects and Art. The credits screen +Rows are grouped into Music, Sound effects and Art; an art pack whose music the game also uses (its +"Used by" mentions BGM or music) appears under Art and Music. The credits screen (hoppys-adventure-arcade/scenes/credits/) shows these after the hand-written sections in credits.json. Standard library only. """ @@ -27,7 +28,13 @@ LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)") PARENTHESES = re.compile(r"\s*\([^()]*\)") LABELLED = re.compile(r"^[A-Z][A-Za-z ]*: ") -CREDIT = re.compile(r"Credit: (.+?)(?:;|\.(?:\s|$))") +# A credit line runs to a semicolon or a sentence-ending period. A period after an initial +# ("Jane Q. Public") or a common abbreviation doesn't end it. +CREDIT = re.compile(r"Credit: (.+?)(?:;|(?<! [A-Z])(?<!\bJr)(?<!\bSr)(?<!\bDr)(?<!\bSt)(?<!\bCo)(?<!\bInc)(?<!\bLtd)" + r"\.(?:\s|$))") +# Cells split on pipes that aren't escaped; "\|" stays in the cell as a pipe. +CELL_SPLIT = re.compile(r"(?<!\\)\|") +MUSIC_USE = re.compile(r"\b(?:BGM|music)\b", re.I) def plain(text: str) -> str: @@ -47,13 +54,17 @@ def table(markdown: str, heading: str) -> list[dict[str, str]]: if line.startswith("## "): break if line.startswith("|"): - rows.append([cell.strip() for cell in line.strip().strip("|").split("|")]) + inner = line.strip()[1:-1] if line.strip().endswith("|") else line.strip()[1:] + rows.append([cell.strip().replace("\\|", "|") for cell in CELL_SPLIT.split(inner)]) elif rows: break if len(rows) < 2: raise ValueError(f"no table under '## {heading}'") header, body = rows[0], rows[2:] - return [dict(zip(header, row)) for row in body if len(row) == len(header)] + for row in body: + if len(row) != len(header): + raise ValueError(f"a row under '## {heading}' has {len(row)} cells, not {len(header)}: {row[0]}") + return [dict(zip(header, row)) for row in body] def license_name(cell: str) -> str: @@ -73,9 +84,11 @@ def credit(row: dict[str, str]) -> dict[str, str]: asset, creator, cell = plain(row["Asset"]), plain(row["Creator"]), row["License"] links = [url for _, url in LINK.findall(cell)] license_url = next((url for url in links if "creativecommons.org" in url), "") + # The source: a non-license link in the License cell, else one on the asset's or creator's name. source_url = next((url for url in links if "creativecommons.org" not in url), "") - if not source_url: - source_url = next((url for _, url in LINK.findall(row["Creator"])), "") + for other in ("Asset", "Creator"): + if not source_url: + source_url = next((url for _, url in LINK.findall(row[other])), "") stated = CREDIT.search(plain(cell)) return { "asset": asset, @@ -88,11 +101,14 @@ def credit(row: dict[str, str]) -> dict[str, str]: } -def section(row: dict[str, str]) -> str: +def sections(row: dict[str, str]) -> list[str]: + """The sections a row belongs in: an art pack whose music is used too goes in Art and Music.""" path, used_by = plain(row["Path"]), plain(row["Used by"]) if "SFX" in used_by.split() or used_by.endswith("SFX"): - return "Sound effects" - return "Music" if path.startswith("Audio/") else "Art" + return ["Sound effects"] + if path.startswith("Audio/"): + return ["Music"] + return ["Art", "Music"] if MUSIC_USE.search(used_by) else ["Art"] def build(markdown: str) -> dict: @@ -100,7 +116,8 @@ def build(markdown: str) -> dict: for row in table(markdown, "Third-party"): if plain(row["Used by"]).lower() == "not referenced": continue - grouped[section(row)].append(credit(row)) + for name in sections(row): + grouped[name].append(credit(row)) return { "generated_by": "tools/credits/build_credits.py from ASSETS_LICENSES.md; do not edit by hand", "sections": [{"title": name, "credits": grouped[name]} for name in SECTIONS if grouped[name]], diff --git a/tools/credits/test_build_credits.py b/tools/credits/test_build_credits.py index 3dd68a8e..3d0a0647 100644 --- a/tools/credits/test_build_credits.py +++ b/tools/credits/test_build_credits.py @@ -24,6 +24,8 @@ | Street | `Backgrounds/street/` | Luis Zuno ([ansimuz.com](https://ansimuz.itch.io/street)) | CC0 (bundled `license.pdf`). Older listing says CC BY 3.0. | Main menu preview | ✅ | | Valor of Heroes | `Audio/Orbital/Valor of Heroes.mp3` | Trevor Lentz | [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/) ([OpenGameArt](https://opengameart.org/content/valor-of-heroes), byte-identical). Credit: "Valor of Heroes" by Trevor Lentz. Preserve ShareAlike terms. | Orbital Ops: boss | ⚠️ | | Frivolous Hero | `Audio/HvV/frivolous_hero.ogg` | Alexandr Zhelanov | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([OpenGameArt](https://opengameart.org/content/frivolous-hero)). Credit: "Frivolous Hero" by Alexandr Zhelanov; retain the creator link. | Heroes vs. Villains | ✅ | +| Jump \| Land | `Audio/SFX/jump_land.ogg` | Jane Q. Public | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Credit: "Jump \| Land" by Jane Q. Public. Keep this note. | Platformer jump SFX | ✅ | +| [Night Loop](https://example.org/night-loop) | `Backgrounds/night/` | Studio | CC0 | Runner background and BGM | ✅ | ### Notes @@ -53,6 +55,8 @@ def test_groups_rows_and_skips_unused_assets(self) -> None: self.assertEqual(self.section_of("Forest"), "Sound effects") self.assertEqual(self.section_of("Valor of Heroes"), "Music") self.assertEqual(self.section_of("Street"), "Art") + self.assertEqual([s["title"] for s in self.data["sections"] if any(c["asset"] == "Night Loop" for c in s["credits"])], + ["Music", "Art"], "an art pack whose music is used too is credited under both") def test_uses_the_stated_credit_line_and_links(self) -> None: valor = self.by_asset["Valor of Heroes"] @@ -72,12 +76,32 @@ def test_composes_a_credit_and_a_short_license_otherwise(self) -> None: self.assertEqual(street["creator"], "Luis Zuno (ansimuz.com)") self.assertEqual(street["source_url"], "https://ansimuz.itch.io/street", "falls back to the creator's link") + def test_escaped_pipes_and_initials_survive(self) -> None: + jump = self.by_asset["Jump | Land"] + self.assertEqual(jump["credit"], '"Jump | Land" by Jane Q. Public') + self.assertEqual(jump["creator"], "Jane Q. Public") + + def test_a_link_on_the_asset_name_is_its_source(self) -> None: + self.assertEqual(self.by_asset["Night Loop"]["source_url"], "https://example.org/night-loop") + + def test_a_row_with_the_wrong_number_of_cells_is_an_error(self) -> None: + broken = SAMPLE.replace("| Retro SFX | `Audio/SFX pack/` |", "| Retro SFX | `Audio/SFX | pack/` |") + with self.assertRaisesRegex(ValueError, "has 7 cells, not 6: Retro SFX"): + build_credits.build(broken) + def test_missing_section_or_table_is_an_error(self) -> None: with self.assertRaisesRegex(ValueError, "no '## Third-party' section"): build_credits.build("# Asset licenses\n") with self.assertRaisesRegex(ValueError, "no table"): build_credits.build("## Third-party\n\nNothing yet.\n") + def test_the_real_table_parses_every_row(self) -> None: + # Counted from the raw lines, not through table(), so a row the parser drops can't hide. + markdown = build_credits.LICENSES.read_text(encoding="utf-8") + section = markdown.split("## Third-party", 1)[1].split("\n## ", 1)[0] + raw_rows = [line for line in section.split("\n\n", 2)[1].splitlines() if line.startswith("|")][2:] + self.assertEqual(len(build_credits.table(markdown, "Third-party")), len(raw_rows)) + def test_the_real_file_credits_every_cc_by_track(self) -> None: markdown = build_credits.LICENSES.read_text(encoding="utf-8") credits = {c["asset"]: c for s in build_credits.build(markdown)["sections"] for c in s["credits"]} From f00085444284ac97551ab23e54f9986cecc68c5e Mon Sep 17 00:00:00 2001 From: Erik Thorelli <ethorelli@gmail.com> Date: Thu, 15 Oct 2026 20:20:22 -0700 Subject: [PATCH 3/3] fix(menu): one credits step per press, ignoring keyboard echo [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review 5542623607 on #85: holding Up or Down already scrolls continuously in _process(), so the one-step handler no longer accepts keyboard echo, which added extra steps on top. --- hoppys-adventure-arcade/scenes/credits/credits.gd | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hoppys-adventure-arcade/scenes/credits/credits.gd b/hoppys-adventure-arcade/scenes/credits/credits.gd index c8fa1a0c..44c3d59b 100644 --- a/hoppys-adventure-arcade/scenes/credits/credits.gd +++ b/hoppys-adventure-arcade/scenes/credits/credits.gd @@ -159,8 +159,9 @@ func _process(delta: float) -> void: scroll.scroll_vertical = int(offset) func _input(event: InputEvent) -> void: + # One step per press; keyboard echo is ignored, since holding is handled in _process(). for action in [&"ui_up", &"ui_down"]: - if event.is_action_pressed(action, true): + if event.is_action_pressed(action): _scroll_by(STEP if action == &"ui_down" else -STEP) get_viewport().set_input_as_handled() return