Darstellung und Hauptszene

Die Hauptszene verbindet Kern, Karte, Figur, Anzeige und Dateien:
fester Takt, Laufen, Stein schlagen in Reichweite, Speichern mit F5,
Laden mit F9, Pause mit Esc, automatisches Speichern alle zwei Minuten
und beim Schließen. Die Karte erscheint in Platzhalterfarben, bis in
M1d ein Stil gewählt ist. Ein Werkzeug macht Bildschirmfotos ohne
Handarbeit.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 11:53:51 +02:00
parent 3761966303
commit 68483c648e
14 changed files with 416 additions and 0 deletions
+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] [application]
config/name="Grovewright" config/name="Grovewright"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
[display] [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