Add initial project files and implement CameraController and World classes

This commit is contained in:
ImBenji
2025-10-18 17:26:39 +01:00
parent 008ed63eff
commit ca3f88cf17
30 changed files with 964 additions and 0 deletions

117
Source/CameraController.cs Normal file
View File

@@ -0,0 +1,117 @@
using Godot;
public partial class CameraController : Camera2D
{
[Export] public float ZoomSpeed = 1.1f;
[Export] public float MinZoom = 0.01f;
[Export] public float MaxZoom = 5.0f;
[Export] public bool EnableRotation = false;
[Export] public float RotationSpeed = 0.01f;
[Export] public float PanSmoothing = 0.1f;
private bool _isDragging = false;
private Vector2 _dragStartMousePos;
private Vector2 _dragStartCameraPos;
private bool _isRotating = false;
private Vector2 _lastMousePosition;
public override void _Ready()
{
Enabled = true;
}
public override void _UnhandledInput(InputEvent @event)
{
HandleZoom(@event);
HandlePanning(@event);
HandleRotation(@event);
}
private void HandleZoom(InputEvent @event)
{
if (@event is InputEventMouseButton mouseButton && mouseButton.Pressed)
{
Vector2 mouseWorldPos = GetGlobalMousePosition();
if (mouseButton.ButtonIndex == MouseButton.WheelUp)
{
var newZoom = Zoom * ZoomSpeed;
if (newZoom.X <= MaxZoom)
{
ZoomToPoint(newZoom, mouseWorldPos);
}
}
else if (mouseButton.ButtonIndex == MouseButton.WheelDown)
{
var newZoom = Zoom / ZoomSpeed;
if (newZoom.X >= MinZoom)
{
ZoomToPoint(newZoom, mouseWorldPos);
}
}
}
}
private void ZoomToPoint(Vector2 newZoom, Vector2 worldPoint)
{
Vector2 viewportCenter = GetViewportRect().Size / 2;
Vector2 offsetFromCenter = (GetGlobalMousePosition() - GlobalPosition);
Zoom = newZoom;
Vector2 newOffsetFromCenter = offsetFromCenter / (newZoom.X / Zoom.X);
GlobalPosition += offsetFromCenter - newOffsetFromCenter;
}
private void HandlePanning(InputEvent @event)
{
if (@event is InputEventMouseButton mouseButton)
{
if (mouseButton.ButtonIndex == MouseButton.Left)
{
if (mouseButton.Pressed)
{
_isDragging = true;
_dragStartMousePos = mouseButton.GlobalPosition;
_dragStartCameraPos = GlobalPosition;
}
else
{
_isDragging = false;
}
}
}
else if (@event is InputEventMouseMotion mouseMotion && _isDragging)
{
Vector2 mouseDelta = _dragStartMousePos - mouseMotion.GlobalPosition;
GlobalPosition = _dragStartCameraPos + mouseDelta / Zoom;
}
}
private void HandleRotation(InputEvent @event)
{
if (!EnableRotation) return;
if (@event is InputEventMouseButton mouseButton)
{
if (mouseButton.ButtonIndex == MouseButton.Right)
{
if (mouseButton.Pressed)
{
_isRotating = true;
_lastMousePosition = mouseButton.GlobalPosition;
}
else
{
_isRotating = false;
}
}
}
else if (@event is InputEventMouseMotion mouseMotion && _isRotating)
{
var delta = _lastMousePosition - mouseMotion.GlobalPosition;
Rotation += delta.X * RotationSpeed;
_lastMousePosition = mouseMotion.GlobalPosition;
}
}
}

View File

@@ -0,0 +1 @@
uid://ceahximwi24jm

284
Source/Tile.cs Normal file
View File

