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..93a9a1cf 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, 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. + ## 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..9312a004 --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/asset_credits.json @@ -0,0 +1,92 @@ +{ + "generated_by": "tools/credits/build_credits.py from ASSETS_LICENSES.md; do not edit by hand", + "sections": [ + { + "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", + "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..44c3d59b --- /dev/null +++ b/hoppys-adventure-arcade/scenes/credits/credits.gd @@ -0,0 +1,187 @@ +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 +## 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") +var scroll: ScrollContainer +var list: VBoxContainer +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() + 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 + 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 + 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: + # 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): + _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() + +## 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 + if not await router.go("hub"): + leaving = false 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..b553bc5e --- /dev/null +++ b/hoppys-adventure-arcade/tests/credits_screen.gd @@ -0,0 +1,139 @@ +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 + +## 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: + 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: + 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") + var stepped: int = screen.scroll.scroll_vertical + check(stepped >= before + 80, "Down scrolls by hand") + await wait(0.5) + 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() + 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..d744bb4a --- /dev/null +++ b/tools/credits/build_credits.py @@ -0,0 +1,151 @@ +"""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; 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. +""" +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 ]*: ") +# 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: + """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("|"): + 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:] + 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: + """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), "") + # 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), "") + 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, + "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 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"] + if path.startswith("Audio/"): + return ["Music"] + return ["Art", "Music"] if MUSIC_USE.search(used_by) 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 + 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]], + } + + +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..3d0a0647 --- /dev/null +++ b/tools/credits/test_build_credits.py @@ -0,0 +1,136 @@ +"""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 | ✅ | +| 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 + +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") + 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"] + 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_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"]} + 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()