Files
EVEngine/examples/level-designer/level_spawn.nut
T
西风逍遥游 87a1ec52c4 Add a level design tool example with terrain references and play mode
Introduce examples/level-designer: a 3D level design tool assembled from the
existing composable editor, procgen, physics and animation components.

- Terrain is referenced rather than embedded. Levels either regenerate it from
  sampler parameters, or point at a terrain asset on disk. Both encodings the
  runtime persists are decodable: the chunked EVTR archive and the raw EVTRN
  float32 heightfield written next to an eve.terrain/1 asset.
- Whitebox pieces come from the ProcgenKit recipe registry (75 prototype
  pieces). A level stores only the recipe id plus parameter values, so the mesh,
  renderable and play-mode collider are all derived and no geometry is
  duplicated into the level file. Piece parameters are driven by the recipe's
  own schema.
- Player spawn points are ordinary scene nodes carrying facing plus the
  character and motion set play mode should start with.
- Play mode builds a runtime world from the same authored data (terrain height
  field plus one static box per piece) and drives the player with the Motion
  Matching controller, using capsule collision for movement and a third-person
  follow camera. Authored data is never mutated by a play session.

Editor view control is an orbit camera around a pannable focus point: right
drag orbits, middle drag pans along the view's ground basis, Q/E zoom, and F
focuses the selection or frames the whole terrain. Play-mode view is the
third-person follow camera; camera yaw is also the locomotion basis, so mouse
look steers where WASD runs.

Add procgen terrain file decoding:

- TerrainFile.h/.cpp decode EVTR and EVTRN into a Heightmap plus spacing, height
  range and format metadata. Failures return structured diagnostics
  (not_found, parse_error, unsupported) and never a partially filled grid.
- procgen.loadTerrainFile(path, format) and procgen.loadTerrainBytes(bytes,
  format) expose it to script through the existing owning-handle proxy path.
  EVTR stores no metres-per-cell, so hasSpacing reports whether the decoded
  spacing is authoritative.

Verification: examples/level-designer/smoke.nut drives the live engine over MCP
(56 checks: terrain sampling and snapping, whitebox placement and parameter
rebuild, spawn resolution, panel construction in every tool state, play-mode
assembly and landing on the height field, camera-relative locomotion, character
and motion set switching, level document round trip and rejection, asset
terrain loading and rebuild atomicity). The new decoder has a unit test with 58
assertions covering both encodings, magic mismatches, truncated payloads and
dimension/size disagreement.
2026-09-10 23:22:28 +08:00

97 lines
3.4 KiB
Plaintext

// Player spawn points.
//
// A spawn point is an ordinary scene node (so it moves with the same gizmo and
// participates in undo/hierarchy like everything else) carrying designer metadata
// the level file persists: facing yaw plus the character and motion set play mode
// should use when the level starts there.
function spawnDefault() {
// Squirrel throws when reading an absent table slot, so every derived field a
// spawn record may touch is declared up front.
return {yaw = 0.0, character = "", motionSet = "", entity = null, facing = null};
}
function spawnBuildMarker(spawn, object) {
if (spawn.entity == null) {
local column = eve.Renderable3D();
column.setMesh(gfx.newMeshCylinder(18, 1, true));
column.setTint(0.30, 0.85, 0.95, 1.0);
column.setRoughness(0.55);
column.setCastShadow(true);
spawn.entity = column;
local facing = eve.Renderable3D();
facing.setMesh(gfx.newMeshCube(1.0));
facing.setTint(1.0, 0.85, 0.25, 1.0);
facing.setRoughness(0.5);
spawn.facing = facing;
}
spawn.entity.setVisible(true);
spawn.facing.setVisible(true);
}
function spawnApplyTransform(spawn, object) {
if (spawn.entity == null || object == null) return;
// `base` is a Squirrel keyword, so the node origin is named explicitly.
local origin = scene.localToWorldAt(levelHost(), object.id, 0.0, 0.0, 0.0);
spawn.entity.setPosition(origin[0], origin[1] + 0.85, origin[2]);
spawn.entity.setScale(0.34, 0.85, 0.34);
spawn.entity.setYaw(spawn.yaw);
// The nose cube shows the facing the player will be spawned with.
local nose = scene.localToWorldAt(levelHost(), object.id,
0.55 * sin(spawn.yaw), 0.55, 0.55 * cos(spawn.yaw));
spawn.facing.setPosition(nose[0], nose[1], nose[2]);
spawn.facing.setScale(0.30, 0.30, 0.55);
spawn.facing.setYaw(spawn.yaw);
}
function spawnPlace(worldX, worldY, worldZ, yaw) {
local id = levelNextId("spawn");
local placed = levelCommand("scene.object.create.v1", {
object = id,
name = "PlayerStart " + level.serial,
parent = levelObject("root") != null ? "root" : "",
position = [worldX, worldY, worldZ]
});
if (!placed) return "";
local spawn = spawnDefault();
spawn.yaw = yaw;
level.spawns[id] <- spawn;
spawnBuildMarker(spawn, levelObject(id));
spawnApplyTransform(spawn, levelObject(id));
level.selected = id;
levelSyncGizmo();
level.status = "placed " + (levelObject(id) != null ? levelObject(id).name : id);
return id;
}
function spawnReconcile() {
local stale = [];
foreach (id, spawn in level.spawns)
if (levelObject(id) == null) stale.push(id);
foreach (id in stale) {
local spawn = level.spawns[id];
if (spawn.entity != null) spawn.entity.setVisible(false);
if (spawn.facing != null) spawn.facing.setVisible(false);
delete level.spawns[id];
}
}
function spawnSyncTransforms() {
foreach (id, spawn in level.spawns) spawnApplyTransform(spawn, levelObject(id));
}
function spawnCount() {
local total = 0;
foreach (id, spawn in level.spawns) total += 1;
return total;
}
/** @brief The spawn play mode starts from: a selected one, else the first. */
function spawnResolveStart() {
if (level.selected != "" && (level.selected in level.spawns)) return level.selected;
foreach (id, spawn in level.spawns) return id;
return "";
}