diff --git a/.serena/project.yml b/.serena/project.yml index 0abd05a..90a1c0f 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -169,3 +169,17 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL +# that would otherwise be disabled (particularly optional methods, which are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_apis: [] + +# list of APIs (facades or facade methods, e.g. "lsp" or "lsp.find_symbol") to exclude from the REPL. +# This extends the existing exclusions (e.g. from the global configuration). +excluded_apis: [] + +# The interface through which the agent (LLM) accesses Serena's functionality (overrides the global setting). +# Valid values: tools, REPL (see the global configuration for details); leave empty to use the global setting. +# Note: the interface is fixed at startup. If a project is activated post-init, its setting is not applied. +agent_interface: diff --git a/game/content/loader.gd b/game/content/loader.gd index a7a23eb..fb4563a 100644 --- a/game/content/loader.gd +++ b/game/content/loader.gd @@ -55,6 +55,10 @@ const TUNING_SCHEMA := { "grid_line_alpha": "number", "device_pixel_ratio_cap": "number", "pause_vignette_alpha": "number", + "wall_border_width_px": "number", + "wall_border_alpha": "number", + "wall_border_wrap_alpha": "number", + "wall_border_wrap_dash_fraction": "number", }, "input": { "swipe_threshold_cell_fraction": "number", @@ -82,6 +86,7 @@ const PALETTE_SCHEMA := { "background": TYPE_STRING, "checkerboard_tile": TYPE_STRING, "grid_line": TYPE_STRING, + "wall_border": TYPE_STRING, "food_fill": TYPE_STRING, "food_shadow": TYPE_STRING, "snake_head_fill": TYPE_STRING, diff --git a/game/content/palette.json b/game/content/palette.json index 59968ab..bfabb09 100644 --- a/game/content/palette.json +++ b/game/content/palette.json @@ -16,14 +16,26 @@ "background": "#0c1628", "checkerboard_tile": "#ffffff", "grid_line": "#38bdf8", + "wall_border": "#e2e8f0", "food_fill": "#ef4444", "food_shadow": "#f87171", "snake_head_fill": "#a7f3d0", "snake_head_shadow": "#4ade80", - "snake_body_base_rgb": [74, 222, 128], - "snake_body_rgb_delta": [30, -20, 30], + "snake_body_base_rgb": [ + 74, + 222, + 128 + ], + "snake_body_rgb_delta": [ + 30, + -20, + 30 + ], "eye_fill": "#062c1a", - "particle_hues": ["#f87171", "#fbbf24"], + "particle_hues": [ + "#f87171", + "#fbbf24" + ], "flash_dead": "#ef4444", "flash_eat": "#fbbf24", "pause_vignette": "#060b16" diff --git a/game/content/tuning.json b/game/content/tuning.json index 04360c4..204e653 100644 --- a/game/content/tuning.json +++ b/game/content/tuning.json @@ -34,7 +34,11 @@ "checkerboard_alpha": 0.019, "grid_line_alpha": 0.055, "device_pixel_ratio_cap": 2, - "pause_vignette_alpha": 0.55 + "pause_vignette_alpha": 0.55, + "wall_border_width_px": 2.5, + "wall_border_alpha": 0.3, + "wall_border_wrap_alpha": 0.1, + "wall_border_wrap_dash_fraction": 0.55 }, "input": { "swipe_threshold_cell_fraction": 0.5 diff --git a/game/main.tscn b/game/main.tscn index 9ac1dfe..7d333b4 100644 --- a/game/main.tscn +++ b/game/main.tscn @@ -6,5 +6,5 @@ layout_mode = 3 anchors_preset = 0 offset_right = 520.0 -offset_bottom = 600.0 +offset_bottom = 545.0 script = ExtResource("1") diff --git a/game/presentation/board/board_geometry.gd b/game/presentation/board/board_geometry.gd index db3023a..fc59fba 100644 --- a/game/presentation/board/board_geometry.gd +++ b/game/presentation/board/board_geometry.gd @@ -9,7 +9,7 @@ extends RefCounted ## operationalized as data so board_view.gd's _draw() order can be pinned by ## a test instead of relying on code review alone (AC#4). const DRAW_LAYER_ORDER := [ - "background", "checkerboard", "grid", "food", "snake", "eyes", + "background", "checkerboard", "grid", "walls", "food", "snake", "eyes", "particles", "flash", "pause_vignette", ] @@ -96,7 +96,7 @@ static func build_checkerboard_image(cols: int, rows: int, tile_color: Color, al ## Decodes the fields of docs/canonical-state.md's 44-byte header that ## board_view.gd needs and has no other accessor for (cols/rows/food -## position aren't exposed by SimulationWorld.player_view_get or +## position/wrap flag aren't exposed by SimulationWorld.player_view_get or ## .body_copy). PackedByteArray.decode_u16 is little-endian, matching the ## canonical format's own endianness. static func decode_canon_header(bytes: PackedByteArray) -> Dictionary: @@ -105,4 +105,32 @@ static func decode_canon_header(bytes: PackedByteArray) -> Dictionary: "rows": bytes.decode_u16(12), "food_x": bytes.decode_u16(40), "food_y": bytes.decode_u16(42), + "wrap": (bytes.decode_u16(14) & 1) != 0, } + +## draw_rect()'s unfilled outline is centered on its own boundary, so a +## `thickness`-px border around the board must be inset by half its own +## width on every side to stay inside the 0..cols*cell / 0..rows*cell rect +## that core/world.zig:340's bounds check actually kills you on. +static func wall_border_rect(cols: int, rows: int, cell: float, thickness: float) -> Rect2: + var half := thickness / 2.0 + return Rect2(half, half, cols * cell - thickness, rows * cell - thickness) + +## One dash per perimeter cell (`wrap` mode's edge is passable, so the +## border reads as a broken line rather than solid), each centered on that +## cell along the same half-thickness-inset boundary wall_border_rect() +## draws solid. Returns an Array of [from: Vector2, to: Vector2] pairs. +static func wall_dash_segments(cols: int, rows: int, cell: float, thickness: float, dash_fraction: float) -> Array: + var half := thickness / 2.0 + var dash := cell * dash_fraction + var pad := (cell - dash) / 2.0 + var out := [] + for x in cols: + var cx := x * cell + pad + out.append([Vector2(cx, half), Vector2(cx + dash, half)]) + out.append([Vector2(cx, rows * cell - half), Vector2(cx + dash, rows * cell - half)]) + for y in rows: + var cy := y * cell + pad + out.append([Vector2(half, cy), Vector2(half, cy + dash)]) + out.append([Vector2(cols * cell - half, cy), Vector2(cols * cell - half, cy + dash)]) + return out diff --git a/game/presentation/board/board_view.gd b/game/presentation/board/board_view.gd index db50fba..eb87357 100644 --- a/game/presentation/board/board_view.gd +++ b/game/presentation/board/board_view.gd @@ -72,6 +72,7 @@ func _draw() -> void: _draw_background(cols, rows, cell) _draw_checkerboard(cols, rows, cell) _draw_grid(cols, rows, cell) + _draw_walls(header, cols, rows, cell) _draw_food(header, cell) if body.result == SimulationWorld.OK: _draw_snake(body.cells, cell) @@ -103,6 +104,21 @@ func _draw_grid(cols: int, rows: int, cell: float) -> void: for y in BoardGeometry.grid_line_offsets(rows, cell): draw_line(Vector2(0, y), Vector2(cols * cell, y), color, 1.0) +## Solid in wall mode (the edge kills you); dim and dashed in wrap mode +## (the edge is passable) -- reference/snake.html has no equivalent, this +## is the port's own answer to grid_line_offsets() drawing interior lines +## only (board_geometry.gd:78-82) and leaving the actual kill line invisible. +func _draw_walls(header: Dictionary, cols: int, rows: int, cell: float) -> void: + var width: float = tuning.render.wall_border_width_px + var color := Color(palette.board.wall_border) + if header.wrap: + color.a = tuning.render.wall_border_wrap_alpha + for segment in BoardGeometry.wall_dash_segments(cols, rows, cell, width, tuning.render.wall_border_wrap_dash_fraction): + draw_line(segment[0], segment[1], color, width) + else: + color.a = tuning.render.wall_border_alpha + draw_rect(BoardGeometry.wall_border_rect(cols, rows, cell, width), color, false, width) + func _draw_food(header: Dictionary, cell: float) -> void: if header.food_x == BoardGeometry.NO_CELL_COORD: return diff --git a/game/presentation/screens/game_screen.gd b/game/presentation/screens/game_screen.gd index 555a95c..0fe934a 100644 --- a/game/presentation/screens/game_screen.gd +++ b/game/presentation/screens/game_screen.gd @@ -88,7 +88,7 @@ var _save_data: Dictionary var _settings_open := false func _ready() -> void: - ## project.godot's viewport_width/height (520x600) is only the design + ## project.godot's viewport_width/height (520x545) is only the design ## canvas that canvas_items/keep stretch scales up to fill the window -- ## it's also the actual initial OS window size unless overridden here, ## since window_width_override/height_override (the project-settings @@ -99,12 +99,12 @@ func _ready() -> void: ## QHD/4K display doesn't blow the board up past a sane on-screen size. ## Window.size is in physical pixels, not the OS window manager's points ## -- on a Retina/hiDPI display (screen_get_scale() > 1) an unscaled - ## Vector2i(780, 900) here nets a window that visually measures half + ## Vector2i(780, 817.5) here nets a window that visually measures half ## that on screen, so the target size must be scaled up to compensate. var screen := DisplayServer.window_get_current_screen() var display_scale := DisplayServer.screen_get_scale(screen) - get_window().size = Vector2i(Vector2(780, 900) * display_scale) - get_window().max_size = Vector2i(Vector2(936, 1080) * display_scale) + get_window().size = Vector2i(Vector2(780, 817.5) * display_scale) + get_window().max_size = Vector2i(Vector2(936, 981) * display_scale) var content := ContentLoader.load_all() if not content.ok: @@ -169,6 +169,8 @@ func _ready() -> void: overlay.return_to_title_requested.connect(_on_return_to_title_requested) overlay.mode_selected.connect(_on_mode_selected) overlay.settings_requested.connect(_on_settings_requested) + overlay.set_quit_available(not OS.has_feature("web")) + overlay.quit_requested.connect(_quit_game) settings_panel = SettingsPanel.new() settings_panel.position = board_view.position @@ -235,16 +237,21 @@ func _capture_state_arg() -> String: return arg.substr("--capture-state=".length()) return "" +## NOTIFICATION_WM_CLOSE_REQUEST fires for both a window close and a +## terminal SIGINT, so routing it through _quit_game() covers both. +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST: + _quit_game() + ## A still-looping AudioStreamOggVorbis at process exit leaks its playback ## objects (AudioStreamPlaybackOggVorbis/OggPacketSequencePlayback) -- ## Godot only releases them cleanly once AudioStreamPlayer.stop() has run, -## not merely on the node being freed. NOTIFICATION_WM_CLOSE_REQUEST fires -## for both a window close and a terminal SIGINT, so this covers both. -func _notification(what: int) -> void: - if what == NOTIFICATION_WM_CLOSE_REQUEST: - if music != null: - music.stop() - get_tree().quit() +## not merely on the node being freed. Shared by the window-close path +## above and the title screen's own Quit button. +func _quit_game() -> void: + if music != null: + music.stop() + get_tree().quit() func _process(delta: float) -> void: var pre_food_x := BoardGeometry.NO_CELL_COORD diff --git a/game/presentation/screens/game_screen_state.gd b/game/presentation/screens/game_screen_state.gd index 781f820..7abdd4c 100644 --- a/game/presentation/screens/game_screen_state.gd +++ b/game/presentation/screens/game_screen_state.gd @@ -51,6 +51,7 @@ static func overlay_content(screen: String, score: int, best: int, snake_len: in "show_mode_select": true, "show_player_select": true, "show_return_to_title": false, + "show_quit": true, "controls": CONTROLS_LEGEND, } SCREEN_PAUSED: @@ -61,6 +62,7 @@ static func overlay_content(screen: String, score: int, best: int, snake_len: in "show_mode_select": false, "show_player_select": false, "show_return_to_title": false, + "show_quit": false, "controls": "", } SCREEN_DEAD: @@ -72,6 +74,7 @@ static func overlay_content(screen: String, score: int, best: int, snake_len: in "show_mode_select": false, "show_player_select": false, "show_return_to_title": true, + "show_quit": false, "controls": "", } return { @@ -81,6 +84,7 @@ static func overlay_content(screen: String, score: int, best: int, snake_len: in "show_mode_select": false, "show_player_select": false, "show_return_to_title": true, + "show_quit": false, "controls": "", } _: diff --git a/game/presentation/screens/hud.gd b/game/presentation/screens/hud.gd index de68254..76b3388 100644 --- a/game/presentation/screens/hud.gd +++ b/game/presentation/screens/hud.gd @@ -7,39 +7,44 @@ extends Control ## (snake.html:218-223), which this task's text never asks for. Built in ## code, matching board_view.gd's code-first Control convention: this repo ## has no .tscn precedent to follow instead. +## +## Both players' fields share a single HBoxContainer row (game_screen.gd +## sets custom_minimum_size.y to the 25px strip below the board, which only +## fits one text row) rather than the two stacked rows an earlier version +## used. var _score_label := Label.new() var _best_label := Label.new() var _status_label := Label.new() -## TASK-051: player 1's own row. No best-score label -- best-score +## TASK-051: player 1's own fields. No best-score label -- best-score ## persistence stays player-0/mode-scoped only (see backlog/decisions). var _p2_score_label := Label.new() var _p2_status_label := Label.new() var _p2_box: HBoxContainer +const _FONT_SIZE := 14 + func _ready() -> void: - ## Both rows are plain Control children with no shared layout container, - ## so without this VBoxContainer wrapper they both default to (0, 0) - ## and render on top of each other. - var rows := VBoxContainer.new() - add_child(rows) - - var box := HBoxContainer.new() - box.add_theme_constant_override("separation", 16) - rows.add_child(box) - box.add_child(_score_label) - box.add_child(_best_label) - box.add_child(_status_label) + custom_minimum_size = Vector2(0, 25) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 16) + add_child(row) + row.add_child(_score_label) + row.add_child(_best_label) + row.add_child(_status_label) update(0, 0, "") _p2_box = HBoxContainer.new() _p2_box.add_theme_constant_override("separation", 16) - rows.add_child(_p2_box) + row.add_child(_p2_box) _p2_box.add_child(_p2_score_label) _p2_box.add_child(_p2_status_label) update_p2(0, "") + for label in [_score_label, _best_label, _status_label, _p2_score_label, _p2_status_label]: + label.add_theme_font_size_override("font_size", _FONT_SIZE) + ## status_text mirrors the S.status equivalent transition currently in ## effect (menu/playing/paused/dead) -- not part of the oracle's own HUD, ## but explicit in this task's Description ("status text matching S.status diff --git a/game/presentation/screens/overlay_panel.gd b/game/presentation/screens/overlay_panel.gd index 136a61a..3d6354c 100644 --- a/game/presentation/screens/overlay_panel.gd +++ b/game/presentation/screens/overlay_panel.gd @@ -21,6 +21,7 @@ signal player_count_selected(count: int) signal return_to_title_requested signal mode_selected(mode_id: String) signal settings_requested +signal quit_requested var _title_label := Label.new() var _sub_label := Label.new() @@ -42,6 +43,14 @@ var _action_button := Button.new() var _return_button := Button.new() var _settings_button := Button.new() + +## Menu-only, same as the mode select/player-count buttons. Hidden outright +## on a platform where quitting is impossible (a browser tab -- see +## game_screen.gd's OS.has_feature("web") check) via set_quit_available(), +## not just left visible and inert. +var _quit_button := Button.new() +var _quit_available := true + var _mode_ids: Array[String] = [] ## Tracks the visible->hidden/hidden->visible edge across configure() calls @@ -73,12 +82,15 @@ func _ready() -> void: box.add_child(_return_button) _settings_button.text = "Settings" box.add_child(_settings_button) + _quit_button.text = "Quit" + box.add_child(_quit_button) _action_button.pressed.connect(func() -> void: action_pressed.emit()) _return_button.pressed.connect(func() -> void: return_to_title_requested.emit()) _one_player_button.pressed.connect(func() -> void: player_count_selected.emit(1)) _two_player_button.pressed.connect(func() -> void: player_count_selected.emit(2)) _mode_select.item_selected.connect(_on_item_selected) _settings_button.pressed.connect(func() -> void: settings_requested.emit()) + _quit_button.pressed.connect(func() -> void: quit_requested.emit()) func _on_item_selected(index: int) -> void: mode_selected.emit(_mode_ids[index]) @@ -118,3 +130,10 @@ func configure(content: Dictionary) -> void: _controls_label.text = content.controls _controls_label.visible = content.controls != "" _mode_select.visible = content.show_mode_select + _quit_button.visible = content.show_quit and _quit_available + +## game_screen.gd calls this once, before the first configure(), with +## `not OS.has_feature("web")` -- a browser tab can't quit itself, so +## get_tree().quit() would be a visible no-op there. +func set_quit_available(available: bool) -> void: + _quit_available = available diff --git a/game/project.godot b/game/project.godot index 84e78b9..833479e 100644 --- a/game/project.godot +++ b/game/project.godot @@ -18,7 +18,7 @@ run/main_scene="res://main.tscn" [display] window/size/viewport_width=520 -window/size/viewport_height=600 +window/size/viewport_height=545 window/stretch/mode="canvas_items" window/stretch/aspect="keep" diff --git a/game/tests/test_board_geometry.gd b/game/tests/test_board_geometry.gd index c86ea37..08894a4 100644 --- a/game/tests/test_board_geometry.gd +++ b/game/tests/test_board_geometry.gd @@ -107,10 +107,52 @@ func test_decode_canon_header_passes_through_no_cell_coord_unchanged() -> void: assert_int(header.food_y).is_equal(BoardGeometry.NO_CELL_COORD) +## docs/canonical-state.md:23 -- flags bit 0 is S.wrap. +func test_decode_canon_header_reads_wrap_flag_bit() -> void: + var wrapped := PackedByteArray() + wrapped.resize(44) + wrapped.encode_u16(14, 1) + assert_bool(BoardGeometry.decode_canon_header(wrapped).wrap).is_true() + + var walled := PackedByteArray() + walled.resize(44) + walled.encode_u16(14, 0) + assert_bool(BoardGeometry.decode_canon_header(walled).wrap).is_false() + + ## Operationalizes AC#4 (statement order matches snake.html's back-to-front ## layering) as a pinned data check rather than leaving it to code review. func test_draw_layer_order_matches_snake_htmls_back_to_front_layering() -> void: assert_array(BoardGeometry.DRAW_LAYER_ORDER).is_equal([ - "background", "checkerboard", "grid", "food", "snake", "eyes", + "background", "checkerboard", "grid", "walls", "food", "snake", "eyes", "particles", "flash", "pause_vignette", ]) + + +## draw_rect's unfilled outline is centered on the boundary, so a +## `thickness`-px border is inset by half its own width on every side. +func test_wall_border_rect_is_inset_by_half_the_border_thickness() -> void: + var rect := BoardGeometry.wall_border_rect(24, 24, 20.0, 2.5) + assert_vector(rect.position).is_equal_approx(Vector2(1.25, 1.25), Vector2(0.0001, 0.0001)) + assert_vector(rect.size).is_equal_approx(Vector2(477.5, 477.5), Vector2(0.0001, 0.0001)) + + +## One dash per perimeter cell: cols across the top + cols across the +## bottom + rows down the left + rows down the right. +func test_wall_dash_segments_has_one_dash_per_perimeter_cell() -> void: + var segments := BoardGeometry.wall_dash_segments(4, 3, 10.0, 2.0, 0.5) + assert_int(segments.size()).is_equal(2 * (4 + 3)) + + +## First dash is the top-left corner cell's own top edge, centered on that +## cell and inset by half the border thickness -- same boundary line as +## wall_border_rect's solid path. +func test_wall_dash_segments_first_dash_sits_on_the_inset_top_edge() -> void: + var segments: Array = BoardGeometry.wall_dash_segments(4, 3, 10.0, 2.0, 0.5) + var first: Array = segments[0] + var from: Vector2 = first[0] + var to: Vector2 = first[1] + assert_float(from.y).is_equal_approx(1.0, 0.0001) + assert_float(to.y).is_equal_approx(1.0, 0.0001) + assert_float(from.x).is_equal_approx(2.5, 0.0001) + assert_float(to.x).is_equal_approx(7.5, 0.0001) diff --git a/game/tests/test_content_loader.gd b/game/tests/test_content_loader.gd index 5f98d13..824694f 100644 --- a/game/tests/test_content_loader.gd +++ b/game/tests/test_content_loader.gd @@ -38,6 +38,13 @@ func test_load_palette_from_the_real_content_file_succeeds() -> void: var result := ContentLoaderScript.load_palette() assert_bool(result.ok).is_true() assert_str(result.data.ui.snake).is_equal("#4ade80") + assert_str(result.data.board.wall_border).is_equal("#e2e8f0") + + +func test_load_tuning_from_the_real_content_file_includes_wall_border_tuning() -> void: + var result := ContentLoaderScript.load_tuning() + assert_bool(result.ok).is_true() + assert_float(result.data.render.wall_border_width_px).is_equal_approx(2.5, 0.0001) func test_load_modes_from_the_real_content_file_succeeds() -> void: @@ -76,6 +83,17 @@ func test_wrong_field_type_reports_a_clear_error() -> void: assert_str(result.error).contains("expected an array") +func test_missing_wall_border_key_reports_a_clear_error() -> void: + var path := _write_temp("missing_wall_border.json", JSON.stringify({ + "version": 1, + "ui": {"bg": "#0f172a", "panel": "#111c33", "grid_a": "#16233f", "grid_b": "#13203a", "snake": "#4ade80", "snake_head": "#a7f3d0", "food": "#f87171", "text": "#e2e8f0", "muted": "#7f8ea8", "accent": "#38bdf8"}, + "board": {"background": "#0c1628", "checkerboard_tile": "#ffffff", "grid_line": "#38bdf8", "food_fill": "#ef4444", "food_shadow": "#f87171", "snake_head_fill": "#a7f3d0", "snake_head_shadow": "#4ade80", "snake_body_base_rgb": [74, 222, 128], "snake_body_rgb_delta": [30, -20, 30], "eye_fill": "#062c1a", "particle_hues": ["#f87171", "#fbbf24"], "flash_dead": "#ef4444", "flash_eat": "#fbbf24", "pause_vignette": "#060b16"}, + })) + var result := ContentLoaderScript.load_palette(path) + assert_bool(result.ok).is_false() + assert_str(result.error).contains("missing required key 'wall_border'") + + func test_missing_file_reports_a_clear_error() -> void: var result := ContentLoaderScript.load_tuning("res://content/does_not_exist.json") assert_bool(result.ok).is_false() @@ -89,7 +107,7 @@ func test_best_score_defaults_mismatched_with_modes_reports_a_clear_error() -> v "flash": {"decay_per_ms": 0.004, "dead_alpha_factor": 0.28, "eat_alpha_factor": 0.12}, "food": {"pulse_period_ms": 260, "pulse_pad_base_fraction": 0.2, "pulse_pad_amplitude_fraction": 0.04, "shadow_blur_base_fraction": 0.7, "shadow_blur_amplitude_fraction": 0.6, "corner_radius_fraction": 0.28}, "snake": {"head_shadow_blur_fraction": 0.75, "body_pad_base_fraction": 0.12, "body_pad_amplitude_fraction": 0.16, "corner_radius_fraction": 0.3, "eye_side_offset_fraction": 0.19, "eye_forward_offset_fraction": 0.16, "eye_radius_fraction": 0.072, "eye_radius_min_px": 1.4}, - "render": {"checkerboard_alpha": 0.019, "grid_line_alpha": 0.055, "device_pixel_ratio_cap": 2, "pause_vignette_alpha": 0.55}, + "render": {"checkerboard_alpha": 0.019, "grid_line_alpha": 0.055, "device_pixel_ratio_cap": 2, "pause_vignette_alpha": 0.55, "wall_border_width_px": 2.5, "wall_border_alpha": 0.3, "wall_border_wrap_alpha": 0.1, "wall_border_wrap_dash_fraction": 0.55}, "input": {"swipe_threshold_cell_fraction": 0.5}, "scoring": {"best_score_defaults": {"wall": 0, "extra_mode": 0}}, })) diff --git a/game/tests/test_game_screen.gd b/game/tests/test_game_screen.gd index 606b8d5..47f4d43 100644 --- a/game/tests/test_game_screen.gd +++ b/game/tests/test_game_screen.gd @@ -120,6 +120,20 @@ func test_hud_reflects_score_and_status_after_start() -> void: assert_str(_screen.hud._status_label.text).is_equal(GameScreenState.SCREEN_PLAYING.capitalize()) +## project.godot's viewport_height is 545 with a 520x520 board, leaving a +## 25px strip for the HUD -- it must fit inside that, not spill below it. +func test_hud_fits_inside_the_25px_strip_below_the_board() -> void: + assert_float(_screen.hud.custom_minimum_size.y).is_less_equal(25.0) + assert_float(_screen.hud.position.y + _screen.hud.custom_minimum_size.y).is_less_equal(545.0) + + +## Quit (added for the title screen) reuses _quit_game() -- the same +## music.stop()-then-quit path _notification() already runs on window +## close -- rather than calling get_tree().quit() from a second place. +func test_overlay_quit_requested_is_wired_to_quit_game() -> void: + assert_bool(_screen.overlay.quit_requested.is_connected(_screen._quit_game)).is_true() + + func test_start_or_restart_plays_the_start_cue() -> void: _screen._start_or_restart() assert_bool(_screen.sfx._players["start"].playing).is_true() diff --git a/game/tests/test_game_screen_state.gd b/game/tests/test_game_screen_state.gd index ed57b01..39f1f13 100644 --- a/game/tests/test_game_screen_state.gd +++ b/game/tests/test_game_screen_state.gd @@ -83,3 +83,16 @@ func test_overlay_content_menu_and_paused_have_no_return_to_title() -> void: for screen in [GameScreenState.SCREEN_MENU, GameScreenState.SCREEN_PAUSED]: var content := GameScreenState.overlay_content(screen, 0, 0, 3, false) assert_bool(content.show_return_to_title).is_false() + + +## Only the menu offers Quit -- every other screen already has a way out +## (Resume, Play Again, Return to Title). +func test_overlay_content_menu_offers_quit() -> void: + var content := GameScreenState.overlay_content(GameScreenState.SCREEN_MENU, 0, 0, 3, false) + assert_bool(content.show_quit).is_true() + + +func test_overlay_content_paused_and_dead_have_no_quit() -> void: + for screen in [GameScreenState.SCREEN_PAUSED, GameScreenState.SCREEN_DEAD]: + var content := GameScreenState.overlay_content(screen, 0, 0, 3, false) + assert_bool(content.show_quit).is_false() diff --git a/game/tests/test_overlay_panel.gd b/game/tests/test_overlay_panel.gd new file mode 100644 index 0000000..abafa10 --- /dev/null +++ b/game/tests/test_overlay_panel.gd @@ -0,0 +1,41 @@ +extends GdUnitTestSuite + +## Exercises OverlayPanel's configure()-driven visibility directly against +## the real Control tree (no GameScreen needed), the way test_hud.gd would +## if it existed -- the Quit button (added for the title-screen quit +## option) is the only piece of overlay wiring this repo tests in +## isolation rather than through test_game_screen.gd's end-to-end flow. + +var _overlay: OverlayPanel + + +func before_test() -> void: + _overlay = OverlayPanel.new() + add_child(_overlay) + + +func _menu_content() -> Dictionary: + return GameScreenState.overlay_content(GameScreenState.SCREEN_MENU, 0, 0, 3, false) + + +func _paused_content() -> Dictionary: + return GameScreenState.overlay_content(GameScreenState.SCREEN_PAUSED, 0, 0, 3, false) + + +func test_quit_button_is_visible_on_the_menu_by_default() -> void: + _overlay.configure(_menu_content()) + assert_bool(_overlay._quit_button.visible).is_true() + + +func test_quit_button_is_hidden_on_paused() -> void: + _overlay.configure(_paused_content()) + assert_bool(_overlay._quit_button.visible).is_false() + + +## A browser tab can't quit itself -- get_tree().quit() is a no-op there -- +## so game_screen.gd tells the overlay quit isn't available on web, and the +## button must stay hidden even on the menu. +func test_quit_button_stays_hidden_when_quit_is_unavailable() -> void: + _overlay.set_quit_available(false) + _overlay.configure(_menu_content()) + assert_bool(_overlay._quit_button.visible).is_false()