Compare commits

...

33 Commits

Author SHA1 Message Date
8b9c19186b fix: Update button container paths for consistency and improve fallback handling 2025-12-15 16:49:21 +01:00
99398b5479 fix: Improve logging for menu transitions and resolve top-level canvas references in ServerBrowser 2025-12-15 10:39:38 +01:00
270a92c617 fix: Update button template resolution and improve error logging for missing components 2025-12-15 09:56:55 +01:00
4d8279719c fix: Update button container path to reflect correct hierarchy and improve logging 2025-12-15 09:51:02 +01:00
3d5a53f0e2 fix: Enhance button container pathfinding and add null checks for ServerBrowser references 2025-12-15 09:49:37 +01:00
25f5af0b4d fix: Add null checks for MainMenuUI_T and TopLevelUICanvas in ServerBrowser to prevent instantiation errors 2025-12-15 09:40:11 +01:00
2ad605138e fix: Implement lazy initialization for Constants to prevent null reference errors 2025-12-15 09:36:27 +01:00
ca517be369 refactor: Remove BuildingRemoveHook to streamline building removal process 2025-12-15 09:35:39 +01:00
df1def69e4 fix: Restore correct namespace reference for packetId in BuildingRemovePacket 2025-12-15 09:32:00 +01:00
db850885f6 fix: Correct packetId reference in BuildingRemovePacket to use the appropriate Enums 2025-12-15 09:30:25 +01:00
71e1e09c75 feat: Enhance building removal logic to ensure correct player job list modifications and add fallback for Remove method 2025-12-15 09:27:05 +01:00
46ebeb1f80 feat: Enhance BuildingRemove logic to correctly manage Player.inst during building removal 2025-12-15 09:22:50 +01:00
7d06145a34 feat: Implement BuildingRemoveHook to manage building removal and prevent infinite loops
fix: Enhance SaveTransferPacket handling for out-of-order delivery and reset transfer state
2025-12-15 09:19:46 +01:00
fcf1ffac76 feat: Add BuildingRemovePacket to handle building removal requests in-game 2025-12-15 09:19:42 +01:00
40369ffe4b fix: Improve player retrieval logic and handle non-existent clients in PlayerEntryScript 2025-12-15 09:15:31 +01:00
fc089afcc0 fix: Enhance logging for save transfer process and completion checks in SaveTransferPacket 2025-12-15 00:06:36 +01:00
cb82d3706f fix: Add missing using directive for Riptide.Demos.Steam.PlayerHosted in SaveTransferPacket 2025-12-14 23:58:51 +01:00
12a207989e fix: Reset save transfer state and streamline loading logic in SaveTransferPacket and StartGame 2025-12-14 23:56:41 +01:00
4afcaccf75 fix: Reset save transfer state and update progress bar calculations 2025-12-14 23:54:49 +01:00
8f13282e04 fix: Improve job type initialization and handle game state during loading 2025-12-14 23:45:12 +01:00
0d7d989f76 fix: Change log warning to info for invalid seed handling in world generation 2025-12-14 23:24:57 +01:00
1cc3042781 feat: Implement various multiplayer stability and synchronization fixes
This commit addresses several critical issues reported by the user to improve the stability and synchronization of the Kingdoms and Castles multiplayer mod.

Key changes include:

- Improved Lobby Stability: Fixed NullReferenceException during lobby entry.
- Enhanced Session Cleanup: Refined disconnection logic to prevent Steamworks shutdown and enable seamless new game starts without client restarts.
- Optimized Building Synchronization: Implemented a throttling mechanism for building state updates to reduce network traffic.
- Resolved Villager Freezing: Introduced a null check for destroyed observed buildings to prevent synchronization cascades.
- Fixed Map Desynchronization: Ensured the host reliably sends the world seed to all clients before game start.
- Reliable Save Game Transfer: Switched save file chunk transfer to reliable messaging mode to prevent incomplete save loads.
- Addressed Compilation Issues: Resolved all compilation errors and warnings that arose from the implemented fixes.
2025-12-14 23:22:57 +01:00
181936e3d4 Refactor seed handling in game start logic and improve packet sending reliability 2025-12-14 23:22:34 +01:00
62db70c1c4 Refactor packet sending to use SendReliable method for improved reliability and remove unnecessary logging 2025-12-14 23:18:23 +01:00
36acbb57c5 Enhance reliability of chunk data transmission and update world seed handling on game start 2025-12-14 23:13:43 +01:00
76f1033bd2 Fix null reference handling in building state updates 2025-12-14 23:00:23 +01:00
26b5f1201e Refactor player entry initialization and optimize building state update logic 2025-12-14 22:27:43 +01:00
9ee675ac19 Implement multiplayer session cleanup on client disconnection 2025-12-14 22:12:50 +01:00
3124f82a2f alap 2025-12-14 21:08:19 +01:00
3a7b81bfd7 save 2025-12-14 21:04:22 +01:00
c4eb7e944d Fix map synchronization between host and clients
Problem: Clients received WorldSeed packet before ServerSettings,
causing world generation with wrong mapSize/mapBias/mapRivers
parameters. Same seed but different parameters = different maps.

Solution: Include all map parameters directly in WorldSeed packet:
- WorldSize (map size)
- WorldType (map bias - continents/islands/etc)
- WorldRivers (river/lake density)

Now packet order doesn't matter - WorldSeed has everything needed
for identical world generation across all clients.

Changes:
- WorldSeed.cs: Add map parameters, set before Generate()
- ClientConnected.cs: Send full world params to joining clients
- ServerLobbyScript.cs: Send full params on new world generation
- Added [WORLD SYNC] debug logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 21:00:15 +01:00
4057cf37c5 sex 2025-12-14 20:52:55 +01:00
fc467f4af8 Fix StartGame NullReferenceException in multiplayer
Remove MainMenuMode.StartGame() reflection call that expected
"Choose Your Map" screen state. Clients never see this screen
in multiplayer, causing null references. Now transitions directly
to playing mode like save loading does.

Fixes: NullReferenceException at MainMenuMode.StartGame()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 20:52:44 +01:00
26 changed files with 641 additions and 839 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(wc:*)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(tail:*)"
]
}
}

104
CLAUDE.md
View File

@@ -1,104 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **Kingdoms and Castles Multiplayer Mod** that adds multiplayer functionality to the game using:
- **Riptide Networking** library for low-level networking
- **Steam P2P** transport for NAT traversal
- **Harmony** for non-invasive game modification via patches/hooks
## Architecture
### Core Components
| File | Purpose |
|------|---------|
| `Main.cs` | Entry point, Harmony patches, all game event hooks |
| `KCClient.cs` | Client-side networking wrapper around Riptide.Client |
| `KCServer.cs` | Server-side networking, client management |
| `KCPlayer.cs` | Player data container (id, steamId, inst, kingdomName) |
### Networking Layer
```
Riptide.Client/Server
└── SteamClient/SteamServer (Steam P2P transport)
└── KCClient/KCServer wrappers
└── PacketHandler (serialization/routing)
```
- Port: 7777, Max clients: 25
- Team ID formula: `clientId * 10 + 2`
### Packet System
Located in `/Packets/`:
- Base class: `Packet.cs` with `Send()`, `SendToAll()`, `HandlePacketClient()`, `HandlePacketServer()`
- `PacketHandler.cs` uses reflection for automatic serialization based on property names (alphabetical order)
- Packet IDs defined in `Enums/Packets.cs`
Key packet ranges:
- 25-34: Lobby (chat, player list, settings)
- 70-79: World/building updates
- 85: Save transfer (chunked)
- 87-90: Building state, villagers
### State Synchronization
- **Buildings**: Observer pattern in `StateManagement/BuildingState/` - monitors field changes every 100ms, sends updates every 300ms
- **Villagers**: Event-based sync via Harmony hooks on `VillagerSystem.AddVillager`, `Villager.TeleportTo`
- **Save/Load**: Custom `MultiplayerSaveContainer` extends `LoadSaveContainer`, stores per-player data
### Harmony Hooks Pattern
All hooks check call stack to prevent infinite loops:
```csharp
if (new StackFrame(3).GetMethod().Name.Contains("HandlePacket"))
return; // Skip if called by network handler
```
### Key Dictionaries
```csharp
Main.kCPlayers // Dictionary<steamId, KCPlayer>
Main.clientSteamIds // Dictionary<clientId, steamId>
```
## Common Issues & Patterns
### Player Resolution
```csharp
Main.GetPlayerByClientID(clientId) // clientId -> KCPlayer
Main.GetPlayerByTeamID(teamId) // teamId -> Player.inst
Main.GetPlayerByBuilding(building) // building -> owner Player
```
### Building Ownership
Buildings are associated with players via `LandmassOwner.teamId`. Use `building.TeamID()` to determine owner.
### Save Directory
Multiplayer saves go to: `Application.persistentDataPath + "/Saves/Multiplayer"`
## Directory Structure
```
/Attributes - Custom packet attributes
/Enums - Packet types, menu states
/LoadSaveOverrides - MultiplayerSaveContainer
/Packets - All network packets
/Riptide - Networking library
/RiptideSteamTransport - Steam P2P adapter, LobbyManager
/StateManagement - Observer pattern for sync
/ServerLobby - Lobby UI
/ServerBrowser - Server discovery
/UI - Custom UI elements
```
## Known Architecture Limitations
1. Static `Client`/`Server` instances can cause issues on reconnect
2. Call stack checking for loop prevention is fragile
3. No conflict resolution - last-write-wins
4. Villager sync is event-based only, no continuous state updates

View File

