diff --git a/game/main.tscn b/game/main.tscn new file mode 100644 index 0000000..b54e251 --- /dev/null +++ b/game/main.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") diff --git a/game/project.godot b/game/project.godot index ddf4ef3..4a8f097 100644 --- a/game/project.godot +++ b/game/project.godot @@ -7,6 +7,7 @@ config_version=5 [application] config/name="Grovewright" +run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") [display] diff --git a/game/scripts/hud.gd b/game/scripts/hud.gd new file mode 100644 index 0000000..e81b625 --- /dev/null +++ b/game/scripts/hud.gd @@ -0,0 +1,58 @@ +class_name Hud +extends CanvasLayer +## Text over the game: the stone count, short messages and a help line. + +const MESSAGE_SECONDS := 2.5 + +var _stone := Label.new() +var _message := Label.new() +var _help := Label.new() +var _message_left := 0.0 + + +func _ready() -> void: + _stone.position = Vector2(16, 12) + _stone.add_theme_font_size_override("font_size", 22) + _message.position = Vector2(16, 48) + _message.add_theme_font_size_override("font_size", 18) + _help.text = tr("HELP") + _help.add_theme_font_size_override("font_size", 14) + for label: Label in [_stone, _message, _help]: + label.add_theme_color_override("font_outline_color", Color.BLACK) + label.add_theme_constant_override("outline_size", 4) + add_child(label) + _help.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT, Control.PRESET_MODE_MINSIZE, 16) + + +func _process(delta: float) -> void: + if _message_left <= 0.0: + return + _message_left -= delta + if _message_left <= 0.0: + _message.text = "" + + +func show_stone(count: int) -> void: + _stone.text = tr("STONE_COUNT") % count + + +## Shows the text for `key` for a few seconds. +func flash(key: String) -> void: + _message.text = tr(key) + _message_left = MESSAGE_SECONDS + + +## Shows or hides the pause notice. It stays until the game continues. +func show_paused(paused: bool) -> void: + _message.text = tr("PAUSED") if paused else "" + _message_left = 0.0 + + +## The text currently shown as a message. Tests read it. +func message() -> String: + return _message.text + + +## The text currently shown as the stone count. Tests read it. +func stone_text() -> String: + return _stone.text diff --git a/game/scripts/hud.gd.uid b/game/scripts/hud.gd.uid new file mode 100644 index 0000000..5cc06a0 --- /dev/null +++ b/game/scripts/hud.gd.uid @@ -0,0 +1 @@ +uid://cosq3mrn0ve5v diff --git a/game/scripts/main.gd b/game/scripts/main.gd new file mode 100644 index 0000000..8ef9828 --- /dev/null +++ b/game/scripts/main.gd @@ -0,0 +1,162 @@ +extends Node2D +## Composition root of a solo session: connects the simulation core with the +## map view, the figure, the HUD and the files on disk. + +## Seed of a new world until the slice ships its chosen seed. +const NEW_WORLD_SEED := 2026 +const PLAYER_NAME := "Spieler" +## How far the pickaxe reaches, in tiles from the figure to the tile centre. +const REACH_TILES := 2.5 +## Ticks between two automatic saves: two minutes. +const AUTOSAVE_TICKS := 2400 +const CAMERA_ZOOM := Vector2(3.0, 3.0) + +## Folder for the player key, the save and the recordings. Tests use their +## own folder. +@export var storage_dir := "user://" + +var session: GwSession +var store: SaveStore +var clock: TickClock +var map_view: MapView +var marker: TileMarker +var avatar: Avatar +var hud: Hud + +var _player_key: PackedByteArray +var _next_dig_tick := 0 + + +func _ready() -> void: + I18n.install() + InputSetup.install() + store = SaveStore.new(storage_dir) + _player_key = store.player_key() + clock = TickClock.new(GwSession.TICKS_PER_SECOND) + map_view = MapView.new() + add_child(map_view) + marker = TileMarker.new() + add_child(marker) + avatar = Avatar.new() + add_child(avatar) + var camera := Camera2D.new() + camera.zoom = CAMERA_ZOOM + avatar.add_child(camera) + hud = Hud.new() + add_child(hud) + if not _start_from_save(): + _start_new_world() + + +func _process(delta: float) -> void: + if session == null or session.is_broken(): + return + _update_marker() + if Input.is_action_pressed("dig") and not clock.paused: + _try_dig() + for i in clock.advance(delta): + _run_tick() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("save_game"): + hud.flash("SAVED" if save_game() else "SAVE_FAILED") + elif event.is_action_pressed("load_game"): + _write_recording() + hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED") + elif event.is_action_pressed("toggle_pause"): + clock.paused = not clock.paused + avatar.set_process(not clock.paused) + hud.show_paused(clock.paused) + + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null: + save_game() + _write_recording() + + +func _start_new_world() -> void: + var fresh := GwSession.new() + fresh.start_new(NEW_WORLD_SEED) + _enter(fresh) + + +## Continues the saved world. Returns false if there is no usable save. An +## unusable save is set aside, so that the next save does not overwrite it. +func _start_from_save() -> bool: + if not store.has_save(): + return false + var loaded := GwSession.new() + if not loaded.start_loaded(store.read_save()): + var aside := store.set_aside_save() + push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside]) + hud.flash("LOAD_FAILED") + return false + _enter(loaded) + return true + + +## Makes `next` the running session and lets the local player join it. +func _enter(next: GwSession) -> void: + session = next + session.join(_player_key, PLAYER_NAME) + _run_tick() + _next_dig_tick = 0 + map_view.session = session + map_view.queue_redraw() + avatar.is_walkable = session.is_walkable + avatar.position = GridMotion.tile_center(session.spawn_point()) + hud.show_stone(session.item_count(GwSession.ITEM_STONE)) + + +func _run_tick() -> void: + _apply(session.step()) + if session.tick() % AUTOSAVE_TICKS == 0: + save_game() + + +func _apply(events: Array[Dictionary]) -> void: + var local := session.local_player() + for event in events: + match event["type"]: + "tile_hit", "tile_changed": + map_view.queue_redraw() + "item_gained": + if event["player"] == local: + hud.show_stone(session.item_count(GwSession.ITEM_STONE)) + "inventory_full": + if event["player"] == local: + hud.flash("INVENTORY_FULL") + if session.is_broken(): + hud.flash("SIMULATION_STOPPED") + push_error(session.last_error()) + + +func _update_marker() -> void: + var tile := GridMotion.tile_of(get_global_mouse_position()) + var distance := avatar.position.distance_to(GridMotion.tile_center(tile)) + marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE) + + +func _try_dig() -> void: + if marker.in_reach: + dig_at(marker.tile) + + +## Queues a pickaxe hit on `tile`, at most once per cooldown. The core checks +## everything else. +func dig_at(tile: Vector2i) -> void: + if session.tick() < _next_dig_tick: + return + session.dig(tile.x, tile.y) + _next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS + + +## Writes the running world as the save. Returns false if nothing was written. +func save_game() -> bool: + return store.write_save(session.save()) + + +func _write_recording() -> void: + store.write_recording(session.recording_bytes()) diff --git a/game/scripts/main.gd.uid b/game/scripts/main.gd.uid new file mode 100644 index 0000000..c0369b6 --- /dev/null +++ b/game/scripts/main.gd.uid @@ -0,0 +1 @@ +uid://by56f6vi542a1 diff --git a/game/scripts/map_view.gd b/game/scripts/map_view.gd new file mode 100644 index 0000000..f9913d0 --- /dev/null +++ b/game/scripts/map_view.gd @@ -0,0 +1,48 @@ +class_name MapView +extends Node2D +## Draws the terrain of the shared world. The colours are placeholders until +## a style is chosen from the samples of plan M1d. + +const CRACK_COLOR := Color(0.1, 0.08, 0.06, 0.8) + +var session: GwSession + + +func _draw() -> void: + if session == null or not session.is_running(): + return + var width := session.map_width() + var height := session.map_height() + var terrain := session.terrain_codes() + var damage := session.damage_codes() + var size := GridMotion.TILE_SIZE + for y in height: + for x in width: + var index := y * width + x + var rect := Rect2(x * size, y * size, size, size) + draw_rect(rect, color_of(terrain[index])) + if damage[index] > 0: + _draw_cracks(rect, damage[index]) + + +## The placeholder colour of a terrain code. +static func color_of(terrain: int) -> Color: + if terrain == GwSession.TERRAIN_GROUND: + return Color(0.42, 0.36, 0.28) + if terrain == GwSession.TERRAIN_SOFT_ROCK: + return Color(0.56, 0.55, 0.52) + if terrain == GwSession.TERRAIN_HARD_ROCK: + return Color(0.33, 0.33, 0.37) + if terrain == GwSession.TERRAIN_BEDROCK: + return Color(0.12, 0.12, 0.14) + return Color.MAGENTA + + +## One crack line for every hit the tile has taken. +func _draw_cracks(rect: Rect2, hits: int) -> void: + var inset := rect.grow(-3.0) + for line in hits: + var share := (line + 1.0) / (hits + 1.0) + var from := Vector2(inset.position.x, inset.position.y + inset.size.y * share) + var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - share)) + draw_line(from, to, CRACK_COLOR, 1.0) diff --git a/game/scripts/map_view.gd.uid b/game/scripts/map_view.gd.uid new file mode 100644 index 0000000..d2926de --- /dev/null +++ b/game/scripts/map_view.gd.uid @@ -0,0 +1 @@ +uid://dasl7vpyifxob diff --git a/game/scripts/tile_marker.gd b/game/scripts/tile_marker.gd new file mode 100644 index 0000000..123fc9b --- /dev/null +++ b/game/scripts/tile_marker.gd @@ -0,0 +1,25 @@ +class_name TileMarker +extends Node2D +## Outlines the tile under the mouse: green when the figure can reach it, +## red when it is too far away. + +const REACHABLE_COLOR := Color(0.4, 1.0, 0.4) +const UNREACHABLE_COLOR := Color(1.0, 0.4, 0.4) + +var tile := Vector2i(-1, -1) +var in_reach := false + + +## Moves the outline to `new_tile`. Redraws only when something changed. +func show_tile(new_tile: Vector2i, reachable: bool) -> void: + if new_tile == tile and reachable == in_reach: + return + tile = new_tile + in_reach = reachable + queue_redraw() + + +func _draw() -> void: + var size := GridMotion.TILE_SIZE + var color := REACHABLE_COLOR if in_reach else UNREACHABLE_COLOR + draw_rect(Rect2(Vector2(tile) * size, Vector2(size, size)), color, false, 1.0) diff --git a/game/scripts/tile_marker.gd.uid b/game/scripts/tile_marker.gd.uid new file mode 100644 index 0000000..cd3db8e --- /dev/null +++ b/game/scripts/tile_marker.gd.uid @@ -0,0 +1 @@ +uid://cjnucbgwr65ud diff --git a/game/tests/test_main.gd b/game/tests/test_main.gd new file mode 100644 index 0000000..103d87f --- /dev/null +++ b/game/tests/test_main.gd @@ -0,0 +1,60 @@ +extends TestCase +## Starts the real main scene with its own storage folder and runs a few +## frames. + + +func _start_main(directory: String) -> Node: + var main: Node = load("res://main.tscn").instantiate() + main.storage_dir = directory + tree.root.add_child(main) + return main + + +func _stop_main(main: Node) -> void: + tree.root.remove_child(main) + main.free() + + +func test_a_new_game_starts_and_the_player_joins() -> void: + var directory := TestFiles.temporary_dir("main") + var main := _start_main(directory) + for frame in 5: + await tree.process_frame + var session: GwSession = main.session + check(session != null and session.is_running(), "a session runs") + check(not session.is_broken(), "the simulation is healthy") + check_eq(session.local_player(), 0, "the local player has joined") + check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn") + check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones") + _stop_main(main) + TestFiles.remove_dir(directory) + + +func test_a_saved_game_is_continued_on_the_next_start() -> void: + var directory := TestFiles.temporary_dir("continue") + var first := _start_main(directory) + await tree.process_frame + var tick_at_save: int = first.session.tick() + check(first.save_game(), "the game was saved") + _stop_main(first) + + var second := _start_main(directory) + await tree.process_frame + check(second.session.tick() > tick_at_save, "the saved world continues") + check_eq(second.session.local_player(), 0, "the same player rejoins") + _stop_main(second) + TestFiles.remove_dir(directory) + + +func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: + var directory := TestFiles.temporary_dir("broken") + var store := SaveStore.new(directory) + store.write_save(PackedByteArray([1, 2, 3])) + var main := _start_main(directory) + await tree.process_frame + check(main.session.is_running(), "a new world runs") + check(main.session.tick() <= 5, "it is new, tick %d" % main.session.tick()) + check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside") + check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told") + _stop_main(main) + TestFiles.remove_dir(directory) diff --git a/game/tests/test_main.gd.uid b/game/tests/test_main.gd.uid new file mode 100644 index 0000000..04807e2 --- /dev/null +++ b/game/tests/test_main.gd.uid @@ -0,0 +1 @@ +uid://d0qbyofproc3j diff --git a/game/tools/screenshot.gd b/game/tools/screenshot.gd new file mode 100644 index 0000000..b417a71 --- /dev/null +++ b/game/tools/screenshot.gd @@ -0,0 +1,50 @@ +extends SceneTree +## Starts the game in a temporary storage folder, walks the figure toward the +## rock beside the clearing, hits it once and saves a screenshot. Lets a +## change to the look be checked without playing by hand. The player's real +## key and save stay untouched. +## +## From the repository root (opens a window for a moment): +## godot --path game -s res://tools/screenshot.gd -- + +const STORAGE := "user://screenshot-run" +const FRAMES_AFTER_HIT := 30 + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var arguments := OS.get_cmdline_user_args() + var output := arguments[0] if arguments.size() > 0 else "user://screenshot.png" + DirAccess.make_dir_recursive_absolute(STORAGE) + var main: Node = load("res://main.tscn").instantiate() + main.storage_dir = STORAGE + root.add_child(main) + await process_frame + + # Four tiles to the right, toward the rock outcrop beside the clearing. + for frame in 48: + main.avatar.walk(Vector2.RIGHT, 1.0 / 60.0) + await process_frame + var session: GwSession = main.session + var rock := session.spawn_point() + Vector2i(7, 0) + main.dig_at(rock) + for frame in FRAMES_AFTER_HIT: + await process_frame + + var image := root.get_texture().get_image() + var error := image.save_png(output) + print("screenshot %s: %s" % ["saved" if error == OK else "failed", ProjectSettings.globalize_path(output)]) + root.remove_child(main) + main.free() + for file in DirAccess.get_files_at(STORAGE): + DirAccess.remove_absolute(STORAGE.path_join(file)) + for directory in DirAccess.get_directories_at(STORAGE): + var path := STORAGE.path_join(directory) + for file in DirAccess.get_files_at(path): + DirAccess.remove_absolute(path.path_join(file)) + DirAccess.remove_absolute(path) + DirAccess.remove_absolute(STORAGE) + quit(0 if error == OK else 1) diff --git a/game/tools/screenshot.gd.uid b/game/tools/screenshot.gd.uid new file mode 100644 index 0000000..f2834e2 --- /dev/null +++ b/game/tools/screenshot.gd.uid @@ -0,0 +1 @@ +uid://cltfxvihioc4v