@@ -0,0 +1,284 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Godot;
public partial class Tile : Node2D
{
private Vector2I _size;
private Vector2I _chunkId;
private World _world;
public CancellationToken CancellationToken { get; set; }
public Tile(Vector2I size, Vector2I chunkId, World world)
{
_size = size;
_chunkId = chunkId;
_world = world;
}
public override void _Ready()
{
loadTile();
}
void loadTile()
{
Image image = Image.CreateEmpty(_size.X, _size.Y, false, Image.Format.Rgba8);
Vector2I heightMapResolution = new Vector2I(
_world.HeightMapImage.GetWidth(),
_world.HeightMapImage.GetHeight()
);
Vector2I mapResolution = _world.ChunksPerAxis * _world.TileSize;
Vector2I tileOffset = new Vector2I(
_chunkId.X * _world.TileSize,
_chunkId.Y * _world.TileSize
);
// Store parameters locally to avoid changes during generation
float rockThreshold = _world.RockThreshold;
float snowHeightThreshold = _world.SnowHeightThreshold;
float waterThreshold = _world.WaterThreshold;
float desertMoistureThreshold = _world.DesertMoistureThreshold;
float desertTemperatureThreshold = _world.DesertTemperatureThreshold;
DebugType debugType = _world.DebugType;
// Run in thread
Task.Run(() =>
{
try
{
for (int x = 0; x < _size.X; x++)
{
if (CancellationToken.IsCancellationRequested) return;
for (int y = 0; y < _size.Y; y++)
{
if (CancellationToken.IsCancellationRequested) return;
Vector2I pixelOffset = new Vector2I(
x + tileOffset.X,
y + tileOffset.Y
);
float heightValue = GetValueAtMapPosition(pixelOffset);
float heatValue = GetHeatAtMapPosition(pixelOffset);
float moistureValue = GetMoistureAtMapPosition(pixelOffset);
// Get the max difference between the coord and its neighbors
float maxDiff = 0.0f;
float centerHeight = heightValue;
bool bordersSea = false;
for (int offsetX = -1; offsetX <= 1; offsetX++)
{
for (int offsetY = -1; offsetY <= 1; offsetY++)
{
if (offsetX == 0 && offsetY == 0)
continue;
Vector2I neighborCoords = new Vector2I(
pixelOffset.X + offsetX,
pixelOffset.Y + offsetY
);
float neighborHeight = GetValueAtMapPosition(neighborCoords);
if (IsSeaAtMapPosition(neighborCoords, waterThreshold))
{
bordersSea = true;
}
float diff = Mathf.Abs(centerHeight - neighborHeight);
if (diff > maxDiff)
{
maxDiff = diff;
}
}
}
if (debugType == DebugType.None)
{
// Grass (default)
image.SetPixel(x, y, Color.FromString("#bed58a", Colors.Purple));
if (IsSeaAtMapPosition(pixelOffset, waterThreshold))
{
// Water
image.SetPixel(x, y, Color.FromString("#4380b0", Colors.Purple));
continue;
}
if (moistureValue < desertMoistureThreshold && heatValue > desertTemperatureThreshold)
{
// Desert
image.SetPixel(x, y, Color.FromString("#edc9af", Colors.Purple));
}
if (bordersSea)
{
// Beach
image.SetPixel(x, y, Color.FromString("#808080", Colors.Purple));
continue;
}
if (heightValue > snowHeightThreshold)
{
// Snow
image.SetPixel(x, y, Color.FromString("#f4f4f4", Colors.Purple));
}
// We want steep areas to be grey for rock.
if (maxDiff > rockThreshold)
{
// Steep area
image.SetPixel(x, y, Color.FromString("#e4d3a6", Colors.Purple));
continue;
}
}
else if (debugType == DebugType.Height)
{
float grayValue = heightValue;
image.SetPixel(x, y, new Color(grayValue, grayValue, grayValue));
}
else if (debugType == DebugType.Slope)
{
float grayValue = maxDiff * 5.0f;
image.SetPixel(x, y, new Color(grayValue, grayValue, grayValue));
} else if (debugType == DebugType.Temperature)
{
image.SetPixel(x, y, new Color(heatValue, heatValue, heatValue));
} else if (debugType == DebugType.Moisture)
{
image.SetPixel(x, y, new Color(moistureValue, moistureValue, moistureValue));
}
}
}
if (!CancellationToken.IsCancellationRequested)
{
// Create a texture from the image and assign it to a Sprite2D
Texture2D texture = ImageTexture.CreateFromImage(image);
Sprite2D sprite = new Sprite2D();
sprite.Texture = texture;
sprite.TextureFilter = TextureFilterEnum.Nearest;
CallDeferred("add_child", sprite);
}
}
catch (OperationCanceledException)
{
// Generation was cancelled, do nothing
}
}, CancellationToken);
}
// Terrain Query Methods
private float GetValueAtMapPosition(Vector2I position)
{
// Clamp position to map bounds
position.X = Mathf.Clamp(position.X, 0, _world.ChunksPerAxis.X * _world.TileSize - 1);
position.Y = Mathf.Clamp(position.Y, 0, _world.ChunksPerAxis.Y * _world.TileSize - 1);
Vector2I heightMapResolution = new Vector2I(
_world.HeightMapImage.GetWidth(),
_world.HeightMapImage.GetHeight()
);
Vector2I mapResolution = _world.ChunksPerAxis * _world.TileSize;
Vector2 mapDelta = (Vector2) position / (Vector2) mapResolution;
Vector2I heightMapCoords = new Vector2I(
(int)(mapDelta.X * heightMapResolution.X),
(int)(mapDelta.Y * heightMapResolution.Y)
);
Color hmColor = _world.HeightMapImage.GetPixel(heightMapCoords.X, heightMapCoords.Y);
return hmColor.R;
}
private bool isSeaAtMapPosition(Vector2I position)
{
float heightValue = GetValueAtMapPosition(position);
return heightValue <= _world.WaterThreshold;
}
private bool IsSeaAtMapPosition(Vector2I position, float waterThreshold)
{
float heightValue = GetValueAtMapPosition(position);
return heightValue <= waterThreshold;
}
private FastNoiseLite _heatNoise = null;
private float GetHeatAtMapPosition(Vector2I position)
{
if (_heatNoise == null)
{
_heatNoise = new FastNoiseLite();
_heatNoise.SetNoiseType(FastNoiseLite.NoiseTypeEnum.Perlin);
_heatNoise.SetFrequency(1/_world.TemperatureNoiseFrequency);
_heatNoise.SetSeed(1738);
}
float heat = _heatNoise.GetNoise2D(position.X, position.Y);
heat *= 0.2f;
// Fall off towards the poles. Use sine function for a globe effect.
float mapResolutionY = _world.ChunksPerAxis.Y * _world.TileSize;
float latitudeFactor = Mathf.Sin(Mathf.Pi * position.Y / mapResolutionY);
heat = latitudeFactor + heat;
heat = Mathf.Clamp(
heat,
0.0f,
1.0f
);
return heat;
}
private FastNoiseLite _moistureNoise = null;
private float GetMoistureAtMapPosition(Vector2I position)
{
if (_moistureNoise == null)
{
_moistureNoise = new FastNoiseLite();
_moistureNoise.SetNoiseType(FastNoiseLite.NoiseTypeEnum.Perlin);
_moistureNoise.SetFrequency(1/_world.MoistureNoiseFrequency);
_moistureNoise.SetSeed(1337);
}
float moisture = _moistureNoise.GetNoise2D(position.X, position.Y);
moisture *= 0.2f;
// Fall off towards the poles. Use sine function for a globe effect.
float mapResolutionY = _world.ChunksPerAxis.Y * _world.TileSize;
float latitudeFactor = Mathf.Sin(Mathf.Pi * position.Y / mapResolutionY);
moisture = latitudeFactor + moisture;
moisture = Mathf.Clamp(
moisture,
0.0f,
1.0f
);
return moisture;
}
}

