Initial Brogether prototype
This commit is contained in:
44
src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd
Normal file
44
src/mods-unpacked/Brogether-Brogether/extensions/main_ext.gd
Normal 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")
|
||||
18
src/mods-unpacked/Brogether-Brogether/manifest.json
Normal file
18
src/mods-unpacked/Brogether-Brogether/manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
472
src/mods-unpacked/Brogether-Brogether/mod_main.gd
Normal file
472
src/mods-unpacked/Brogether-Brogether/mod_main.gd
Normal 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])
|
||||
Reference in New Issue
Block a user