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>
This commit is contained in:
+300
-61
@@ -1,45 +1,83 @@
|
||||
extends Node2D
|
||||
## Composition root of a solo session: connects the simulation core with the
|
||||
## map view, the figure, the HUD and the files on disk.
|
||||
## 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
|
||||
const PLAYER_NAME := "Spieler"
|
||||
## 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. Tests use their
|
||||
## own folder.
|
||||
@export var storage_dir := "user://"
|
||||
## 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 session: GwSession
|
||||
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()
|
||||
@@ -47,76 +85,268 @@ func _ready() -> void:
|
||||
avatar.add_child(camera)
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
if not _start_from_save():
|
||||
_start_new_world()
|
||||
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 session == null or session.is_broken():
|
||||
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()
|
||||
for i in clock.advance(delta):
|
||||
_run_tick()
|
||||
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"):
|
||||
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
||||
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"):
|
||||
_write_recording()
|
||||
hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED")
|
||||
_reload()
|
||||
elif event.is_action_pressed("toggle_pause"):
|
||||
clock.paused = not clock.paused
|
||||
avatar.set_process(not clock.paused)
|
||||
hud.show_paused(clock.paused)
|
||||
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 and session != null:
|
||||
if not save_game():
|
||||
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()
|
||||
|
||||
|
||||
func _start_new_world() -> void:
|
||||
var fresh := GwSession.new()
|
||||
fresh.start_new(NEW_WORLD_SEED)
|
||||
_enter(fresh)
|
||||
|
||||
|
||||
## Continues the saved world. Returns false if there is no usable save. An
|
||||
## unusable save is set aside, so that the next save does not overwrite it.
|
||||
func _start_from_save() -> bool:
|
||||
if not store.has_save():
|
||||
return false
|
||||
var loaded := GwSession.new()
|
||||
if not loaded.start_loaded(store.read_save()):
|
||||
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" % loaded.last_error())
|
||||
else:
|
||||
push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside])
|
||||
hud.flash("LOAD_FAILED")
|
||||
return false
|
||||
_save_blocked = false
|
||||
_enter(loaded)
|
||||
return true
|
||||
|
||||
|
||||
## Makes `next` the running session and lets the local player join it.
|
||||
func _enter(next: GwSession) -> void:
|
||||
session = next
|
||||
session.join(_player_key, PLAYER_NAME)
|
||||
_run_tick()
|
||||
_next_dig_tick = 0
|
||||
map_view.session = session
|
||||
map_view.queue_redraw()
|
||||
avatar.is_walkable = session.is_walkable
|
||||
avatar.position = GridMotion.tile_center(session.spawn_point())
|
||||
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
||||
if link != null:
|
||||
link.link.close()
|
||||
|
||||
|
||||
func _run_tick() -> void:
|
||||
@@ -138,6 +368,14 @@ func _apply(events: Array[Dictionary]) -> void:
|
||||
"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())
|
||||
@@ -166,9 +404,10 @@ func dig_at(tile: Vector2i) -> void:
|
||||
|
||||
|
||||
## Writes the running world as the save. Returns false if nothing was
|
||||
## written, also while an unusable save could not be set aside.
|
||||
## 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:
|
||||
if _save_blocked or not session.is_host():
|
||||
return false
|
||||
return store.write_save(session.save())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user