Switched to HTML5

This commit is contained in:
2026-09-16 19:59:18 +02:00
parent b6f8fd3504
commit 0b8c5bee68
367 changed files with 10993 additions and 15796 deletions
-84
View File
@@ -1,84 +0,0 @@
extends Node
## Global game clock. One real second equals one in-game hour, so a full day
## passes every 24 real seconds. The authoritative server drives the clock; a
## client disables auto-advance and mirrors the time it is sent. Fast systems
## (training) subscribe to hour_passed; slower ones (the economy) subscribe to
## day_passed.
signal hour_passed
signal day_passed
## Real seconds that elapse per in-game hour.
const SECONDS_PER_HOUR : float = 1.0
const HOURS_PER_DAY : int = 24
const DAYS_PER_YEAR : int = 365
const HOURS_PER_YEAR : int = HOURS_PER_DAY * DAYS_PER_YEAR
## Unix seconds represented by one in-game hour.
const UNIX_SECONDS_PER_HOUR : int = 60 * 60
## 2000-01-01 00:00:00 UTC.
const START_UNIX_TIME : int = 946684800
const MONTH_NAMES := [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
]
var total_hours : int = 0
## When false the clock never advances on its own; the server ticks it and the
## client mirrors the authoritative time.
var auto_advance : bool = true
## Signals whether time is flowing. Paused clocks ignore _process but still
## advance when _advance_hour is called explicitly.
var paused : bool = false
var _accumulator : float = 0.0
func _process(delta: float) -> void:
if not auto_advance or paused:
return
_accumulator += delta / SECONDS_PER_HOUR
while _accumulator >= 1.0:
_accumulator -= 1.0
_advance_hour()
func reset() -> void:
total_hours = 0
_accumulator = 0.0
## Mirrors a time received from the authoritative server without running the
## clock forward locally.
func set_hours(value: int) -> void:
if value == total_hours:
return
total_hours = value
hour_passed.emit()
func get_unix_time() -> int:
return START_UNIX_TIME + total_hours * UNIX_SECONDS_PER_HOUR
func get_datetime() -> Dictionary:
return Time.get_datetime_dict_from_unix_time(get_unix_time())
func get_date_string() -> String:
var datetime := get_datetime()
return "%d %s %d, %02d:00" % [
datetime["day"],
MONTH_NAMES[datetime["month"] - 1],
datetime["year"],
datetime["hour"],
]
func _advance_hour() -> void:
total_hours += 1
hour_passed.emit()
if total_hours % HOURS_PER_DAY == 0:
day_passed.emit()
-1
View File
@@ -1 +0,0 @@
uid://b7u5rhubpj2s3
-13
View File
@@ -1,13 +0,0 @@
extends Node
## Holds the options chosen in the new-game screen until the world map is
## loaded. The world map falls back to France and the full civilization list
## when nothing has been configured (for example when run directly).
var player_civ: CivDescription = null
var player_name: String = ""
## Password chosen on first join; re-sent when reconnecting to the same game.
var player_password: String = ""
var civilisations: Array[CivDescription] = []
var is_admin: bool = false
var local_player_id: int = 0
-1
View File
@@ -1 +0,0 @@
uid://csquh18goupic
-113
View File
@@ -1,113 +0,0 @@
class_name HexPathfinder
extends RefCounted
## A* pathfinding over a hex grid, independent of units, terrain and
## ownership. The caller describes the graph with callables, which keeps this
## class pure and testable:
##
## neighbours(coords) -> Array[Vector2i]
## can_enter(coords) -> bool
## step_cost(from, to) -> float
## heuristic(from, to) -> float
##
## find_path returns the cells from start to goal inclusive, or an empty array
## when the goal cannot be entered or is unreachable.
func find_path(
start: Vector2i,
goal: Vector2i,
neighbours: Callable,
can_enter: Callable,
step_cost: Callable,
heuristic: Callable
) -> Array[Vector2i]:
if start == goal or not can_enter.call(goal):
return []
var open := _MinHeap.new()
var came_from: Dictionary = {}
var g_score: Dictionary = {start: 0.0}
var closed: Dictionary = {}
open.push(float(heuristic.call(start, goal)), start)
while not open.is_empty():
var current := open.pop()
if closed.has(current):
continue
if current == goal:
return _reconstruct(came_from, current)
closed[current] = true
for neighbour in neighbours.call(current):
if closed.has(neighbour):
continue
if neighbour != goal and not can_enter.call(neighbour):
continue
var tentative := float(g_score.get(current, INF)) + float(step_cost.call(current, neighbour))
if tentative < float(g_score.get(neighbour, INF)):
came_from[neighbour] = current
g_score[neighbour] = tentative
open.push(tentative + float(heuristic.call(neighbour, goal)), neighbour)
return []
func _reconstruct(came_from: Dictionary, current: Vector2i) -> Array[Vector2i]:
var path: Array[Vector2i] = [current]
while came_from.has(current):
current = came_from[current]
path.push_front(current)
return path
## Binary min-heap keyed by f-score. Stale entries left behind when a node is
## improved are skipped by the closed set, so there is no decrease-key step.
class _MinHeap:
var _keys := PackedFloat64Array()
var _values: Array[Vector2i] = []
func is_empty() -> bool:
return _keys.is_empty()
func push(key: float, value: Vector2i) -> void:
_keys.append(key)
_values.append(value)
var index := _keys.size() - 1
while index > 0:
var parent := (index - 1) >> 1
if _keys[parent] <= _keys[index]:
break
# Swaps are inlined: the heap is the hottest loop in A*, and a
# GDScript call per level dominated the profile.
var key_swap := _keys[parent]
_keys[parent] = _keys[index]
_keys[index] = key_swap
var value_swap := _values[parent]
_values[parent] = _values[index]
_values[index] = value_swap
index = parent
func pop() -> Vector2i:
var value := _values[0]
var last := _keys.size() - 1
_keys[0] = _keys[last]
_values[0] = _values[last]
_keys.resize(last)
_values.resize(last)
var index := 0
var count := _keys.size()
while true:
var smallest := index
var left := (index << 1) + 1
var right := left + 1
if left < count and _keys[left] < _keys[smallest]:
smallest = left
if right < count and _keys[right] < _keys[smallest]:
smallest = right
if smallest == index:
break
var key_swap := _keys[index]
_keys[index] = _keys[smallest]
_keys[smallest] = key_swap
var value_swap := _values[index]
_values[index] = _values[smallest]
_values[smallest] = value_swap
index = smallest
return value
-1
View File
@@ -1 +0,0 @@
uid://c1vax2tkotle1
-72
View File
@@ -1,72 +0,0 @@
class_name LoginManager
extends RefCounted
## Per-game player credentials. There is no account system: a player picks a
## name and password the first time they join a particular game, and the same
## pair authenticates them when they come back to that game. A manager lives on
## the authoritative server for the lifetime of the game, so credentials never
## cross between games.
##
## Passwords are never kept in the clear. Each game salts and hashes them, so
## the stored value is useless outside this game.
## Name (stripped) -> salted SHA-256 hash of the password.
var _passwords: Dictionary = {}
var _salt: String
## `salt` is only supplied by tests; a real game gets a fresh random salt.
func _init(salt: String = "") -> void:
_salt = salt if not salt.is_empty() else _random_salt()
func has_player(name: String) -> bool:
return _passwords.has(_key(name))
func player_count() -> int:
return _passwords.size()
## Authenticates a joining player, registering them the first time their name is
## seen. That first join is the only "registration" in the game. Returns true
## when the player may enter.
func login(name: String, password: String) -> bool:
var key := _key(name)
if key.is_empty() or password.is_empty():
return false
if not _passwords.has(key):
_passwords[key] = _hash(password)
return true
return _passwords[key] == _hash(password)
## Explicitly sets a password for a new player. Returns false when the name is
## invalid or already taken.
func register(name: String, password: String) -> bool:
var key := _key(name)
if key.is_empty() or password.is_empty() or _passwords.has(key):
return false
_passwords[key] = _hash(password)
return true
## Checks a password for a name that is expected to exist.
func authenticate(name: String, password: String) -> bool:
var key := _key(name)
if key.is_empty() or not _passwords.has(key):
return false
return _passwords[key] == _hash(password)
func _key(name: String) -> String:
return name.strip_edges()
func _hash(password: String) -> String:
return (_salt + password).sha256_text()
func _random_salt() -> String:
return "%d:%d" % [Time.get_ticks_usec(), randi()]
-1
View File
@@ -1 +0,0 @@
uid://sggyfyqlwfdx
-262
View File
@@ -1,262 +0,0 @@
class_name MapGenerator
extends RefCounted
## Builds a hex tile map from Perlin/simplex noise and chooses capital sites
## on the generated land. All tuning lives in a MapGenerationConfig resource.
var config : MapGenerationConfig
var topology : MapTopology
var land_cells : Array[Vector2i] = []
## Generated tiles keyed by coordinate. Each value is a plain dictionary with
## the atlas location, terrain classification and modifiers, so the simulation
## can work from data alone and the client can rebuild the TileMapLayer from it.
var tiles : Dictionary = {}
var _landmass_size : Dictionary = {}
func _init(map_config: MapGenerationConfig) -> void:
config = map_config
topology = config.make_topology()
## Fills the terrain layer with tiles chosen from its TileSet and records the
## land cells. Land tiles are picked by the cell's noise value (its elevation),
## the poles become ice, and everything below sea level is water.
func generate(terrain_layer: TileMapLayer, seed_value: int) -> void:
var noise := config.create_noise(seed_value)
var palette := TerrainPalette.new(terrain_layer.tile_set)
land_cells.clear()
tiles.clear()
terrain_layer.clear()
var half := config.map_size / 2
var wrapped := topology.is_cylindrical()
var radius := float(config.map_size.x) / TAU
for row in config.map_size.y:
for column in config.map_size.x:
var coords := Vector2i(column - half.x, row - half.y)
var value := _sample_noise(noise, column, coords, wrapped, radius)
value = _apply_falloff(coords, value, wrapped)
var tile := _choose_tile(coords, value, palette)
tiles[coords] = tile
terrain_layer.set_cell(coords, int(tile["source_id"]), tile["coords"])
if TerrainPalette.is_land(tile):
land_cells.append(coords)
_compute_landmasses(terrain_layer)
## The tile a cell should use: ice at the poles, otherwise a land band chosen
## by the noise value, otherwise the sea tile. Falls back to the configured
## atlas coordinates when the tileset carries no terrain layers yet.
func _choose_tile(coords: Vector2i, value: float, palette: TerrainPalette) -> Dictionary:
if palette.has_ice() and _is_polar(coords):
return palette.ice_tile()
if value >= config.sea_level:
if palette.has_land():
return palette.land_tile_for(value)
return _fallback_tile(config.land_atlas_coords, TerrainPalette.LAND)
if palette.has_sea():
return palette.sea_tile()
return _fallback_tile(config.sea_atlas_coords, TerrainPalette.SEA)
func _fallback_tile(atlas_coords: Vector2i, terrain_class: String) -> Dictionary:
return {
"source_id": config.tile_source_id,
"coords": atlas_coords,
"terrain_type": terrain_class,
"terrain_class": terrain_class,
"min_elevation": 0.0,
TerrainPalette.POPULATION_MULTIPLIER_LAYER: 0.0,
TerrainPalette.GDP_MULTIPLIER_LAYER: 0.0,
TerrainPalette.MOVEMENT_COST_MULTIPLIER_LAYER: 0.0,
}
## True when a cell sits in the frozen band at the top or bottom of the map.
func _is_polar(coords: Vector2i) -> bool:
var half := float(config.map_size.y) / 2.0
if half <= 0.0:
return false
return absf(float(coords.y)) / half >= config.ice_latitude
## Picks up to count land tiles, keeping the chosen sites apart by
## min_capital_distance when possible.
func pick_capitals(count: int, rng: RandomNumberGenerator) -> Array[Vector2i]:
if count <= 0 or land_cells.is_empty():
return []
var candidates := _capital_candidates(false)
if candidates.is_empty():
candidates = _capital_candidates(true)
if candidates.is_empty():
candidates.assign(land_cells)
_shuffle(candidates, rng)
var chosen: Array[Vector2i] = []
for coords in candidates:
if chosen.size() >= count:
break
if _is_far_enough(coords, chosen, config.min_capital_distance):
chosen.append(coords)
if chosen.size() < count:
for coords in candidates:
if chosen.size() >= count:
break
if not chosen.has(coords):
chosen.append(coords)
return chosen
## Places up to cities_per_civ cities for each starting civilisation, seeded by
## its capital. Sites are chosen on land and kept at least min_spacing tiles
## apart from every other city already placed. Cities are founded in distance
## order around each capital, one civilisation at a time, so no nation gets
## starved. Returns an array of dictionaries with "coords", "civ_index" and
## "is_capital" keys.
func pick_cities(
capitals: Array[Vector2i],
cities_per_civ: int,
min_spacing: int
) -> Array[Dictionary]:
var sites: Array[Dictionary] = []
if capitals.is_empty():
return sites
var occupied: Array[Vector2i] = []
for i in capitals.size():
sites.append({"coords": capitals[i], "civ_index": i, "is_capital": true})
occupied.append(capitals[i])
if cities_per_civ <= 1:
return sites
var candidate_lists: Array = []
var counts: Array[int] = []
counts.resize(capitals.size())
counts.fill(1)
for capital in capitals:
candidate_lists.append(_city_candidates(capital))
var placed_any := true
while placed_any:
placed_any = false
for i in capitals.size():
if counts[i] >= cities_per_civ:
continue
var candidates: Array = candidate_lists[i]
while not candidates.is_empty():
var coords: Vector2i = candidates.pop_front()
if not _is_far_enough(coords, occupied, min_spacing):
continue
sites.append({"coords": coords, "civ_index": i, "is_capital": false})
occupied.append(coords)
counts[i] += 1
placed_any = true
break
return sites
func _city_candidates(capital: Vector2i) -> Array:
var candidates: Array = []
for coords in land_cells:
if coords != capital:
candidates.append(coords)
candidates.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
var da := topology.tile_distance(capital, a)
var db := topology.tile_distance(capital, b)
if is_equal_approx(da, db):
if a.x == b.x:
return a.y < b.y
return a.x < b.x
return da < db)
return candidates
func _compute_landmasses(terrain_layer: TileMapLayer) -> void:
_landmass_size.clear()
var land_set := {}
for coords in land_cells:
land_set[coords] = true
var visited := {}
for coords in land_cells:
if visited.has(coords):
continue
var component: Array[Vector2i] = []
var frontier: Array[Vector2i] = [coords]
visited[coords] = true
while not frontier.is_empty():
var current: Vector2i = frontier.pop_back()
component.append(current)
for neighbour in topology.surrounding_cells(terrain_layer, current):
if land_set.has(neighbour) and not visited.has(neighbour):
visited[neighbour] = true
frontier.append(neighbour)
for tile in component:
_landmass_size[tile] = component.size()
## Samples the terrain noise. On a cylindrical map the horizontal axis is bent
## into a circle so the noise value at column 0 and column width match, which
## makes the wrapped seam invisible.
func _sample_noise(
noise: FastNoiseLite,
column: int,
coords: Vector2i,
wrapped: bool,
radius: float
) -> float:
if not wrapped:
return noise.get_noise_2d(float(coords.x), float(coords.y))
var angle := TAU * float(column) / float(config.map_size.x)
return noise.get_noise_3d(cos(angle) * radius, sin(angle) * radius, float(coords.y))
func _apply_falloff(coords: Vector2i, value: float, wrapped: bool = false) -> float:
if not config.falloff_enabled:
return value
var half := Vector2(config.map_size) * 0.5
if half.x <= 0.0 or half.y <= 0.0:
return value
# A cylinder has no east/west edge, so only the north/south poles fade out.
var normalized := absf(float(coords.y) / half.y) if wrapped \
else Vector2(float(coords.x) / half.x, float(coords.y) / half.y).length()
var fade := smoothstep(config.falloff_start, 1.0, normalized)
return value - fade * config.falloff_strength
func _capital_candidates(ignore_margin: bool) -> Array[Vector2i]:
var limit := config.map_size / 2 - Vector2i.ONE * config.capital_margin
var largest := 0
for coords in land_cells:
largest = maxi(largest, int(_landmass_size.get(coords, 0)))
if largest <= 0:
return []
var threshold := mini(maxi(config.min_capital_landmass, 1), largest)
var candidates: Array[Vector2i] = []
for coords in land_cells:
if int(_landmass_size.get(coords, 0)) < threshold:
continue
if not ignore_margin and _outside_margin(coords, limit):
continue
candidates.append(coords)
return candidates
## The east/west edge of a cylinder is not a real border, so capitals may sit
## right up against it; only the poles keep a margin.
func _outside_margin(coords: Vector2i, limit: Vector2i) -> bool:
if absi(coords.y) > limit.y:
return true
return not topology.is_cylindrical() and absi(coords.x) > limit.x
func _is_far_enough(coords: Vector2i, chosen: Array[Vector2i], min_distance: float) -> bool:
for other in chosen:
if topology.tile_distance(coords, other) < min_distance:
return false
return true
func _shuffle(values: Array[Vector2i], rng: RandomNumberGenerator) -> void:
for i in range(values.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var swap := values[i]
values[i] = values[j]
values[j] = swap
-1
View File
@@ -1 +0,0 @@
uid://bx2i1b45ji11k
-102
View File
@@ -1,102 +0,0 @@
class_name MapTopology
extends RefCounted
## Describes how the tile grid connects at its edges. FLAT keeps every
## coordinate independent; CYLINDRICAL wraps the east and west edges into one
## another (the north and south edges stay bounded), so the world behaves like
## the surface of a cylinder. This class centralises the wrapping maths so the
## generator, pathfinding, territory and fog all agree on the neighbourhood.
enum Type { FLAT, CYLINDRICAL }
## Wrapped copies of the world rendered on each side of the real one. Two is
## enough for the largest zoom the camera allows.
const COPY_OFFSETS: Array[int] = [-2, -1, 1, 2]
var type: Type = Type.FLAT
var _size: Vector2i = Vector2i.ZERO
var _origin: Vector2i = Vector2i.ZERO
func _init(size: Vector2i = Vector2i.ZERO, topology_type: Type = Type.FLAT) -> void:
_size = size
_origin = -size / 2
type = topology_type
func is_cylindrical() -> bool:
return type == Type.CYLINDRICAL
func width() -> int:
return _size.x
## Folds a single horizontal coordinate into the map range.
func wrap_x(value: int) -> int:
if not is_cylindrical() or _size.x <= 0:
return value
return posmod(value - _origin.x, _size.x) + _origin.x
## Folds a coordinate into the map range on the wrapped axis.
func wrap_coords(coords: Vector2i) -> Vector2i:
if not is_cylindrical():
return coords
return Vector2i(wrap_x(coords.x), coords.y)
## Shortest signed offset from one tile to another, taking the wrap into
## account. On a flat map this is simply to - from.
func wrapped_delta(from: Vector2i, to: Vector2i) -> Vector2i:
var delta := to - from
if is_cylindrical() and _size.x > 0:
delta.x = posmod(delta.x + _size.x / 2, _size.x) - _size.x / 2
return delta
## Euclidean tile distance using the shortest wrapped offset.
func tile_distance(from: Vector2i, to: Vector2i) -> float:
return Vector2(wrapped_delta(from, to)).length()
## Returns the neighbours of a cell, folded back into the map when wrapping.
func surrounding_cells(terrain_layer: TileMapLayer, coords: Vector2i) -> Array[Vector2i]:
var result: Array[Vector2i] = []
if not terrain_layer:
return result
for neighbour in terrain_layer.get_surrounding_cells(coords):
result.append(wrap_coords(neighbour))
return result
## Pixel vector from a tile to another tile, following the shortest wrapped
## route. The offset is measured between the two real cells (not from the
## origin) because the hexagonal grid shifts alternate rows horizontally, so
## the pixel delta depends on where it starts.
func pixel_delta(terrain_layer: TileMapLayer, from: Vector2i, to: Vector2i) -> Vector2:
var near := from + wrapped_delta(from, to)
return terrain_layer.map_to_local(near) - terrain_layer.map_to_local(from)
## Vector from a tile to its wrapped neighbour, which is what border and path
## drawing need at the seam.
func neighbour_offset(terrain_layer: TileMapLayer, from: Vector2i, to: Vector2i) -> Vector2:
return pixel_delta(terrain_layer, from, to)
## Converts a tile-space offset into a pixel offset using the layer's basis.
## Prefer pixel_delta when a starting cell is known, since the hex grid's row
## offsets make the result position-dependent.
func map_delta_to_local(terrain_layer: TileMapLayer, delta: Vector2i) -> Vector2:
return terrain_layer.map_to_local(delta) - terrain_layer.map_to_local(Vector2i.ZERO)
## Horizontal pixel period of the map: the distance between a tile and the
## wrapped copy of that same tile.
func period_pixels(terrain_layer: TileMapLayer) -> Vector2:
if not is_cylindrical() or _size.x <= 0:
return Vector2.ZERO
return map_delta_to_local(terrain_layer, Vector2i(_size.x, 0))
-1
View File
@@ -1 +0,0 @@
uid://dcigghgg0ik35
-496
View File
@@ -1,496 +0,0 @@
extends Node
## Owns this process's network role: a dedicated authoritative server (started
## with `--server`) or a client that talks to one.
##
## The transport is WebSocket rather than raw UDP so the same server can serve a
## browser client over HTTP(S). A dedicated server listens as a WebSocket server
## (usually behind a reverse proxy that terminates TLS); clients connect to a
## `ws://` or `wss://` URL. On the web the client defaults to the page's own
## origin, so a server and its web client can share one host and port.
##
## Creating a new game connects this process to an authoritative server as its
## first player, who becomes the game's admin and configures it. On the web the
## server is the dedicated instance already serving the page (a browser cannot
## spawn processes); elsewhere a dedicated headless instance is spawned and the
## client connects to that. Admin stays with the player across reconnections,
## because admin is tied to the player's name rather than their peer id.
##
## Joining requires a name and a password. A new name sets its password on its
## first join; a known name must present the same password again, which is how
## a player returns to an ongoing game (and reclaims their civilisation). There
## is no other registration.
##
## Both roles expose this node at /root/Network so the RPCs below resolve on
## either side of the connection.
signal session_started
signal session_failed(message: String)
signal server_disconnected
signal players_updated(players: Dictionary)
signal game_configured
signal game_state_received(state: Dictionary)
signal game_list_received(games: Array)
const DEFAULT_PORT := 27015
## Path the reverse proxy forwards to the game server (e.g. wss://host/ws).
const DEFAULT_PATH := "/ws"
const CONNECT_TIMEOUT := 15.0
const SERVER_SCENE := "res://scenes/server_main.tscn"
const READY_FILE := "user://server_%d.ready"
## Deployed server an exported desktop build talks to. Development builds (the
## editor and debug exports) use the local server instead; web clients always
## use the page's own origin.
const PRODUCTION_SERVER_URL := "wss://tismo.cfpi-fpsi.eu/ws"
## Largest frame the transport accepts. A state snapshot is a single WebSocket
## message that can be far bigger than the 64 KiB WebSocketPeer default (the
## default map is already ~190 KiB), so both ends raise their buffers to match.
const MAX_MESSAGE_BYTES := 16 * 1024 * 1024
## Set by the GameServer node in the server process so incoming order RPCs can
## be handed to it. Null on a client.
var game_server: GameServer = null
var _is_server := false
var _is_admin := false
var _local_player_id := 0
var _players: Dictionary = {}
## Peer id -> player name, for peers that have authenticated this session.
var _player_names: Dictionary = {}
var _admin_peer_id := 0
## Set when the server refuses a login, so the disconnect that follows reports
## the real reason instead of a generic one.
var _login_failure := ""
var _server_process_id := 0
## True while the connection exists only to ask the server for its game list.
## The peer then logs in with join_game_from_lobby instead of on connect.
var _lobby_mode := false
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.has("--server"):
_start_server(args)
func is_server() -> bool:
return _is_server
func is_admin() -> bool:
return _is_admin
## True when this process is a client with a live connection to a server. False
## when running standalone, which the world map uses to fall back to local play.
func is_client_connected() -> bool:
return not _is_server and _local_player_id != 0
func get_local_player_id() -> int:
return _local_player_id
func get_players() -> Dictionary:
return _players
func get_admin_peer_id() -> int:
return _admin_peer_id
func get_peer_ids() -> Array:
return _players.keys()
## Registers the authoritative GameServer so RPCs can reach it (server process).
func register_game_server(server: GameServer) -> void:
game_server = server
## Sends the new-game choices to the server. Only the admin (host) may do this.
func send_game_setup(setup: Dictionary) -> void:
if _is_server:
return
_rpc_configure_game.rpc_id(1, setup)
## Sends a gameplay order to the server.
func send_order(order: Dictionary) -> void:
if _is_server:
return
_rpc_submit_order.rpc_id(1, order)
## Pushes a state snapshot to one connected peer (server process only).
func send_game_state(peer_id: int, snapshot: Dictionary) -> void:
if not _is_server or peer_id <= 0:
return
_rpc_game_state.rpc_id(peer_id, snapshot)
## Starts a new game. The web client cannot spawn a server, so it creates the
## game on the dedicated server already serving the page; elsewhere a dedicated
## server process is spawned and the client connects to it.
func create_game() -> Error:
if OS.has_feature("web"):
return join_game_url(default_server_url())
return host_game()
## Spawns a dedicated server process and connects to it as a client.
func host_game() -> Error:
var port := _find_free_port()
var pid := _spawn_server(port)
if pid <= 0:
return ERR_CANT_CREATE
_server_process_id = pid
_connect_when_ready("127.0.0.1", port)
return OK
## Connects to a server given a host and port. `host` may be a bare hostname
## (a `ws://host:port/ws` URL is built) or a full `http(s)://` / `ws(s)://` URL.
func join_game(host: String, port: int = DEFAULT_PORT) -> Error:
return join_game_url(_resolve_url(host, port))
## Connects to an explicit WebSocket URL, e.g. `wss://example.com/ws`, and logs
## in on connect.
func join_game_url(url: String) -> Error:
_lobby_mode = false
return _connect(url)
## Opens a connection only to ask which games the server is running. The peer is
## not logged in; the join screen later calls join_game_from_lobby to enter.
func request_game_list(url: String) -> Error:
_lobby_mode = true
return _connect(url)
## Logs in over the connection opened by request_game_list. Returns an error
## without sending anything when that connection has gone, so the caller can
## start a fresh listing.
func join_game_from_lobby(player_name: String, password: String) -> Error:
if not _connection_is_open():
session_failed.emit("Not connected to the game server.")
return ERR_UNCONFIGURED
_lobby_mode = false
_rpc_login.rpc_id(1, player_name, password)
return OK
func _connect(url: String) -> Error:
var peer := WebSocketMultiplayerPeer.new()
peer.inbound_buffer_size = MAX_MESSAGE_BYTES
peer.outbound_buffer_size = MAX_MESSAGE_BYTES
var error := peer.create_client(url)
if error != OK:
session_failed.emit("Could not reach the game server.")
return error
_install_client_peer(peer)
return OK
func _connection_is_open() -> bool:
var peer := multiplayer.multiplayer_peer
return (
peer != null
and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
)
## Default server to join: the page's own origin when running in a browser (so
## the web client and its server can share one host), otherwise a local server
## in development and the deployed host in release builds.
func default_server_url() -> String:
if OS.has_feature("web"):
var origin := str(JavaScriptBridge.eval("window.location.origin"))
if not origin.is_empty() and origin != "null":
return _websocket_url(origin)
return _desktop_server_url(_use_local_server())
## Desktop server address for the given environment. Split out so the choice can
## be tested without depending on how the test binary was built.
func _desktop_server_url(use_local: bool) -> String:
if use_local:
return _websocket_url("http://127.0.0.1:%d" % DEFAULT_PORT)
return PRODUCTION_SERVER_URL
## Development builds talk to localhost; an exported release build talks to the
## deployed server. Web builds never reach this, as they use their own origin.
func _use_local_server() -> bool:
return OS.has_feature("editor") or OS.is_debug_build()
func _resolve_url(host: String, port: int) -> String:
var h := host.strip_edges()
if (
h.begins_with("ws://")
or h.begins_with("wss://")
or h.begins_with("http://")
or h.begins_with("https://")
):
return _websocket_url(h)
return _websocket_url("http://%s:%d" % [h, port])
## Normalises an http(s) origin or ws(s) base into a ws(s) URL ending in
## DEFAULT_PATH unless the URL already carries a path of its own.
func _websocket_url(base: String) -> String:
var url := base.strip_edges().rstrip("/")
if url.begins_with("https://"):
url = "wss://" + url.substr("https://".length())
elif url.begins_with("http://"):
url = "ws://" + url.substr("http://".length())
elif not url.begins_with("ws://") and not url.begins_with("wss://"):
url = "ws://" + url
var after_scheme := url.substr(url.find("://") + 3)
if not after_scheme.contains("/"):
url += DEFAULT_PATH
return url
## Stops the server process this client spawned, if any.
func stop_hosted_server() -> void:
if _server_process_id > 0:
OS.kill(_server_process_id)
_server_process_id = 0
func _start_server(args: PackedStringArray) -> void:
_is_server = true
var port := _int_arg(args, "--port", DEFAULT_PORT)
# Bind locally by default so a reverse proxy is the only public entry point.
# Pass `--bind *` to accept direct connections from other machines.
var bind := _string_arg(args, "--bind", "127.0.0.1")
var peer := WebSocketMultiplayerPeer.new()
peer.inbound_buffer_size = MAX_MESSAGE_BYTES
peer.outbound_buffer_size = MAX_MESSAGE_BYTES
var error := peer.create_server(port, bind)
if error != OK:
push_error("Game server could not listen on %s:%d (error %d)." % [bind, port, error])
get_tree().quit(1)
return
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
_write_ready_file(port)
print("Game server listening on ws://%s:%d%s." % [bind, port, DEFAULT_PATH])
func _connect_when_ready(host: String, port: int) -> void:
var deadline := Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0)
var ready_path := READY_FILE % port
while Time.get_ticks_msec() < deadline:
if FileAccess.file_exists(ready_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(ready_path))
join_game(host, port)
return
await get_tree().create_timer(0.2).timeout
session_failed.emit("Timed out waiting for the game server to start.")
func _install_client_peer(peer: WebSocketMultiplayerPeer) -> void:
# The server owns the clock; this client mirrors the time it is sent.
GameClock.auto_advance = false
multiplayer.multiplayer_peer = peer
_connect_client_signals()
## Connects the client callbacks once. They live on the MultiplayerAPI singleton
## shared by every node, so reconnecting a client must not stack duplicates.
func _connect_client_signals() -> void:
if not multiplayer.connected_to_server.is_connected(_on_connected_to_server):
multiplayer.connected_to_server.connect(_on_connected_to_server)
if not multiplayer.connection_failed.is_connected(_on_connection_failed):
multiplayer.connection_failed.connect(_on_connection_failed)
if not multiplayer.server_disconnected.is_connected(_on_server_disconnected):
multiplayer.server_disconnected.connect(_on_server_disconnected)
## A raw connection is not a player yet: it must send credentials via _rpc_login
## before it is added to the roster, receives state or may place orders.
func _on_peer_connected(_id: int) -> void:
pass
func _on_peer_disconnected(id: int) -> void:
_players.erase(id)
_player_names.erase(id)
if id == _admin_peer_id:
# The admin's identity is remembered, so they resume as admin when they
# log back in. Until then the game keeps running under nobody.
_admin_peer_id = 0
_sync_players.rpc(_players)
if game_server:
game_server.on_peer_left(id)
## Admits a peer that supplied valid credentials and announces it to everyone.
func _accept_login(peer_id: int, player_name: String, is_admin: bool) -> void:
_player_names[peer_id] = player_name
if is_admin:
_admin_peer_id = peer_id
_players[peer_id] = {"id": peer_id, "name": player_name, "admin": is_admin}
_assigned.rpc_id(peer_id, peer_id, is_admin, player_name)
_sync_players.rpc(_players)
if game_server:
game_server.on_peer_joined(peer_id, player_name)
func _on_connected_to_server() -> void:
# A lobby connection only asks what is running; the player logs in later.
if _lobby_mode:
_rpc_request_games.rpc_id(1)
return
# Identify ourselves before the server will assign us a peer id or state.
_rpc_login.rpc_id(1, GameConfig.player_name, GameConfig.player_password)
func _on_connection_failed() -> void:
session_failed.emit("Could not connect to the game server.")
func _on_server_disconnected() -> void:
_is_admin = false
if not _login_failure.is_empty():
var reason := _login_failure
_login_failure = ""
session_failed.emit(reason)
return
server_disconnected.emit()
@rpc("authority", "call_remote", "reliable")
func _assigned(player_id: int, is_admin: bool, player_name: String) -> void:
_local_player_id = player_id
_is_admin = is_admin
GameConfig.player_name = player_name
session_started.emit()
@rpc("authority", "call_remote", "reliable")
func _sync_players(players: Dictionary) -> void:
_players = players
players_updated.emit(players)
## Asks the server which games it is running. Sent before login so the join
## screen can show them without credentials.
@rpc("any_peer", "call_remote", "reliable")
func _rpc_request_games() -> void:
if not _is_server or game_server == null:
return
_rpc_game_list.rpc_id(multiplayer.get_remote_sender_id(), game_server.game_list())
@rpc("authority", "call_remote", "reliable")
func _rpc_game_list(games: Array) -> void:
game_list_received.emit(games)
## First message a client sends. The server validates or creates the password
## for that name and either admits the peer or turns it away.
@rpc("any_peer", "call_remote", "reliable")
func _rpc_login(player_name: String, password: String) -> void:
if not _is_server or game_server == null:
return
var peer_id := multiplayer.get_remote_sender_id()
var result := game_server.authenticate(peer_id, player_name, password)
if not bool(result.get("ok", false)):
var reason := str(result.get("reason", "Login failed."))
_login_rejected.rpc_id(peer_id, reason)
if multiplayer.multiplayer_peer is WebSocketMultiplayerPeer:
(multiplayer.multiplayer_peer as WebSocketMultiplayerPeer).disconnect_peer(peer_id)
return
_accept_login(peer_id, str(result["name"]), bool(result["admin"]))
@rpc("authority", "call_remote", "reliable")
func _login_rejected(reason: String) -> void:
_login_failure = reason
if multiplayer.multiplayer_peer is WebSocketMultiplayerPeer:
(multiplayer.multiplayer_peer as WebSocketMultiplayerPeer).close()
# Either this arrives before the disconnect and reports the reason here, or
# _on_server_disconnected has already consumed it. Never report twice.
if _login_failure == reason:
_login_failure = ""
session_failed.emit(reason)
@rpc("any_peer", "call_remote", "reliable")
func _rpc_configure_game(setup: Dictionary) -> void:
if not _is_server or game_server == null:
return
if multiplayer.get_remote_sender_id() != _admin_peer_id:
return
game_server.configure_game(setup)
_rpc_game_configured.rpc()
@rpc("authority", "call_remote", "reliable")
func _rpc_game_configured() -> void:
game_configured.emit()
@rpc("any_peer", "call_remote", "reliable")
func _rpc_submit_order(order: Dictionary) -> void:
if not _is_server or game_server == null:
return
game_server.handle_order(multiplayer.get_remote_sender_id(), order)
@rpc("authority", "call_remote", "reliable")
func _rpc_game_state(state: Dictionary) -> void:
game_state_received.emit(state)
func _find_free_port() -> int:
var tcp := TCPServer.new()
if tcp.listen(0, "127.0.0.1") != OK:
return DEFAULT_PORT
var port := tcp.get_local_port()
tcp.stop()
return port
func _spawn_server(port: int) -> int:
var args := PackedStringArray(["--headless"])
if OS.has_feature("editor"):
args.append_array(["--path", ProjectSettings.globalize_path("res://"), SERVER_SCENE])
args.append_array(["--", "--server", "--port", str(port)])
_forward_profiling_args(args)
return OS.create_process(OS.get_executable_path(), args)
## Passes a profiling interval chosen on the client command line through to the
## spawned server, so one launch profiles both sides of the connection.
func _forward_profiling_args(args: PackedStringArray) -> void:
var own := OS.get_cmdline_user_args()
var index := own.find("--profile-interval")
if index >= 0 and index + 1 < own.size():
args.append_array(["--profile-interval", own[index + 1]])
func _write_ready_file(port: int) -> void:
var file := FileAccess.open(READY_FILE % port, FileAccess.WRITE)
if file:
file.store_string(str(port))
func _int_arg(args: PackedStringArray, name: String, fallback: int) -> int:
var index := args.find(name)
if index >= 0 and index + 1 < args.size():
return int(args[index + 1])
return fallback
func _string_arg(args: PackedStringArray, name: String, fallback: String) -> String:
var index := args.find(name)
if index >= 0 and index + 1 < args.size():
return args[index + 1]
return fallback
-1
View File
@@ -1 +0,0 @@
uid://dtxlglc03lk62
-353
View File
@@ -1,353 +0,0 @@
class_name PerformanceMonitor
extends RefCounted
## Client-side frame-rate watchdog. The HUD feeds it one sample per frame; it
## keeps a rolling window of frame times plus a ring buffer of recent gameplay
## actions (input, orders, snapshots, map rebuilds). When the frame rate takes a
## dive it prints one self-contained report describing the dip and everything
## that happened just before it, so a player testing under real conditions can
## paste the log back for diagnosis.
##
## Like Profiler, all state is static, so any script can record an action
## without holding a reference:
##
## PerformanceMonitor.record_action("order", {"type": "move"})
##
## Only the HUD should call tick()/sample_frame(), and only once per frame.
## Master switch. When false, sampling and recording are both no-ops.
static var enabled: bool = true
## Label stamped on every report, so client and server logs are distinguishable.
static var tag: String = "client"
## Below this many FPS is always a dive, whatever the recent baseline was.
static var fps_floor: float = 30.0
## A dive is also declared when FPS falls below this fraction of the baseline.
static var drop_ratio: float = 0.65
## Recent frames averaged into the "current" FPS (roughly half a second at 60).
static var short_frames: int = 30
## Recent frames averaged into the baseline FPS (roughly five seconds at 60).
static var long_frames: int = 300
## Consecutive dived frames needed before a report is printed, so a one-frame
## hiccup does not spam the log.
static var confirm_frames: int = 10
## Minimum real seconds between two dive reports.
static var cooldown_seconds: float = 5.0
## How far back (real seconds) a report looks for actions to print.
static var context_seconds: float = 15.0
## Ring-buffer capacity for recorded actions.
static var max_actions: int = 300
## Newest actions printed per report.
static var report_actions: int = 80
## Frames slower than this many milliseconds are logged as their own action.
static var hitch_ms: float = 50.0
## Set by the HUD; when valid, used to stamp reports with in-game time.
static var game_time_provider: Callable = Callable()
## When false, report() stays silent (tests turn it off).
static var log_reports: bool = true
static var _frame_seconds: Array[float] = []
static var _frame_ids: Array[int] = []
static var _actions: Array[Dictionary] = []
static var _below_count: int = 0
static var _last_report_msec: int = -1000000
static var _last_tick_usec: int = 0
static var _current_fps: float = 0.0
static var _baseline_fps: float = 0.0
static var _worst_frame_ms: float = 0.0
static var _peak_frame_ms: float = 0.0
static var _dive_count: int = 0
## Samples one frame using the wall clock. Call once per frame, from the HUD.
## Uses Time directly rather than the engine's delta (which honours time_scale)
## so the measured rate is real frames per real second.
static func tick() -> void:
var now := Time.get_ticks_usec()
if _last_tick_usec == 0:
_last_tick_usec = now
return
var frame_seconds := float(now - _last_tick_usec) / 1000000.0
_last_tick_usec = now
if enabled:
sample_frame(frame_seconds)
## Records a frame of a known duration. tick() calls this; tests call it
## directly so the detection algorithm can be exercised deterministically.
static func sample_frame(frame_seconds: float) -> void:
if not enabled:
return
frame_seconds = maxf(frame_seconds, 0.0001)
_frame_seconds.append(frame_seconds)
_frame_ids.append(Engine.get_frames_drawn())
if _frame_seconds.size() > long_frames:
_frame_seconds.pop_front()
_frame_ids.pop_front()
var milliseconds := frame_seconds * 1000.0
_peak_frame_ms = maxf(_peak_frame_ms, milliseconds)
if milliseconds >= hitch_ms:
_record_action_internal("frame.hitch", {"ms": snappedf(milliseconds, 0.1)})
_current_fps = _fps_over(short_frames)
_baseline_fps = _fps_over(long_frames)
_worst_frame_ms = _max_frame_over(short_frames) * 1000.0
if is_diving():
_below_count += 1
else:
_below_count = 0
if _below_count >= confirm_frames and _cooldown_elapsed():
_dive_count += 1
_below_count = 0
_last_report_msec = Time.get_ticks_msec()
report("PERFORMANCE DIVE #%d" % _dive_count)
## True while the current rate is below the floor or the baseline's safe ratio.
static func is_diving() -> bool:
if not enabled or _current_fps <= 0.0:
return false
if _current_fps < fps_floor:
return true
return _baseline_fps > 0.0 and _current_fps < _baseline_fps * drop_ratio
## Stores an action with the wall-clock time and frame it happened on. Cheap
## enough to call on every order, snapshot and map rebuild.
static func record_action(label: String, detail: Dictionary = {}) -> void:
if not enabled:
return
_record_action_internal(label, detail)
static func _record_action_internal(label: String, detail: Dictionary) -> void:
_actions.append({
"label": label,
"detail": detail,
"msec": Time.get_ticks_msec(),
"frame": Engine.get_frames_drawn(),
})
while _actions.size() > max_actions:
_actions.pop_front()
## Actions recorded within the last `window_seconds` (or context_seconds when
## negative), oldest first.
static func get_recent_actions(window_seconds: float = -1.0) -> Array[Dictionary]:
var window := window_seconds if window_seconds >= 0.0 else context_seconds
var cutoff := Time.get_ticks_msec() - int(window * 1000.0)
var recent: Array[Dictionary] = []
for action in _actions:
if int(action["msec"]) >= cutoff:
recent.append(action)
return recent
static func current_fps() -> float:
return _current_fps
static func baseline_fps() -> float:
return _baseline_fps
static func worst_frame_ms() -> float:
return _worst_frame_ms
static func peak_frame_ms() -> float:
return _peak_frame_ms
static func dive_count() -> int:
return _dive_count
static func action_count() -> int:
return _actions.size()
## Prints a complete, self-contained snapshot: frame-rate metrics, engine and
## renderer counters, memory, the worst recent frames and the recent actions.
static func report(title: String = "Performance report") -> void:
if not log_reports:
return
var divider := "================================================================"
var now_msec := Time.get_ticks_msec()
print(divider)
print(" %s [%s]" % [title, tag])
print(divider)
print(" wall : %s game: %s" % [
Time.get_datetime_string_from_system(), _game_time_string(),
])
print(" fps : %.1f baseline %.1f floor %.0f drop ratio %.2f" % [
_current_fps, _baseline_fps, fps_floor, drop_ratio,
])
print(" frame : %.2f ms avg (last %d) worst %.2f ms peak %.2f ms" % [
_average_frame_seconds(short_frames) * 1000.0,
mini(short_frames, _frame_seconds.size()), _worst_frame_ms, _peak_frame_ms,
])
var version: Dictionary = Engine.get_version_info()
print(" engine : Godot %s frames %d fps %.0f physics %d Hz max_fps %d time_scale %.2f" % [
str(version.get("string", "?")), Engine.get_frames_drawn(),
Engine.get_frames_per_second(), Engine.physics_ticks_per_second,
Engine.max_fps, Engine.time_scale,
])
print(" render : %s adapter %s draw calls %d objects %d prims %d" % [
_rendering_method(), RenderingServer.get_video_adapter_name(),
_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
])
print(" memory : static %.1f MB peak %.1f MB objects %d nodes %d resources %d orphans %d" % [
OS.get_static_memory_usage() / 1048576.0,
OS.get_static_memory_peak_usage() / 1048576.0,
_monitor(Performance.OBJECT_COUNT),
_monitor(Performance.OBJECT_NODE_COUNT),
_monitor(Performance.OBJECT_RESOURCE_COUNT),
_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
])
print(" process : process %.2f ms physics %.2f ms navigation %.2f ms" % [
Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0,
])
_print_worst_frames()
_print_recent_actions(now_msec)
print(divider)
static func _print_worst_frames() -> void:
print(" -- worst frames (last %d) --" % mini(short_frames, _frame_seconds.size()))
for row in _worst_recent_frames(8):
print(" %8.2f ms age %5.2f s frame #%d" % [
row["ms"], row["age"], row["frame"],
])
static func _print_recent_actions(now_msec: int) -> void:
var recent := get_recent_actions()
var first := maxi(0, recent.size() - report_actions)
print(" -- recent actions (last %.1f s, %d of %d) --" % [
context_seconds, recent.size() - first, _actions.size(),
])
if recent.is_empty():
print(" (none)")
return
for i in range(first, recent.size()):
var action: Dictionary = recent[i]
var age := float(now_msec - int(action["msec"])) / 1000.0
print(" -%6.2fs %-20s %s" % [
age, action["label"], _format_detail(action["detail"]),
])
## Frames from the short window, newest first, sorted by duration. `age` is the
## time before the report at which the frame ran.
static func _worst_recent_frames(count: int) -> Array[Dictionary]:
var window := mini(short_frames, _frame_seconds.size())
var rows: Array[Dictionary] = []
var elapsed := 0.0
for offset in window:
var i := _frame_seconds.size() - 1 - offset
rows.append({
"ms": _frame_seconds[i] * 1000.0,
"age": elapsed,
"frame": _frame_ids[i],
})
elapsed += _frame_seconds[i]
rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["ms"] > b["ms"])
if rows.size() > count:
rows.resize(count)
return rows
static func _format_detail(detail: Dictionary) -> String:
if detail.is_empty():
return ""
var parts := PackedStringArray()
for key in detail.keys():
parts.append("%s=%s" % [key, detail[key]])
return " ".join(parts)
static func _average_frame_seconds(count: int) -> float:
var n := mini(maxi(count, 0), _frame_seconds.size())
if n <= 0:
return 0.0
var total := 0.0
for i in range(_frame_seconds.size() - n, _frame_seconds.size()):
total += _frame_seconds[i]
return total / float(n)
static func _fps_over(count: int) -> float:
var average := _average_frame_seconds(count)
return 1.0 / average if average > 0.0 else 0.0
static func _max_frame_over(count: int) -> float:
var n := mini(maxi(count, 0), _frame_seconds.size())
if n <= 0:
return 0.0
var worst := 0.0
for i in range(_frame_seconds.size() - n, _frame_seconds.size()):
worst = maxf(worst, _frame_seconds[i])
return worst
static func _cooldown_elapsed() -> bool:
return Time.get_ticks_msec() - _last_report_msec >= int(cooldown_seconds * 1000.0)
static func _monitor(id: int) -> int:
return int(Performance.get_monitor(id))
static func _rendering_method() -> String:
return str(ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"))
static func _game_time_string() -> String:
if game_time_provider.is_valid():
return str(game_time_provider.call())
return "?"
## Clears all runtime state (samples, actions, counters) but keeps the config.
static func reset() -> void:
_frame_seconds.clear()
_frame_ids.clear()
_actions.clear()
_below_count = 0
_last_report_msec = -1000000
_last_tick_usec = 0
_current_fps = 0.0
_baseline_fps = 0.0
_worst_frame_ms = 0.0
_peak_frame_ms = 0.0
_dive_count = 0
## Restores every tuning value to its default. Tests call this so one test's
## tweaks cannot leak into the next.
static func reset_config() -> void:
enabled = true
tag = "client"
fps_floor = 30.0
drop_ratio = 0.65
short_frames = 30
long_frames = 300
confirm_frames = 10
cooldown_seconds = 5.0
context_seconds = 15.0
max_actions = 300
report_actions = 80
hitch_ms = 50.0
game_time_provider = Callable()
log_reports = true
@@ -1 +0,0 @@
uid://cmw28dk0r2ebq
-96
View File
@@ -1,96 +0,0 @@
class_name Profiler
extends RefCounted
## Lightweight named-scope profiler. The headless dedicated server and the GL
## Compatibility client cannot use the editor profiler, so hot paths time
## themselves here. All state is static, so any script can call the class
## directly without an autoload:
##
## Profiler.begin("state.snapshot")
## ...
## Profiler.end("state.snapshot")
##
## Or measure a callable in one line with measure(). Nested scopes (including a
## label nested inside itself) are supported, and begin()/end() are cheap no-ops
## when `enabled` is false.
## When false, begin()/end()/measure() return immediately.
static var enabled: bool = true
## Number of rows report() prints, sorted by total time.
static var report_top: int = 15
static var _starts: Dictionary = {}
static var _total_usec: Dictionary = {}
static var _calls: Dictionary = {}
static var _max_usec: Dictionary = {}
static func begin(label: StringName) -> void:
if not enabled:
return
var stack: Array = _starts.get(label, [])
stack.append(Time.get_ticks_usec())
_starts[label] = stack
static func end(label: StringName) -> void:
if not enabled:
return
var stack: Array = _starts.get(label, [])
if stack.is_empty():
return
var elapsed: int = Time.get_ticks_usec() - int(stack.pop_back())
_total_usec[label] = int(_total_usec.get(label, 0)) + elapsed
_calls[label] = int(_calls.get(label, 0)) + 1
_max_usec[label] = maxi(int(_max_usec.get(label, 0)), elapsed)
## Times a single call and returns whatever it returns.
static func measure(label: StringName, callable: Callable) -> Variant:
begin(label)
var result: Variant = callable.call()
end(label)
return result
## One row per label, sorted by total time spent.
static func get_stats() -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for label in _total_usec.keys():
var calls: int = int(_calls[label])
var total_usec: int = int(_total_usec[label])
rows.append({
"label": label,
"calls": calls,
"total_ms": total_usec / 1000.0,
"avg_ms": (total_usec / 1000.0) / float(calls) if calls > 0 else 0.0,
"max_ms": int(_max_usec[label]) / 1000.0,
})
rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["total_ms"] > b["total_ms"])
return rows
static func report(title: String = "Profiler") -> void:
if not enabled:
return
print("--- %s ---" % title)
print("%-32s %8s %10s %10s %10s" % ["label", "calls", "total ms", "avg ms", "max ms"])
var printed := 0
for row in get_stats():
if printed >= report_top:
break
print("%-32s %8d %10.2f %10.3f %10.3f" % [
row["label"], row["calls"], row["total_ms"], row["avg_ms"], row["max_ms"],
])
printed += 1
if printed == 0:
print("(no samples)")
static func reset() -> void:
_starts.clear()
_total_usec.clear()
_calls.clear()
_max_usec.clear()
-1
View File
@@ -1 +0,0 @@
uid://d14vvoky48t1s
-106
View File
@@ -1,106 +0,0 @@
class_name TerrainPalette
extends RefCounted
## Snapshot of the terrain TileSet's tiles, grouped so the map generator can ask
## which tile a cell should use. The tileset stays the single source of truth:
## each tile carries a `terrain_type` biome name, a `terrain_class` custom data
## layer (Land, Sea or Ice) that groups it, and the land tiles carry a
## `min_elevation` layer that places them in the elevation bands.
##
## Keeping the lookup here leaves the selection rules testable without a scene.
const TERRAIN_TYPE_LAYER := "terrain_type"
const TERRAIN_CLASS_LAYER := "terrain_class"
const MIN_ELEVATION_LAYER := "min_elevation"
## Terrain modifiers read alongside the classification, so a generated tile
## dictionary carries everything the simulation needs without a TileMapLayer.
const POPULATION_MULTIPLIER_LAYER := "population_multiplier"
const GDP_MULTIPLIER_LAYER := "gdp_multiplier"
const MOVEMENT_COST_MULTIPLIER_LAYER := "movement_cost_multiplier"
const LAND := "Land"
const SEA := "Sea"
const ICE := "Ice"
## Land tiles sorted ascending by min_elevation; the highest band whose
## min_elevation is at most the cell's noise value wins.
var _land : Array[Dictionary] = []
var _sea : Dictionary = {}
var _ice : Dictionary = {}
func _init(tile_set: TileSet) -> void:
if tile_set == null:
return
for i in tile_set.get_source_count():
var source_id := tile_set.get_source_id(i)
var source := tile_set.get_source(source_id)
if not source is TileSetAtlasSource:
continue
var atlas := source as TileSetAtlasSource
for t in atlas.get_tiles_count():
var coords := atlas.get_tile_id(t)
var data := atlas.get_tile_data(coords, 0)
var terrain_type := str(data.get_custom_data(TERRAIN_TYPE_LAYER))
var class_value: Variant = data.get_custom_data(TERRAIN_CLASS_LAYER)
var terrain_class := str(class_value) if class_value != null else terrain_type
if terrain_class.is_empty():
terrain_class = terrain_type
var entry := {
"source_id": source_id,
"coords": coords,
"terrain_type": terrain_type,
"terrain_class": terrain_class,
"min_elevation": float(data.get_custom_data(MIN_ELEVATION_LAYER)),
POPULATION_MULTIPLIER_LAYER: float(data.get_custom_data(POPULATION_MULTIPLIER_LAYER)),
GDP_MULTIPLIER_LAYER: float(data.get_custom_data(GDP_MULTIPLIER_LAYER)),
MOVEMENT_COST_MULTIPLIER_LAYER: float(data.get_custom_data(MOVEMENT_COST_MULTIPLIER_LAYER)),
}
match terrain_class:
LAND:
_land.append(entry)
SEA:
if _sea.is_empty():
_sea = entry
ICE:
if _ice.is_empty():
_ice = entry
_land.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a["min_elevation"]) < float(b["min_elevation"]))
func has_land() -> bool:
return not _land.is_empty()
func has_sea() -> bool:
return not _sea.is_empty()
func has_ice() -> bool:
return not _ice.is_empty()
## True when a tile chosen by this palette belongs to the Land class.
static func is_land(tile: Dictionary) -> bool:
return str(tile.get("terrain_class", "")) == LAND
func sea_tile() -> Dictionary:
return _sea
func ice_tile() -> Dictionary:
return _ice
## Land tile for a cell at the given noise value: the band with the highest
## min_elevation that does not exceed it, falling back to the lowest band.
func land_tile_for(elevation: float) -> Dictionary:
if _land.is_empty():
return {}
for i in range(_land.size() - 1, -1, -1):
if float(_land[i]["min_elevation"]) <= elevation:
return _land[i]
return _land[0]
-1
View File
@@ -1 +0,0 @@
uid://c7w2j6mh33hlr
-89
View File
@@ -1,89 +0,0 @@
class_name TerrainStats
extends RefCounted
## Reads the per-terrain-type figures authored on the terrain TileSet's custom
## data layers. The tile types themselves (and their Land/Sea classification)
## still live in the TileSet; this class only looks the numbers up.
##
## Three layers are recognised:
## population_multiplier (float) scales the tile's base population
## gdp_multiplier (float) scales the tile's base GDP per capita
## movement_cost_multiplier (float) multiplier applied to a unit's travel time
##
## A value of 0 (the default for an unfilled layer) means "not authored". The
## callers pick the fallback in that case, so the game keeps running while the
## layers are being filled in.
const POPULATION_MULTIPLIER_LAYER := "population_multiplier"
const GDP_MULTIPLIER_LAYER := "gdp_multiplier"
const MOVEMENT_COST_MULTIPLIER_LAYER := "movement_cost_multiplier"
## Travel-time multiplier used for tiles with no authored movement cost.
const DEFAULT_MOVEMENT_COST_MULTIPLIER := 1.0
var _layer: TileMapLayer
var _tiles: Dictionary = {}
var _use_tiles: bool = false
func _init(layer: TileMapLayer) -> void:
_layer = layer
## Builds a stats reader from generated tile dictionaries (coords -> tile data)
## instead of a live TileMapLayer. This keeps the simulation independent of the
## scene tree.
static func from_tiles(tiles: Dictionary) -> TerrainStats:
var stats := TerrainStats.new(null)
stats._tiles = tiles
stats._use_tiles = true
return stats
## Authored population multiplier, or 0.0 when the layer is empty.
func population_multiplier(coords: Vector2i) -> float:
return float(_value(coords, POPULATION_MULTIPLIER_LAYER))
## Authored GDP multiplier, or 0.0 when the layer is empty.
func gdp_multiplier(coords: Vector2i) -> float:
return float(_value(coords, GDP_MULTIPLIER_LAYER))
## Travel-time multiplier for the tile. Unset or non-positive values fall back
## to a normal cost so a unit is never frozen.
func movement_cost_multiplier(coords: Vector2i) -> float:
var value := float(_value(coords, MOVEMENT_COST_MULTIPLIER_LAYER))
return value if value > 0.0 else DEFAULT_MOVEMENT_COST_MULTIPLIER
## Cheapest authored movement cost across every tile in use. The A* heuristic
## uses it so it never overestimates the cost of a path that runs over a
## terrain that is faster than normal.
func min_movement_cost_multiplier() -> float:
var cells: Array = _tiles.keys() if _use_tiles else (
_layer.get_used_cells() if _layer != null else []
)
if cells.is_empty():
return DEFAULT_MOVEMENT_COST_MULTIPLIER
var cheapest := INF
for coords in cells:
cheapest = minf(cheapest, movement_cost_multiplier(coords))
return cheapest if cheapest < INF else DEFAULT_MOVEMENT_COST_MULTIPLIER
func _value(coords: Vector2i, layer_name: String) -> Variant:
if _use_tiles:
var tile: Dictionary = _tiles.get(coords, {})
return tile.get(layer_name, 0)
var data := _tile_data(coords)
if data == null:
return 0
return data.get_custom_data(layer_name)
func _tile_data(coords: Vector2i) -> TileData:
if _layer == null:
return null
return _layer.get_cell_tile_data(coords)
-1
View File
@@ -1 +0,0 @@
uid://b3bkkawu6v17u
-66
View File
@@ -1,66 +0,0 @@
class_name TextFormat
extends RefCounted
## Small formatting helpers shared by the UI.
## Formats an integer with a thin thousands separator, for example 1234567
## becomes "1 234 567". Negative values keep their leading minus sign.
static func group_digits(value: int) -> String:
var digits := str(absi(value))
var grouped := ""
var count := 0
for i in range(digits.length() - 1, -1, -1):
grouped = digits[i] + grouped
count += 1
if count % 3 == 0 and i > 0:
grouped = " " + grouped
return ("-" if value < 0 else "") + grouped
## Formats a number with at most three significant digits, abbreviating
## thousands, millions, billions and trillions with K, M, B and T suffixes.
## Examples: 7 -> "7", 1234 -> "1.23K", 1234567 -> "1.23M", 999950 -> "1M".
static func compact(value: float) -> String:
if is_zero_approx(value):
return "0"
var sign := "-" if value < 0.0 else ""
var amount := absf(value)
var suffixes := ["", "K", "M", "B", "T"]
var index := 0
while amount >= 1000.0 and index < suffixes.size() - 1:
amount /= 1000.0
index += 1
var text := _three_significant(amount)
if text == "1000" and index < suffixes.size() - 1:
index += 1
text = "1"
return sign + text + suffixes[index]
## Rounds a positive number to at most three significant digits, trimming
## trailing zeros from the fractional part.
static func _three_significant(value: float) -> String:
var digits := int(floor(log(value) / log(10.0))) + 1
var decimals := maxi(0, 3 - digits)
var factor := pow(10.0, decimals)
var rounded: float = round(value * factor) / factor
var text := String.num(rounded, decimals)
if text.contains("."):
text = text.rstrip("0").rstrip(".")
return text
## Joins improvement names into a comma-separated list, or "None" when empty.
static func improvements(improvements: PackedStringArray) -> String:
if improvements.is_empty():
return "None"
return ", ".join(improvements)
## Formats a duration in game hours, showing two decimals below one hour.
static func hours(value: float) -> String:
if value < 1.0:
return "%.2f h" % value
return "%.1f h" % value
-1
View File
@@ -1 +0,0 @@
uid://b8si83yh82cw0
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
# Install the versioned git hooks for this repository by pointing core.hooksPath
# at the githooks directory. This is a one-time, per-checkout setup:
#
# ./scripts/install-git-hooks.sh
#
# After this, every `git commit` runs the test suite first and aborts if it
# fails. Skip once with `git commit --no-verify`.
#
# Uninstall with:
# git config --unset core.hooksPath
set -e
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
HOOKS_DIR="$PROJECT_DIR/githooks"
if [ ! -d "$HOOKS_DIR" ]; then
echo "error: hooks directory not found at $HOOKS_DIR" >&2
exit 1
fi
REPO_ROOT=$(git -C "$PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null) || {
echo "error: $PROJECT_DIR is not inside a git repository." >&2
echo " Run 'git init' first, then re-run this script." >&2
exit 1
}
# Git only runs hooks that are executable.
chmod +x "$HOOKS_DIR"/*
# core.hooksPath is local (not committed), so each clone runs this once.
git -C "$REPO_ROOT" config core.hooksPath "$HOOKS_DIR"
echo "Installed git hooks from $HOOKS_DIR"
echo " repository: $REPO_ROOT"
echo " core.hooksPath $HOOKS_DIR"
if ! command -v node >/dev/null 2>&1 && command -v nix-shell >/dev/null 2>&1; then
echo " note: 'node' is not on PATH; the hook will fall back to 'nix-shell -p nodejs'."
fi
echo ""
echo "Every commit now runs the test suite. Skip once with: git commit --no-verify"
-317
View File
@@ -1,317 +0,0 @@
class_name GameServer
extends Node
## Authoritative game server. Lives only in the dedicated server process (the
## root of server_main.tscn). It owns the GameState, turns player orders into
## state changes and pushes snapshots back to every connected client. The
## client never simulates anything; it renders what this node broadcasts.
signal game_configured
var state: GameState
var host_civ: int = 0
## Map parameters used when the game is configured. Overridable for tests.
var map_config: MapGenerationConfig = GameState.MAP_GENERATION
## When above zero, print a Profiler report every this many seconds. Useful on
## the headless server, where the editor profiler is unavailable. Set with
## `--profile-interval <seconds>` on the server command line.
var profile_interval: float = 0.0
## Every connected peer's civilisation index, or -1 for a spectator.
var peer_civ: Dictionary = {}
## Per-game credentials. Created with the server and kept for the game's whole
## life, so players can leave and come back with the same name and password.
var login := LoginManager.new()
## Player name of the game's founder, who is the only admin. Empty until the
## first player logs in; unchanged while the admin is offline.
var admin_name: String = ""
## Peer id -> player name, for logged-in peers.
var peer_names: Dictionary = {}
## Player name -> civilisation index. Unlike peer_civ this survives a
## disconnect, letting a returning player reclaim their nation.
var identity_civ: Dictionary = {}
var _configured: bool = false
var _profile_elapsed: float = 0.0
## Set when the state changed since the last broadcast. Snapshots are expensive,
## so several changes in one frame are coalesced into a single broadcast in
## _process instead of one per changed.emit().
var _state_dirty: bool = false
func _ready() -> void:
profile_interval = _profile_interval_arg()
if Network.is_server():
Network.register_game_server(self)
GameClock.hour_passed.connect(_on_hour)
func _process(delta: float) -> void:
if _configured and state != null:
state.advance_movement(delta / GameClock.SECONDS_PER_HOUR)
if _state_dirty:
_state_dirty = false
_broadcast_state()
if profile_interval <= 0.0:
return
_profile_elapsed += delta
if _profile_elapsed < profile_interval:
return
_profile_elapsed = 0.0
Profiler.report("GameServer")
Profiler.reset()
func _profile_interval_arg() -> float:
var args := OS.get_cmdline_user_args()
var index := args.find("--profile-interval")
if index < 0 or index + 1 >= args.size():
return 0.0
return maxf(0.0, float(args[index + 1]))
func is_configured() -> bool:
return _configured
## The games this server is hosting, for the join screen. A server runs exactly
## one game, so this is empty until the game is configured and has one entry
## afterwards.
func game_list() -> Array:
if not _configured or state == null:
return []
return [game_summary()]
## Public details of the running game: enough for the join screen to describe it
## without exposing any per-player state.
func game_summary() -> Dictionary:
var civ_names: Array[String] = []
for civ in state.civilisations:
civ_names.append(civ.name)
return {
"id": state.seed,
"name": "Game %d" % state.seed,
"seed": state.seed,
"players": peer_names.size(),
"civs": civ_names,
"date": _game_date_string(),
}
func _game_date_string() -> String:
var unix := GameClock.START_UNIX_TIME + state.total_hours * GameClock.UNIX_SECONDS_PER_HOUR
var datetime := Time.get_datetime_dict_from_unix_time(unix)
return "%d %s %d, %02d:00" % [
datetime["day"],
GameClock.MONTH_NAMES[datetime["month"] - 1],
datetime["year"],
datetime["hour"],
]
## Validates a joining player. The first name ever seen becomes the admin; every
## other name must present the password it set when it first joined. The first
## join of a name registers its password, which is the only registration there
## is. Returns {ok, name, admin, reason}.
func authenticate(peer_id: int, player_name: String, password: String) -> Dictionary:
var name := player_name.strip_edges()
if name.is_empty():
return {"ok": false, "reason": "Enter a player name."}
if password.is_empty():
return {"ok": false, "reason": "Enter a password."}
if peer_names.values().has(name):
return {"ok": false, "reason": "%s is already playing." % name}
if not login.login(name, password):
return {"ok": false, "reason": "Wrong password for %s." % name}
var is_admin := admin_name.is_empty()
if is_admin:
admin_name = name
peer_names[peer_id] = name
return {"ok": true, "name": name, "admin": name == admin_name}
## Builds the world from the setup dictionary chosen in the new-game screen.
## Only the first configuration wins, so a client cannot reset a running game.
func configure_game(setup: Dictionary) -> void:
if _configured:
return
host_civ = int(setup.get("player_civ", 0))
state = GameState.new()
state.map_config = map_config
state.configure(_load_civs(setup.get("civilisations", [])), int(setup.get("seed", 0)))
state.changed.connect(_on_state_changed)
_configured = true
for peer_id in Network.get_peer_ids():
_assign_civ(int(peer_id), str(peer_names.get(int(peer_id), "")))
_broadcast_state()
game_configured.emit()
## Resolves the civilisations chosen at setup to resources. Explicit paths take
## precedence; an empty list falls back to the project's own civilisation list.
func _load_civs(paths: Array) -> Array[CivDescription]:
var civs: Array[CivDescription] = []
for path in paths:
if str(path).is_empty():
continue
var resource := load(str(path))
if resource is CivDescription:
civs.append(resource)
if civs.is_empty():
for path in _default_civ_paths():
var resource := load(path)
if resource is CivDescription:
civs.append(resource)
return civs
func _default_civ_paths() -> Array[String]:
return [
"res://data/civilisations/civ_france.tres",
"res://data/civilisations/civ_britain.tres",
"res://data/civilisations/civ_slovenia.tres",
"res://data/civilisations/civ_poland.tres",
"res://data/civilisations/civ_switzerland.tres",
"res://data/civilisations/civ_ticino.tres",
"res://data/civilisations/civ_galicia.tres",
"res://data/civilisations/civ_germany.tres",
"res://data/civilisations/civ_iran.tres",
]
## Applies an order from a peer. The peer may only command its own units and
## cities. Returns true when the order changed the state.
func handle_order(peer_id: int, order: Dictionary) -> bool:
if not _configured:
return false
var civ := int(peer_civ.get(peer_id, -1))
if civ < 0:
return false
match str(order.get("type", "")):
"move":
return _handle_move(civ, order)
"train":
return _handle_train(civ, order)
"build":
return _handle_build(civ, order)
"demolish":
return _handle_demolish(civ, order)
"government":
return _handle_government(civ, order)
"technology":
return _handle_technology(civ, order)
return false
func _handle_move(civ: int, order: Dictionary) -> bool:
var unit := state.find_unit(int(order.get("unit", -1)))
if unit.is_empty() or int(unit["civ"]) != civ:
return false
return state.request_move(int(unit["id"]), _coords(order.get("coords", [])))
func _handle_train(civ: int, order: Dictionary) -> bool:
var city := state.find_city(int(order.get("city", -1)))
if city.is_empty() or int(city["civ"]) != civ:
return false
return state.request_train(int(city["id"]), int(order.get("proto", -1)))
func _handle_build(civ: int, order: Dictionary) -> bool:
var city := state.find_city(int(order.get("city", -1)))
if city.is_empty() or int(city["civ"]) != civ:
return false
return state.request_build(int(city["id"]), int(order.get("building", -1)))
func _handle_demolish(civ: int, order: Dictionary) -> bool:
var city := state.find_city(int(order.get("city", -1)))
if city.is_empty() or int(city["civ"]) != civ:
return false
return state.request_demolish(int(city["id"]), int(order.get("building", -1)))
func _handle_government(civ: int, order: Dictionary) -> bool:
return state.request_set_government(civ, int(order.get("government", -1)))
func _handle_technology(civ: int, order: Dictionary) -> bool:
return state.request_research_technology(civ, int(order.get("technology", -1)))
## Assigns a logged-in player to a civilisation and sends it the current state.
## Called by Network once a peer has authenticated.
func on_peer_joined(peer_id: int, player_name: String = "") -> void:
if not _configured:
return
_assign_civ(peer_id, player_name)
Network.send_game_state(peer_id, state.snapshot(int(peer_civ.get(peer_id, -1))))
## Drops only the peer mapping. The player's identity and civilisation stay in
## identity_civ so the ongoing game remembers them until they log back in.
func on_peer_left(peer_id: int) -> void:
peer_civ.erase(peer_id)
peer_names.erase(peer_id)
## Maps a player to a civilisation. A returning player reclaims the one they
## had before; the admin gets the civ it chose; everyone else gets the next
## free one, and extra players become spectators.
func _assign_civ(peer_id: int, player_name: String = "") -> void:
if peer_id <= 0 or peer_civ.has(peer_id) or state == null:
return
var used := peer_civ.values()
var choice := -1
if not player_name.is_empty() and identity_civ.has(player_name):
choice = int(identity_civ[player_name])
if choice < 0 and peer_id == Network.get_admin_peer_id():
choice = host_civ
if choice < 0 or used.has(choice):
choice = -1
for i in state.civilisations.size():
if not used.has(i):
choice = i
break
peer_civ[peer_id] = choice
if not player_name.is_empty() and choice >= 0:
identity_civ[player_name] = choice
func _on_hour() -> void:
if not _configured or state == null:
return
state.tick_hour()
func _on_state_changed() -> void:
_state_dirty = true
func _broadcast_state() -> void:
if not Network.is_server() or state == null:
return
Profiler.begin("server.broadcast_state")
# The world data is identical for everyone, so serialise it once and only
# add each viewer's explored/visible/stats on top.
var shared := state.serialize_shared()
var stats_cache := {}
for peer_id in Network.get_peer_ids():
var civ := int(peer_civ.get(peer_id, -1))
if not stats_cache.has(civ):
stats_cache[civ] = state.viewer_stats(civ)
Network.send_game_state(peer_id, state.viewer_snapshot(shared, civ, stats_cache[civ]))
Profiler.end("server.broadcast_state")
func _coords(value: Variant) -> Vector2i:
if value is Array and value.size() >= 2:
return Vector2i(int(value[0]), int(value[1]))
return Vector2i(-1, -1)
-1
View File
@@ -1 +0,0 @@
uid://bm8r55h2mqldm
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
uid://dneswisbsuvcx