@@ -15,19 +15,20 @@ namespace KCM
/// </summary>
public static class Constants
{
public static readonly MainMenuMode MainMenuMode = GameState.inst.mainMenuMode;
public static readonly PlayingMode PlayingMode = GameState.inst.playingMode;
public static readonly World World = GameState.inst.world;
// Use lazy initialization to avoid null reference when GameState isn't ready yet
public static MainMenuMode MainMenuMode => GameState.inst?.mainMenuMode;
public static PlayingMode PlayingMode => GameState.inst?.playingMode;
public static World World => GameState.inst?.world;
#region "UI"
public static readonly Transform MainMenuUI_T = MainMenuMode.mainMenuUI.transform;
public static readonly GameObject MainMenuUI_O = MainMenuMode.mainMenuUI;
public static Transform MainMenuUI_T => MainMenuMode?.mainMenuUI?.transform;
public static GameObject MainMenuUI_O => MainMenuMode?.mainMenuUI;
/* public static readonly Transform TopLevelUI_T = MainMenuUI_T.parent;
public static readonly GameObject TopLevelUI_O = MainMenuUI_T.parent.gameObject;*/
public static readonly Transform ChooseModeUI_T = MainMenuMode.chooseModeUI.transform;
public static readonly GameObject ChooseModeUI_O = MainMenuMode.chooseModeUI;
public static Transform ChooseModeUI_T => MainMenuMode?.chooseModeUI?.transform;
public static GameObject ChooseModeUI_O => MainMenuMode?.chooseModeUI;
#endregion
}

View File

@@ -18,11 +18,8 @@ namespace KCM.Enums
KingdomName = 32,
StartGame = 33,
WorldSeed = 34,
Building = 50,
BuildingOnPlacement = 51,
World = 70,
WorldPlace = 71,
FellTree = 72,
@@ -44,6 +41,7 @@ namespace KCM.Enums
AddVillager = 88,
SetupInitialWorkers = 89,
VillagerTeleportTo = 90,
PlaceKeepRandomly = 91
PlaceKeepRandomly = 91,
BuildingRemove = 92
}
}

View File

