Add vehicle rendering to actor editor

Actors with personal vehicles (skateboard, motorcycles, bicycles) can
now be toggled between walking and vehicle mode via a button in the
actor navigation bar. Vehicle geometries are loaded from WDB world
models and rendered alongside the character with matching animations.
This commit is contained in:
Christian Semmler 2026-02-13 15:16:43 -08:00
parent fe87b3d99f
commit 6cfe385070
3 changed files with 263 additions and 21 deletions

View File

@ -98,13 +98,16 @@ export class ActorRenderer extends BaseRenderer {
}
/**
* Load a full actor from global parts.
* Load a full actor from global parts, optionally with a vehicle.
* @param {number} actorIndex - Index into ActorInfoInit (0-65)
* @param {Array} characters - Parsed character state from save file (66 entries)
* @param {Map} globalPartsMap - Namepart lookup for global parts
* @param {Array} globalTextures - Global texture list from WDB
* @param {Map|null} vehiclePartsMap - Namepart lookup for vehicle parts (null if no vehicle)
* @param {Array|null} vehicleTextures - Vehicle texture list (null if no vehicle)
* @param {object|null} vehicleInfo - { vehicleModel, vehicleAnim } or null
*/
loadActor(actorIndex, characters, globalPartsMap, globalTextures) {
loadActor(actorIndex, characters, globalPartsMap, globalTextures, vehiclePartsMap, vehicleTextures, vehicleInfo) {
this.clearModel();
const actorInfo = ActorInfoInit[actorIndex];
@ -118,8 +121,19 @@ export class ActorRenderer extends BaseRenderer {
}
}
// Merge vehicle textures (if present)
if (vehicleInfo && vehicleTextures) {
for (const tex of vehicleTextures) {
if (tex.name && !this.textures.has(tex.name.toLowerCase())) {
this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
}
}
}
this.modelGroup = new THREE.Group();
this.partGroups = [];
this.vehicleGroup = null;
this.vehicleInfo = vehicleInfo || null;
// Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10])
for (let i = 0; i < 10; i++) {
@ -164,15 +178,24 @@ export class ActorRenderer extends BaseRenderer {
this.partGroups[i] = partGroup;
}
// Create vehicle mesh if vehicle info is provided
if (vehicleInfo && vehiclePartsMap) {
this.createVehicleMesh(vehicleInfo, vehiclePartsMap);
}
this.centerAndScaleModel(1.8);
// Rotate 180° around Y so actor faces the camera (negating X for
// left-to-right-handed conversion flips the facing direction)
this.modelGroup.rotation.y = Math.PI;
// Shift model up in vehicle mode so it's better framed
if (this.vehicleGroup) {
this.modelGroup.position.y += 0.2;
}
this.scene.add(this.modelGroup);
// Load and start walking animation based on mood
// Load and start walking/vehicle animation based on mood
const mood = charState?.mood ?? 0;
this.loadAnimationForActor(actorIndex, mood);
this.loadAnimationForActor(actorIndex, mood, vehicleInfo);
this.renderer.render(this.scene, this.camera);
}
@ -284,6 +307,49 @@ export class ActorRenderer extends BaseRenderer {
}
}
/**
* Create vehicle mesh from WDB model ROIs and add to modelGroup.
* vehiclePartsMap maps model name array of { name, lods }.
*/
createVehicleMesh(vehicleInfo, vehiclePartsMap) {
const rois = vehiclePartsMap.get(vehicleInfo.vehicleModel.toLowerCase());
if (!rois || rois.length === 0) return;
this.vehicleGroup = new THREE.Group();
this.vehicleGroup.name = `vehicle_${vehicleInfo.vehicleModel}`;
for (const roi of rois) {
const lods = roi.lods || [];
if (lods.length === 0) continue;
const lod = lods[lods.length - 1]; // Highest quality
for (const mesh of lod.meshes) {
const geometry = this.createGeometry(mesh, lod);
if (!geometry) continue;
let material;
const meshTexName = mesh.properties?.textureName?.toLowerCase();
if (meshTexName && this.textures.has(meshTexName)) {
material = new THREE.MeshLambertMaterial({
map: this.textures.get(meshTexName),
side: THREE.DoubleSide,
color: 0xffffff
});
} else {
const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
material = new THREE.MeshLambertMaterial({
color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
side: THREE.DoubleSide
});
}
this.vehicleGroup.add(new THREE.Mesh(geometry, material));
}
}
this.modelGroup.add(this.vehicleGroup);
}
/**
* Center and scale the actor, excluding the hat from the bounding box
* so that changing hats doesn't shift the actor's position.
@ -296,6 +362,9 @@ export class ActorRenderer extends BaseRenderer {
if (i === 1 || !this.partGroups[i]) continue; // skip hat
box.expandByObject(this.partGroups[i]);
}
if (this.vehicleGroup) {
box.expandByObject(this.vehicleGroup);
}
if (box.isEmpty()) {
super.centerAndScaleModel(scaleFactor);
@ -383,27 +452,40 @@ export class ActorRenderer extends BaseRenderer {
* is queued, plays it first (one-shot), then resumes the walking loop.
* Otherwise loads the walking animation from the g_cycles table using the
* secondary (speed 4.0) variant which NPCs typically use in-game.
* When vehicleInfo is provided, uses the vehicle animation instead.
* Falls back to Y-axis rotation if unavailable.
*/
async loadAnimationForActor(actorIndex, mood = 0) {
async loadAnimationForActor(actorIndex, mood = 0, vehicleInfo = undefined) {
if (!this.modelGroup) return;
// If a click animation is queued, play it first, then resume walking
if (this._queuedClickMove !== null) {
// Use stored vehicleInfo when not explicitly provided (e.g. resuming after click anim)
if (vehicleInfo === undefined) {
vehicleInfo = this.vehicleInfo;
}
// If a click animation is queued (skip in vehicle mode), play it first
if (this._queuedClickMove !== null && !vehicleInfo) {
const move = this._queuedClickMove;
this._queuedClickMove = null;
await this.playClickAnimation(move, actorIndex, mood);
return;
}
this._queuedClickMove = null;
this.stopAnimation();
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
// Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game
// since their walking speed (0.6-2.0) exceeds the 0.7 primary threshold
const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood);
const primaryCol = mood;
const animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol];
let animName;
if (vehicleInfo) {
// Vehicle mode: use the vehicle animation name
animName = vehicleInfo.vehicleAnim;
} else {
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
// Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game
// since their walking speed (0.6-2.0) exceeds the 0.7 primary threshold
const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood);
const primaryCol = mood;
animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol];
}
if (!animName) return; // null entry in g_cycles — no animation for this combo
@ -423,6 +505,11 @@ export class ActorRenderer extends BaseRenderer {
}
}
// Map vehicle animation nodes if in vehicle mode
if (vehicleInfo && this.vehicleGroup) {
this.mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup);
}
const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup);
if (tracks.length === 0) return;
@ -435,6 +522,32 @@ export class ActorRenderer extends BaseRenderer {
}
}
/**
* Map vehicle animation tree nodes to the vehicle group.
* Scans the animation tree for nodes whose name (stripped of trailing
* digits/underscores) matches the vehicle model name, and maps them
* to the vehicleGroup so buildHierarchicalTracks can drive the vehicle.
*/
mapVehicleAnimNodes(animData, vehicleInfo, nodeToPartGroup) {
const vehicleName = vehicleInfo.vehicleModel.toLowerCase();
const scanTree = (node) => {
const name = node.data.name?.toLowerCase();
if (name) {
// Strip trailing digits and underscores to get base name
const baseName = name.replace(/[\d_]+$/, '');
if (baseName === vehicleName) {
nodeToPartGroup.set(name, this.vehicleGroup);
}
}
for (const child of node.children) {
scanTree(child);
}
};
scanTree(animData.rootNode);
}
/**
* Play a one-shot click animation (pose/gesture) determined by the actor's
* m_move value (0-3). After it finishes, the walking animation resumes.
@ -761,6 +874,8 @@ export class ActorRenderer extends BaseRenderer {
this.stopAnimation();
super.clearModel();
this.partGroups = [];
this.vehicleGroup = null;
this.vehicleInfo = null;
}
start() {

View File

@ -665,6 +665,32 @@ export const ActorDisplayNames = Object.freeze([
/* 65 */ 'boatman'
]);
/**
* Vehicle associations for actors. Maps ActorInfoInit index -> vehicle info.
* From g_characters[].m_vehicleId and g_vehicles[] in legoanimationmanager.cpp.
* vehicleAnim = g_cycles[row][10] for each character.
*/
export const ActorVehicles = Object.freeze({
0: { vehicleModel: 'board', vehicleAnim: 'CNs001Sk' }, // pepper -> skateboard
3: { vehicleModel: 'motoni', vehicleAnim: 'CNs011Ni' }, // nick -> motorcycle
4: { vehicleModel: 'motola', vehicleAnim: 'CNs011La' }, // laura -> motorcycle
37: { vehicleModel: 'bikerd', vehicleAnim: 'CNs001Rd' }, // rd -> bicycle
38: { vehicleModel: 'bikepg', vehicleAnim: 'CNs001Pg' }, // pg -> bicycle
39: { vehicleModel: 'bikebd', vehicleAnim: 'CNs001Bd' }, // bd -> bicycle
40: { vehicleModel: 'bikesy', vehicleAnim: 'CNs001Sy' }, // sy -> bicycle
56: { vehicleModel: 'board', vehicleAnim: 'CNs001Sk' }, // pep (pepper alias)
});
export const VehicleDisplayNames = Object.freeze({
'board': 'Skateboard',
'motoni': 'Motorcycle',
'motola': 'Motorcycle',
'bikebd': 'Bicycle',
'bikepg': 'Bicycle',
'bikerd': 'Bicycle',
'bikesy': 'Bicycle',
});
/**
* Save file field offsets within the 16-byte character record.
* The save file stores these per-character values that override defaults:

View File

@ -1,8 +1,8 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
import { WdbParser, buildGlobalPartsMap } from '../../core/formats/WdbParser.js';
import { ActorInfoInit, ActorPart, ActorDisplayNames } from '../../core/savegame/actorConstants.js';
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
import { Actor } from '../../core/savegame/constants.js';
import NavButton from '../NavButton.svelte';
import ResetButton from '../ResetButton.svelte';
@ -19,13 +19,18 @@
// Cached WDB data
let globalPartsMap = null;
let globalTextures = null;
let vehiclePartsMap = null;
let vehicleTextures = null;
let actorIndex = 0;
let loadedActorKey = null;
let showVehicle = false;
$: actorInfo = ActorInfoInit[actorIndex];
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
$: charState = slot?.characters?.[actorIndex];
$: vehicleInfo = ActorVehicles[actorIndex] || null;
$: vehicleName = vehicleInfo ? VehicleDisplayNames[vehicleInfo.vehicleModel] : null;
$: isDefault = actorInfo && charState &&
charState.sound === actorInfo.sound &&
@ -39,8 +44,8 @@
charState.leglftNameIndex === actorInfo.parts[8].nameIndex &&
charState.legrtNameIndex === actorInfo.parts[9].nameIndex;
function actorKey(slotNumber, idx, cs) {
return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}`;
function actorKey(slotNumber, idx, cs, vehicle) {
return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}-${vehicle}`;
}
onMount(async () => {
@ -65,6 +70,55 @@
throw new Error('No global parts found in WORLD.WDB');
}
// Collect vehicle model names needed
const neededVehicles = new Set();
for (const v of Object.values(ActorVehicles)) {
neededVehicles.add(v.vehicleModel.toLowerCase());
}
// Vehicle geometries are stored as models (not parts) in WDB worlds.
// Scan worlds for matching model entries, parse model data, and
// collect all ROIs (root + children) with resolved LODs.
const vModelsMap = new Map();
const vTextures = [];
for (const world of wdbData.worlds) {
let worldPartsMap = null;
for (const model of world.models) {
const modelKey = model.name.toLowerCase();
if (!neededVehicles.has(modelKey) || vModelsMap.has(modelKey)) continue;
const modelData = wdbParser.parseModelData(model.dataOffset);
const roi = modelData.roi;
if (!roi) continue;
// Build world parts map lazily (needed for shared LOD resolution)
if (!worldPartsMap) {
worldPartsMap = buildPartsMap(wdbParser, world.parts);
}
// Collect all renderable ROIs (root + children recursively)
const rois = [];
const collectRois = (node) => {
const lods = resolveLods(node, worldPartsMap);
if (lods.length > 0) {
rois.push({ name: node.name, lods });
}
for (const child of node.children || []) {
collectRois(child);
}
};
collectRois(roi);
if (rois.length > 0) {
vModelsMap.set(modelKey, rois);
}
if (modelData.textures) {
vTextures.push(...modelData.textures);
}
}
}
vehiclePartsMap = vModelsMap;
vehicleTextures = vTextures;
renderer = new ActorRenderer(canvas);
loadCurrentActor();
renderer.start();
@ -80,9 +134,9 @@
renderer?.dispose();
});
// Reload actor when index or character state changes
// Reload actor when index, character state, or vehicle toggle changes
$: if (renderer && !loading && actorInfo && charState) {
if (actorKey(slot?.slotNumber, actorIndex, charState) !== loadedActorKey) {
if (actorKey(slot?.slotNumber, actorIndex, charState, showVehicle) !== loadedActorKey) {
loadCurrentActor();
}
}
@ -90,17 +144,25 @@
function loadCurrentActor() {
if (!renderer || !globalPartsMap || !slot?.characters) return;
renderer.loadActor(actorIndex, slot.characters, globalPartsMap, globalTextures);
loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex]);
const activeVehicle = showVehicle ? vehicleInfo : null;
renderer.loadActor(
actorIndex, slot.characters, globalPartsMap, globalTextures,
activeVehicle ? vehiclePartsMap : null,
activeVehicle ? vehicleTextures : null,
activeVehicle
);
loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex], showVehicle);
}
function prevActor() {
actorIndex = actorIndex > 0 ? actorIndex - 1 : ActorInfoInit.length - 1;
showVehicle = false;
loadedActorKey = null;
}
function nextActor() {
actorIndex = actorIndex < ActorInfoInit.length - 1 ? actorIndex + 1 : 0;
showVehicle = false;
loadedActorKey = null;
}
@ -255,6 +317,19 @@
</div>
<NavButton direction="right" onclick={nextActor} />
</div>
{#if vehicleInfo}
<button
type="button"
class="vehicle-toggle-btn"
class:active={showVehicle}
onclick={() => { showVehicle = !showVehicle; }}
title={showVehicle ? 'Show without vehicle' : `Show with ${vehicleName}`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm8 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zM5.5 11h5V9.5L13 8l-1-3H4L2.5 8l2.5 1.5V11h.5z"/>
</svg>
</button>
{/if}
</div>
<div class="reset-container">
@ -320,6 +395,7 @@
}
.part-nav-wrapper {
position: relative;
margin-top: 10px;
}
@ -348,6 +424,31 @@
color: var(--color-text-light);
}
.vehicle-toggle-btn {
position: absolute;
right: -36px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--color-bg-input);
border: 1px solid var(--color-border-medium);
border-radius: 6px;
color: var(--color-text-light);
cursor: pointer;
transition: all 0.2s ease;
}
.vehicle-toggle-btn:hover,
.vehicle-toggle-btn.active {
border-color: var(--color-primary);
color: var(--color-primary);
}
.reset-container {
height: 1.6em;
}