Initial Brogether prototype

This commit is contained in:
Kroonk
2026-05-23 00:38:59 +02:00
commit 536cbff829
9 changed files with 866 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
extends "res://main.gd"
var _brogether_snapshot_elapsed := 0.0
func _ready() -> void:
._ready()
var brogether = _get_brogether()
if brogether != null:
brogether.on_run_scene_ready(self)
func _physics_process(delta: float) -> void:
._physics_process(delta)
_brogether_snapshot_elapsed += delta
if _brogether_snapshot_elapsed < 0.25:
return
_brogether_snapshot_elapsed = 0.0
var brogether = _get_brogether()
if brogether != null:
brogether.on_run_snapshot_tick(_players)
func _on_EntitySpawner_players_spawned(players: Array) -> void:
._on_EntitySpawner_players_spawned(players)
var brogether = _get_brogether()
if brogether != null:
brogether.on_run_players_spawned(players)
func _exit_tree() -> void:
var brogether = _get_brogether()
if brogether != null:
brogether.on_run_scene_exiting()
func _get_brogether():
var root := get_tree().get_root()
if root == null:
return null
var mod_loader := root.get_node_or_null("ModLoader")
if mod_loader == null:
return null
return mod_loader.get_node_or_null("Brogether-Brogether")

View File

@@ -0,0 +1,18 @@
{
"name": "Brogether",
"namespace": "Brogether",
"version_number": "0.1.0",
"description": "Experimental 2-player online multiplayer foundation for Brotato using Steam lobbies and P2P packets.",
"website_url": "https://example.com",
"dependencies": [],
"extra": {
"godot": {
"authors": ["tommi"],
"compatible_mod_loader_version": ["6.3.0"],
"compatible_game_version": ["1.1.15.3"],
"godot_mod": true,
"incompatibilities": [],
"tags": ["Utilities", "New Mechanics"]
}
}
}

View File

