ab81f66ef0
F2 wechselt im Spiel den Stil und meldet ihn, --style=NAME wählt ihn beim Start, und das Spiel merkt sich die Wahl in den Einstellungen. Das Bildschirmfoto-Werkzeug nimmt Bilder in einem bestimmten Stil und von der Vitrine auf und zeigt eine zweite Figur mit Namen. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
538 lines
16 KiB
GDScript
538 lines
16 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 showcase: Showcase
|
|
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)
|
|
if not art.select(options.style):
|
|
art.select(StringName(store.style()))
|
|
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()
|
|
art.changed.connect(_redraw_art)
|
|
menu.play_chosen.connect(play)
|
|
menu.join_chosen.connect(join)
|
|
menu.showcase_chosen.connect(open_showcase)
|
|
showcase = Showcase.new()
|
|
showcase.art = art
|
|
showcase.closed.connect(close_showcase)
|
|
_hide_showcase()
|
|
add_child(showcase)
|
|
_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()
|
|
_hide_showcase()
|
|
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()
|
|
|
|
|
|
## Shows the style samples instead of the menu.
|
|
func open_showcase() -> void:
|
|
menu.hide()
|
|
showcase.time = 0.0
|
|
showcase.process_mode = Node.PROCESS_MODE_INHERIT
|
|
showcase.show()
|
|
|
|
|
|
## Goes back from the style samples to the menu.
|
|
func close_showcase() -> void:
|
|
_hide_showcase()
|
|
menu.show()
|
|
|
|
|
|
## Hides the style samples and stops them. A world that starts while they are
|
|
## open, such as the one a joining guest receives, takes their place.
|
|
func _hide_showcase() -> void:
|
|
showcase.hide()
|
|
showcase.process_mode = Node.PROCESS_MODE_DISABLED
|
|
|
|
|
|
## Draws the playing field again after the style changed, and remembers the
|
|
## style for the next start.
|
|
func _redraw_art() -> void:
|
|
map_view.redraw()
|
|
for node: Node2D in [marker, figures, avatar]:
|
|
node.queue_redraw()
|
|
store.set_style(String(art.style.key()))
|
|
|
|
|
|
## 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")
|
|
elif event.is_action_pressed("next_style"):
|
|
art.select_next()
|
|
hud.flash("STYLE_SWITCHED", [tr(art.style.title_key())])
|
|
|
|
|
|
## 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())
|