ocker/scripts/world/ChunkManager.cs

480 lines
13 KiB
C#

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();
private readonly Dictionary<(int x, int y), ChunkData> _chunkDataByCoord = new();
public override void _Ready()
{
_player = GetNodeOrNull<CharacterBody2D>(PlayerPath);
if (_player == null)
{
GD.PrintErr("ChunkManager: PlayerPath not set or invalid.");
return;
}
_packs = GetNodeOrNull<PackManager>(PackManagerPath);
if (_packs == null)
{
GD.PrintErr("ChunkManager: PackManagerPath not set or invalid.");
return;
}
_save = GetNodeOrNull<SaveManager>(SaveManagerPath);
if (_save == null) GD.PrintErr("ChunkManager: SaveManagerPath not set or invalid.");
_clock = GetNodeOrNull<WorldClock>(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<WorldIndex>(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);
_chunkDataByCoord.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<ChunkData>(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);
_chunkDataByCoord[(x, y)] = 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)
}
};
rect.ZIndex = -100; // keep the biome overlay behind all markers
rect.ZAsRelative = true;
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)
AddCollisionRects(chunkNode, data);
//if (data.spawn_zones != null)
//{
//foreach (var z in data.spawn_zones)
//SpawnCreaturesForZone(chunkNode, z);
//}
if (data.spawn_zones != null)
{
foreach (var z in data.spawn_zones)
{
var r = z.rect;
var zoneRect = new ColorRect
{
Position = new Vector2(r[0], r[1]),
Size = new Vector2(r[2], r[3]),
Color = new Color(0.9f, 0.9f, 0.1f, 0.10f), // faint yellow overlay
MouseFilter = Control.MouseFilterEnum.Ignore
};
chunkNode.AddChild(zoneRect);
SpawnCreaturesForZone(chunkNode, z);
}
}
}
private void AddCollisionRects(Node2D chunkNode, ChunkData data)
{
if (data.collision_rects == null) return;
foreach (var c in data.collision_rects)
{
var r = c.rect; // [x,y,w,h]
// Physics body
var body = new StaticBody2D
{
Name = $"Col_{c.id.Replace(":", "_")}",
Position = new Vector2(r[0], r[1])
};
var shape = new CollisionShape2D();
var rectShape = new RectangleShape2D
{
Size = new Vector2(r[2], r[3])
};
shape.Shape = rectShape;
// CollisionShape2D is centered, so offset it by half-size to align to top-left rect coords
shape.Position = new Vector2(r[2] / 2f, r[3] / 2f);
body.AddChild(shape);
// Visual outline (gray)
var outline = new RectOutline
{
Size = new Vector2(r[2], r[3]),
ZIndex = 10,
ZAsRelative = true
};
body.AddChild(outline);
chunkNode.AddChild(body);
}
}
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.ZIndex = 50; // draw above debug chunk background
c.ZAsRelative = true;
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 kv in _loaded)
{
var coord = kv.Key;
var chunk = kv.Value;
// Remove existing creature containers
foreach (var childObj in chunk.GetChildren())
{
if (childObj is Node n && n.Name.ToString().StartsWith("Creatures_"))
n.QueueFree();
}
// Re-spawn creatures based on stored ChunkData
if (_chunkDataByCoord.TryGetValue(coord, out var data) && data.spawn_zones != null)
{
foreach (var z in data.spawn_zones)
SpawnCreaturesForZone(chunk, z);
}
}
}
}