Files
Grovewright/game/scripts/main.gd
T
Steven Wroblewski 53ef02ae75 Spielen allein, als Host oder als Gast
Die Hauptszene beginnt mit dem Startmenü oder mit dem Direktstart aus
der Befehlszeile. Gäste sehen die Welt des Hosts und die Figuren aller
Spieler. Nur der Host speichert, Pause gibt es nur ohne Gäste, Laden
nur beim Spielen allein. Ablehnungen, Abbrüche und Abgleiche erscheinen
als Meldung, und ein Spielstand, der sich gerade nicht lesen lässt,
bleibt unangetastet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 22:03:42 +02:00

417 lines
12 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
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 := ""
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)
func _process(delta: float) -> void:
if link != null:
for notice in link.pump(delta):
_on_notice(notice)
if session == null or not session.is_running() or session.is_broken():
return
_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:
match notice["type"]:
"guest_arrived":
# Pausing is for playing alone; a guest ends the pause.
if clock.paused:
_set_paused(false)
"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"):
if 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())