mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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:
parent
fe87b3d99f
commit
6cfe385070
@ -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 - Name→part lookup for global parts
|
||||
* @param {Array} globalTextures - Global texture list from WDB
|
||||
* @param {Map|null} vehiclePartsMap - Name→part 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();
|
||||
|
||||
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;
|
||||
const animName = G_CYCLES[suffixIdx]?.[secondaryCol] ?? G_CYCLES[suffixIdx]?.[primaryCol];
|
||||
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() {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user