M1b: Brücke und Hülle #6

Merged
steven merged 21 commits from m1b-bruecke-und-huelle into main 2026-10-01 11:46:02 +00:00
14 changed files with 416 additions and 0 deletions
Showing only changes of commit 68483c648e - Show all commits
+6
View File
@@ -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")
+1
View File
@@ -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]
+58
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cosq3mrn0ve5v
+162
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://by56f6vi542a1
+48
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dasl7vpyifxob
+25
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cjnucbgwr65ud
+60
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://d0qbyofproc3j
+50
View File
@@ -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 -- <path of the PNG>
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)
+1
View File
@@ -0,0 +1 @@
uid://cltfxvihioc4v