using Godot; using System; using System.Collections.Generic; using System.Text.Json; public partial class ChunkManager : Node2D { [Export] public NodePath PlayerPath { get; set; } [Export] public NodePath PackManagerPath { get; set; } [Export] public int LoadRadius { get; set; } = 1; // 1 => 3x3 [Export] public PackedScene PickupMarkerScene { get; set; } [Export] public NodePath SaveManagerPath { get; set; } [Export] public PackedScene CreatureMarkerScene { get; set; } [Export] public NodePath WorldClockPath { get; set; } [Export] public int MaxCreaturesPerChunk { get; set; } = 3; private WorldClock? _clock; private WorldClock.TimeOfDay? _lastTime; private SaveManager? _save; private CharacterBody2D? _player; private PackManager? _packs; private WorldIndex? _world; private Dictionary<(int x, int y), string> _chunkMap = new(); private readonly Dictionary<(int x, int y), Node2D> _loaded = new(); public override void _Ready() { _player = GetNodeOrNull(PlayerPath); if (_player == null) { GD.PrintErr("ChunkManager: PlayerPath not set or invalid."); return; } _packs = GetNodeOrNull(PackManagerPath); if (_packs == null) { GD.PrintErr("ChunkManager: PackManagerPath not set or invalid."); return; } _save = GetNodeOrNull(SaveManagerPath); if (_save == null) GD.PrintErr("ChunkManager: SaveManagerPath not set or invalid."); _clock = GetNodeOrNull(WorldClockPath); if (_clock == null) GD.PrintErr("ChunkManager: WorldClockPath not set or invalid."); LoadWorldIndex(); } private void LoadWorldIndex() { var worldPath = _packs!.Resolve("data/world/world_main.json"); if (worldPath == null) { GD.PrintErr("ChunkManager: Could not resolve data/world/world_main.json from packs."); return; } var json = FileAccess.GetFileAsString(worldPath); _world = JsonSerializer.Deserialize(json); if (_world == null) { GD.PrintErr("ChunkManager: Failed to parse world index."); return; } _chunkMap = _world.BuildChunkMap(); GD.Print($"World loaded: {_world.world_id}, chunks: {_chunkMap.Count}, chunk_size_px={_world.chunk_size_px}"); // After loading world index, set player spawn position if (_player != null && _world.start_pos_px.Length == 2) { _player.GlobalPosition = new Vector2( _world.start_pos_px[0], _world.start_pos_px[1] ); } } public override void _Process(double delta) { if (_player == null || _world == null) return; var (cx, cy) = WorldPosToChunk(_player.GlobalPosition, _world.chunk_size_px); // Load required chunks for (int dy = -LoadRadius; dy <= LoadRadius; dy++) for (int dx = -LoadRadius; dx <= LoadRadius; dx++) { EnsureLoaded(cx + dx, cy + dy); } // Unload chunks too far away var toRemove = new List<(int x, int y)>(); foreach (var kv in _loaded) { var (x, y) = kv.Key; if (Math.Abs(x - cx) > LoadRadius || Math.Abs(y - cy) > LoadRadius) toRemove.Add((x, y)); } foreach (var k in toRemove) { _loaded[k].QueueFree(); _loaded.Remove(k); } if (_clock != null) { if (_lastTime == null) _lastTime = _clock.Current; if (_clock.Current != _lastTime) { _lastTime = _clock.Current; RefreshCreaturesInLoadedChunks(); } } } private void EnsureLoaded(int x, int y) { var key = (x, y); if (_loaded.ContainsKey(key)) return; if (!_chunkMap.TryGetValue(key, out var relPath)) return; var chunkPath = _packs!.Resolve(relPath); if (chunkPath == null) { GD.PrintErr($"Chunk missing in packs: {relPath}"); return; } var chunkJson = FileAccess.GetFileAsString(chunkPath); var data = JsonSerializer.Deserialize(chunkJson); if (data == null) { GD.PrintErr($"Failed to parse chunk: {chunkPath}"); return; } var biome = data.biome ?? "biome:unknown"; var node = CreateDebugChunkNode(x, y, _world!.chunk_size_px, biome); node.SetMeta("chunk_data", data); // Put the chunk into the scene tree first so Timers can run. AddChild(node); AddChunkMarkers(node, data); _loaded[key] = node; } private static (int cx, int cy) WorldPosToChunk(Vector2 pos, int chunkSizePx) { int cx = Mathf.FloorToInt(pos.X / chunkSizePx); int cy = Mathf.FloorToInt(pos.Y / chunkSizePx); return (cx, cy); } private static Node2D CreateDebugChunkNode(int cx, int cy, int chunkSizePx, string biome) { var n = new Node2D { Name = $"Chunk_{cx}_{cy}", Position = new Vector2(cx * chunkSizePx, cy * chunkSizePx) }; var rect = new ColorRect { Size = new Vector2(chunkSizePx, chunkSizePx), MouseFilter = Control.MouseFilterEnum.Ignore, Color = biome switch { "biome:forest" => new Color(0.1f, 0.35f, 0.1f, 0.25f), "biome:plains" => new Color(0.35f, 0.35f, 0.1f, 0.25f), "biome:town" => new Color(0.25f, 0.25f, 0.25f, 0.25f), _ => new Color(0.2f, 0.2f, 0.2f, 0.25f) } }; n.AddChild(rect); return n; } private void AddChunkMarkers(Node2D chunkNode, ChunkData data) { if (_save == null || PickupMarkerScene == null) return; if (data.pickups != null && _save != null) { var now = NowUnixSeconds(); foreach (var p in data.pickups) { if (_save == null || PickupMarkerScene == null) return; // Unnatural / one-time pickups if (p.respawn_seconds <= 0) { if (_save.State.CollectedPickups.Contains(p.id)) continue; SpawnPickupMarker(chunkNode, p); continue; } // Natural pickups: check cooldown //var now = NowUnixSeconds(); if (_save.State.PickupRespawnAt.TryGetValue(p.id, out var respawnAt) && now < respawnAt) { // Chunk is loaded but pickup is still cooling down -> schedule its appearance. SchedulePickupRespawn(chunkNode, p.id, p.item, p.respawn_seconds, respawnAt, new int[] { p.pos[0], p.pos[1] }); continue; } // Ready now -> spawn immediately SpawnPickupMarker(chunkNode, p); } } // NPCs: blue squares if (data.npcs != null) { foreach (var n in data.npcs) { var r = new ColorRect { Size = new Vector2(12, 12), Color = new Color(0.2f, 0.5f, 1.0f, 0.9f), MouseFilter = Control.MouseFilterEnum.Ignore, Position = new Vector2(n.pos[0] - 6, n.pos[1] - 6) }; chunkNode.AddChild(r); } } // Gates: red rectangles if (data.gates != null) { foreach (var g in data.gates) { var rect = g.rect; // [x,y,w,h] var r = new ColorRect { Size = new Vector2(rect[2], rect[3]), Color = new Color(1.0f, 0.2f, 0.2f, 0.35f), MouseFilter = Control.MouseFilterEnum.Ignore, Position = new Vector2(rect[0], rect[1]) }; chunkNode.AddChild(r); } } // Collision rects: gray outlines (optional later) if (data.spawn_zones != null) { foreach (var z in data.spawn_zones) SpawnCreaturesForZone(chunkNode, z); } } private static long NowUnixSeconds() { return DateTimeOffset.UtcNow.ToUnixTimeSeconds(); } // Timer nodes are attached to the chunk node so they automatically die when the chunk unloads. private const string RespawnTimerPrefix = "RespawnTimer_"; private static string RespawnTimerName(string pickupId) => $"{RespawnTimerPrefix}{pickupId.Replace(":", "_")}"; // Spawn a pickup marker in world-space (y-sort friendly later). private void SpawnPickupMarker(Node2D chunkNode, ChunkPickup p) { if (_save == null || PickupMarkerScene == null) return; var marker = (PickupMarker)PickupMarkerScene.Instantiate(); marker.Position = new Vector2(p.pos[0], p.pos[1]); marker.PickupId = p.id; marker.ItemId = p.item; // Capture only the values we need (avoid relying on foreach capture semantics). var respawnSeconds = p.respawn_seconds; var pickupId = p.id; var itemId = p.item; var posCopy = new int[] { p.pos[0], p.pos[1] }; marker.PickedUp += (_, __) => { if (_save == null) return; // Permanent pickups (unnatural): one-time removal. if (respawnSeconds <= 0) { _save.State.CollectedPickups.Add(pickupId); _save.Save(); marker.QueueFree(); return; } // Natural pickups: schedule respawn by absolute timestamp. var respawnAt = NowUnixSeconds() + respawnSeconds; _save.State.PickupRespawnAt[pickupId] = respawnAt; _save.Save(); marker.QueueFree(); // If chunk stays loaded, respawn should still appear. SchedulePickupRespawn(chunkNode, pickupId, itemId, respawnSeconds, respawnAt, posCopy); }; chunkNode.AddChild(marker); } // Schedule a respawn for a pickup that is currently on cooldown. private void SchedulePickupRespawn( Node2D chunkNode, string pickupId, string itemId, int respawnSeconds, long respawnAtUnix, int[] pos) { // Deduplicate: one timer per pickup per loaded chunk. var timerName = RespawnTimerName(pickupId); if (chunkNode.HasNode(timerName)) return; var now = NowUnixSeconds(); var wait = Math.Max(0.1, respawnAtUnix - now); // remaining time (accounts for time while chunk was unloaded) var timer = new Timer { Name = timerName, OneShot = true, WaitTime = wait }; timer.Timeout += () => { if (_save == null) return; // Re-check at fire time (handles clock changes / save edits). var now2 = NowUnixSeconds(); if (_save.State.PickupRespawnAt.TryGetValue(pickupId, out var ra) && now2 < ra) { // Still not ready: reschedule with the updated remaining time. timer.QueueFree(); SchedulePickupRespawn(chunkNode, pickupId, itemId, respawnSeconds, ra, pos); return; } // Ready: remove the timer node name and spawn the pickup. timer.QueueFree(); var p = new ChunkPickup(pickupId, itemId, pos, respawnSeconds); SpawnPickupMarker(chunkNode, p); }; chunkNode.AddChild(timer); // Start after the node is fully in-tree (avoids start-before-tree edge cases). timer.CallDeferred(Timer.MethodName.Start); } private void SpawnCreaturesForZone(Node2D chunkNode, ChunkSpawnZone zone) { if (_clock == null || CreatureMarkerScene == null) return; var timeStr = WorldClock.ToPackString(_clock.Current); if (zone.time_windows != null && zone.time_windows.Count > 0 && !zone.time_windows.Contains(timeStr)) return; // Dedup: ensure one spawn container per zone var containerName = $"Creatures_{zone.id.Replace(":", "_")}"; if (chunkNode.HasNode(containerName)) return; var container = new Node2D { Name = containerName }; chunkNode.AddChild(container); var r = zone.rect; // [x,y,w,h] var rng = new RandomNumberGenerator(); rng.Randomize(); var count = Math.Min(MaxCreaturesPerChunk, 3); for (int i = 0; i < count; i++) { var c = (Node2D)CreatureMarkerScene.Instantiate(); c.Position = new Vector2( r[0] + rng.RandfRange(0, r[2]), r[1] + rng.RandfRange(0, r[3]) ); container.AddChild(c); } } private void RefreshCreaturesInLoadedChunks() { foreach (var chunk in _loaded.Values) { // Remove existing creature containers foreach (var child in chunk.GetChildren()) { if (child is Node n && n.Name.StartsWith("Creatures_")) n.QueueFree(); } // Re-read spawn zones by re-parsing the chunk json is overkill. // v0 hack: store ChunkData on the chunk node as metadata. if (chunk.HasMeta("chunk_data")) { var data = (ChunkData)chunk.GetMeta("chunk_data"); if (data.spawn_zones != null) foreach (var z in data.spawn_zones) SpawnCreaturesForZone(chunk, z); } } } }