M1b: Brücke und Hülle #6
@@ -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]
|
||||
|
||||
config/name="Grovewright"
|
||||
run/main_scene="res://main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
|
||||
[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