1
Source/Tile.cs.uid Normal file
View File

@@ -0,0 +1 @@
uid://d1af74387s3fk

289
Source/World.cs Normal file
View File

@@ -0,0 +1,289 @@
using Godot;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Godot.Collections;
public enum DebugType
{
None,
Slope,
Height,
Temperature,
Moisture
}
[Tool]
public partial class World : Node2D
{
private int _chunksX = 32;
[Export]
public int ChunkXCount
{
get => _chunksX;
set
{
_chunksX = value;
LoadTiles();
}
}
public Vector2I ChunksPerAxis
{
get
{
if (HeightMapImage == null)
{
return new Vector2I(_chunksX, _chunksX);
}
Vector2I heightMapResolution = new Vector2I(HeightMapImage.GetWidth(), HeightMapImage.GetHeight());
int chunksY = (int)(heightMapResolution.Y / (heightMapResolution.X / _chunksX));
return new Vector2I(_chunksX, chunksY);
}
}
private int _tileSize = 96;
[Export]
public int TileSize
{
get => _tileSize;
set
{
_tileSize = value;
LoadTiles();
}
}
[Export]
public Texture2D HeightMapTexture;
private float _rockThreshold = 0.05f;
[Export]
public float RockThreshold
{
get => _rockThreshold;
set
{
_rockThreshold = value;
LoadTiles();
}
}
private float _snowHeightThreshold = 0.8f;
[Export]
public float SnowHeightThreshold
{
get => _snowHeightThreshold;
set
{
_snowHeightThreshold = value;
LoadTiles();
}
}
private float _waterThreshold = 0.0f;
[Export]
public float WaterThreshold
{
get => _waterThreshold;
set
{
_waterThreshold = value;
LoadTiles();
}
}
private float _desertTemperatureThreshold = 0.7f;
[Export]
public float DesertTemperatureThreshold
{
get => _desertTemperatureThreshold;
set
{
_desertTemperatureThreshold = value;
LoadTiles();
}
}
private float _desertMoistureThreshold = 0.3f;
[Export]
public float DesertMoistureThreshold
{
get => _desertMoistureThreshold;
set
{
_desertMoistureThreshold = value;
LoadTiles();
}
}
private float _temperatureNoiseFrequency = 0.01f;
[Export]
public float TemperatureNoiseFrequency
{
get => _temperatureNoiseFrequency;
set
{
_temperatureNoiseFrequency = value;
LoadTiles();
}
}
private float _moistureNoiseFrequency = 0.01f;
[Export]
public float MoistureNoiseFrequency
{
get => _moistureNoiseFrequency;
set
{
_moistureNoiseFrequency = value;
LoadTiles();
}
}
private DebugType _debugType = DebugType.None;
[Export]
public DebugType DebugType
{
get => _debugType;
set
{
_debugType = value;
LoadTiles();
}
}
ConcurrentDictionary<Vector2I, Tile> _tiles = new ConcurrentDictionary<Vector2I, Tile>();
private CancellationTokenSource _cancellationTokenSource;
public World()
{
}
public override void _Ready()
{
// If the height map image is not assigned, log an error and return
if (HeightMapTexture == null)
{
GD.PrintErr("Height map image is not assigned.");
return;
}
LoadTiles();
}
private void LoadTiles()
{
// Check if HeightMapImage is available
if (HeightMapImage == null)
{
GD.PrintErr("Cannot load tiles: HeightMapImage is null");
return;
}
// Cancel any existing generation
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = _cancellationTokenSource.Token;
// Clear existing tiles
foreach (var tile in _tiles.Values)
{
tile.Visible = false;
// Delete and delete its children
foreach (Node child in tile.GetChildren())
{
child.QueueFree();
}
tile.QueueFree();
}
_tiles.Clear();
int scale = 1;
// Store local copies of parameters to avoid them changing mid-generation
int tileSize = TileSize;
int chunksX = _chunksX;
// Get the resolution of the height map image
Vector2I heightMapResolution = new Vector2I(HeightMapImage.GetWidth(), HeightMapImage.GetHeight());
// Calculate the number of chunks in the Y direction based on the aspect ratio
int chunksY = (int)(heightMapResolution.Y / (heightMapResolution.X / _chunksX));
// Loop through each chunk position and create a Tile
Task.Run(() =>
{
try
{
for (int x = 0; x < chunksX; x++)
{
if (cancellationToken.IsCancellationRequested) return;
Parallel.For(0, chunksY, new ParallelOptions { CancellationToken = cancellationToken }, y =>
{
if (cancellationToken.IsCancellationRequested) return;
Vector2I chunkId = new Vector2I(x, y);
Tile tile = new Tile(new Vector2I(tileSize, tileSize), chunkId, this);
tile.CancellationToken = cancellationToken;
tile.Position = new Vector2(chunkId.X * tileSize, chunkId.Y * tileSize) * scale;
tile.Scale = new Vector2(scale, scale);
if (!cancellationToken.IsCancellationRequested)
{
CallDeferred("add_child", tile);
_tiles[chunkId] = tile;
GD.Print("Loaded tile at chunk " + chunkId);
}
});
}
}
catch (OperationCanceledException)
{
GD.Print("Tile generation cancelled");
}
}, cancellationToken);
}
private Image _heightMapCache;
public Image HeightMapImage
{
get
{
// If not cached, load and cache it
if (_heightMapCache == null && HeightMapTexture != null)
{
_heightMapCache = HeightMapTexture.GetImage();
// If GetImage() returns null, try loading directly from file
if (_heightMapCache == null && HeightMapTexture.ResourcePath != "")
{
_heightMapCache = Image.LoadFromFile(HeightMapTexture.ResourcePath);
if (_heightMapCache == null)
{
GD.PushError($"Could not load heightmap from {HeightMapTexture.ResourcePath}");
}
}
}
return _heightMapCache;
}
}
}

1
Source/World.cs.uid Normal file
View File

@@ -0,0 +1 @@
uid://btqabtn0awg6k