Files
Grovewright/game/scripts/main.gd
T
Steven Wroblewski 55c09b0f1f Eigener Schlag zeigt beim Brechen keine zweiten Splitter
Der Kern meldet einen Schlag, der Fels bricht, nur als geänderte Kachel
ohne Spieler. Die Hauptszene merkt sich deshalb die Kacheln ihrer
eigenen Schläge, bis der Kern sie meldet, und zeigt dann keinen zweiten
Splitterwurf. Als Gast kam der zweite eine Rundreise später und sah
wieder verzögert aus. Splitter gibt es nur noch auf Fels, nicht mehr
außerhalb der Karte.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:04:56 +02:00

482 lines
15 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
## The style the playing field is drawn in.
var art: Art
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
## Tiles whose burst of chips the own figure showed when it hit them, until
## the core reports that hit. Their burst is not shown a second time.
var _own_hits: Dictionary[Vector2i, bool] = {}
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)
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()
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.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()
## 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()
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":
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_hits.erase(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 not _own_hits.erase(tile):
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] = true
## 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())