@@ -0,0 +1,472 @@
extends Node
const MOD_ID := "Brogether-Brogether"
const LOG_PATH := "user://brogether.log"
var steam = null
var lobby_id := 0
var lobby_members := []
var steam_id := 0
var is_host := false
var packet_sequence := 0
var status_layer: CanvasLayer = null
var status_label: Label = null
var status_time_left := 0.0
var current_run_id := ""
var current_run_active := false
var current_players := []
var snapshot_sequence := 0
func _init() -> void:
ModLoaderMod.install_script_extension("res://mods-unpacked/Brogether-Brogether/extensions/main_ext.gd")
func _ready() -> void:
name = MOD_ID
pause_mode = Node.PAUSE_MODE_PROCESS
set_process(true)
set_process_input(true)
_clear_log()
_create_status_overlay()
_init_steam()
_log("loaded. F6=create lobby, F7=find/join lobby, F9=leave, F10=ping, F11=loopback test")
func _process(_delta: float) -> void:
if status_time_left > 0.0:
status_time_left -= _delta
if status_time_left <= 0.0 and status_label != null:
status_label.visible = false
if steam == null:
return
if steam.has_method("run_callbacks"):
steam.call("run_callbacks")
if lobby_id != 0:
_read_all_p2p_packets()
func _input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.echo:
return
match event.scancode:
KEY_F6:
create_lobby()
get_tree().set_input_as_handled()
KEY_F7:
find_lobbies()
get_tree().set_input_as_handled()
KEY_F9:
leave_lobby()
get_tree().set_input_as_handled()
KEY_F10:
send_ping()
get_tree().set_input_as_handled()
KEY_F11:
run_loopback_test()
get_tree().set_input_as_handled()
func create_lobby() -> void:
if steam == null:
_log("cannot create lobby: Steam singleton missing")
return
if lobby_id != 0:
_log("already in lobby %s" % lobby_id)
return
var lobby_type := 2
_log("creating public dev lobby")
steam.call("createLobby", lobby_type, 2)
func find_lobbies() -> void:
if steam == null:
_log("cannot find lobbies: Steam singleton missing")
return
if steam.has_method("addRequestLobbyListStringFilter"):
steam.call("addRequestLobbyListStringFilter", "brogether", "1", 0)
if steam.has_method("addRequestLobbyListDistanceFilter"):
steam.call("addRequestLobbyListDistanceFilter", 2)
_log("requesting lobby list")
steam.call("requestLobbyList")
func join_lobby(target_lobby_id: int) -> void:
if steam == null:
return
if lobby_id != 0:
leave_lobby()
_log("joining lobby %s" % target_lobby_id)
steam.call("joinLobby", target_lobby_id)
func leave_lobby() -> void:
if steam == null or lobby_id == 0:
return
_log("leaving lobby %s" % lobby_id)
if steam.has_method("leaveLobby"):
steam.call("leaveLobby", lobby_id)
lobby_id = 0
lobby_members.clear()
is_host = false
func send_ping() -> void:
if lobby_id == 0:
_log("not in a lobby yet")
return
_send_p2p_packet(0, {
"type": "ping",
"sequence": packet_sequence,
"from": steam_id,
"time": OS.get_ticks_msec()
})
packet_sequence += 1
func run_loopback_test() -> void:
var fake_remote_id := 48000000000000001
_log("starting local loopback test")
_handle_packet(fake_remote_id, {
"type": "handshake",
"from": fake_remote_id
}, true)
_handle_packet(fake_remote_id, {
"type": "ping",
"sequence": packet_sequence,
"from": fake_remote_id,
"time": OS.get_ticks_msec()
}, true)
packet_sequence += 1
_log("local loopback test finished")
func on_run_scene_ready(main_node: Node) -> void:
current_run_active = true
current_run_id = "%s-%s-%s" % [steam_id, OS.get_unix_time(), RunData.current_wave]
var run_state := _build_run_state()
_log("run scene ready: %s" % run_state)
_send_p2p_packet(0, {
"type": "run_started",
"run_id": current_run_id,
"from": steam_id,
"state": run_state
})
func on_run_players_spawned(players: Array) -> void:
current_players = players
_log("players spawned: %s" % _build_players_snapshot(players))
_send_p2p_packet(0, {
"type": "players_spawned",
"run_id": current_run_id,
"from": steam_id,
"players": _build_players_snapshot(players)
})
func on_run_snapshot_tick(players: Array) -> void:
if not current_run_active:
return
var snapshot := _build_players_snapshot(players)
if snapshot.empty():
return
var packet := {
"type": "host_snapshot" if is_host else "client_snapshot",
"run_id": current_run_id,
"sequence": snapshot_sequence,
"from": steam_id,
"wave": RunData.current_wave,
"players": snapshot
}
snapshot_sequence += 1
_send_p2p_packet(0, packet)
func on_run_scene_exiting() -> void:
if current_run_active:
_log("run scene exiting: %s" % current_run_id)
current_run_active = false
current_run_id = ""
current_players.clear()
snapshot_sequence = 0
func prepare_online_coop_slots() -> void:
_log("preparing experimental 2-player online coop slots; this intentionally enters Brotato local coop UI")
RunData.play_mode = RunData.PlayMode.COOP
RunData.set_coop_run(true)
RunData.set_player_count(2)
CoopService.clear_coop_players()
CoopService._add_player(CoopService.KEYBOARD_REMAPPED_DEVICE_ID, CoopService.PlayerType.KEYBOARD_AND_MOUSE)
CoopService._add_player(CoopService.FIRST_DEBUG_DEVICE_ID, CoopService.PlayerType.KEYBOARD_AND_MOUSE)
_log("coop slots ready: RunData players=%s, CoopService players=%s" % [RunData.get_player_count(), CoopService.connected_players])
func _init_steam() -> void:
if not Engine.has_singleton("Steam"):
_log("Steam singleton not available")
return
steam = Engine.get_singleton("Steam")
if steam.has_method("getSteamID"):
steam_id = int(steam.call("getSteamID"))
_connect_steam_signal("lobby_created", "_on_lobby_created")
_connect_steam_signal("lobby_joined", "_on_lobby_joined")
_connect_steam_signal("lobby_match_list", "_on_lobby_match_list")
_connect_steam_signal("lobby_chat_update", "_on_lobby_chat_update")
_connect_steam_signal("p2p_session_request", "_on_p2p_session_request")
_connect_steam_signal("p2p_session_connect_fail", "_on_p2p_session_connect_fail")
if steam.has_method("allowP2PPacketRelay"):
steam.call("allowP2PPacketRelay", true)
_log("Steam ready as %s" % steam_id)
func _connect_steam_signal(signal_name: String, method_name: String) -> void:
if steam == null:
return
if steam.has_signal(signal_name) and not steam.is_connected(signal_name, self, method_name):
steam.connect(signal_name, self, method_name)
func _on_lobby_created(connect: int, created_lobby_id: int) -> void:
if connect != 1:
_log("lobby creation failed: %s" % connect)
return
lobby_id = created_lobby_id
is_host = true
_log("created lobby %s" % lobby_id)
steam.call("setLobbyJoinable", lobby_id, true)
steam.call("setLobbyData", lobby_id, "name", "Brogether")
steam.call("setLobbyData", lobby_id, "brogether", "1")
steam.call("setLobbyData", lobby_id, "version", "0.1.0")
_refresh_lobby_members()
func _on_lobby_joined(joined_lobby_id: int, _permissions: int, _locked: bool, response: int) -> void:
var success := response == 1
if not success:
_log("join failed for lobby %s: %s" % [joined_lobby_id, response])
return
lobby_id = joined_lobby_id
_refresh_lobby_members()
is_host = steam.has_method("getLobbyOwner") and int(steam.call("getLobbyOwner", lobby_id)) == steam_id
_log("joined lobby %s as %s with %s member(s)" % [lobby_id, "host" if is_host else "client", lobby_members.size()])
_send_p2p_packet(0, {"type": "handshake", "from": steam_id})
func _on_lobby_match_list(lobbies: Array) -> void:
_log("found %s lobby candidate(s)" % lobbies.size())
for candidate in lobbies:
var candidate_id := int(candidate)
if steam.has_method("getLobbyData") and steam.call("getLobbyData", candidate_id, "brogether") == "1":
join_lobby(candidate_id)
return
_log("no Brogether lobby found")
func _on_lobby_chat_update(this_lobby_id: int, _changed_id: int, _making_change_id: int, _chat_state: int) -> void:
if this_lobby_id != lobby_id:
return
_refresh_lobby_members()
_send_p2p_packet(0, {"type": "handshake", "from": steam_id})
func _on_p2p_session_request(remote_id: int) -> void:
_log("accepting P2P session from %s" % remote_id)
steam.call("acceptP2PSessionWithUser", remote_id)
_send_p2p_packet(remote_id, {"type": "handshake_ack", "from": steam_id})
func _on_p2p_session_connect_fail(remote_id: int, session_error: int) -> void:
_log("P2P session failed with %s: error %s" % [remote_id, session_error])
func _refresh_lobby_members() -> void:
lobby_members.clear()
if lobby_id == 0 or steam == null or not steam.has_method("getNumLobbyMembers"):
return
var member_count := int(steam.call("getNumLobbyMembers", lobby_id))
for index in range(member_count):
var member_id := int(steam.call("getLobbyMemberByIndex", lobby_id, index))
var persona := str(member_id)
if steam.has_method("getFriendPersonaName"):
persona = steam.call("getFriendPersonaName", member_id)
lobby_members.append({"steam_id": member_id, "name": persona})
_log("members: %s" % lobby_members)
func _read_all_p2p_packets(read_count: int = 0) -> void:
if read_count >= 32 or steam == null or not steam.has_method("getAvailableP2PPacketSize"):
return
var packet_size := int(steam.call("getAvailableP2PPacketSize", 0))
if packet_size <= 0:
return
_read_p2p_packet(packet_size)
_read_all_p2p_packets(read_count + 1)
func _read_p2p_packet(packet_size: int) -> void:
var packet: Dictionary = steam.call("readP2PPacket", packet_size, 0)
if packet.empty() or not packet.has("data"):
_log("read empty packet")
return
var sender := int(packet.get("remote_steam_id", 0))
var data = bytes2var(packet["data"])
_handle_packet(sender, data)
func _handle_packet(sender: int, data, is_loopback: bool = false) -> void:
_log("packet from %s: %s" % [sender, data])
if typeof(data) != TYPE_DICTIONARY:
return
match data.get("type", ""):
"handshake":
_log("handshake received from %s" % sender)
"handshake_ack":
_log("handshake acknowledged by %s" % sender)
"ping":
_log("ping received from %s, sending pong" % sender)
if is_loopback:
_handle_packet(sender, {
"type": "pong",
"sequence": data.get("sequence", -1),
"from": steam_id,
"time": OS.get_ticks_msec()
}, true)
return
_send_p2p_packet(sender, {
"type": "pong",
"sequence": data.get("sequence", -1),
"from": steam_id,
"time": OS.get_ticks_msec()
})
"pong":
_log("pong received from %s for sequence %s" % [sender, data.get("sequence", -1)])
"run_started":
_log("remote run started from %s: %s" % [sender, data.get("state", {})])
"players_spawned":
_log("remote players spawned from %s: %s" % [sender, data.get("players", [])])
"host_snapshot":
_log("host snapshot %s from %s: %s player(s)" % [data.get("sequence", -1), sender, data.get("players", []).size()])
_apply_remote_snapshot(sender, data)
"client_snapshot":
_log("client snapshot %s from %s: %s player(s)" % [data.get("sequence", -1), sender, data.get("players", []).size()])
_apply_remote_snapshot(sender, data)
_:
_log("unknown packet type from %s" % sender)
func _send_p2p_packet(target: int, packet_data: Dictionary) -> void:
if steam == null or lobby_id == 0:
return
var send_type := 2
var payload := PoolByteArray()
payload.append_array(var2bytes(packet_data))
if target == 0:
for member in lobby_members:
var member_id := int(member["steam_id"])
if member_id != steam_id:
steam.call("sendP2PPacket", member_id, payload, send_type, 0)
else:
steam.call("sendP2PPacket", target, payload, send_type, 0)
func _log(message: String) -> void:
var line := "[Brogether] %s" % message
print(line)
_show_status(message)
var file := File.new()
var error := file.open(LOG_PATH, File.READ_WRITE)
if error != OK:
error = file.open(LOG_PATH, File.WRITE)
if error == OK:
file.seek_end()
file.store_line(line)
file.close()
func _clear_log() -> void:
var file := File.new()
if file.open(LOG_PATH, File.WRITE) == OK:
file.store_line("[Brogether] log started")
file.close()
func _create_status_overlay() -> void:
status_layer = CanvasLayer.new()
status_layer.name = "BrogetherStatusLayer"
status_layer.pause_mode = Node.PAUSE_MODE_PROCESS
add_child(status_layer)
status_label = Label.new()
status_label.name = "BrogetherStatusLabel"
status_label.anchor_left = 0.0
status_label.anchor_top = 0.0
status_label.margin_left = 16
status_label.margin_top = 16
status_label.rect_min_size = Vector2(620, 32)
status_label.add_color_override("font_color", Color(0.25, 1.0, 0.65, 1.0))
status_label.add_color_override("font_color_shadow", Color(0, 0, 0, 0.85))
status_label.add_constant_override("shadow_offset_x", 2)
status_label.add_constant_override("shadow_offset_y", 2)
status_label.visible = false
status_layer.add_child(status_label)
func _show_status(message: String) -> void:
if status_label == null:
return
status_label.text = "Brogether: " + message
status_label.visible = true
status_time_left = 5.0
func _build_run_state() -> Dictionary:
return {
"run_id": current_run_id,
"wave": RunData.current_wave,
"zone": RunData.current_zone,
"player_count": RunData.get_player_count(),
"is_coop_run": RunData.is_coop_run,
"is_host": is_host,
"lobby_id": str(lobby_id)
}
func _build_players_snapshot(players: Array) -> Array:
var snapshot := []
for player in players:
if not is_instance_valid(player):
continue
var health := -1
var max_health := -1
if "current_stats" in player and player.current_stats != null:
health = int(player.current_stats.health)
if "max_stats" in player and player.max_stats != null:
max_health = int(player.max_stats.health)
snapshot.append({
"player_index": int(player.player_index) if "player_index" in player else snapshot.size(),
"x": float(player.global_position.x),
"y": float(player.global_position.y),
"health": health,
"max_health": max_health,
"dead": bool(player.dead) if "dead" in player else false
})
return snapshot
func _apply_remote_snapshot(sender: int, data: Dictionary) -> void:
if current_players.size() < 2:
_log("remote snapshot from %s ignored: no second local player exists" % sender)
return
var players: Array = data.get("players", [])
if players.empty():
return
var remote_player: Dictionary = players[0]
var target_index: int = 1
if target_index >= current_players.size() or not is_instance_valid(current_players[target_index]):
return
var target = current_players[target_index]
target.global_position = Vector2(float(remote_player.get("x", target.global_position.x)), float(remote_player.get("y", target.global_position.y)))
if "current_stats" in target and target.current_stats != null and remote_player.has("health"):
target.current_stats.health = int(remote_player.health)
_log("applied remote snapshot from %s to local player %s" % [sender, target_index])