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())