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:
+297
-58
@@ -1,45 +1,83 @@
|
|||||||
extends Node2D
|
extends Node2D
|
||||||
## Composition root of a solo session: connects the simulation core with the
|
## Composition root. Shows the start menu, then runs a session as host or as
|
||||||
## map view, the figure, the HUD and the files on disk.
|
## 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.
|
## Seed of a new world until the slice ships its chosen seed.
|
||||||
const NEW_WORLD_SEED := 2026
|
const NEW_WORLD_SEED := 2026
|
||||||
const PLAYER_NAME := "Spieler"
|
|
||||||
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
|
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
|
||||||
const REACH_TILES := 2.5
|
const REACH_TILES := 2.5
|
||||||
## Ticks between two automatic saves: two minutes.
|
## Ticks between two automatic saves: two minutes.
|
||||||
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
|
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
|
||||||
const CAMERA_ZOOM := Vector2(3.0, 3.0)
|
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
|
## Folder for the player key, the save and the recordings. If it is empty,
|
||||||
## own folder.
|
## the command line decides; tests set a folder of their own.
|
||||||
@export var storage_dir := "user://"
|
@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
|
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 clock: TickClock
|
||||||
|
var menu: Menu
|
||||||
var map_view: MapView
|
var map_view: MapView
|
||||||
var marker: TileMarker
|
var marker: TileMarker
|
||||||
var avatar: Avatar
|
var avatar: Avatar
|
||||||
|
var figures: Figures
|
||||||
var hud: Hud
|
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 _player_key: PackedByteArray
|
||||||
var _next_dig_tick := 0
|
var _next_dig_tick := 0
|
||||||
## True while an unusable save could not be set aside. Saving is refused
|
## True while an unusable save could not be set aside. Saving is refused
|
||||||
## then, so that the unusable save is never overwritten.
|
## then, so that the unusable save is never overwritten.
|
||||||
var _save_blocked := false
|
var _save_blocked := false
|
||||||
|
var _figure_wait := 0.0
|
||||||
|
## The address a guest is joining, for messages.
|
||||||
|
var _joining := ""
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
I18n.install()
|
I18n.install()
|
||||||
InputSetup.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)
|
store = SaveStore.new(storage_dir)
|
||||||
_player_key = store.player_key()
|
_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)
|
clock = TickClock.new(GwSession.TICKS_PER_SECOND)
|
||||||
map_view = MapView.new()
|
map_view = MapView.new()
|
||||||
add_child(map_view)
|
add_child(map_view)
|
||||||
marker = TileMarker.new()
|
marker = TileMarker.new()
|
||||||
add_child(marker)
|
add_child(marker)
|
||||||
|
figures = Figures.new()
|
||||||
|
add_child(figures)
|
||||||
avatar = Avatar.new()
|
avatar = Avatar.new()
|
||||||
add_child(avatar)
|
add_child(avatar)
|
||||||
var camera := Camera2D.new()
|
var camera := Camera2D.new()
|
||||||
@@ -47,76 +85,268 @@ func _ready() -> void:
|
|||||||
avatar.add_child(camera)
|
avatar.add_child(camera)
|
||||||
hud = Hud.new()
|
hud = Hud.new()
|
||||||
add_child(hud)
|
add_child(hud)
|
||||||
if not _start_from_save():
|
menu = Menu.new()
|
||||||
_start_new_world()
|
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:
|
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
|
return
|
||||||
_update_marker()
|
_update_marker()
|
||||||
if Input.is_action_pressed("dig") and not clock.paused:
|
if Input.is_action_pressed("dig") and not clock.paused:
|
||||||
_try_dig()
|
_try_dig()
|
||||||
|
if session.is_host():
|
||||||
for i in clock.advance(delta):
|
for i in clock.advance(delta):
|
||||||
_run_tick()
|
_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:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if session == null or not session.is_running():
|
||||||
|
return
|
||||||
if event.is_action_pressed("save_game"):
|
if event.is_action_pressed("save_game"):
|
||||||
|
if session.is_host():
|
||||||
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
||||||
|
else:
|
||||||
|
hud.flash("HOST_SAVES")
|
||||||
elif event.is_action_pressed("load_game"):
|
elif event.is_action_pressed("load_game"):
|
||||||
_write_recording()
|
_reload()
|
||||||
hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED")
|
|
||||||
elif event.is_action_pressed("toggle_pause"):
|
elif event.is_action_pressed("toggle_pause"):
|
||||||
clock.paused = not clock.paused
|
if session.is_host() and session.guest_count() == 0:
|
||||||
avatar.set_process(not clock.paused)
|
_set_paused(not clock.paused)
|
||||||
hud.show_paused(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:
|
func _notification(what: int) -> void:
|
||||||
if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null:
|
if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
|
||||||
if not save_game():
|
return
|
||||||
|
if session.is_running():
|
||||||
|
if session.is_host() and not save_game():
|
||||||
push_warning("the game could not be saved on close")
|
push_warning("the game could not be saved on close")
|
||||||
_write_recording()
|
_write_recording()
|
||||||
|
if link != null:
|
||||||
|
link.link.close()
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
func _run_tick() -> void:
|
func _run_tick() -> void:
|
||||||
@@ -138,6 +368,14 @@ func _apply(events: Array[Dictionary]) -> void:
|
|||||||
"inventory_full":
|
"inventory_full":
|
||||||
if event["player"] == local:
|
if event["player"] == local:
|
||||||
hud.flash("INVENTORY_FULL")
|
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():
|
if session.is_broken():
|
||||||
hud.flash("SIMULATION_STOPPED")
|
hud.flash("SIMULATION_STOPPED")
|
||||||
push_error(session.last_error())
|
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
|
## 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:
|
func save_game() -> bool:
|
||||||
if _save_blocked:
|
if _save_blocked or not session.is_host():
|
||||||
return false
|
return false
|
||||||
return store.write_save(session.save())
|
return store.write_save(session.save())
|
||||||
|
|
||||||
|
|||||||
+232
-21
@@ -1,76 +1,162 @@
|
|||||||
extends TestCase
|
extends TestCase
|
||||||
## Starts the real main scene with its own storage folder and runs a few
|
## Starts the real main scene with its own storage folder. The network tests
|
||||||
## frames.
|
## start two of them in this process: a host and a guest.
|
||||||
|
|
||||||
|
var _started: Array[Node] = []
|
||||||
|
|
||||||
|
|
||||||
func _start_main(directory: String) -> Node:
|
func _start_main(directory: String) -> Node:
|
||||||
var main: Node = load("res://main.tscn").instantiate()
|
var main: Node = load("res://main.tscn").instantiate()
|
||||||
main.storage_dir = directory
|
main.storage_dir = directory
|
||||||
tree.root.add_child(main)
|
tree.root.add_child(main)
|
||||||
|
_started.append(main)
|
||||||
return main
|
return main
|
||||||
|
|
||||||
|
|
||||||
func _stop_main(main: Node) -> void:
|
func _stop_main(main: Node) -> void:
|
||||||
|
_started.erase(main)
|
||||||
tree.root.remove_child(main)
|
tree.root.remove_child(main)
|
||||||
|
if main.link != null:
|
||||||
|
main.link.link.close()
|
||||||
main.free()
|
main.free()
|
||||||
|
|
||||||
|
|
||||||
|
## Stops every main scene this test started and removes their folders.
|
||||||
|
func _clean_up(directories: Array[String]) -> void:
|
||||||
|
for main in _started.duplicate():
|
||||||
|
_stop_main(main)
|
||||||
|
for directory in directories:
|
||||||
|
TestFiles.remove_dir(directory)
|
||||||
|
|
||||||
|
|
||||||
|
## A main scene with an open world that listens on the loopback interface.
|
||||||
|
func _start_host(directory: String) -> Node:
|
||||||
|
var host := _start_main(directory)
|
||||||
|
host.options.port = 0
|
||||||
|
host.listen_address = "127.0.0.1"
|
||||||
|
host.menu.name_field.text = "Host"
|
||||||
|
check(host.play(true), "the world opens")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
## A main scene that joins `host` as "Grace".
|
||||||
|
func _start_guest(directory: String, host: Node) -> Node:
|
||||||
|
var guest := _start_main(directory)
|
||||||
|
guest.menu.name_field.text = "Grace"
|
||||||
|
guest.join("127.0.0.1:%d" % host.link.link.local_port())
|
||||||
|
return guest
|
||||||
|
|
||||||
|
|
||||||
|
## Lets frames pass until `condition` holds, at most `frames` of them.
|
||||||
|
func _wait_until(condition: Callable, frames := 600) -> bool:
|
||||||
|
for frame in frames:
|
||||||
|
if condition.call():
|
||||||
|
return true
|
||||||
|
await tree.process_frame
|
||||||
|
return condition.call()
|
||||||
|
|
||||||
|
|
||||||
|
func _joined(guest: Node) -> bool:
|
||||||
|
return guest.session != null and guest.session.local_player() >= 0
|
||||||
|
|
||||||
|
|
||||||
|
func _press(main: Node, action: String) -> void:
|
||||||
|
var event := InputEventAction.new()
|
||||||
|
event.action = action
|
||||||
|
event.pressed = true
|
||||||
|
main._unhandled_input(event)
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_menu_comes_first_and_remembers_the_name() -> void:
|
||||||
|
var directory := TestFiles.temporary_dir("menu")
|
||||||
|
var main := _start_main(directory)
|
||||||
|
await tree.process_frame
|
||||||
|
check(main.menu.visible, "the menu shows")
|
||||||
|
check(main.session == null, "nothing runs yet")
|
||||||
|
main.menu.name_field.text = " Ada "
|
||||||
|
check(main.play(false), "playing alone starts")
|
||||||
|
check(not main.menu.visible, "the menu is gone")
|
||||||
|
check_eq(main.hud.status_text(), "", "no status line when playing alone")
|
||||||
|
check_eq(main.session.player_name(main.session.local_player()), "Ada", "the player has the name")
|
||||||
|
_stop_main(main)
|
||||||
|
|
||||||
|
var later := _start_main(directory)
|
||||||
|
check_eq(later.menu.name_field.text, "Ada", "the name is remembered")
|
||||||
|
_clean_up([directory])
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_command_line_can_start_without_the_menu() -> void:
|
||||||
|
var directory := TestFiles.temporary_dir("command-line")
|
||||||
|
var main: Node = load("res://main.tscn").instantiate()
|
||||||
|
main.storage_dir = directory
|
||||||
|
main.command_line = PackedStringArray(["--solo", "--name=Linus"])
|
||||||
|
tree.root.add_child(main)
|
||||||
|
_started.append(main)
|
||||||
|
check(main.session != null and main.session.is_running(), "a game runs at once")
|
||||||
|
check(not main.menu.visible, "without the menu")
|
||||||
|
check_eq(main.session.player_name(main.session.local_player()), "Linus", "with the name from the command line")
|
||||||
|
_clean_up([directory])
|
||||||
|
|
||||||
|
|
||||||
func test_a_new_game_starts_and_the_player_joins() -> void:
|
func test_a_new_game_starts_and_the_player_joins() -> void:
|
||||||
var directory := TestFiles.temporary_dir("main")
|
var directory := TestFiles.temporary_dir("main")
|
||||||
var main := _start_main(directory)
|
var main := _start_main(directory)
|
||||||
|
main.play(false)
|
||||||
for frame in 5:
|
for frame in 5:
|
||||||
await tree.process_frame
|
await tree.process_frame
|
||||||
var session: GwSession = main.session
|
var session: GwSession = main.session
|
||||||
check(session != null and session.is_running(), "a session runs")
|
check(session != null and session.is_running(), "a session runs")
|
||||||
check(not session.is_broken(), "the simulation is healthy")
|
check(not session.is_broken(), "the simulation is healthy")
|
||||||
|
check(session.is_host(), "playing alone is hosting without guests")
|
||||||
check_eq(session.local_player(), 0, "the local player has joined")
|
check_eq(session.local_player(), 0, "the local player has joined")
|
||||||
check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn")
|
check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn")
|
||||||
check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones")
|
check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones")
|
||||||
_stop_main(main)
|
_clean_up([directory])
|
||||||
TestFiles.remove_dir(directory)
|
|
||||||
|
|
||||||
|
|
||||||
func test_a_saved_game_is_continued_on_the_next_start() -> void:
|
func test_a_saved_game_is_continued_on_the_next_start() -> void:
|
||||||
var directory := TestFiles.temporary_dir("continue")
|
var directory := TestFiles.temporary_dir("continue")
|
||||||
var first := _start_main(directory)
|
var first := _start_main(directory)
|
||||||
|
first.play(false)
|
||||||
await tree.process_frame
|
await tree.process_frame
|
||||||
var tick_at_save: int = first.session.tick()
|
var tick_at_save: int = first.session.tick()
|
||||||
check(first.save_game(), "the game was saved")
|
check(first.save_game(), "the game was saved")
|
||||||
_stop_main(first)
|
_stop_main(first)
|
||||||
|
|
||||||
var second := _start_main(directory)
|
var second := _start_main(directory)
|
||||||
|
second.play(false)
|
||||||
await tree.process_frame
|
await tree.process_frame
|
||||||
check(second.session.tick() > tick_at_save, "the saved world continues")
|
check(second.session.tick() > tick_at_save, "the saved world continues")
|
||||||
check_eq(second.session.local_player(), 0, "the same player rejoins")
|
check_eq(second.session.local_player(), 0, "the same player rejoins")
|
||||||
_stop_main(second)
|
_clean_up([directory])
|
||||||
TestFiles.remove_dir(directory)
|
|
||||||
|
|
||||||
|
|
||||||
func test_closing_the_window_saves_the_game_and_records_the_session() -> void:
|
func test_closing_the_window_saves_the_game_and_records_the_session() -> void:
|
||||||
var directory := TestFiles.temporary_dir("close")
|
var directory := TestFiles.temporary_dir("close")
|
||||||
var main := _start_main(directory)
|
var main := _start_main(directory)
|
||||||
|
main.play(false)
|
||||||
await tree.process_frame
|
await tree.process_frame
|
||||||
main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
|
main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
|
||||||
var store := SaveStore.new(directory)
|
var store := SaveStore.new(directory)
|
||||||
check(store.has_save(), "the game was saved")
|
check(store.has_save(), "the game was saved")
|
||||||
check_eq(store.recordings().size(), 1, "the session was recorded")
|
check_eq(store.recordings().size(), 1, "the session was recorded")
|
||||||
_stop_main(main)
|
_clean_up([directory])
|
||||||
TestFiles.remove_dir(directory)
|
|
||||||
|
|
||||||
|
|
||||||
func test_the_game_is_saved_automatically_every_two_minutes() -> void:
|
func test_the_game_is_saved_automatically_every_two_minutes() -> void:
|
||||||
var directory := TestFiles.temporary_dir("autosave")
|
var directory := TestFiles.temporary_dir("autosave")
|
||||||
var main := _start_main(directory)
|
var main := _start_main(directory)
|
||||||
|
main.play(false)
|
||||||
var store := SaveStore.new(directory)
|
var store := SaveStore.new(directory)
|
||||||
check(not store.has_save(), "there is no save at the start")
|
check(not store.has_save(), "there is no save at the start")
|
||||||
# No frames are awaited, so only these ticks run.
|
# No frames are awaited, so only these ticks run. The loop is counted, so
|
||||||
while main.session.tick() < main.AUTOSAVE_TICKS - 1:
|
# that a session that stops ticking fails the test instead of hanging it.
|
||||||
|
for i in main.AUTOSAVE_TICKS - 1 - main.session.tick():
|
||||||
main._run_tick()
|
main._run_tick()
|
||||||
check(not store.has_save(), "still no save one tick before the interval ends")
|
check(not store.has_save(), "still no save one tick before the interval ends")
|
||||||
main._run_tick()
|
main._run_tick()
|
||||||
check(store.has_save(), "saved when the interval ends")
|
check(store.has_save(), "saved when the interval ends")
|
||||||
_stop_main(main)
|
_clean_up([directory])
|
||||||
TestFiles.remove_dir(directory)
|
|
||||||
|
|
||||||
|
|
||||||
func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
|
func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
|
||||||
@@ -78,13 +164,11 @@ func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
|
|||||||
var store := SaveStore.new(directory)
|
var store := SaveStore.new(directory)
|
||||||
store.write_save(PackedByteArray([1, 2, 3]))
|
store.write_save(PackedByteArray([1, 2, 3]))
|
||||||
var main := _start_main(directory)
|
var main := _start_main(directory)
|
||||||
await tree.process_frame
|
check(main.play(false), "a new world starts")
|
||||||
check(main.session.is_running(), "a new world runs")
|
check(main.session.tick() <= 1, "it is new, tick %d" % main.session.tick())
|
||||||
check(main.session.tick() <= 5, "it is new, tick %d" % main.session.tick())
|
|
||||||
check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside")
|
check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside")
|
||||||
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
|
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
|
||||||
_stop_main(main)
|
_clean_up([directory])
|
||||||
TestFiles.remove_dir(directory)
|
|
||||||
|
|
||||||
|
|
||||||
func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void:
|
func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void:
|
||||||
@@ -94,10 +178,137 @@ func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> vo
|
|||||||
# A folder under the set-aside name keeps the unusable save from moving.
|
# A folder under the set-aside name keeps the unusable save from moving.
|
||||||
DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken"))
|
DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken"))
|
||||||
var main := _start_main(directory)
|
var main := _start_main(directory)
|
||||||
await tree.process_frame
|
check(main.play(false), "a new world starts")
|
||||||
check(main.session.is_running(), "a new world runs")
|
|
||||||
check(not main.save_game(), "saving is refused")
|
check(not main.save_game(), "saving is refused")
|
||||||
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched")
|
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched")
|
||||||
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
|
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
|
||||||
_stop_main(main)
|
_clean_up([directory])
|
||||||
TestFiles.remove_dir(directory)
|
|
||||||
|
|
||||||
|
func test_a_save_that_cannot_be_read_right_now_is_left_alone() -> void:
|
||||||
|
var directory := TestFiles.temporary_dir("held")
|
||||||
|
SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
|
||||||
|
var main := _start_main(directory)
|
||||||
|
var held := HeldFileStore.new(directory)
|
||||||
|
held.unreadable = SaveStore.SAVE_FILE
|
||||||
|
main.store = held
|
||||||
|
check(not main.play(false), "nothing starts")
|
||||||
|
check_eq(main.menu.status(), tr("SAVE_UNREADABLE"), "the menu says why")
|
||||||
|
check(main.session == null, "no session")
|
||||||
|
check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the save was not set aside")
|
||||||
|
_clean_up([directory])
|
||||||
|
|
||||||
|
|
||||||
|
func test_without_a_readable_key_the_game_stays_in_the_menu() -> void:
|
||||||
|
var directory := TestFiles.temporary_dir("no-key")
|
||||||
|
var main := _start_main(directory)
|
||||||
|
main._player_key = PackedByteArray()
|
||||||
|
check(not main.play(false), "playing alone does not start")
|
||||||
|
main.join("127.0.0.1")
|
||||||
|
check(main.session == null, "joining does not start either")
|
||||||
|
check_eq(main.menu.status(), tr("KEY_UNREADABLE"), "the menu says why")
|
||||||
|
_clean_up([directory])
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_guest_joins_an_open_world_and_both_have_the_same_world() -> void:
|
||||||
|
var host_dir := TestFiles.temporary_dir("host")
|
||||||
|
var guest_dir := TestFiles.temporary_dir("guest")
|
||||||
|
var host := _start_host(host_dir)
|
||||||
|
var port: int = host.link.link.local_port()
|
||||||
|
check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 1], "the host sees the port")
|
||||||
|
var guest := _start_guest(guest_dir, host)
|
||||||
|
check_eq(guest.menu.status(), tr("CONNECTING") % ("127.0.0.1:%d" % host.link.link.local_port()), "the guest waits")
|
||||||
|
check(await _wait_until(_joined.bind(guest)), "the guest is in the world")
|
||||||
|
check(not guest.menu.visible, "the guest sees the world")
|
||||||
|
check_eq(guest.session.local_player(), 1, "the guest is player 1")
|
||||||
|
check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_JOINED") % "Grace"), "the host is told")
|
||||||
|
check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 2], "and counts two players")
|
||||||
|
check_eq(guest.hud.status_text(), tr("STATUS_GUEST") % ["127.0.0.1:%d" % port, 2], "the guest sees whose guest it is")
|
||||||
|
|
||||||
|
# Stop the host's clock and let the guest catch up.
|
||||||
|
host.clock.paused = true
|
||||||
|
check(await _wait_until(func() -> bool: return guest.session.tick() == host.session.tick()), "the guest catches up")
|
||||||
|
check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
|
||||||
|
_clean_up([host_dir, guest_dir])
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_figures_of_host_and_guest_appear_on_the_other_side() -> void:
|
||||||
|
var host_dir := TestFiles.temporary_dir("host")
|
||||||
|
var guest_dir := TestFiles.temporary_dir("guest")
|
||||||
|
var host := _start_host(host_dir)
|
||||||
|
var guest := _start_guest(guest_dir, host)
|
||||||
|
await _wait_until(_joined.bind(guest))
|
||||||
|
host.avatar.position += Vector2(16.0, 0.0)
|
||||||
|
var host_figure: Vector2 = host.avatar.position
|
||||||
|
check(await _wait_until(func() -> bool: return guest.figures.shown_at(0).distance_to(host_figure) < 0.5), "the guest sees the host's figure")
|
||||||
|
check(await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF), "the host sees the guest's figure")
|
||||||
|
_clean_up([host_dir, guest_dir])
|
||||||
|
|
||||||
|
|
||||||
|
func test_only_the_host_saves_and_only_alone_one_may_pause_or_load() -> void:
|
||||||
|
var host_dir := TestFiles.temporary_dir("host")
|
||||||
|
var guest_dir := TestFiles.temporary_dir("guest")
|
||||||
|
var host := _start_host(host_dir)
|
||||||
|
var guest := _start_guest(guest_dir, host)
|
||||||
|
await _wait_until(_joined.bind(guest))
|
||||||
|
|
||||||
|
_press(guest, "save_game")
|
||||||
|
check_eq(guest.hud.message(), tr("HOST_SAVES"), "a guest does not save")
|
||||||
|
_press(guest, "toggle_pause")
|
||||||
|
check_eq(guest.hud.message(), tr("PAUSE_ONLY_ALONE"), "a guest cannot pause")
|
||||||
|
_press(host, "toggle_pause")
|
||||||
|
check_eq(host.hud.message(), tr("PAUSE_ONLY_ALONE"), "nor can a host with a guest")
|
||||||
|
_press(host, "load_game")
|
||||||
|
check_eq(host.hud.message(), tr("LOAD_ONLY_ALONE"), "loading is for playing alone")
|
||||||
|
|
||||||
|
guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
|
||||||
|
var guest_store := SaveStore.new(guest_dir)
|
||||||
|
check(not guest_store.has_save(), "the guest wrote no save")
|
||||||
|
check_eq(guest_store.recordings().size(), 1, "but a recording of its session")
|
||||||
|
_clean_up([host_dir, guest_dir])
|
||||||
|
|
||||||
|
|
||||||
|
func test_the_host_sees_a_guest_leave() -> void:
|
||||||
|
var host_dir := TestFiles.temporary_dir("host")
|
||||||
|
var guest_dir := TestFiles.temporary_dir("guest")
|
||||||
|
var host := _start_host(host_dir)
|
||||||
|
var guest := _start_guest(guest_dir, host)
|
||||||
|
await _wait_until(_joined.bind(guest))
|
||||||
|
await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF)
|
||||||
|
guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
|
||||||
|
check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host is told")
|
||||||
|
check_eq(host.figures.shown_at(1), Vector2.INF, "the guest's figure is gone")
|
||||||
|
check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online")
|
||||||
|
_clean_up([host_dir, guest_dir])
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_guest_goes_back_to_the_menu_when_the_host_closes() -> void:
|
||||||
|
var host_dir := TestFiles.temporary_dir("host")
|
||||||
|
var guest_dir := TestFiles.temporary_dir("guest")
|
||||||
|
var host := _start_host(host_dir)
|
||||||
|
var guest := _start_guest(guest_dir, host)
|
||||||
|
await _wait_until(_joined.bind(guest))
|
||||||
|
host.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
|
||||||
|
check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends")
|
||||||
|
check(guest.menu.visible, "the menu is back")
|
||||||
|
check_eq(guest.menu.status(), tr("HOST_LEFT"), "and says why")
|
||||||
|
_clean_up([host_dir, guest_dir])
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_guest_that_is_turned_away_learns_why() -> void:
|
||||||
|
var host_dir := TestFiles.temporary_dir("host")
|
||||||
|
var host := _start_host(host_dir)
|
||||||
|
# A second window on the same profile has the same player key.
|
||||||
|
var twin := _start_guest(host_dir, host)
|
||||||
|
check(await _wait_until(func() -> bool: return twin.session == null), "the twin is turned away")
|
||||||
|
check_eq(twin.menu.status(), tr("REFUSED_ALREADY_PLAYING"), "the menu says why")
|
||||||
|
_clean_up([host_dir])
|
||||||
|
|
||||||
|
|
||||||
|
func test_a_mistyped_address_is_reported() -> void:
|
||||||
|
var directory := TestFiles.temporary_dir("typo")
|
||||||
|
var main := _start_main(directory)
|
||||||
|
main.join("host:port")
|
||||||
|
check(main.session == null, "nothing starts")
|
||||||
|
check_eq(main.menu.status(), tr("ADDRESS_INVALID") % "host:port", "the menu says why")
|
||||||
|
_clean_up([directory])
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ func _run() -> void:
|
|||||||
DirAccess.make_dir_recursive_absolute(STORAGE)
|
DirAccess.make_dir_recursive_absolute(STORAGE)
|
||||||
var main: Node = load("res://main.tscn").instantiate()
|
var main: Node = load("res://main.tscn").instantiate()
|
||||||
main.storage_dir = STORAGE
|
main.storage_dir = STORAGE
|
||||||
|
main.command_line = PackedStringArray()
|
||||||
root.add_child(main)
|
root.add_child(main)
|
||||||
|
main.play(false)
|
||||||
await process_frame
|
await process_frame
|
||||||
|
|
||||||
# Four tiles to the right, toward the rock outcrop beside the clearing.
|
# Four tiles to the right, toward the rock outcrop beside the clearing.
|
||||||
|
|||||||
Reference in New Issue
Block a user