Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/main-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion hoppys-adventure-arcade/app/app_router.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<game> --headless -- --smoke-routes`): load every route, then quit
## nonzero on the first failure. tools/build/export_game.py runs it against each export.
Expand Down
2 changes: 1 addition & 1 deletion hoppys-adventure-arcade/app/controller_service.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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 := {
Expand Down
92 changes: 92 additions & 0 deletions hoppys-adventure-arcade/scenes/credits/asset_credits.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
187 changes: 187 additions & 0 deletions hoppys-adventure-arcade/scenes/credits/credits.gd
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/scenes/credits/credits.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://cydku6vi25d8k
12 changes: 12 additions & 0 deletions hoppys-adventure-arcade/scenes/credits/credits.json
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}
10 changes: 10 additions & 0 deletions hoppys-adventure-arcade/scenes/credits/credits.tscn
Original file line number Diff line number Diff line change
@@ -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")
13 changes: 11 additions & 2 deletions hoppys-adventure-arcade/scenes/main_menu/main_menu.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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()])
Expand Down
Loading
Loading