Add Unity worldbuilder prototype scripts

This commit is contained in:
Alexa Amundson
2025-11-25 14:35:02 -06:00
parent ffc2842bd0
commit df5bf41d93
9 changed files with 643 additions and 0 deletions

86
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,86 @@
# Copilot / Codex Prompt: Unity Worldbuilder Game Architect 🎮
Use this prompt in Copilot chat to rapidly scaffold Unity gameplay for the world-builder prototype. It assumes the Unity project already contains the scripts added in this PR (fly camera, grid placement, day/night, procedural spawner).
```
SYSTEM ROLE: Unity Worldbuilder Game Architect 🎮
You are working INSIDE a Unity 3D project for the repo `blackroad-worldbuilder`.
The project already has:
- FlyCameraController (WASD + mouse look)
- WorldGrid (grid-based block storage)
- BlockDatabase + BlockType (ScriptableObjects for blocks)
- BlockPlacer (raycasts to place/remove blocks)
GOAL
Turn this into a minimal but real **world-building sandbox game**, step by step, with clean C#.
RULES
- Use Unity 2021+ compatible APIs (no deprecated stuff).
- Use namespaces: `BlackRoad.Worldbuilder.*`.
- Keep scripts focused and composable.
- No editor code that requires custom packages.
- No secrets or network keys.
- No giant binary assets; assume basic cube/materials exist.
TASKS
1) PLAYER EXPERIENCE
- Implement a proper Player rig:
- Fly camera we already have is fine, but add:
- Optional “grounded” mode with CharacterController.
- Simple HUD showing currently selected block type.
- Add input mappings:
- Left click: place block at target.
- Right click: remove block.
- Scroll or number keys 19: switch block type.
- Make sure code is configurable via public fields (no magic numbers).
2) BLOCK VARIETY
- Extend BlockDatabase + BlockType:
- Add categories: terrain, structure, decorative.
- Add support for hardness / break time.
- Add helper utility:
- A `BlockSelectionBar` UI script that reads BlockDatabase and builds a simple hotbar.
3) SAVE / LOAD
- Implement a simple JSON save format for the world grid:
- Save grid positions + block IDs.
- Add `WorldSerializer`:
- `Save(string slotName)` and `Load(string slotName)`.
- Use `Application.persistentDataPath`.
- Wire save/load to keys:
- F5 → Save to "slot1".
- F9 → Load from "slot1".
4) GAMELOOP HOOKS
- Add a `GameState` enum (MainMenu, Playing, Paused).
- Extend GameManager:
- Track state.
- Manage pause (stop camera & building, show pause menu).
- Implement a super simple main menu scene:
- “New World”, “Load World”, “Quit”.
5) CODE QUALITY
- For each new script:
- Include XML summary comments at class + key methods.
- Use `SerializeField` with private fields where possible.
- Provide example usage in comments at top of each script.
OUTPUT
- Generate C# scripts for:
- `Assets/Scripts/Core/GameState.cs`
- `Assets/Scripts/Core/WorldSerializer.cs`
- `Assets/Scripts/UI/BlockSelectionBar.cs`
- Any additional small scripts you need (e.g., simple MainMenu UI).
- Make them self-contained and ready to paste into the Unity project.
- Do NOT generate Unity meta files.
If something is ambiguous, assume:
- PC / keyboard + mouse.
- Single-player.
- No networking yet.
Respond with only code blocks and very short comments around them so I can open PRs with minimal editing.
```

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using UnityEngine;
namespace BlackRoad.Worldbuilder.Building
{
[CreateAssetMenu(
fileName = "BlockDatabase",
menuName = "BlackRoad/Worldbuilder/BlockDatabase",
order = 1
)]
public class BlockDatabase : ScriptableObject
{
public BlockType[] blocks;
private Dictionary<string, BlockType> _byId;
private void OnEnable()
{
BuildIndex();
}
private void BuildIndex()
{
_byId = new Dictionary<string, BlockType>();
if (blocks == null) return;
foreach (var block in blocks)
{
if (block == null || string.IsNullOrEmpty(block.blockId)) continue;
if (!_byId.ContainsKey(block.blockId))
{
_byId.Add(block.blockId, block);
}
}
}
public BlockType Get(string id)
{
if (_byId == null || _byId.Count == 0)
BuildIndex();
return _byId != null && _byId.TryGetValue(id, out var block) ? block : null;
}
public BlockType GetDefault()
{
if (blocks != null && blocks.Length > 0)
return blocks[0];
return null;
}
}
}

