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:
@@ -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")
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cosq3mrn0ve5v
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://by56f6vi542a1
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dasl7vpyifxob
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cjnucbgwr65ud
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d0qbyofproc3j
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cltfxvihioc4v
|
||||||
Reference in New Issue
Block a user