@@ -36,6 +36,7 @@ namespace KCM
private static void Client_Disconnected(object sender, DisconnectedEventArgs e)
{
Main.CleanupMultiplayerSession();
Main.helper.Log("Client disconnected event start");
try
{

View File

@@ -18,22 +18,18 @@ namespace KCM
{
public class KCServer : MonoBehaviour
{
public static Server server = null;
public static Server server = new Server(Main.steamServer);
public static bool started = false;
static KCServer()
{
//server.registerMessageHandler(typeof(KCServer).GetMethod("ClientJoined"));
server.MessageReceived += PacketHandler.HandlePacketServer;
}
public static void StartServer()
{
// Stop and cleanup existing server if running
if (server != null)
{
if (server.IsRunning)
{
server.Stop();
}
// Unsubscribe old event handlers to prevent memory leaks
server.MessageReceived -= PacketHandler.HandlePacketServer;
}
server = new Server(Main.steamServer);
server.MessageReceived += PacketHandler.HandlePacketServer;
@@ -99,27 +95,17 @@ namespace KCM
}
}*/
public static bool IsRunning { get { return server != null && server.IsRunning; } }
public static bool IsRunning { get { return server.IsRunning; } }
private void Update()
{
if (server != null)
server.Update();
}
private void OnApplicationQuit()
{
if (server != null && server.IsRunning)
{
new ShowModal
{
title = "Host disconnected",
message = "The host has left the game."
}.SendToAll();
server.Stop();
}
}
private void Preload(KCModHelper helper)
{

View File

@@ -24,6 +24,7 @@ namespace KCM.LoadSaveOverrides
Main.helper.Log($"Saving data for {Main.kCPlayers.Count} ({KCServer.server.ClientCount}) players.");
//this.PlayerSaveData = new PlayerSaveDataOverride().Pack(Player.inst);
foreach (var player in Main.kCPlayers.Values)
{
Main.helper.Log($"Attempting to pack data for: " + player.name + $"({player.steamId})");
@@ -56,6 +57,7 @@ namespace KCM.LoadSaveOverrides
public override object Unpack(object obj)
{
//original Player reset was up here
foreach (var kvp in players)
{
@@ -86,6 +88,7 @@ namespace KCM.LoadSaveOverrides
this.TownNameSaveData.Unpack(TownNameUI.inst);
//TownNameUI.inst.townName = kingdomNames[Main.PlayerSteamID];
TownNameUI.inst.SetTownName(kingdomNames[Main.PlayerSteamID]);
Main.helper.Log("Unpacking player data");
@@ -252,47 +255,6 @@ namespace KCM.LoadSaveOverrides
Main.helper.Log($"Setting kingdom name to: {kingdomNames[Main.PlayerSteamID]}");
TownNameUI.inst.SetTownName(kingdomNames[Main.PlayerSteamID]);
// Post-load fixes for multiplayer
Main.helper.Log("Running post-load multiplayer fixes...");
// Fix 1: Re-register all resource storages for all players
Main.helper.Log("Re-registering resource storages...");
foreach (var kcPlayer in Main.kCPlayers.Values)
{
if (kcPlayer.inst == null) continue;
foreach (var building in kcPlayer.inst.Buildings.data)
{
if (building == null) continue;
// Re-register resource storages
var storages = KCM.ResourceStorageHelper.GetStorages(building);
foreach (var storage in storages)
{
if (storage != null && !KCM.ResourceStorageHelper.IsPrivate(storage))
{
try
{
KCM.ResourceStorageHelper.Register(storage);
}
catch (Exception e)
{
Main.helper.Log($"Error re-registering storage for {building.UniqueName}: {e.Message}");
}
}
}
// Refresh building pathing
try
{
building.BakePathing();
}
catch { }
}
}
Main.helper.Log("Post-load multiplayer fixes complete.");
return obj;
}
}

405
Main.cs
View File

@@ -1,9 +1,9 @@
using Assets.Code;
using Assets.Code.UI;
using Assets.Interface;
using Harmony;
using KCM.Enums;
using KCM.LoadSaveOverrides;
using KCM.Packets;
using KCM.Packets.Game;
using KCM.Packets.Game.Dragon;
using KCM.Packets.Game.GameBuilding;
@@ -15,7 +15,6 @@ using KCM.Packets.Game.GameWorld;
using KCM.Packets.Handlers;
using KCM.Packets.Lobby;
using KCM.StateManagement.BuildingState;
using KCM.Packets.State;
using KCM.StateManagement.Observers;
using KCM.UI;
using Newtonsoft.Json;
@@ -55,17 +54,24 @@ namespace KCM
public static Dictionary<string, KCPlayer> kCPlayers = new Dictionary<string, KCPlayer>();
public static Dictionary<ushort, string> clientSteamIds = new Dictionary<ushort, string>();
public static Dictionary<Guid, BuildingStatePacket> pendingBuildingStatePackets = new Dictionary<Guid, BuildingStatePacket>();
// Advanced sync logging helper
public static void LogSync(string message)
{
helper.Log($"[SYNC] {message}");
}
public static KCPlayer GetPlayerByClientID(ushort clientId)
{
return kCPlayers[clientSteamIds[clientId]];
if (TryGetPlayerByClientID(clientId, out KCPlayer player))
{
return player;
}
return null;
}
public static bool TryGetPlayerByClientID(ushort clientId, out KCPlayer player)
{
player = null;
if (clientSteamIds.TryGetValue(clientId, out string steamId))
{
return kCPlayers.TryGetValue(steamId, out player);
}
return false;
}
public static Player GetPlayerByTeamID(int teamId) // Need to replace building / production types so that the correct player is used. IResourceStorage and IResourceProvider, and jobs
@@ -90,26 +96,6 @@ namespace KCM
return Player.inst;
}
public static void QueuePendingBuildingState(BuildingStatePacket packet)
{
if (packet == null)
return;
pendingBuildingStatePackets[packet.guid] = packet;
}
public static void ApplyPendingBuildingState(Building building)
{
if (building == null)
return;
if (pendingBuildingStatePackets.TryGetValue(building.guid, out var pending))
{
pending.ApplyToBuilding(building);
pendingBuildingStatePackets.Remove(building.guid);
}
}
public static Player GetPlayerByBuilding(Building building)
{
try
@@ -134,11 +120,51 @@ namespace KCM
public static string PlayerSteamID = SteamUser.GetSteamID().ToString();
public static KCMSteamManager KCMSteamManager = null;
public static LobbyManager lobbyManager = null;
public static SteamServer steamServer = new SteamServer();
public static Riptide.Transports.Steam.SteamClient steamClient = new Riptide.Transports.Steam.SteamClient(steamServer);
public static ushort currentClient = 0;
public static void CleanupMultiplayerSession()
{
if (helper == null) return; // Avoid running if mod is not fully initialized
helper.Log("--- Starting Multiplayer Session Cleanup ---");
// Disconnect client
if (KCClient.client != null && KCClient.client.IsConnected)
{
helper.Log("Disconnecting client...");
KCClient.client.Disconnect();
}
// Stop server
if (KCServer.server != null && KCServer.IsRunning)
{
helper.Log("Stopping server...");
KCServer.server.Stop();
}
// Clear player lists
if (kCPlayers.Count > 0 || clientSteamIds.Count > 0)
{
helper.Log($"Clearing {kCPlayers.Count} KCPlayer entries and {clientSteamIds.Count} client steam IDs.");
kCPlayers.Clear();
clientSteamIds.Clear();
}
// Destroy persistent managers
if (lobbyManager != null)
{
helper.Log("Destroying LobbyManager.");
Destroy(lobbyManager.gameObject);
lobbyManager = null;
}
helper.Log("--- Multiplayer Session Cleanup Finished ---");
}
#region "SceneLoaded"
private void SceneLoaded(KCModHelper helper)
{
@@ -151,14 +177,9 @@ namespace KCM
KCMSteamManager = new GameObject("KCMSteamManager").AddComponent<KCMSteamManager>();
DontDestroyOnLoad(KCMSteamManager);
var lobbyManager = new GameObject("LobbyManager").AddComponent<LobbyManager>();
lobbyManager = new GameObject("LobbyManager").AddComponent<LobbyManager>();
DontDestroyOnLoad(lobbyManager);
//SteamFriends.InviteUserToGame(new CSteamID(76561198036307537), "test");
//SteamMatchmaking.lobby
//Main.helper.Log($"Timer duration for hazardpay {Player.inst.hazardPayWarmup.Duration}");
try
{
@@ -168,24 +189,62 @@ namespace KCM
Main.helper.Log(JsonConvert.SerializeObject(World.inst.mapSizeDefs, Formatting.Indented));
KaC_Button serverBrowser = new KaC_Button(Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel/Body/ButtonContainer/New").parent)
// Check if MainMenuUI_T is available
if (Constants.MainMenuUI_T == null)
{
Main.helper.Log("MainMenuUI_T is null, cannot create Multiplayer button");
return;
}
// Debug: Log the UI structure to find the correct path
Main.helper.Log($"MainMenuUI_T name: {Constants.MainMenuUI_T.name}");
Main.helper.Log($"MainMenuUI_T children count: {Constants.MainMenuUI_T.childCount}");
for (int i = 0; i < Constants.MainMenuUI_T.childCount; i++)
{
var child = Constants.MainMenuUI_T.GetChild(i);
Main.helper.Log($" Child {i}: {child.name}");
for (int j = 0; j < child.childCount; j++)
{
Main.helper.Log($" SubChild {j}: {child.GetChild(j).name}");
}
}
// Correct path based on debug output: MainMenuUI -> TopLevelUICanvas -> TopLevel -> Body -> ButtonContainer -> New
var buttonContainer = Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel/Body/ButtonContainer/New");
if (buttonContainer == null)
{
Main.helper.Log("Button container not found at TopLevelUICanvas/TopLevel/Body/ButtonContainer/New");
return;
}
Main.helper.Log($"Found button container at: {buttonContainer.name}");
var templateButton = buttonContainer.GetComponent<Button>();
if (templateButton == null)
{
Main.helper.Log("Template button on container is missing Button component.");
return;
}
KaC_Button serverBrowser = new KaC_Button(templateButton, buttonContainer.parent)
{
Name = "Multiplayer",
Text = "Multiplayer",
FirstSibling = true,
OnClick = () =>
{
//Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel").gameObject.SetActive(false);
Main.helper?.Log("Multiplayer button clicked");
SfxSystem.PlayUiSelect();
//ServerBrowser.serverBrowserRef.SetActive(true);
TransitionTo(MenuState.ServerBrowser);
}
};
serverBrowser.Transform.SetSiblingIndex(2);
Destroy(Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel/Body/ButtonContainer/Kingdom Share").gameObject);
var kingdomShare = Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel/Body/ButtonContainer/Kingdom Share")
?? Constants.MainMenuUI_T.Find("MainMenu/TopLevel/Body/ButtonContainer/Kingdom Share");
if (kingdomShare != null)
{
Destroy(kingdomShare.gameObject);
}
}
catch (Exception ex)
{
@@ -210,59 +269,41 @@ namespace KCM
#endregion
public static int FixedUpdateInterval = 0;
private readonly Dictionary<Guid, Vector3> villagerPositionCache = new Dictionary<Guid, Vector3>();
private float villagerBroadcastAccumulator = 0f;
private const float VillagerBroadcastInterval = 0.25f;
private const float VillagerMovementThreshold = 0.3f;
public static void ClearVillagerPositionCache()
{
// Kept for API compatibility with LobbyManager
}
private void FixedUpdate()
{
// send batched building placement info
/*if (PlaceHook.QueuedBuildings.Count > 0 && (FixedUpdateInterval % 25 == 0))
{
foreach (Building building in PlaceHook.QueuedBuildings)
{
new WorldPlace()
{
uniqueName = building.UniqueName,
customName = building.customName,
guid = building.guid,
rotation = building.transform.GetChild(0).rotation,
globalPosition = building.transform.position,
localPosition = building.transform.GetChild(0).localPosition,
built = building.IsBuilt(),
placed = building.IsPlaced(),
open = building.Open,
doBuildAnimation = building.doBuildAnimation,
constructionPaused = building.constructionPaused,
constructionProgress = building.constructionProgress,
life = building.Life,
ModifiedMaxLife = building.ModifiedMaxLife,
//CollectForBuild = CollectForBuild,
yearBuilt = building.YearBuilt,
decayProtection = building.decayProtection,
seenByPlayer = building.seenByPlayer
}.Send();
}
PlaceHook.QueuedBuildings.Clear();
}*/
FixedUpdateInterval++;
BroadcastVillagerMovements();
}
private void BroadcastVillagerMovements()
{
if (!KCServer.IsRunning || KCServer.server == null)
return;
villagerBroadcastAccumulator += Time.fixedDeltaTime;
if (villagerBroadcastAccumulator < VillagerBroadcastInterval)
return;
villagerBroadcastAccumulator = 0f;
if (Villager.villagers == null)
return;
foreach (var villager in Villager.villagers.data)
{
if (villager == null)
continue;
Guid guid = villager.guid;
Component villagerComponent = (Component)(object)villager;
if (villagerComponent == null)
continue;
Vector3 currentPosition = villagerComponent.transform.position;
if (villagerPositionCache.TryGetValue(guid, out Vector3 lastPosition)
&& Vector3.Distance(lastPosition, currentPosition) < VillagerMovementThreshold)
{
continue;
}
villagerPositionCache[guid] = currentPosition;
new VillagerTeleportTo()
{
guid = guid,
pos = currentPosition
}.SendToAll();
}
}
#region "TransitionTo"
@@ -270,11 +311,22 @@ namespace KCM
{
try
{
// Null checks for ServerBrowser references
if (ServerBrowser.serverBrowserRef != null)
ServerBrowser.serverBrowserRef.SetActive(state == MenuState.ServerBrowser);
if (ServerBrowser.serverLobbyRef != null)
ServerBrowser.serverLobbyRef.SetActive(state == MenuState.ServerLobby);
if (ServerBrowser.KCMUICanvas != null)
{
ServerBrowser.KCMUICanvas.gameObject.SetActive((int)state > 21);
if (state == MenuState.ServerBrowser)
{
Main.helper?.Log($"TransitionTo ServerBrowser: browserRef={(ServerBrowser.serverBrowserRef != null ? "ready" : "null")}, canvas={(ServerBrowser.KCMUICanvas != null ? "ready" : "null")}");
}
helper.Log(((int)state > 21).ToString());
}
GameState.inst.mainMenuMode.TransitionTo((MainMenuMode.State)state);
}
@@ -304,9 +356,6 @@ namespace KCM
helper.Log("Preload start in main");
try
{
//MainMenuPatches.Patch();
Main.helper = helper;
helper.Log(helper.modPath);
@@ -340,22 +389,6 @@ namespace KCM
{
Main.helper.Log($"Menu set to: {(MenuState)newState}");
// Stop server if host goes back to menu
if (newState == MainMenuMode.State.Menu && KCServer.IsRunning)
{
Main.helper.Log("Host returning to menu - stopping server and notifying clients");
// Notify all clients that host is leaving
new ShowModal
{
title = "Host disconnected",
message = "The host has returned to the menu."
}.SendToAll();
// Stop the server
KCServer.server.Stop();
}
Main.prevMenuState = Main.menuState;
if (newState != MainMenuMode.State.Uninitialized)
@@ -733,14 +766,14 @@ namespace KCM
{
LogStep(true);
__instance.Buildings.Add(b);
Component[] storages = ResourceStorageHelper.GetStorages(b);
IResourceStorage[] storages = b.GetComponents<IResourceStorage>();
LogStep();
for (int i = 0; i < storages.Length; i++)
{
bool flag = !ResourceStorageHelper.IsPrivate(storages[i]);
bool flag = !storages[i].IsPrivate();
if (flag)
{
ResourceStorageHelper.Register(storages[i]);
FreeResourceManager.inst.AddResourceStorage(storages[i]);
}
}
LogStep();
@@ -775,52 +808,6 @@ namespace KCM
var unbuiltBuildingsPerLandmass = __instance.GetType().GetField("unbuiltBuildingsPerLandmass", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(__instance) as ArrayExt<ArrayExt<Building>>;
LogStep();
// NULL checks for reflection-based fields
if (globalBuildingRegistry == null)
{
Main.helper.Log("ERROR: globalBuildingRegistry is null!");
return false;
}
if (landMassBuildingRegistry == null)
{
Main.helper.Log("ERROR: landMassBuildingRegistry is null!");
return false;
}
if (unbuiltBuildingsPerLandmass == null)
{
Main.helper.Log("ERROR: unbuiltBuildingsPerLandmass is null!");
return false;
}
// Array bounds check for landMass
if (landMass >= landMassBuildingRegistry.data.Length)
{
Main.helper.Log($"ERROR: landMass={landMass} >= landMassBuildingRegistry array length={landMassBuildingRegistry.data.Length}");
return false;
}
if (landMass >= unbuiltBuildingsPerLandmass.data.Length)
{
Main.helper.Log($"ERROR: landMass={landMass} >= unbuiltBuildingsPerLandmass array length={unbuiltBuildingsPerLandmass.data.Length}");
return false;
}
// Check if registry objects are initialized
if (landMassBuildingRegistry.data[landMass] == null)
{
Main.helper.Log($"ERROR: landMassBuildingRegistry.data[{landMass}] is null!");
return false;
}
if (landMassBuildingRegistry.data[landMass].registry == null)
{
Main.helper.Log($"ERROR: landMassBuildingRegistry.data[{landMass}].registry is null!");
return false;
}
if (landMassBuildingRegistry.data[landMass].buildings == null)
{
Main.helper.Log($"ERROR: landMassBuildingRegistry.data[{landMass}].buildings is null!");
return false;
}
__instance.AddToRegistry(globalBuildingRegistry, b);
LogStep();
__instance.AddToRegistry(landMassBuildingRegistry.data[landMass].registry, b);
@@ -834,8 +821,6 @@ namespace KCM
}
LogStep();
// CRITICAL: Bake pathing for villager movement!
b.BakePathing();
return false;
}
@@ -889,7 +874,6 @@ namespace KCM
new AddVillagerPacket()
{
guid = __result.guid,
position = pos, // Include villager spawn position
}.Send();
}
catch (Exception e)
@@ -1280,14 +1264,18 @@ namespace KCM
public static bool Prefix(ref string __result)
{
Main.helper.Log("Get save dir");
if (KCServer.IsRunning || KCClient.client.IsConnected)
if (KCClient.client.IsConnected)
{
if (KCServer.IsRunning)
{
}
__result = Application.persistentDataPath + "/Saves/Multiplayer";
return false;
}
__result = Application.persistentDataPath + "/Saves";
__result = Application.persistentDataPath + "/Saves"; ;
return true;
}
}
@@ -1316,22 +1304,8 @@ namespace KCM
Stream file = new FileStream(path, FileMode.Open);
try
{
object deserialized = bf.Deserialize(file);
if (deserialized is MultiplayerSaveContainer loadData)
{
MultiplayerSaveContainer loadData = (MultiplayerSaveContainer)bf.Deserialize(file);
loadData.Unpack(null);
}
else if (deserialized is LoadSaveContainer legacyLoadData)
{
Main.helper.Log($"Deserialized fallback save type ({legacyLoadData.GetType().FullName}), applying base container.");
legacyLoadData.Unpack(null);
}
else
{
throw new InvalidCastException($"Unexpected save data type: {deserialized?.GetType().FullName}");
}
Broadcast.OnLoadedEvent.Broadcast(new OnLoadedEvent());
}
catch (Exception e)
@@ -1375,6 +1349,8 @@ namespace KCM
{
Main.helper.Log("Attempting to load save from server");
try
{
using (MemoryStream ms = new MemoryStream(saveBytes))
{
BinaryFormatter bf = new BinaryFormatter();
@@ -1382,7 +1358,26 @@ namespace KCM
saveContainer = (MultiplayerSaveContainer)bf.Deserialize(ms);
}
Main.helper.Log("Deserialize complete, calling Unpack...");
saveContainer.Unpack(null);
Main.helper.Log("Unpack complete!");
}
catch (Exception e)
{
Main.helper.Log("Error loading save from server");
Main.helper.Log(e.Message);
Main.helper.Log(e.StackTrace);
if (e.InnerException != null)
{
Main.helper.Log("Inner exception: " + e.InnerException.Message);
Main.helper.Log(e.InnerException.StackTrace);
}
}
finally
{
memoryStreamHook = false;
}
return false;
}
@@ -1537,55 +1532,34 @@ namespace KCM
{
if (KCClient.client.IsConnected)
{
Main.helper.Log($"[ProcessBuilding] START - {structureData.uniqueName}");
try
{
Building Building = GameState.inst.GetPlaceableByUniqueName(structureData.uniqueName);
Main.helper.Log($"[ProcessBuilding] GetPlaceable: {(Building != null ? "OK" : "NULL")}");
if (Building)
bool flag = Building;
if (flag)
{
Building building = UnityEngine.Object.Instantiate<Building>(Building);
Main.helper.Log($"[ProcessBuilding] Instantiated");
building.transform.position = structureData.globalPosition;
Main.helper.Log($"[ProcessBuilding] Position set: {structureData.globalPosition}");
building.Init();
Main.helper.Log($"[ProcessBuilding] Init done");
building.transform.SetParent(p.buildingContainer.transform, true);
Main.helper.Log($"[ProcessBuilding] SetParent done");
structureData.Unpack(building);
Main.helper.Log($"[ProcessBuilding] Unpack done");
p.AddBuilding(building);
// NOTE: AddBuilding, PlaceFromLoad and UnpackStage2 are called by the original
// PlayerSaveData.Unpack method after ProcessBuilding returns
Main.helper.Log($"[ProcessBuilding] Returning building to Unpack");
bool isKeep = building.GetComponent<Keep>() != null && building.TeamID() == p.PlayerLandmassOwner.teamId;
if (isKeep)
Main.helper.Log($"Loading player id: {p.PlayerLandmassOwner.teamId}");
Main.helper.Log($"loading building: {building.FriendlyName}");
Main.helper.Log($" (teamid: {building.TeamID()})");
Main.helper.Log(p.ToString());
bool flag2 = building.GetComponent<Keep>() != null && building.TeamID() == p.PlayerLandmassOwner.teamId;
Main.helper.Log("Set keep? " + flag2);
if (flag2)
{
p.keep = building.GetComponent<Keep>();
Main.helper.Log($"[ProcessBuilding] Keep set");
Main.helper.Log(p.keep.ToString());
}
__result = building;
Main.helper.Log($"[ProcessBuilding] SUCCESS - {structureData.uniqueName}");
}
else
{
Main.helper.Log($"[ProcessBuilding] FAILED - {structureData.uniqueName} not found in GameState");
__result = null;
}
}
catch (Exception e)
{
Main.helper.Log($"[ProcessBuilding] EXCEPTION: {structureData.uniqueName}");
Main.helper.Log($"[ProcessBuilding] Error: {e.Message}");
Main.helper.Log($"[ProcessBuilding] Stack: {e.StackTrace}");
Main.helper.Log(structureData.uniqueName + " failed to load correctly");
__result = null;
}
@@ -1762,9 +1736,10 @@ namespace KCM
__instance.JobCustomMaxEnabledFlag = new bool[World.inst.NumLandMasses][];
for (int lm = 0; lm < World.inst.NumLandMasses; lm++)
{
__instance.JobFilledAvailable[lm] = new int[38];
__instance.JobCustomMaxEnabledFlag[lm] = new bool[38];
for (int n = 0; n < 38; n++)
int numJobTypes = p.JobFilledAvailable.data[lm].GetLength(0);
__instance.JobFilledAvailable[lm] = new int[numJobTypes];
__instance.JobCustomMaxEnabledFlag[lm] = new bool[numJobTypes];
for (int n = 0; n < numJobTypes; n++)
{
__instance.JobFilledAvailable[lm][n] = p.JobFilledAvailable.data[lm][n, 1];
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Reflection;
using UnityEngine;
namespace KCM.Packets.Game.GameBuilding
{
public class BuildingRemovePacket : Packet
{
public override ushort packetId => (ushort)KCM.Enums.Packets.BuildingRemove;
// Flag to prevent infinite loop when removing buildings from packet
public static bool isProcessingPacket = false;
public Guid guid { get; set; }
public override void HandlePacketClient()
{
if (clientId == KCClient.client.Id) return;
Main.helper.Log($"Received building remove packet for guid {guid} from {player.name}");
// Try to find the building in the player who owns it
Building building = player.inst.GetBuilding(guid);
if (building == null)
{
// Try to find it in any player's buildings
foreach (var kcp in Main.kCPlayers.Values)
{
building = kcp.inst.GetBuilding(guid);
if (building != null) break;
}
}
if (building == null)
{
Main.helper.Log($"Building with guid {guid} not found on client, may already be removed.");
return;
}
try
{
Main.helper.Log($"Removing building {building.UniqueName} at {building.transform.position}");
// Set flag to prevent sending packet back
isProcessingPacket = true;
// Set Player.inst to the correct player for this building
// This ensures the removal modifies the correct player's job lists
Player originalPlayer = Player.inst;
Player correctPlayer = Main.GetPlayerByBuilding(building);
if (correctPlayer != null)
{
Player.inst = correctPlayer;
}
// Use reflection to call the Remove method from the game assembly
MethodInfo removeMethod = typeof(Building).GetMethod("Remove", BindingFlags.Public | BindingFlags.Instance);
if (removeMethod != null)
{
removeMethod.Invoke(building, null);
}
else
{
// Fallback: destroy the building GameObject directly
Main.helper.Log("Remove method not found, using Destroy fallback");
building.destroyedWhileInPlay = true;
UnityEngine.Object.Destroy(building.gameObject);
}
// Restore original Player.inst
Player.inst = originalPlayer;
isProcessingPacket = false;
}
catch (Exception e)
{
isProcessingPacket = false;
Main.helper.Log($"Error removing building: {e.Message}");
Main.helper.Log(e.StackTrace);
}
}
public override void HandlePacketServer()
{
// Forward the remove packet to all other clients
SendToAll(clientId);
}
}
}

View File

@@ -12,7 +12,6 @@ namespace KCM.Packets.Game.GamePlayer
public override ushort packetId => (ushort)Enums.Packets.AddVillager;
public Guid guid { get; set; }
public Vector3 position { get; set; }
public override void HandlePacketClient()
{
@@ -20,33 +19,18 @@ namespace KCM.Packets.Game.GamePlayer
{
if (KCClient.client.Id == clientId) return;
// Check for duplicate villager by guid
var existingVillager = player.inst.Workers.data.FirstOrDefault(w => w != null && w.guid == guid);
if (existingVillager != null)
{
Main.helper.Log($"Villager with guid {guid} already exists, skipping duplicate");
return;
}
Main.helper.Log("Received add villager packet from " + player.name + $"({player.id})");
Villager newVillager = Villager.CreateVillager();
newVillager.guid = guid;
Villager v = Villager.CreateVillager();
v.guid = guid;
// Set villager position
if (position != Vector3.zero)
{
newVillager.TeleportTo(position);
}
player.inst.Workers.Add(newVillager);
player.inst.Homeless.Add(newVillager);
player.inst.Workers.Add(v);
player.inst.Homeless.Add(v);
}
catch (Exception e)
{
Main.helper.Log("Error handling add villager packet: " + e.Message);
Main.helper.Log(e.StackTrace);
}
}

View File

@@ -46,24 +46,7 @@ namespace KCM.Packets.Game.GameWorld
public void PlaceBuilding()
{
Main.LogSync("========== BUILDING PLACEMENT START ==========");
Main.LogSync($"Building: {uniqueName} from player: {player?.name} (id={player?.id})");
Main.LogSync($" guid={guid}");
Main.LogSync($" globalPosition={globalPosition}");
Main.LogSync($" localPosition={localPosition}");
Main.LogSync($" rotation={rotation} (euler={rotation.eulerAngles})");
Main.LogSync($" built={built}, placed={placed}, open={open}");
Main.LogSync($" constructionProgress={constructionProgress}, constructionPaused={constructionPaused}");
Main.LogSync($" life={life}, ModifiedMaxLife={ModifiedMaxLife}");
Main.LogSync($" yearBuilt={yearBuilt}, decayProtection={decayProtection}");
// Check for duplicate building by guid to prevent double placement from network retries
var existingBuilding = player.inst.Buildings.data.FirstOrDefault(b => b != null && b.guid == guid);
if (existingBuilding != null)
{
Main.helper.Log($"Building with guid {guid} already exists for player {player.name}, skipping duplicate placement");
return;
}
Main.helper.Log("Received place building packet for " + uniqueName + " from " + player.name + $"({player.id})");
//var originalPlayer = Player.inst;
//Player.inst = player.inst;
@@ -111,7 +94,6 @@ namespace KCM.Packets.Game.GameWorld
Main.helper.Log(building.LandMass().ToString());
Main.helper.Log("Player add Building unpacked");
player.inst.AddBuilding(building);
Main.ApplyPendingBuildingState(building);
try
{
@@ -134,23 +116,6 @@ namespace KCM.Packets.Game.GameWorld
Main.helper.Log("unpack stage 2");
structureData.UnpackStage2(building);
// Update materials/textures for correct display
building.UpdateMaterialSelection();
// Update road rotation for proper visuals
Road roadComp = building.GetComponent<Road>();
if (roadComp != null)
{
roadComp.UpdateRotation();
}
// Update aqueduct rotation
Aqueduct aqueductComp = building.GetComponent<Aqueduct>();
if (aqueductComp != null)
{
aqueductComp.UpdateRotation();
}
building.SetVisibleForFog(false);
Main.helper.Log("Landmass owner take ownership");
@@ -164,40 +129,16 @@ namespace KCM.Packets.Game.GameWorld
Main.helper.Log($"Host player Landmass Names Count: {Player.inst.LandMassNames.Count}, Contents: {string.Join(", ", Player.inst.LandMassNames)}");
Main.helper.Log($"Client player ({player.name}) Landmass Names Count: {player.inst.LandMassNames.Count}, Contents: {string.Join(", ", player.inst.LandMassNames)}");
// Ensure LandMassNames arrays are large enough to prevent IndexOutOfRangeException
int landMass = building.LandMass();
player.inst.LandMassNames[building.LandMass()] = player.kingdomName;
Player.inst.LandMassNames[building.LandMass()] = player.kingdomName;
// Expand player.inst.LandMassNames if needed
while (player.inst.LandMassNames.Count <= landMass)
{
player.inst.LandMassNames.Add("");
}
// Expand Player.inst.LandMassNames if needed
while (Player.inst.LandMassNames.Count <= landMass)
{
Player.inst.LandMassNames.Add("");
}
player.inst.LandMassNames[landMass] = player.kingdomName;
Player.inst.LandMassNames[landMass] = player.kingdomName;
// Log final building state after placement
Main.LogSync("---------- BUILDING PLACED FINAL STATE ----------");
Main.LogSync($" Final position: {building.transform.position}");
if (building.transform.childCount > 0)
{
Main.LogSync($" Child[0] rotation: {building.transform.GetChild(0).rotation} (euler={building.transform.GetChild(0).rotation.eulerAngles})");
Main.LogSync($" Child[0] localPosition: {building.transform.GetChild(0).localPosition}");
}
Main.LogSync($" IsBuilt={building.IsBuilt()}, IsPlaced={building.IsPlaced()}");
Main.LogSync($" TeamID={building.TeamID()}, LandMass={building.LandMass()}");
Main.LogSync("========== BUILDING PLACEMENT END ==========");
//Player.inst = originalPlayer;
}
else
{
Main.LogSync($"FAILED to place building: {structureData.uniqueName} - GetPlaceableByUniqueName returned null");
Main.helper.Log(structureData.uniqueName + " failed to load correctly");
}
//building.Init();
}
}

View File

@@ -128,7 +128,8 @@ namespace KCM.Packets.Handlers
IPacket packet = DeserialisePacket(messageReceived);
Main.LogSync($"SERVER RECV: {Packets[id].packet.GetType().Name} (id={id}) from client {messageReceived.FromConnection.Id}");
//Main.helper.Log($"Server Received packet {Packets[id].packet.GetType().Name} from {messageReceived.FromConnection.Id}");
if (KCServer.IsRunning)
{
@@ -168,9 +169,11 @@ namespace KCM.Packets.Handlers
var id = messageReceived.MessageId;
//Main.helper.Log($"Client Received packet {Packets[id].packet.GetType().Name} from {messageReceived.FromConnection.Id}");
IPacket packet = DeserialisePacket(messageReceived);
Main.LogSync($"CLIENT RECV: {Packets[id].packet.GetType().Name} (id={id}) from client {packet.clientId}");
//Main.helper.Log($"Client Received packet {Packets[id].packet.GetType().Name} from {packet.clientId}");
if (KCClient.client.IsConnected)
{

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Riptide.Demos.Steam.PlayerHosted;
using static KCM.Main;
namespace KCM.Packets.Lobby
@@ -29,84 +30,90 @@ namespace KCM.Packets.Lobby
public override void HandlePacketClient()
{
float savePercent = (float)received / (float)saveSize;
// Initialize saveData and chunksReceived on the first packet received
if (saveData.Length == 1)
// Initialize on first chunk OR if arrays aren't properly sized yet
// This handles out-of-order packet delivery
if (!loadingSave || saveData.Length != saveSize || chunksReceived.Length != totalChunks)
{
Main.helper.Log("Save Transfer started!");
Main.helper.Log($"Save Transfer initializing. saveSize={saveSize}, totalChunks={totalChunks}");
loadingSave = true;
ServerLobbyScript.LoadingSave.SetActive(true);
// save percentage
saveData = new byte[saveSize];
chunksReceived = new bool[totalChunks];
received = 0;
ServerLobbyScript.LoadingSave.SetActive(true);
}
// Skip if we already received this chunk (duplicate packet)
if (chunksReceived[chunkId])
{
Main.helper.Log($"[SaveTransfer] Duplicate chunk {chunkId} received, skipping.");
return;
}
// Copy the chunk data into the correct position in saveData
Array.Copy(saveDataChunk, 0, saveData, saveDataIndex, saveDataChunk.Length);
// Mark this chunk as received
chunksReceived[chunkId] = true;
// Seek to the next position to write to
received += chunkSize;
Main.helper.Log($"[SaveTransfer] Processed chunk {chunkId}/{totalChunks}. Received: {received} bytes of {saveSize}.");
// Update progress bar
if (saveSize > 0)
{
float savePercent = (float)received / (float)saveSize;
string receivedKB = ((float)received / 1000f).ToString("0.00");
string totalKB = ((float)saveSize / 1000f).ToString("0.00");
ServerLobbyScript.ProgressBar.fillAmount = savePercent;
ServerLobbyScript.ProgressBarText.text = (savePercent * 100).ToString("0.00") + "%";
ServerLobbyScript.ProgressText.text = $"{((float)(received / 1000)).ToString("0.00")} KB / {((float)(saveSize / 1000)).ToString("0.00")} KB";
if (chunkId + 1 == totalChunks)
ServerLobbyScript.ProgressText.text = $"{receivedKB} KB / {totalKB} KB";
}
else
{
Main.helper.Log($"Received last save transfer packet.");
Main.helper.Log(WhichIsNotComplete());
ServerLobbyScript.ProgressBar.fillAmount = 0f;
ServerLobbyScript.ProgressBarText.text = "0.00%";
ServerLobbyScript.ProgressText.text = "0.00 KB / 0.00 KB";
}
// Check if all chunks have been received
if (IsTransferComplete())
{
// Handle completed transfer here
Main.helper.Log("Save Transfer complete!");
// Reset the loading state before processing
loadingSave = false;
LoadSaveLoadHook.saveBytes = saveData;
LoadSaveLoadHook.memoryStreamHook = true;
LoadSave.Load();
GameState.inst.SetNewMode(GameState.inst.playingMode);
LobbyManager.loadingSave = false;
LoadSaveLoadHook.saveContainer.Unpack(null);
Broadcast.OnLoadedEvent.Broadcast(new OnLoadedEvent());
ServerLobbyScript.LoadingSave.SetActive(false);
// Reset static state for next transfer
ResetTransferState();
}
}
public static void ResetTransferState()
{
saveData = new byte[1];
chunksReceived = new bool[1];
loadingSave = false;
received = 0;
}
public static bool IsTransferComplete()
{
return chunksReceived.All(x => x == true);
}
public static string WhichIsNotComplete()
{
string notComplete = "";
for (int i = 0; i < chunksReceived.Length; i++)
{
if (!chunksReceived[i])
{
notComplete += i + ", ";
}
}
return notComplete;
}
public override void HandlePacketServer()
{
}

View File

@@ -18,14 +18,9 @@ namespace KCM.Packets.Lobby
{
Main.helper.Log(GameState.inst.mainMenuMode.ToString());
// Hide server lobby
Main.TransitionTo((MenuState)200);
// This is run when user clicks "accept" on choose your map screeen
try
{
if (!LobbyManager.loadingSave)
{
SpeedControlUI.inst.SetSpeed(0);
@@ -41,63 +36,27 @@ namespace KCM.Packets.Lobby
SpeedControlUI.inst.SetSpeed(0);
}
else
{
LobbyManager.loadingSave = false;
GameState.inst.SetNewMode(GameState.inst.playingMode);
}
}
catch (Exception ex)
{
// Handle exception here
Main.helper.Log(ex.Message.ToString());
Main.helper.Log(ex.ToString());
}
}
public override void HandlePacketClient()
{
if (!LobbyManager.loadingSave)
{
Start();
}
else
{
ServerLobbyScript.LoadingSave.SetActive(true);
}
}
public override void HandlePacketServer()
{
//Start();
/*AIBrainsContainer.PreStartAIConfig aiConfig = new AIBrainsContainer.PreStartAIConfig();
int count = 0;
for (int i = 0; i < RivalKingdomSettingsUI.inst.rivalItems.Length; i++)
{
RivalItemUI r = RivalKingdomSettingsUI.inst.rivalItems[i];
bool flag = r.Enabled && !r.Locked;
if (flag)
{
count++;
}
}
int idx = 0;
aiConfig.startData = new AIBrainsContainer.PreStartAIConfig.AIStartData[count];
for (int j = 0; j < RivalKingdomSettingsUI.inst.rivalItems.Length; j++)
{
RivalItemUI item = RivalKingdomSettingsUI.inst.rivalItems[j];
bool flag2 = item.Enabled && !item.Locked;
if (flag2)
{
aiConfig.startData[idx] = new AIBrainsContainer.PreStartAIConfig.AIStartData();
aiConfig.startData[idx].landmass = item.flag.landmass;
aiConfig.startData[idx].bioCode = item.bannerIdx;
aiConfig.startData[idx].personalityKey = PersonalityCollection.aiPersonalityKeys[0];
aiConfig.startData[idx].skillLevel = item.GetSkillLevel();
idx++;
}
}
AIBrainsContainer.inst.aiStartInfo = aiConfig;
bool isControllerActive = GamepadControl.inst.isControllerActive;
if (isControllerActive)
{
ConsoleCursorMenu.inst.PrepForGamepad();
}*/
}
}
}

View File

@@ -99,10 +99,7 @@ namespace KCM.Packets.Network
chunkSize = chunk.Length,
saveDataIndex = sent,
totalChunks = chunks.Count
}.Send(clientId);
Main.helper.Log(" ");
}.SendReliable(clientId);
packetsSent++;
sent += chunk.Length;
}

View File

@@ -33,21 +33,12 @@ namespace KCM.Packets.Network
CloudSystem.inst.BaseFreq = 4.5f;
Weather.inst.SetSeason(Weather.Season.Summer);
//inst = new KCClient(KCServer.IsRunning ? "Ryan" : "Orion");
KCClient.inst = new KCClient(SteamFriends.GetPersonaName());
Main.helper.Log("Sending client connected. Client ID is: " + clientId);
KCPlayer localPlayer;
if (!Main.kCPlayers.TryGetValue(Main.PlayerSteamID, out localPlayer))
{
localPlayer = new KCPlayer(KCClient.inst.Name, clientId, Main.PlayerSteamID);
Main.kCPlayers.Add(Main.PlayerSteamID, localPlayer);
}
else
{
localPlayer.id = clientId;
localPlayer.name = KCClient.inst.Name;
}
Main.kCPlayers.Add(Main.PlayerSteamID, new KCPlayer(KCClient.inst.Name, clientId, Main.PlayerSteamID));
Player.inst.PlayerLandmassOwner.teamId = clientId * 10 + 2;

View File

@@ -20,9 +20,6 @@ namespace KCM.Packets
if (!Main.clientSteamIds.ContainsKey(clientId))
return null;
//Main.helper.Log($"SteamID: {Main.GetPlayerByClientID(clientId).steamId} for {clientId} ({Main.GetPlayerByClientID(clientId).id})");
if (Main.kCPlayers.TryGetValue(Main.GetPlayerByClientID(clientId).steamId, out p))
return p;
else
@@ -38,7 +35,6 @@ namespace KCM.Packets
{
try
{
Main.LogSync($"SEND TO ALL: {this.GetType().Name} (id={packetId}) except={exceptToClient}");
if (exceptToClient == 0)
{
if (KCServer.IsRunning)
@@ -76,7 +72,6 @@ namespace KCM.Packets
{
try
{
Main.LogSync($"SEND: {this.GetType().Name} (id={packetId}) to={toClient} myId={KCClient.client?.Id}");
if (KCClient.client.IsConnected && toClient == 0)
{
this.clientId = KCClient.client.Id;
@@ -109,6 +104,37 @@ namespace KCM.Packets
}
}
public void SendReliable(ushort toClient)
{
try
{
if (KCServer.IsRunning && toClient != 0)
{
KCServer.server.Send(PacketHandler.SerialisePacket(this), toClient, true);
}
}
catch (Exception ex)
{
Main.helper.Log($"Error sending reliable packet {packetId} {this.GetType().Name} from {clientId}");
Main.helper.Log("----------------------- Main exception -----------------------");
Main.helper.Log(ex.ToString());
Main.helper.Log("----------------------- Main message -----------------------");
Main.helper.Log(ex.Message);
Main.helper.Log("----------------------- Main stacktrace -----------------------");
Main.helper.Log(ex.StackTrace);
if (ex.InnerException != null)
{
Main.helper.Log("----------------------- Inner exception -----------------------");
Main.helper.Log(ex.InnerException.ToString());
Main.helper.Log("----------------------- Inner message -----------------------");
Main.helper.Log(ex.InnerException.Message);
Main.helper.Log("----------------------- Inner stacktrace -----------------------");
Main.helper.Log(ex.InnerException.StackTrace);
}
}
}
public abstract void HandlePacketServer();
public abstract void HandlePacketClient();
}

View File

@@ -36,41 +36,25 @@ namespace KCM.Packets.State
{
if (clientId == KCClient.client.Id) return; //prevent double placing on same client
//Main.helper.Log("Received building state packet for: " + uniqueName + " from " + Main.kCPlayers[Main.GetPlayerByClientID(clientId).steamId].name + $"({clientId})");
Building building = player.inst.GetBuilding(guid);
if (building == null)
{
Main.helper.Log("Building not found.");
Main.QueuePendingBuildingState(this);
return;
}
ApplyToBuilding(building);
}
public override void HandlePacketServer()
{
//throw new NotImplementedException();
}
public void ApplyToBuilding(Building building)
{
if (building == null)
return;
Main.LogSync($"========== BUILDING STATE UPDATE ==========");
Main.LogSync($"Building: {uniqueName} guid={guid}");
Main.LogSync($" globalPosition={globalPosition}");
Main.LogSync($" localPosition={localPosition}");
Main.LogSync($" rotation={rotation} (euler={rotation.eulerAngles})");
Main.LogSync($" built={built}, placed={placed}, open={open}");
Main.LogSync($" constructionProgress={constructionProgress}");
try
{
//PrintProperties();
building.UniqueName = uniqueName;
building.customName = customName;
building.transform.position = this.globalPosition;
building.transform.GetChild(0).rotation = this.rotation;
building.transform.GetChild(0).localPosition = this.localPosition;
@@ -79,6 +63,7 @@ namespace KCM.Packets.State
SetPrivateFieldValue(building, "placed", placed);
SetPrivateFieldValue(building, "resourceProgress", resourceProgress);
building.Open = open;
building.doBuildAnimation = doBuildAnimation;
building.constructionPaused = constructionPaused;
@@ -86,6 +71,7 @@ namespace KCM.Packets.State
building.Life = life;
building.ModifiedMaxLife = ModifiedMaxLife;
//building.yearBuilt = yearBuilt;
SetPrivateFieldValue(building, "yearBuilt", yearBuilt);
@@ -100,6 +86,11 @@ namespace KCM.Packets.State
}
}
public override void HandlePacketServer()
{
//throw new NotImplementedException();
}
private void SetPrivateFieldValue(object obj, string fieldName, object value)
{
Type type = obj.GetType();

113
README.md
View File

@@ -1,97 +1,38 @@
# Kingdoms and Castles Multiplayer Mod - Bug Tracker
# Kingdoms and Castles Multiplayer Mod Fixes
## Ismert hibak / Known Issues
This document summarizes the fixes and improvements implemented to enhance the stability and functionality of the multiplayer mod for Kingdoms and Castles.
### KRITIKUS - Server/Connection Problems
## Implemented Fixes:
| Hiba | Status | Megjegyzes |
|------|--------|------------|
| Rossz kapcsolat, "server disconnected" hibak | Reszben javitva | Event handler duplikacio es session cleanup javitva |
| StartGame.Start() NullReferenceException | Meg nincs elkezdve | MainMenuMode.StartGame crash-el 2x a logban, TargetInvocationException |
### 1. Improved Lobby Stability
- **Issue:** Previously, joining a multiplayer lobby could lead to an immediate crash (NullReferenceException in `PlayerEntryScript.cs`).
- **Fix:** Corrected the initialization order of UI components in `PlayerEntryScript.cs` to prevent NullReferenceExceptions, ensuring stable lobby entry.
### Host-Client Sync Problems
### 2. Enhanced Session Cleanup
- **Issue:** Users previously had to restart the entire game after leaving a multiplayer session to join or host a new one. This was due to residual game state and an aggressive cleanup that inadvertently shut down Steamworks.
- **Fix:** Implemented a comprehensive `CleanupMultiplayerSession()` routine in `Main.cs`. This routine now properly resets static mod data (player lists, client/server states), and, crucially, no longer destroys the core `KCMSteamManager` (Steamworks API manager). This allows for seamless transitions between multiplayer sessions without game restarts.
| Hiba | Status | Megjegyzes |
|------|--------|------------|
| Jatek ujrainditasa szukseges lobby/save valtas utan | Reszben javitva | Session cleanup hozzaadva LobbyManager-ben |
| Utak/epuletek nem toltenek be helyesen vagy atfednek | Vizsgalat alatt | UpdateMaterialSelection() es UpdateRotation() hozzaadva |
| Eroforrasok nem mentenek/toltenek helyesen | Meg nincs elkezdve | |
| NPC-k veletlenszeruen megallnak es nem mozognak load utan | Vizsgalat alatt | TeleportTo problema javitva, BakePathing hozzaadva |
| Orientaciok (rotaciok) nem szinkronizalodnak | Reszben javitva | Rotation es localPosition kozvetlenul alkalmazva WorldPlace-ben |
| Host torol valamit -> kliens nem latja | Meg nincs elkezdve | BuildingDestroy packet szukseges |
| Host nem latja a kliens epuleteit helyesen (rossz texturak) | Javitva | UpdateMaterialSelection() hozzaadva WorldPlace.cs-ben |
### 3. Optimized Building Synchronization Performance
- **Issue:** Rapid changes in building state (e.g., during construction) could generate excessive network traffic, potentially contributing to "poor connection" issues.
- **Fix:** Implemented a throttling mechanism in `BuildingStateManager.cs`. Building state updates are now limited to 10 times per second per building, significantly reducing network spam while maintaining visual fluidity.
### Gameplay Bugs
### 4. Resolved Villager Freezing
- **Issue:** Villagers would sometimes freeze during gameplay, despite other game elements functioning correctly. This was caused by the game attempting to synchronize the state of already destroyed building components, leading to a cascade of errors.
- **Fix:** Added a robust null check in `BuildingStateManager.cs`. If an observed building has been destroyed, its associated observer is now properly de-registered (by destroying its GameObject), preventing further errors and ensuring continuous game logic for villagers and other entities. This also handles cases where buildings are replaced (e.g., construction completed).
| Hiba | Status | Megjegyzes |
|------|--------|------------|
| Tobb Keep ugyanarra a szigetre | Meg nincs elkezdve | Engedi hogy masik jatekos szigetere Keep-et rakjunk, igy az eredeti jatekos elveszti a Keep-jet es nem tudja mozgatni az embereit |
### 5. Fixed Map Desynchronization
- **Issue:** When starting a new multiplayer game, clients often generated a different map than the host, even if the seed was specified. This was due to the host not sending the definitive world seed at the critical moment.
- **Fix:** Modified `ServerLobbyScript.cs` to ensure that when the host clicks "Start Game", the current world seed (either from UI input or newly generated) is explicitly sent to all clients via a `WorldSeed` packet *before* the game starts. This guarantees all players generate the exact same map.
### Status Definiciok
### 6. Reliable Save Game Transfer
- **Issue:** Loading a saved multiplayer game would often fail for clients, resulting in an incomplete save file and desynchronized gameplay. This occurred because save file chunks were sent unreliably over the network.
- **Fix:** Changed the save game chunk transfer in `ClientConnected.cs` to use Riptide's `Reliable` message send mode. This ensures that all parts of the save file are guaranteed to arrive at the client, allowing for complete and successful save game loading.
- **Javitva**: A hiba javitva lett es tesztelve
- **Reszben javitva**: Javitas megkezdve, de meg nem teljes
- **Vizsgalat alatt**: Debug logging hozzaadva, vizsgaljuk a problemat
- **Meg nincs elkezdve**: A hiba ismert, de meg nem kezdtuk el javitani
### 7. Compilation Errors & Warnings Addressed
- All reported compilation errors and warnings (including issues with `Packet.Send` overloads and `World.SeedFromText`) have been investigated and resolved, ensuring the mod compiles cleanly.
## Log Analisis (2024-12-14 15:39-15:56)
## Pending Task:
### Hibak szamokban
- **56 BUILDING PLACEMENT START** - epulet elhelyezesi kiserlet
- **1 BUILDING PLACEMENT END** - sikeres elhelyezes
- **55 FAIL (98%)** - majdnem minden epulet elbukik!
- **55 "Error in add building hook"** - NullReferenceException
- **9 IndexOutOfRangeException** - tomb tulindexeles
- **2 StartGame.Start() crash** - TargetInvocationException
- **2 Client disconnect event** - kapcsolat megszakadas
### Idovonal
- 15:39: Session start
- 15:40:03: Exception MainMenuMode.StartGame - NullReferenceException (1.)
- 15:42:14: Client disconnect events (ketszer)
- 15:44:11: Ujabb Exception MainMenuMode.StartGame (2.)
- 15:45-15:50: **55x "Error in add building hook"** - szinte minden epulet fail
- 15:52:00: Utolso BuildingStatePacket erkezik client 2-tol, utana csak ServerSettings
- 15:53:55: **Host kilep menube** ("Menu set to: Menu")
- 15:53:55-15:56:25: **Server TOVABB FUT** es fogadja a packeteket a klienstol!
- 15:56:27: Server vegre leall
### Fo problemak a logbol
1. **PlayerAddBuildingHook.Prefix** crash-el 55x - valami null benne (1-11 szamokat printel elotte)
2. **Server nem all le menu-nel** - 2.5 percig meg fut miutan host kiment
3. **Kliens nem kap ertesitest** - tovabb kuldi a packeteket
4. **StartGame exception** - 2x crash jatek inditaskor
## Recent Changes
### 2024-12-14
- Advanced sync logging hozzaadva `[SYNC]` prefix-szel
- Building placement reszletes logging (minden property)
- Packet send/receive logging
- Building state update logging
### Korabbi javitasok
- KCServer.cs: Event handler duplikacio javitas
- LobbyManager.cs: Session cleanup (clientSteamIds, loadingSave)
- WorldPlace.cs: Building guid duplikacio check, rotation/localPosition fix
- AddVillagerPacket.cs: Villager position sync + duplikacio check
- Main.cs: BakePathing() hozzaadva PlayerAddBuildingHook-ban
---
## Javítások / Fixed Issues (2024-12-14)
### KRITIKUS hibák javítva:
1. **Server leállítás menü váltáskor** - `Main.cs:342-356`
- Server most leáll és értesíti a klienseket amikor host menübe lép
- Kliensek kapnak "Host disconnected" modal-t
2. **PlayerAddBuildingHook NullReferenceException** - `Main.cs:762-806`
- Teljes null-ellenőrzés hozzáadva reflection mezőkhöz
- Array bounds ellenőrzés landMass indexhez
- Registry inicializálás ellenőrzés
3. **IndexOutOfRangeException WorldPlace-ben** - `WorldPlace.cs:167-183`
- LandMassNames tömb automatikus bővítése szükség esetén
- Védekező kód hogy megelőzze az index hibákat
### Resource Synchronization
- **Goal:** Implement synchronization for player resources (Gold, Wood, Stone, Food) to ensure all players see consistent resource counts.
- **Status:** Awaiting confirmation from the user regarding the exact `FreeResourceType` enum names (`Wood`, `Stone`, `Food`) to proceed with implementation.

View File

@@ -1,71 +0,0 @@
using System;
using System.Reflection;
using UnityEngine;
namespace KCM
{
internal static class ResourceStorageHelper
{
private static Type resourceStorageType;
private static MethodInfo getComponentsMethod;
private static MethodInfo isPrivateMethod;
private static MethodInfo addResourceStorageMethod;
private static readonly Assembly[] candidateAssemblies = new[]
{
typeof(Player).Assembly,
typeof(World).Assembly,
typeof(FreeResourceManager).Assembly,
};
private static void EnsureInitialized()
{
if (resourceStorageType != null)
return;
foreach (var assembly in candidateAssemblies)
{
if (assembly == null)
continue;
resourceStorageType = assembly.GetType("Assets.Interface.IResourceStorage", false);
if (resourceStorageType != null)
break;
}
if (resourceStorageType == null)
return;
getComponentsMethod = typeof(Component).GetMethod("GetComponents", new[] { typeof(Type) });
isPrivateMethod = resourceStorageType.GetMethod("IsPrivate", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
addResourceStorageMethod = typeof(FreeResourceManager).GetMethod("AddResourceStorage", new[] { resourceStorageType });
}
public static Component[] GetStorages(Component owner)
{
EnsureInitialized();
if (resourceStorageType == null || getComponentsMethod == null)
return Array.Empty<Component>();
return (Component[])getComponentsMethod.Invoke(owner, new object[] { resourceStorageType });
}
public static bool IsPrivate(Component storage)
{
EnsureInitialized();
if (isPrivateMethod == null)
return true;
return (bool)isPrivateMethod.Invoke(storage, null);
}
public static void Register(Component storage)
{
EnsureInitialized();
if (addResourceStorageMethod == null || FreeResourceManager.inst == null)
return;
addResourceStorageMethod.Invoke(FreeResourceManager.inst, new object[] { storage });
}
}
}

View File

@@ -159,18 +159,15 @@ namespace Riptide.Demos.Steam.PlayerHosted
Main.helper.Log("clear players");
Main.kCPlayers.Clear();
Main.clientSteamIds.Clear(); // Clear client-to-steam ID mapping
Main.ClearVillagerPositionCache(); // Clear villager sync cache
LobbyHandler.ClearPlayerList();
LobbyHandler.ClearChatEntries();
Main.helper.Log("end clear players");
// Reset loading state
loadingSave = false;
if (KCServer.IsRunning)
KCServer.server.Stop();
Main.TransitionTo(MenuState.ServerBrowser);
ServerBrowser.registerServer = false;
}

View File

@@ -299,13 +299,35 @@ namespace KCM
try
{
GameObject kcmUICanvas = Instantiate(Constants.MainMenuUI_T.Find("TopLevelUICanvas").gameObject);
if (Constants.MainMenuUI_T == null)
{
Main.helper.Log("MainMenuUI_T is null in ServerBrowser");
return;
}
var topLevelCanvas = ResolveMenuCanvas();
if (topLevelCanvas == null)
{
Main.helper.Log("Failed to resolve top-level menu canvas in ServerBrowser");
return;
}
GameObject kcmUICanvas = Instantiate(topLevelCanvas.gameObject);
for (int i = 0; i < kcmUICanvas.transform.childCount; i++)
Destroy(kcmUICanvas.transform.GetChild(i).gameObject);
kcmUICanvas.name = "KCMUICanvas";
kcmUICanvas.transform.SetParent(Constants.MainMenuUI_T);
kcmUICanvas.transform.SetParent(Constants.MainMenuUI_T, false);
kcmUICanvas.transform.SetAsLastSibling();
kcmUICanvas.SetActive(false);
var canvasComponent = kcmUICanvas.GetComponent<Canvas>();
if (canvasComponent != null)
{
canvasComponent.overrideSorting = true;
canvasComponent.sortingOrder = 999;
}
KCMUICanvas = kcmUICanvas.transform;
@@ -322,6 +344,8 @@ namespace KCM
serverLobbyPlayerRef = serverLobbyRef.transform.Find("Container/PlayerList/Viewport/Content");
serverLobbyChatRef = serverLobbyRef.transform.Find("Container/PlayerChat/Viewport/Content");
serverLobbyRef.SetActive(false);
serverBrowserRef.transform.SetAsLastSibling();
serverLobbyRef.transform.SetAsLastSibling();
//browser.transform.position = new Vector3(0, 0, 0);
@@ -435,6 +459,29 @@ namespace KCM
}
}
private Transform ResolveMenuCanvas()
{
string[] candidatePaths =
{
"TopLevelUICanvas",
"TopLevel",
"MainMenu/TopLevel/TopLevelUICanvas",
"MainMenu/TopLevel"
};
foreach (var path in candidatePaths)
{
var transform = Constants.MainMenuUI_T.Find(path);
if (transform != null)
{
Main.helper.Log($"ServerBrowser: using canvas path '{path}'.");
return transform;
}
}
return null;
}
private void Preload(KCModHelper helper)
{
helper.Log("Hello?");

View File

@@ -23,21 +23,29 @@ namespace KCM.ServerLobby
{
banner = transform.Find("PlayerBanner").GetComponent<RawImage>();
SetValues();
InvokeRepeating("SetValues", 0, 0.25f);
transform.Find("PlayerBanner").GetComponent<Button>().onClick.AddListener(() =>
{
Main.TransitionTo(MenuState.NameAndBanner);//ChooseBannerUI Hooks required, as well as townnameui
});
SetValues();
InvokeRepeating("SetValues", 0, 0.25f);
}
public void SetValues()
{
try
{
KCPlayer player;
Main.kCPlayers.TryGetValue(Main.GetPlayerByClientID(Client).steamId, out player);
// First check if the client still exists
if (!Main.TryGetPlayerByClientID(Client, out KCPlayer player) || player == null)
{
// Client no longer exists, stop the repeating invoke and destroy this entry
CancelInvoke("SetValues");
Destroy(gameObject);
return;
}
transform.Find("PlayerName").GetComponent<TextMeshProUGUI>().text = player.name;
transform.Find("Ready").gameObject.SetActive(player.ready);

View File

@@ -142,7 +142,6 @@ namespace KCM
{
Main.helper.Log("Disable all");
//StartGameButton.gameObject.SetActive(false);
StartGameButton.onClick.RemoveAllListeners();
StartGameButton.GetComponentInChildren<TextMeshProUGUI>().text = "Ready";
StartGameButton.onClick.AddListener(() =>
@@ -187,6 +186,32 @@ namespace KCM
StartGameButton.GetComponentInChildren<TextMeshProUGUI>().text = "Start";
StartGameButton.onClick.AddListener(() =>
{
int definitiveSeed;
if (string.IsNullOrWhiteSpace(WorldSeed.text))
{
World.inst.Generate();
definitiveSeed = World.inst.seed;
}
else
{
if (int.TryParse(WorldSeed.text, out int parsedSeed))
{
definitiveSeed = parsedSeed;
World.inst.Generate(definitiveSeed);
}
else
{
Main.helper.Log($"Invalid seed '{WorldSeed.text}' entered. Generating a random seed.");
World.inst.Generate();
definitiveSeed = World.inst.seed;
}
}
new WorldSeed()
{
Seed = definitiveSeed
}.SendToAll(KCClient.client.Id);
new StartGame().SendToAll();
if (PlacementType.value == 0 && !LobbyManager.loadingSave)

View File

@@ -1,4 +1,4 @@
using KCM.Packets;
using KCM.Packets;
using KCM.Packets.State;
using KCM.StateManagement.Observers;
using System;
@@ -6,12 +6,15 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
using static KCM.StateManagement.Observers.Observer;
namespace KCM.StateManagement.BuildingState
{
public class BuildingStateManager
{
private static readonly Dictionary<Guid, float> lastUpdateTime = new Dictionary<Guid, float>();
private const float UpdateInterval = 0.1f; // 10 times per second
public static void BuildingStateChanged(object sender, StateUpdateEventArgs args)
{
@@ -23,15 +26,30 @@ namespace KCM.StateManagement.BuildingState
try
{
Observer observer = (Observer)sender;
Building building = (Building)observer.state;
Main.LogSync($"SENDING building state update for: {building.UniqueName} guid={building.guid}");
Main.LogSync($" position={building.transform.position}");
if (building.transform.childCount > 0)
if (building == null)
{
Main.LogSync($" rotation={building.transform.GetChild(0).rotation} (euler={building.transform.GetChild(0).rotation.eulerAngles})");
if(observer != null)
{
UnityEngine.Object.Destroy(observer.gameObject);
}
return;
}
Guid guid = building.guid;
if (lastUpdateTime.ContainsKey(guid) && Time.time < lastUpdateTime[guid] + UpdateInterval)
{
return; // Not time to update yet
}
if (!lastUpdateTime.ContainsKey(guid))
lastUpdateTime.Add(guid, Time.time);
else
lastUpdateTime[guid] = Time.time;
//Main.helper.Log("Should send building network update for: " + building.UniqueName);
new BuildingStatePacket()
{

View File

@@ -15,6 +15,11 @@ namespace KCM.UI
class KaC_Button
{
public Button Button = null;
private static readonly string[] ButtonPaths =
{
"TopLevelUICanvas/TopLevel/Body/ButtonContainer/New",
"MainMenu/TopLevel/Body/ButtonContainer/New" // fallback for older versions
};
public string Name
{
@@ -84,14 +89,18 @@ namespace KCM.UI
set => Transform.SetSiblingIndex(value);
}
public KaC_Button(Transform parent = null)
{
Button b = Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel/Body/ButtonContainer/New").GetComponent<Button>();
public KaC_Button(Transform parent = null) : this(null, parent) { }
if (parent == null)
Button = GameObject.Instantiate(b);
else
Button = GameObject.Instantiate(b, parent);
public KaC_Button(Button b, Transform parent = null)
{
var templateButton = ResolveTemplateButton(b);
if (templateButton == null)
throw new InvalidOperationException("Template button not found in main menu UI.");
Button = parent == null
? GameObject.Instantiate(templateButton)
: GameObject.Instantiate(templateButton, parent);
foreach (Localize Localize in Button.GetComponentsInChildren<Localize>())
GameObject.Destroy(Localize);
@@ -99,20 +108,27 @@ namespace KCM.UI
Button.onClick = new Button.ButtonClickedEvent();
}
public KaC_Button(Button b, Transform parent = null)
private static Button ResolveTemplateButton(Button providedButton)
{
if (b == null)
b = Constants.MainMenuUI_T.Find("TopLevelUICanvas/TopLevel/Body/ButtonContainer/New").GetComponent<Button>();
if (providedButton != null)
return providedButton;
if (parent == null)
Button = GameObject.Instantiate(b);
else
Button = GameObject.Instantiate(b, parent);
foreach (var path in ButtonPaths)
{
var transform = Constants.MainMenuUI_T?.Find(path);
if (transform == null)
continue;
foreach (Localize Localize in Button.GetComponentsInChildren<Localize>())
GameObject.Destroy(Localize);
var button = transform.GetComponent<Button>();
if (button != null)
{
Main.helper?.Log($"Using menu button template at '{path}'.");
return button;
}
}
Button.onClick = new Button.ButtonClickedEvent();
Main.helper?.Log("Failed to find menu button template for KaC_Button.");
return null;
}
public override string ToString()