View File

@@ -0,0 +1,63 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Building
{
[RequireComponent(typeof(Camera))]
public class BlockPlacer : MonoBehaviour
{
[Header("Refs")]
public WorldGrid worldGrid;
public BlockDatabase blockDatabase;
[Header("Placement")]
public float maxDistance = 50f;
public LayerMask placementMask = ~0;
private Camera _camera;
private BlockType _currentBlock;
private void Awake()
{
_camera = GetComponent<Camera>();
}
private void Start()
{
if (blockDatabase != null)
{
_currentBlock = blockDatabase.GetDefault();
}
if (worldGrid == null)
{
// Try to find one in the scene
worldGrid = FindObjectOfType<WorldGrid>();
}
}
private void Update()
{
if (_camera == null || worldGrid == null || _currentBlock == null)
return;
Ray ray = _camera.ScreenPointToRay(UnityEngine.Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, placementMask))
{
// Place with left click
if (UnityEngine.Input.GetMouseButtonDown(0))
{
Vector3Int gridPos = worldGrid.WorldToGrid(hit.point + hit.normal * 0.5f);
worldGrid.PlaceBlock(gridPos, _currentBlock);
}
// Remove with right click
if (UnityEngine.Input.GetMouseButtonDown(1))
{
Vector3Int gridPos = worldGrid.WorldToGrid(hit.point - hit.normal * 0.5f);
worldGrid.RemoveBlock(gridPos);
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Building
{
[CreateAssetMenu(
fileName = "BlockType",
menuName = "BlackRoad/Worldbuilder/BlockType",
order = 0
)]
public class BlockType : ScriptableObject
{
public string blockId = "block.dirt";
public string displayName = "Dirt";
public GameObject prefab;
public Color gizmoColor = Color.gray;
}
}

View File

@@ -0,0 +1,67 @@
using System.Collections.Generic;
using UnityEngine;
namespace BlackRoad.Worldbuilder.Building
{
public class WorldGrid : MonoBehaviour
{
[Header("Grid Settings")]
public float cellSize = 1f;
private readonly Dictionary<Vector3Int, GameObject> _placedBlocks =
new Dictionary<Vector3Int, GameObject>();
public Vector3Int WorldToGrid(Vector3 worldPos)
{
return new Vector3Int(
Mathf.RoundToInt(worldPos.x / cellSize),
Mathf.RoundToInt(worldPos.y / cellSize),
Mathf.RoundToInt(worldPos.z / cellSize)
);
}
public Vector3 GridToWorld(Vector3Int gridPos)
{
return new Vector3(
gridPos.x * cellSize,
gridPos.y * cellSize,
gridPos.z * cellSize
);
}
public bool TryGetBlock(Vector3Int gridPos, out GameObject block)
{
return _placedBlocks.TryGetValue(gridPos, out block);
}
public GameObject PlaceBlock(Vector3Int gridPos, BlockType blockType)
{
if (blockType == null || blockType.prefab == null)
return null;
if (_placedBlocks.ContainsKey(gridPos))
return _placedBlocks[gridPos];
Vector3 worldPos = GridToWorld(gridPos);
var instance = Instantiate(blockType.prefab, worldPos, Quaternion.identity, transform);
_placedBlocks.Add(gridPos, instance);
return instance;
}
public bool RemoveBlock(Vector3Int gridPos)
{
if (!_placedBlocks.TryGetValue(gridPos, out var instance))
return false;
_placedBlocks.Remove(gridPos);
if (instance != null)
{
Destroy(instance);
}
return true;
}
}
}

View File

@@ -0,0 +1,40 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Core
{
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
[Header("Building")]
public Building.BlockDatabase blockDatabase;
public Building.BlockPlacer blockPlacer;
[Header("Input")]
public Input.FlyCameraController flyCamera;
[Header("Settings")]
public bool showGridGizmos = true;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Update()
{
// Simple escape to quit / future menus
if (Input.GetKeyDown(KeyCode.Escape))
{
Application.Quit();
}
}
}
}

View File

@@ -0,0 +1,92 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Environment
{
/// <summary>
/// Simple day/night cycle: rotates the directional light and
/// adjusts intensity + fog color over a 24h-style loop.
/// </summary>
public class DayNightCycle : MonoBehaviour
{
[Header("References")]
[Tooltip("Main directional light used as the sun.")]
public Light sunLight;
[Header("Time")]
[Tooltip("Length of a full day/night cycle in real seconds.")]
public float dayLengthSeconds = 600f; // 10 minutes
[Range(0f, 1f)]
[Tooltip("Current normalized time of day (0 = sunrise, 0.5 = sunset).")]
public float timeOfDay = 0.25f;
[Header("Lighting")]
public Gradient sunColorOverDay;
public AnimationCurve sunIntensityOverDay = AnimationCurve.Linear(0, 0, 1, 1);
[Header("Fog")]
public bool controlFog = true;
public Gradient fogColorOverDay;
public AnimationCurve fogDensityOverDay = AnimationCurve.Linear(0, 0.002f, 1, 0.01f);
private float _timeSpeed;
private void Reset()
{
// Reasonable defaults if you drop it into scene
dayLengthSeconds = 600f;
timeOfDay = 0.25f;
sunIntensityOverDay = AnimationCurve.EaseInOut(0, 0, 0.25f, 1f);
}
private void Start()
{
if (sunLight == null)
{
sunLight = RenderSettings.sun;
}
if (dayLengthSeconds <= 0f)
dayLengthSeconds = 600f;
_timeSpeed = 1f / dayLengthSeconds;
}
private void Update()
{
// Advance normalized time [01]
timeOfDay += _timeSpeed * Time.deltaTime;
if (timeOfDay > 1f)
timeOfDay -= 1f;
UpdateSun();
UpdateFog();
}
private void UpdateSun()
{
if (sunLight == null) return;
// Rotate sun: 0..1 -> 0..360 degrees
float sunAngle = timeOfDay * 360f - 90f; // -90 makes 0 = sunrise on horizon
sunLight.transform.rotation = Quaternion.Euler(sunAngle, 170f, 0f);
if (sunColorOverDay != null)
sunLight.color = sunColorOverDay.Evaluate(timeOfDay);
if (sunIntensityOverDay != null)
sunLight.intensity = sunIntensityOverDay.Evaluate(timeOfDay);
}
private void UpdateFog()
{
if (!controlFog) return;
if (fogColorOverDay != null)
RenderSettings.fogColor = fogColorOverDay.Evaluate(timeOfDay);
if (fogDensityOverDay != null)
RenderSettings.fogDensity = fogDensityOverDay.Evaluate(timeOfDay);
}
}
}

View File

@@ -0,0 +1,129 @@
using System.Collections.Generic;
using UnityEngine;
namespace BlackRoad.Worldbuilder.Environment
{
/// <summary>
/// Spawns environment prefabs (trees, rocks, houses) on a Terrain
/// using simple rules for height, slope, and density.
/// Run once in editor or at runtime.
/// </summary>
public class ProceduralSpawner : MonoBehaviour
{
[System.Serializable]
public class SpawnRule
{
public string id = "trees";
public GameObject prefab;
[Tooltip("How many instances to try to place.")]
public int attempts = 500;
[Tooltip("Minimum normalized height [0..1] on terrain.")]
[Range(0f, 1f)] public float minHeight = 0f;
[Range(0f, 1f)] public float maxHeight = 1f;
[Tooltip("Maximum terrain slope allowed in degrees.")]
[Range(0f, 90f)] public float maxSlope = 30f;
[Tooltip("Min distance from other instances of this rule.")]
public float minSpacing = 3f;
}
[Header("Terrain")]
public Terrain targetTerrain;
[Header("Spawning Rules")]
public List<SpawnRule> rules = new List<SpawnRule>();
[Header("Randomness")]
public int randomSeed = 12345;
private TerrainData _terrainData;
private Vector3 _terrainPos;
private void OnValidate()
{
if (targetTerrain == null)
targetTerrain = GetComponent<Terrain>();
}
[ContextMenu("Spawn All")]
public void SpawnAll()
{
if (targetTerrain == null)
{
Debug.LogError("[ProceduralSpawner] No Terrain assigned.");
return;
}
_terrainData = targetTerrain.terrainData;
_terrainPos = targetTerrain.transform.position;
Random.InitState(randomSeed);
foreach (var rule in rules)
{
SpawnForRule(rule);
}
}
private void SpawnForRule(SpawnRule rule)
{
if (rule.prefab == null)
{
Debug.LogWarning($"[ProceduralSpawner] Rule {rule.id} has no prefab.");
return;
}
var placedPositions = new List<Vector3>();
for (int i = 0; i < rule.attempts; i++)
{
// Random point in terrain space (0..1)
float rx = Random.value;
float rz = Random.value;
float height = _terrainData.GetInterpolatedHeight(rx, rz);
float normHeight = Mathf.InverseLerp(
_terrainData.bounds.min.y,
_terrainData.bounds.max.y,
height + _terrainPos.y
);
if (normHeight < rule.minHeight || normHeight > rule.maxHeight)
continue;
// World position
float worldX = _terrainPos.x + rx * _terrainData.size.x;
float worldZ = _terrainPos.z + rz * _terrainData.size.z;
Vector3 worldPos = new Vector3(worldX, height + _terrainPos.y, worldZ);
// Check slope (avoid steep areas)
Vector3 normal = _terrainData.GetInterpolatedNormal(rx, rz);
float slope = Vector3.Angle(normal, Vector3.up);
if (slope > rule.maxSlope)
continue;
// Respect min spacing
bool tooClose = false;
foreach (var p in placedPositions)
{
if (Vector3.SqrMagnitude(p - worldPos) < rule.minSpacing * rule.minSpacing)
{
tooClose = true;
break;
}
}
if (tooClose) continue;
// Random rotation around Y
Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
// Slight random scale
float scaleFactor = Random.Range(0.85f, 1.15f);
var instance = Instantiate(rule.prefab, worldPos, rot, transform);
instance.transform.localScale *= scaleFactor;
placedPositions.Add(worldPos);
}
Debug.Log($"[ProceduralSpawner] Rule {rule.id}: placed {placedPositions.Count} instances.");
}
}
}

View File

@@ -0,0 +1,94 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Input
{
[RequireComponent(typeof(Camera))]
public class FlyCameraController : MonoBehaviour
{
[Header("Movement")]
public float moveSpeed = 10f;
public float fastMultiplier = 2f;
public float slowMultiplier = 0.5f;
[Header("Look")]
public float lookSensitivity = 3f;
public float minPitch = -80f;
public float maxPitch = 80f;
private float _yaw;
private float _pitch;
private void Start()
{
var euler = transform.eulerAngles;
_yaw = euler.y;
_pitch = euler.x;
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
private void Update()
{
HandleLook();
HandleMove();
// Toggle cursor lock for debugging
if (UnityEngine.Input.GetKeyDown(KeyCode.Tab))
{
if (Cursor.lockState == CursorLockMode.Locked)
{
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
else
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
}
private void HandleLook()
{
if (Cursor.lockState != CursorLockMode.Locked) return;
float mouseX = UnityEngine.Input.GetAxis("Mouse X") * lookSensitivity;
float mouseY = UnityEngine.Input.GetAxis("Mouse Y") * lookSensitivity;
_yaw += mouseX;
_pitch -= mouseY;
_pitch = Mathf.Clamp(_pitch, minPitch, maxPitch);
transform.rotation = Quaternion.Euler(_pitch, _yaw, 0f);
}
private void HandleMove()
{
float speed = moveSpeed;
if (UnityEngine.Input.GetKey(KeyCode.LeftShift))
speed *= fastMultiplier;
else if (UnityEngine.Input.GetKey(KeyCode.LeftControl))
speed *= slowMultiplier;
Vector3 input =
new Vector3(
UnityEngine.Input.GetAxisRaw("Horizontal"),
0f,
UnityEngine.Input.GetAxisRaw("Vertical")
);
// Vertical up/down
if (UnityEngine.Input.GetKey(KeyCode.E))
input.y += 1f;
if (UnityEngine.Input.GetKey(KeyCode.Q))
input.y -= 1f;
Vector3 worldMove =
transform.TransformDirection(input.normalized) * (speed * Time.deltaTime);
transform.position += worldMove;
}
}
}