331 lines
8.8 KiB
C#
331 lines
8.8 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; }
|
|
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<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.");
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
AddChunkMarkers(node, data);
|
|
AddChild(node);
|
|
_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)
|
|
}
|
|
|
|
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);
|
|
timer.Start();
|
|
}
|
|
}
|