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 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 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) map_view = MapView.new() add_child(map_view) marker = TileMarker.new() add_child(marker) figures = Figures.new() add_child(figures) avatar = Avatar.new() 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.queue_redraw() figures.session = session 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 ## 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.queue_redraw() avatar.is_walkable = Callable() figures.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. func _stop_sharing() -> void: if not session.is_host(): _leave(tr("SIMULATION_STOPPED")) return link.link.close() link = null figures.clear() _update_status() ## 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() and link != null: _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.queue_redraw() 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", "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") "player_joined": if event["player"] != local: hud.flash("PLAYER_JOINED", [session.player_name(event["player"])]) _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. 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 ## 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())