69b642cc60
Als Gast kann die Meldung eines Schlags erst nach dem nächsten Schlag auf dieselbe Kachel ankommen. Ein Merker pro Kachel ging dabei verloren, und der brechende Schlag zeigte seine Splitter doch zweimal. Die Hauptszene zählt ihre noch nicht gemeldeten Schläge deshalb pro Kachel und vergisst sie, wenn die Welt neu abgeglichen wird. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
494 lines
15 KiB
GDScript
494 lines
15 KiB
GDScript
extends Node2D
|
|
## Composition root. Shows the start menu, then runs a session as host or as
|
|
## guest with the map view, the figures, the HUD and the files on disk.
|
|
|
|
## Seed of a new world until the slice ships its chosen seed.
|
|
const NEW_WORLD_SEED := 2026
|
|
## 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 := 2 * 60 * GwSession.TICKS_PER_SECOND
|
|
const CAMERA_ZOOM := Vector2(3.0, 3.0)
|
|
## Most ticks a guest runs in one frame. A guest that fell behind catches up
|
|
## within a few frames without freezing.
|
|
const MAX_GUEST_TICKS_PER_FRAME := 20
|
|
## Seconds between two position updates of the own figure.
|
|
const FIGURE_SECONDS := 0.1
|
|
## The text for every reason a host gives for turning a guest away.
|
|
const REFUSAL_TEXTS := {
|
|
GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL",
|
|
GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES",
|
|
GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL",
|
|
GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS",
|
|
GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING",
|
|
}
|
|
|
|
## Folder for the player key, the save and the recordings. If it is empty,
|
|
## the command line decides; tests set a folder of their own.
|
|
@export var storage_dir := ""
|
|
## Network interface an open world listens on. Tests use the loopback one.
|
|
var listen_address := "*"
|
|
## The command line arguments after "--". Tools and tests may set their own
|
|
## before the scene enters the tree.
|
|
var command_line := OS.get_cmdline_user_args()
|
|
|
|
var options: LaunchOptions
|
|
var store: SaveStore
|
|
## The running session, or null in the menu. A guest's session has no world
|
|
## until the host sent it.
|
|
var session: GwSession
|
|
## The network connection of an open world or a guest, or null.
|
|
var link: SessionLink
|
|
var clock: TickClock
|
|
## The style the playing field is drawn in.
|
|
var art: Art
|
|
var menu: Menu
|
|
var map_view: MapView
|
|
var marker: TileMarker
|
|
var avatar: Avatar
|
|
var figures: Figures
|
|
var hud: Hud
|
|
## How often a guest's world diverged and was replaced by the host's.
|
|
var resyncs := 0
|
|
|
|
var _player_key: PackedByteArray
|
|
var _next_dig_tick := 0
|
|
## True while an unusable save could not be set aside. Saving is refused
|
|
## then, so that the unusable save is never overwritten.
|
|
var _save_blocked := false
|
|
var _figure_wait := 0.0
|
|
## The address a guest is joining, for messages.
|
|
var _joining := ""
|
|
## The host's accepted guests when _process last looked. A new one ends a
|
|
## pause.
|
|
var _known_guests := 0
|
|
## How many own hits on each tile showed their burst of chips at once and
|
|
## are not reported by the core yet. Their burst is not shown a second time.
|
|
var _own_hits: Dictionary[Vector2i, int] = {}
|
|
|
|
|
|
func _ready() -> void:
|
|
I18n.install()
|
|
InputSetup.install()
|
|
options = LaunchOptions.parse(command_line)
|
|
for argument in options.ignored:
|
|
push_warning("ignored command line argument: %s" % argument)
|
|
if storage_dir.is_empty():
|
|
storage_dir = options.storage_dir()
|
|
store = SaveStore.new(storage_dir)
|
|
_player_key = store.player_key()
|
|
if _player_key.is_empty():
|
|
push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
|
|
clock = TickClock.new(GwSession.TICKS_PER_SECOND)
|
|
art = Art.new()
|
|
art.load_overrides(Art.OVERRIDE_DIR)
|
|
map_view = MapView.new()
|
|
map_view.art = art
|
|
add_child(map_view)
|
|
marker = TileMarker.new()
|
|
marker.art = art
|
|
add_child(marker)
|
|
figures = Figures.new()
|
|
figures.art = art
|
|
add_child(figures)
|
|
avatar = Avatar.new()
|
|
avatar.art = art
|
|
add_child(avatar)
|
|
var camera := Camera2D.new()
|
|
camera.zoom = CAMERA_ZOOM
|
|
avatar.add_child(camera)
|
|
hud = Hud.new()
|
|
add_child(hud)
|
|
menu = Menu.new()
|
|
add_child(menu)
|
|
menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
|
|
menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
|
|
menu.play_chosen.connect(play)
|
|
menu.join_chosen.connect(join)
|
|
_show_menu("")
|
|
match options.start:
|
|
"solo":
|
|
play(false)
|
|
"host":
|
|
play(true)
|
|
"join":
|
|
join(options.address)
|
|
|
|
|
|
## Plays the saved world, or a new one, as its host. With `open`, guests may
|
|
## join. Returns false if it could not start; the menu then says why.
|
|
func play(open: bool) -> bool:
|
|
if not _can_start():
|
|
return false
|
|
var next := GwSession.new()
|
|
var set_aside := false
|
|
if store.has_save():
|
|
var save := store.read_save()
|
|
if store.last_read_error != OK:
|
|
_show_menu(tr("SAVE_UNREADABLE"))
|
|
return false
|
|
set_aside = not _load(next, save)
|
|
if not next.is_running():
|
|
next.start_new(NEW_WORLD_SEED)
|
|
if open:
|
|
var net := NetLink.new()
|
|
if net.host(options.port, listen_address) != OK:
|
|
_show_menu(tr("OPEN_FAILED") % options.port)
|
|
return false
|
|
link = SessionLink.new(next, net)
|
|
session = next
|
|
session.join(_player_key, _player_name())
|
|
_run_tick()
|
|
_show_world()
|
|
if set_aside:
|
|
hud.flash("LOAD_FAILED")
|
|
return true
|
|
|
|
|
|
## Starts joining the host at `address`, "host" or "host:port". The world
|
|
## arrives later; until then the menu says that it is connecting.
|
|
func join(address: String) -> void:
|
|
if not _can_start():
|
|
return
|
|
var target := NetLink.split_address(address)
|
|
if target.is_empty():
|
|
_show_menu(tr("ADDRESS_INVALID") % address)
|
|
return
|
|
var net := NetLink.new()
|
|
if net.join(target[0], target[1]) != OK:
|
|
_show_menu(tr("CONNECT_FAILED") % address)
|
|
return
|
|
session = GwSession.new()
|
|
session.start_guest(_player_key, _player_name())
|
|
link = SessionLink.new(session, net)
|
|
_joining = address
|
|
store.set_last_address(address)
|
|
_show_menu(tr("CONNECTING") % address)
|
|
|
|
|
|
func _can_start() -> bool:
|
|
if session != null:
|
|
return false
|
|
if _player_key.is_empty():
|
|
_show_menu(tr("KEY_UNREADABLE"))
|
|
return false
|
|
store.set_player_name(menu.player_name())
|
|
return true
|
|
|
|
|
|
func _player_name() -> String:
|
|
var typed := menu.player_name()
|
|
return typed if not typed.is_empty() else tr("DEFAULT_NAME")
|
|
|
|
|
|
## Starts `next` from `save`. If the save is unusable, it is set aside so
|
|
## that the next save does not overwrite it, and false is returned.
|
|
func _load(next: GwSession, save: PackedByteArray) -> bool:
|
|
if next.start_loaded(save):
|
|
_save_blocked = false
|
|
return true
|
|
var aside := store.set_aside_save()
|
|
if aside.is_empty():
|
|
_save_blocked = true
|
|
push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error())
|
|
else:
|
|
push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside])
|
|
return false
|
|
|
|
|
|
func _show_menu(status: String) -> void:
|
|
menu.show_status(status)
|
|
menu.show()
|
|
hud.hide()
|
|
for node: Node2D in [avatar, marker, figures]:
|
|
node.hide()
|
|
|
|
|
|
## Shows the world of the session and puts the figure on the spawn.
|
|
func _show_world() -> void:
|
|
menu.hide()
|
|
hud.show()
|
|
for node: Node2D in [avatar, marker, figures]:
|
|
node.show()
|
|
map_view.session = session
|
|
map_view.reload()
|
|
figures.session = session
|
|
avatar.player = session.local_player()
|
|
figures.clear()
|
|
avatar.is_walkable = session.is_walkable
|
|
avatar.position = GridMotion.tile_center(session.spawn_point())
|
|
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
|
_update_status()
|
|
_next_dig_tick = 0
|
|
_own_hits.clear()
|
|
|
|
|
|
## Says in the status line how the game is shared, and with how many.
|
|
func _update_status() -> void:
|
|
if link == null:
|
|
hud.show_status("")
|
|
elif session.is_host():
|
|
hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()])
|
|
else:
|
|
hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()])
|
|
|
|
|
|
## Ends a guest's session and goes back to the menu with `message`.
|
|
func _leave(message: String) -> void:
|
|
if session == null:
|
|
return
|
|
_write_recording()
|
|
if link != null:
|
|
link.link.close()
|
|
link = null
|
|
session = null
|
|
map_view.session = null
|
|
map_view.reload()
|
|
avatar.is_walkable = Callable()
|
|
figures.clear()
|
|
_own_hits.clear()
|
|
_show_menu(message)
|
|
|
|
|
|
## A broken session runs no more ticks. Instead of leaving the others
|
|
## waiting, a host closes its world to guests and a guest leaves. The host's
|
|
## status line says why its world stands still.
|
|
func _stop_sharing() -> void:
|
|
if not session.is_host():
|
|
_leave(tr("SIMULATION_STOPPED"))
|
|
return
|
|
if link != null:
|
|
link.link.close()
|
|
link = null
|
|
figures.clear()
|
|
hud.show_status(tr("SIMULATION_STOPPED"))
|
|
|
|
|
|
## Ends a pause once the host accepted a guest: the guest joins in the next
|
|
## tick, and a paused world would keep it waiting. A connection that has not
|
|
## greeted yet is no guest.
|
|
func _end_pause_for_new_guests() -> void:
|
|
var guests := session.guest_count()
|
|
if guests > _known_guests and clock.paused:
|
|
_set_paused(false)
|
|
_known_guests = guests
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if link != null:
|
|
for notice in link.pump(delta):
|
|
_on_notice(notice)
|
|
if session != null and session.is_broken():
|
|
_stop_sharing()
|
|
if session == null or not session.is_running() or session.is_broken():
|
|
return
|
|
_end_pause_for_new_guests()
|
|
_update_marker()
|
|
if Input.is_action_pressed("dig") and not clock.paused:
|
|
_try_dig()
|
|
if session.is_host():
|
|
for i in clock.advance(delta):
|
|
_run_tick()
|
|
else:
|
|
for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME):
|
|
_apply(session.step())
|
|
if link != null:
|
|
link.flush()
|
|
_figure_wait -= delta
|
|
if _figure_wait <= 0.0:
|
|
_figure_wait = FIGURE_SECONDS
|
|
link.send_figure(avatar.position)
|
|
|
|
|
|
func _on_notice(notice: Dictionary) -> void:
|
|
# An earlier notice of the same batch may have ended the session.
|
|
if session == null:
|
|
return
|
|
match notice["type"]:
|
|
"world_received":
|
|
if notice["resync"]:
|
|
resyncs += 1
|
|
_keep_diverged_recording()
|
|
map_view.reload()
|
|
_own_hits.clear()
|
|
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
|
hud.flash("RESYNCED")
|
|
else:
|
|
_show_world()
|
|
"refused":
|
|
_leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR")))
|
|
"protocol_error":
|
|
push_warning("broken message from the host: %s" % session.last_error())
|
|
_leave(tr("PROTOCOL_ERROR"))
|
|
"host_lost":
|
|
_leave(tr("HOST_LEFT"))
|
|
"failed":
|
|
_leave(tr("CONNECT_FAILED") % _joining)
|
|
"divergence":
|
|
push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]])
|
|
"figure":
|
|
figures.place(notice["player"], notice["position"])
|
|
|
|
|
|
## Keeps the recording of a guest's world that diverged from the host's, so
|
|
## that the divergence can be replayed.
|
|
func _keep_diverged_recording() -> void:
|
|
var path := store.write_recording(session.take_diverged_recording())
|
|
push_warning("the world diverged from the host's; its recording is %s" % path)
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if session == null or not session.is_running():
|
|
return
|
|
if event.is_action_pressed("save_game"):
|
|
if session.is_host():
|
|
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
|
else:
|
|
hud.flash("HOST_SAVES")
|
|
elif event.is_action_pressed("load_game"):
|
|
_reload()
|
|
elif event.is_action_pressed("toggle_pause"):
|
|
# Ending a pause is always allowed, starting one only alone.
|
|
if clock.paused or (session.is_host() and session.guest_count() == 0):
|
|
_set_paused(not clock.paused)
|
|
else:
|
|
hud.flash("PAUSE_ONLY_ALONE")
|
|
|
|
|
|
## Goes back to the saved world. Only when playing alone: guests would no
|
|
## longer have the host's world.
|
|
func _reload() -> void:
|
|
if link != null:
|
|
hud.flash("LOAD_ONLY_ALONE")
|
|
return
|
|
var save := store.read_save()
|
|
if store.last_read_error != OK:
|
|
hud.flash("LOAD_FAILED")
|
|
return
|
|
var next := GwSession.new()
|
|
if not _load(next, save):
|
|
hud.flash("LOAD_FAILED")
|
|
return
|
|
_write_recording()
|
|
session = next
|
|
session.join(_player_key, _player_name())
|
|
_run_tick()
|
|
_show_world()
|
|
hud.flash("LOADED")
|
|
|
|
|
|
func _set_paused(paused: bool) -> void:
|
|
clock.paused = paused
|
|
avatar.set_process(not paused)
|
|
hud.show_paused(paused)
|
|
|
|
|
|
func _notification(what: int) -> void:
|
|
if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
|
|
return
|
|
if session.is_running():
|
|
if session.is_host() and not save_game():
|
|
push_warning("the game could not be saved on close")
|
|
_write_recording()
|
|
if link != null:
|
|
link.link.close()
|
|
|
|
|
|
func _run_tick() -> void:
|
|
_apply(session.step())
|
|
if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
|
|
hud.flash("SAVE_FAILED")
|
|
push_warning("the automatic save failed")
|
|
|
|
|
|
func _apply(events: Array[Dictionary]) -> void:
|
|
var local := session.local_player()
|
|
for event in events:
|
|
match event["type"]:
|
|
"tile_hit":
|
|
var tile := Vector2i(event["x"], event["y"])
|
|
map_view.refresh_tile(tile)
|
|
# The own hits showed their burst when they were made.
|
|
if event["player"] == local:
|
|
_own_hit_reported(tile)
|
|
else:
|
|
map_view.hit(tile)
|
|
"tile_changed":
|
|
var tile := Vector2i(event["x"], event["y"])
|
|
map_view.refresh_tile(tile)
|
|
# The core does not say whose hit broke the tile; an own hit
|
|
# showed its burst already.
|
|
if _own_hits.has(tile):
|
|
_own_hits.erase(tile)
|
|
else:
|
|
map_view.hit(tile)
|
|
"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")
|
|
"player_joined":
|
|
if event["player"] != local:
|
|
hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
|
|
else:
|
|
avatar.player = local
|
|
avatar.queue_redraw()
|
|
_update_status()
|
|
"player_left":
|
|
figures.forget(event["player"])
|
|
hud.flash("PLAYER_LEFT", [session.player_name(event["player"])])
|
|
_update_status()
|
|
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, and shows its
|
|
## burst of chips at once: the hit itself lands with a later tick, which for a
|
|
## guest comes back from the host only after the round trip. The caller must
|
|
## check the reach: figures move outside the lockstep, so the core does not
|
|
## know where the figure is. The core checks the cooldown, the tile, its
|
|
## terrain, the tool tier and the free space in the inventory.
|
|
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
|
|
if MapView.SOLID.has(session.terrain_at(tile.x, tile.y)):
|
|
map_view.hit(tile)
|
|
_own_hits[tile] = _own_hits.get(tile, 0) + 1
|
|
|
|
|
|
## Counts one own hit on `tile` as reported by the core.
|
|
func _own_hit_reported(tile: Vector2i) -> void:
|
|
var left: int = _own_hits.get(tile, 0) - 1
|
|
if left > 0:
|
|
_own_hits[tile] = left
|
|
else:
|
|
_own_hits.erase(tile)
|
|
|
|
|
|
## Writes the running world as the save. Returns false if nothing was
|
|
## written: for a guest, whose world belongs to the host, and while an
|
|
## unusable save could not be set aside.
|
|
func save_game() -> bool:
|
|
if _save_blocked or not session.is_host():
|
|
return false
|
|
return store.write_save(session.save())
|
|
|
|
|
|
func _write_recording() -> void:
|
|
store.write_recording(session.recording_bytes())
|