Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.
PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.
This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.
The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:
- framing each map from OUTSIDE by merging every VisualInstance3D's AABB
produced five tiny dioramas floating on a table, and one solid black
rectangle. That is a minimap, and a minimap is not a photograph.
- standing at a spawn point fixed three maps and left two black: fps_blockout
is genuinely dark and most of its spawns face an unlit wall, and
procedural_arena builds its geometry at runtime so no fixed offset is
reliably inside it.
- so the tool now RENDERS several vantages and scores each result — mean
luminance times its standard deviation, because brightness alone picks the
empty sky and variance alone picks a high-contrast corner of a dark room.
Their product picks a photograph. A black rectangle passes any check that
only asks whether the camera ended up somewhere sensible.
Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.
`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.
The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.
spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.
Co-Authored-By: Claude Opus 5 <[email protected]>
431 lines
16 KiB
GDScript
431 lines
16 KiB
GDScript
extends Node
|
|
|
|
signal player_connected(id: int)
|
|
signal player_disconnected(id: int)
|
|
signal connection_failed
|
|
signal connection_succeeded
|
|
signal match_state_updated
|
|
signal stats_updated
|
|
signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String)
|
|
## The match is over. `result` carries the reason ("score" / "time"), the winner's
|
|
## name, and the final standings. The HUD raises the summary screen on this.
|
|
signal match_ended(result: Dictionary)
|
|
## A Gun Game player moved up a rung and needs their weapon swapped.
|
|
signal ladder_promoted(peer_id: int, rung: int, weapon_id: String)
|
|
|
|
const DEFAULT_PORT = 31415
|
|
|
|
var connected_players: Array[int] = []
|
|
|
|
## ID -> { username, kills, deaths, assists, ping, color, score, team, streak,
|
|
## best_streak, rung }
|
|
##
|
|
## `score` is what the MODE counts and is what decides the match — in Deathmatch
|
|
## it tracks kills, in Gun Game it is the ladder rung. `kills` stays a plain
|
|
## kill count regardless, because the scoreboard shows both and they are not the
|
|
## same number in every mode.
|
|
var player_stats: Dictionary = {}
|
|
|
|
var match_active: bool = false
|
|
var match_time_remaining: float = 0.0
|
|
## A GameMode id — see globals/game_mode.gd. Was a display string ("Deathmatch")
|
|
## compared by equality in three places; an id is what the mode table is keyed on
|
|
## and what survives being sent over the wire.
|
|
var current_gamemode: String = GameMode.DEATHMATCH
|
|
var current_scene_path: String = ""
|
|
## Set when the match ends, so a late-arriving HUD can still show the summary.
|
|
var last_result: Dictionary = {}
|
|
|
|
var _ping_timer: Timer
|
|
|
|
func _ready() -> void:
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
|
|
|
_ping_timer = Timer.new()
|
|
_ping_timer.wait_time = 1.0
|
|
_ping_timer.autostart = false
|
|
_ping_timer.timeout.connect(_on_ping_timer)
|
|
add_child(_ping_timer)
|
|
|
|
func host_game(port: int = DEFAULT_PORT) -> Error:
|
|
var peer = ENetMultiplayerPeer.new()
|
|
var err = peer.create_server(port)
|
|
if err == OK:
|
|
multiplayer.multiplayer_peer = peer
|
|
connected_players.clear()
|
|
connected_players.append(1) # Host is 1
|
|
_init_player_stats(1, SettingsManager.username)
|
|
_ping_timer.start()
|
|
print("Hosting on port %s" % port)
|
|
return err
|
|
|
|
func join_game(ip: String, port: int = DEFAULT_PORT) -> Error:
|
|
var peer = ENetMultiplayerPeer.new()
|
|
var err = peer.create_client(ip, port)
|
|
if err == OK:
|
|
multiplayer.multiplayer_peer = peer
|
|
print("Connecting to %s:%s" % [ip, port])
|
|
return err
|
|
|
|
func disconnect_game() -> void:
|
|
multiplayer.multiplayer_peer = null
|
|
connected_players.clear()
|
|
player_stats.clear()
|
|
match_active = false
|
|
_ping_timer.stop()
|
|
|
|
func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void:
|
|
# Ensure peer is offline if not already connected
|
|
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
|
|
connected_players.clear()
|
|
connected_players.append(1)
|
|
# Normalised, because `current_gamemode` used to be a DISPLAY string
|
|
# ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups
|
|
# all fall back safely, but an id that is not in the table would compare
|
|
# unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon
|
|
# issuing — a mode that half works is worse than one that does not load.
|
|
current_gamemode = gamemode if GameMode.MODES.has(gamemode) \
|
|
else GameMode.DEATHMATCH
|
|
player_stats.clear()
|
|
_init_player_stats(1, SettingsManager.username)
|
|
|
|
last_result = {}
|
|
match_active = true
|
|
# The mode's own clock, not a hardcoded five minutes. Gun Game is a fifteen
|
|
# minute mode and Deathmatch a ten minute one; both used to be five.
|
|
match_time_remaining = GameMode.time_limit(gamemode)
|
|
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
|
|
|
|
func _init_player_stats(id: int, username: String) -> void:
|
|
# Keep existing color if player re-joins, or generate new
|
|
var p_color = Color(randf_range(0.2, 1.0), randf_range(0.2, 1.0), randf_range(0.2, 1.0)).to_html(false)
|
|
var team := GameMode.assign_team(current_gamemode, player_stats)
|
|
if team > 0:
|
|
# In a team mode the player's colour IS their team's, because a killfeed
|
|
# where a teammate and an enemy are both "some random colour" is a
|
|
# killfeed that cannot be read at a glance.
|
|
p_color = GameMode.team_color(team).to_html(false)
|
|
|
|
player_stats[id] = {
|
|
"username": username,
|
|
"kills": 0,
|
|
"deaths": 0,
|
|
"assists": 0,
|
|
"ping": 0,
|
|
"color": p_color,
|
|
# What the MODE counts. See the note on player_stats.
|
|
"score": 0,
|
|
"team": team,
|
|
"streak": 0,
|
|
"best_streak": 0,
|
|
# Gun Game ladder position.
|
|
"rung": 0,
|
|
}
|
|
stats_updated.emit()
|
|
|
|
func _on_peer_connected(id: int) -> void:
|
|
print("Player connected: ", id)
|
|
if not connected_players.has(id):
|
|
connected_players.append(id)
|
|
|
|
# If server, we wait for the client to register themselves via RPC
|
|
pass
|
|
|
|
func _on_peer_disconnected(id: int) -> void:
|
|
print("Player disconnected: ", id)
|
|
connected_players.erase(id)
|
|
if player_stats.has(id):
|
|
player_stats.erase(id)
|
|
player_disconnected.emit(id)
|
|
if multiplayer.is_server():
|
|
rpc("sync_player_stats", player_stats)
|
|
|
|
func _on_connected_to_server() -> void:
|
|
print("Successfully connected to server")
|
|
connection_succeeded.emit()
|
|
# Tell the server our username
|
|
rpc_id(1, "register_player", SettingsManager.username)
|
|
|
|
func _on_connection_failed() -> void:
|
|
print("Failed to connect to server")
|
|
multiplayer.multiplayer_peer = null
|
|
connection_failed.emit()
|
|
|
|
func _on_server_disconnected() -> void:
|
|
print("Server disconnected")
|
|
multiplayer.multiplayer_peer = null
|
|
connected_players.clear()
|
|
player_stats.clear()
|
|
match_active = false
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func register_player(username: String) -> void:
|
|
if multiplayer.is_server():
|
|
var sender_id = multiplayer.get_remote_sender_id()
|
|
_init_player_stats(sender_id, username)
|
|
player_connected.emit(sender_id)
|
|
|
|
# Sync everyone's stats
|
|
rpc("sync_player_stats", player_stats)
|
|
|
|
# If a match is active, force the late-joiner to load the level
|
|
if match_active:
|
|
rpc_id(sender_id, "rpc_load_level", current_scene_path)
|
|
rpc_id(sender_id, "sync_match_state", match_active, match_time_remaining, current_gamemode)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func update_username(new_name: String) -> void:
|
|
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
var sender_id = multiplayer.get_remote_sender_id()
|
|
if sender_id == 0:
|
|
sender_id = 1
|
|
if player_stats.has(sender_id):
|
|
player_stats[sender_id]["username"] = new_name
|
|
stats_updated.emit()
|
|
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
rpc("sync_player_stats", player_stats)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_player_stats(stats: Dictionary) -> void:
|
|
player_stats = stats
|
|
stats_updated.emit()
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func sync_match_state(active: bool, time_rem: float, mode: String) -> void:
|
|
match_active = active
|
|
match_time_remaining = time_rem
|
|
current_gamemode = mode
|
|
match_state_updated.emit()
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func rpc_load_level(scene_path: String) -> void:
|
|
print("Loading level: ", scene_path)
|
|
current_scene_path = scene_path
|
|
if multiplayer.is_server():
|
|
# Reset the scores as well as the clock. Loading a level used to restart
|
|
# the timer and leave every kill from the previous match standing, so the
|
|
# second match on a server started with someone already at the frag limit
|
|
# — and now that reaching the limit ENDS the match, it would have ended
|
|
# on the first kill.
|
|
last_result = {}
|
|
for pid in player_stats:
|
|
for key in ["kills", "deaths", "assists", "score", "streak",
|
|
"best_streak", "rung"]:
|
|
player_stats[pid][key] = 0
|
|
match_active = true
|
|
match_time_remaining = GameMode.time_limit(current_gamemode)
|
|
rpc("sync_player_stats", player_stats)
|
|
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
|
|
get_tree().change_scene_to_file(scene_path)
|
|
|
|
func _process(delta: float) -> void:
|
|
if not match_active:
|
|
return
|
|
if match_time_remaining > 0:
|
|
match_time_remaining -= delta
|
|
if match_time_remaining <= 0:
|
|
match_time_remaining = 0
|
|
# Time is a win CONDITION, not an off switch. This used to set
|
|
# `match_active = false` and stop, so the clock reached zero, the
|
|
# timer froze at 00:00 and nothing else happened — no winner, no
|
|
# summary, no way back to the menu. The match did not end so much as
|
|
# stop mattering.
|
|
if _is_authority():
|
|
_check_win()
|
|
# We emit this so HUDs can update their timers smoothly
|
|
match_state_updated.emit()
|
|
|
|
|
|
## Whether this peer decides the match. True for the server, and true in
|
|
## singleplayer, where there is no peer but somebody still has to call it.
|
|
func _is_authority() -> bool:
|
|
if not multiplayer.has_multiplayer_peer():
|
|
return true
|
|
if multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
return true
|
|
return multiplayer.is_server()
|
|
|
|
|
|
## Has anyone won? Called on the server after every kill and when the clock runs
|
|
## out. GameMode.check_win is a pure function of the stats, so the rule lives
|
|
## next to the mode that defines it and can be tested without a match running.
|
|
func _check_win() -> void:
|
|
if not match_active:
|
|
return
|
|
var result := GameMode.check_win(current_gamemode, player_stats,
|
|
match_time_remaining)
|
|
if result.is_empty():
|
|
return
|
|
result["mode"] = current_gamemode
|
|
result["standings"] = GameMode.standings(player_stats)
|
|
result["stats"] = player_stats.duplicate(true)
|
|
if _is_authority() and multiplayer.has_multiplayer_peer() \
|
|
and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
rpc("end_match", result)
|
|
else:
|
|
end_match(result)
|
|
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func end_match(result: Dictionary) -> void:
|
|
if not match_active:
|
|
return
|
|
match_active = false
|
|
last_result = result
|
|
match_state_updated.emit()
|
|
match_ended.emit(result)
|
|
|
|
|
|
## Wipe the scores and start the clock again on the same map and mode. The
|
|
## summary screen's "Play Again".
|
|
@rpc("authority", "call_local", "reliable")
|
|
func restart_match() -> void:
|
|
for pid in player_stats:
|
|
for key in ["kills", "deaths", "assists", "score", "streak",
|
|
"best_streak", "rung"]:
|
|
player_stats[pid][key] = 0
|
|
last_result = {}
|
|
match_active = true
|
|
match_time_remaining = GameMode.time_limit(current_gamemode)
|
|
stats_updated.emit()
|
|
match_state_updated.emit()
|
|
|
|
# --- PING SYSTEM ---
|
|
func _on_ping_timer() -> void:
|
|
if multiplayer.is_server():
|
|
var time = Time.get_ticks_msec()
|
|
for pid in connected_players:
|
|
if pid != 1:
|
|
rpc_id(pid, "receive_ping_request", time)
|
|
rpc("sync_player_stats", player_stats)
|
|
|
|
@rpc("authority", "call_remote", "unreliable")
|
|
func receive_ping_request(server_time: int) -> void:
|
|
rpc_id(1, "receive_ping_response", server_time)
|
|
|
|
@rpc("any_peer", "call_remote", "unreliable")
|
|
func receive_ping_response(server_time: int) -> void:
|
|
if multiplayer.is_server():
|
|
var sender_id = multiplayer.get_remote_sender_id()
|
|
var rtt = Time.get_ticks_msec() - server_time
|
|
if player_stats.has(sender_id):
|
|
player_stats[sender_id]["ping"] = rtt
|
|
|
|
# --- GAMEPLAY EVENTS ---
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_victim_name: String = "", assist_ids: Array = []) -> void:
|
|
if _is_authority():
|
|
# A kill after the final whistle counts for nothing. Without this a
|
|
# rocket already in the air when the clock hit zero could change the
|
|
# result after the summary was on screen.
|
|
if not match_active:
|
|
return
|
|
|
|
var clean_kill := killer_id != 0 and killer_id != victim_id
|
|
# A team kill is not a score. Friendly fire may be ON in a mode and the
|
|
# kill still must not advance the killer — otherwise the fastest way to
|
|
# win Team Deathmatch is to shoot your own team.
|
|
if clean_kill and GameMode.is_team_mode(current_gamemode) \
|
|
and player_stats.has(killer_id) and player_stats.has(victim_id) \
|
|
and player_stats[killer_id]["team"] == player_stats[victim_id]["team"]:
|
|
clean_kill = false
|
|
|
|
if clean_kill and player_stats.has(killer_id):
|
|
var ks: Dictionary = player_stats[killer_id]
|
|
ks["kills"] += 1
|
|
ks["streak"] += 1
|
|
ks["best_streak"] = maxi(ks["best_streak"], ks["streak"])
|
|
if current_gamemode == GameMode.GUN_GAME:
|
|
# A kill is a RUNG, not a point, and the rung IS the score — so
|
|
# reaching the top of the ladder is the same event as reaching
|
|
# the score limit, and only one win condition has to exist.
|
|
ks["rung"] += 1
|
|
ks["score"] = ks["rung"]
|
|
var weapon := GameMode.ladder_weapon(ks["rung"])
|
|
ladder_promoted.emit(killer_id, ks["rung"], weapon)
|
|
rpc("notify_promotion", killer_id, ks["rung"], weapon)
|
|
else:
|
|
ks["score"] += int(GameMode.get_mode(current_gamemode).get(
|
|
"kill_score", 1))
|
|
|
|
if victim_id != -1:
|
|
if player_stats.has(victim_id):
|
|
player_stats[victim_id]["deaths"] += 1
|
|
player_stats[victim_id]["streak"] = 0
|
|
var penalty := int(GameMode.get_mode(current_gamemode).get(
|
|
"death_score", 0))
|
|
if penalty != 0:
|
|
player_stats[victim_id]["score"] = maxi(
|
|
player_stats[victim_id]["score"] - penalty, 0)
|
|
|
|
for aid in assist_ids:
|
|
if player_stats.has(aid) and aid != killer_id and aid != victim_id:
|
|
player_stats[aid]["assists"] += 1
|
|
|
|
var v_name = player_stats[victim_id]["username"] if player_stats.has(victim_id) else (custom_victim_name if custom_victim_name != "" else "Unknown")
|
|
var k_name = player_stats[killer_id]["username"] if player_stats.has(killer_id) else "Unknown"
|
|
|
|
var v_color = player_stats[victim_id].get("color", "cccccc") if player_stats.has(victim_id) else "cccccc"
|
|
var k_color = player_stats[killer_id].get("color", "cccccc") if player_stats.has(killer_id) else "cccccc"
|
|
|
|
# Auto-fallback for singleplayer testing where player_stats might not be initialized
|
|
if not player_stats.has(killer_id) and killer_id == 1:
|
|
k_name = SettingsManager.username
|
|
k_color = "55aaff" # Default blueish for uninitialized local player
|
|
|
|
if victim_id == -1 and "Dummy" in custom_victim_name:
|
|
v_color = "ff5555" # Red for dummies
|
|
|
|
if killer_id == 0 or killer_id == victim_id:
|
|
k_name = "" # Suicide or world death
|
|
|
|
# `.rpc()` on an offline peer does not call locally, so singleplayer got
|
|
# no killfeed and no stat sync at all. Both paths now go through the
|
|
# same two calls.
|
|
if multiplayer.has_multiplayer_peer() \
|
|
and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
|
|
sync_player_stats.rpc(player_stats)
|
|
else:
|
|
broadcast_killfeed(v_name, k_name, weapon_name, v_color, k_color)
|
|
sync_player_stats(player_stats)
|
|
|
|
# The scores just changed, so this is exactly when a match can end.
|
|
_check_win()
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
|
|
killfeed_event.emit(victim, killer, weapon, v_color, k_color)
|
|
|
|
|
|
## A Gun Game player moved up. Broadcast so every client's killfeed can say so
|
|
## and the promoted player's own client can swap their weapon.
|
|
@rpc("authority", "call_local", "reliable")
|
|
func notify_promotion(peer_id: int, rung: int, weapon_id: String) -> void:
|
|
ladder_promoted.emit(peer_id, rung, weapon_id)
|
|
|
|
|
|
## Whether `a` may damage `b`, per the mode. The only place friendly fire is
|
|
## decided, so a mode that turns it off turns it off everywhere.
|
|
func can_damage(attacker_id: int, victim_id: int) -> bool:
|
|
if attacker_id == victim_id:
|
|
return true
|
|
if GameMode.friendly_fire(current_gamemode):
|
|
return true
|
|
if not player_stats.has(attacker_id) or not player_stats.has(victim_id):
|
|
return true
|
|
var ta: int = int(player_stats[attacker_id].get("team", 0))
|
|
var tb: int = int(player_stats[victim_id].get("team", 0))
|
|
return ta == 0 or ta != tb
|
|
|
|
|
|
## Which team a peer is on, 0 for free-for-all.
|
|
func team_of(peer_id: int) -> int:
|
|
return int(player_stats.get(peer_id, {}).get("team", 0))
|