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 {number} actorIndex - Index into ActorInfoInit (0-65)
|
||||||
* @param {Array} characters - Parsed character state from save file (66 entries)
|
* @param {Array} characters - Parsed character state from save file (66 entries)
|
||||||
* @param {Map} globalPartsMap - Name→part lookup for global parts
|
* @param {Map} globalPartsMap - Name→part lookup for global parts
|
||||||
* @param {Array} globalTextures - Global texture list from WDB
|
* @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();
|
this.clearModel();
|
||||||
|
|
||||||
const actorInfo = ActorInfoInit[actorIndex];
|
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.modelGroup = new THREE.Group();
|
||||||
this.partGroups = [];
|
this.partGroups = [];
|
||||||
|
this.vehicleGroup = null;
|
||||||
|
this.vehicleInfo = vehicleInfo || null;
|
||||||
|
|
||||||
// Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10])
|
// Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10])
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
@ -164,15 +178,24 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
this.partGroups[i] = partGroup;
|
this.partGroups[i] = partGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create vehicle mesh if vehicle info is provided
|
||||||
|
if (vehicleInfo && vehiclePartsMap) {
|
||||||
|
this.createVehicleMesh(vehicleInfo, vehiclePartsMap);
|
||||||
|
}
|
||||||
|
|
||||||
this.centerAndScaleModel(1.8);
|
this.centerAndScaleModel(1.8);
|
||||||
// Rotate 180° around Y so actor faces the camera (negating X for
|
// Rotate 180° around Y so actor faces the camera (negating X for
|
||||||
// left-to-right-handed conversion flips the facing direction)
|
// left-to-right-handed conversion flips the facing direction)
|
||||||
this.modelGroup.rotation.y = Math.PI;
|
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);
|
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;
|
const mood = charState?.mood ?? 0;
|
||||||
this.loadAnimationForActor(actorIndex, mood);
|
this.loadAnimationForActor(actorIndex, mood, vehicleInfo);
|
||||||
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
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
|
* Center and scale the actor, excluding the hat from the bounding box
|
||||||
* so that changing hats doesn't shift the actor's position.
|
* 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
|
if (i === 1 || !this.partGroups[i]) continue; // skip hat
|
||||||
box.expandByObject(this.partGroups[i]);
|
box.expandByObject(this.partGroups[i]);
|
||||||
}
|
}
|
||||||
|
if (this.vehicleGroup) {
|
||||||
|
box.expandByObject(this.vehicleGroup);
|
||||||
|
}
|
||||||
|
|
||||||
if (box.isEmpty()) {
|
if (box.isEmpty()) {
|
||||||
super.centerAndScaleModel(scaleFactor);
|
super.centerAndScaleModel(scaleFactor);
|
||||||
@ -383,27 +452,40 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
* is queued, plays it first (one-shot), then resumes the walking loop.
|
* is queued, plays it first (one-shot), then resumes the walking loop.
|
||||||
* Otherwise loads the walking animation from the g_cycles table using the
|
* Otherwise loads the walking animation from the g_cycles table using the
|
||||||
* secondary (speed 4.0) variant which NPCs typically use in-game.
|
* 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.
|
* 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 (!this.modelGroup) return;
|
||||||
|
|
||||||
// If a click animation is queued, play it first, then resume walking
|
// Use stored vehicleInfo when not explicitly provided (e.g. resuming after click anim)
|
||||||
if (this._queuedClickMove !== null) {
|
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;
|
const move = this._queuedClickMove;
|
||||||
this._queuedClickMove = null;
|
this._queuedClickMove = null;
|
||||||
await this.playClickAnimation(move, actorIndex, mood);
|
await this.playClickAnimation(move, actorIndex, mood);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._queuedClickMove = null;
|
||||||
|
|
||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
|
|
||||||
|
let animName;
|
||||||
|
if (vehicleInfo) {
|
||||||
|
// Vehicle mode: use the vehicle animation name
|
||||||
|
animName = vehicleInfo.vehicleAnim;
|
||||||
|
} else {
|
||||||
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
|
const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
|
||||||
// Use secondary animation (speed 4.0 threshold) — this is what NPCs use in-game
|
// 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
|
// since their walking speed (0.6-2.0) exceeds the 0.7 primary threshold
|
||||||
const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood);
|
const secondaryCol = ActorRenderer.getSecondaryAnimColumn(mood);
|
||||||
const primaryCol = 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
|
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);
|
const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup);
|
||||||
if (tracks.length === 0) return;
|
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
|
* 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.
|
* m_move value (0-3). After it finishes, the walking animation resumes.
|
||||||
@ -761,6 +874,8 @@ export class ActorRenderer extends BaseRenderer {
|
|||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
super.clearModel();
|
super.clearModel();
|
||||||
this.partGroups = [];
|
this.partGroups = [];
|
||||||
|
this.vehicleGroup = null;
|
||||||
|
this.vehicleInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
|||||||
@ -665,6 +665,32 @@ export const ActorDisplayNames = Object.freeze([
|
|||||||
/* 65 */ 'boatman'
|
/* 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.
|
* Save file field offsets within the 16-byte character record.
|
||||||
* The save file stores these per-character values that override defaults:
|
* The save file stores these per-character values that override defaults:
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
|
import { ActorRenderer } from '../../core/rendering/ActorRenderer.js';
|
||||||
import { WdbParser, buildGlobalPartsMap } from '../../core/formats/WdbParser.js';
|
import { WdbParser, buildGlobalPartsMap, buildPartsMap, resolveLods } from '../../core/formats/WdbParser.js';
|
||||||
import { ActorInfoInit, ActorPart, ActorDisplayNames } from '../../core/savegame/actorConstants.js';
|
import { ActorInfoInit, ActorPart, ActorDisplayNames, ActorVehicles, VehicleDisplayNames } from '../../core/savegame/actorConstants.js';
|
||||||
import { Actor } from '../../core/savegame/constants.js';
|
import { Actor } from '../../core/savegame/constants.js';
|
||||||
import NavButton from '../NavButton.svelte';
|
import NavButton from '../NavButton.svelte';
|
||||||
import ResetButton from '../ResetButton.svelte';
|
import ResetButton from '../ResetButton.svelte';
|
||||||
@ -19,13 +19,18 @@
|
|||||||
// Cached WDB data
|
// Cached WDB data
|
||||||
let globalPartsMap = null;
|
let globalPartsMap = null;
|
||||||
let globalTextures = null;
|
let globalTextures = null;
|
||||||
|
let vehiclePartsMap = null;
|
||||||
|
let vehicleTextures = null;
|
||||||
|
|
||||||
let actorIndex = 0;
|
let actorIndex = 0;
|
||||||
let loadedActorKey = null;
|
let loadedActorKey = null;
|
||||||
|
let showVehicle = false;
|
||||||
|
|
||||||
$: actorInfo = ActorInfoInit[actorIndex];
|
$: actorInfo = ActorInfoInit[actorIndex];
|
||||||
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
$: actorName = ActorDisplayNames[actorIndex] || actorInfo?.name || 'Unknown';
|
||||||
$: charState = slot?.characters?.[actorIndex];
|
$: charState = slot?.characters?.[actorIndex];
|
||||||
|
$: vehicleInfo = ActorVehicles[actorIndex] || null;
|
||||||
|
$: vehicleName = vehicleInfo ? VehicleDisplayNames[vehicleInfo.vehicleModel] : null;
|
||||||
|
|
||||||
$: isDefault = actorInfo && charState &&
|
$: isDefault = actorInfo && charState &&
|
||||||
charState.sound === actorInfo.sound &&
|
charState.sound === actorInfo.sound &&
|
||||||
@ -39,8 +44,8 @@
|
|||||||
charState.leglftNameIndex === actorInfo.parts[8].nameIndex &&
|
charState.leglftNameIndex === actorInfo.parts[8].nameIndex &&
|
||||||
charState.legrtNameIndex === actorInfo.parts[9].nameIndex;
|
charState.legrtNameIndex === actorInfo.parts[9].nameIndex;
|
||||||
|
|
||||||
function actorKey(slotNumber, idx, cs) {
|
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}`;
|
return `${slotNumber}-${idx}-${cs.hatPartNameIndex}-${cs.hatNameIndex}-${cs.infogronNameIndex}-${cs.armlftNameIndex}-${cs.armrtNameIndex}-${cs.leglftNameIndex}-${cs.legrtNameIndex}-${cs.mood}-${vehicle}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -65,6 +70,55 @@
|
|||||||
throw new Error('No global parts found in WORLD.WDB');
|
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);
|
renderer = new ActorRenderer(canvas);
|
||||||
loadCurrentActor();
|
loadCurrentActor();
|
||||||
renderer.start();
|
renderer.start();
|
||||||
@ -80,9 +134,9 @@
|
|||||||
renderer?.dispose();
|
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 (renderer && !loading && actorInfo && charState) {
|
||||||
if (actorKey(slot?.slotNumber, actorIndex, charState) !== loadedActorKey) {
|
if (actorKey(slot?.slotNumber, actorIndex, charState, showVehicle) !== loadedActorKey) {
|
||||||
loadCurrentActor();
|
loadCurrentActor();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,17 +144,25 @@
|
|||||||
function loadCurrentActor() {
|
function loadCurrentActor() {
|
||||||
if (!renderer || !globalPartsMap || !slot?.characters) return;
|
if (!renderer || !globalPartsMap || !slot?.characters) return;
|
||||||
|
|
||||||
renderer.loadActor(actorIndex, slot.characters, globalPartsMap, globalTextures);
|
const activeVehicle = showVehicle ? vehicleInfo : null;
|
||||||
loadedActorKey = actorKey(slot?.slotNumber, actorIndex, slot.characters[actorIndex]);
|
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() {
|
function prevActor() {
|
||||||
actorIndex = actorIndex > 0 ? actorIndex - 1 : ActorInfoInit.length - 1;
|
actorIndex = actorIndex > 0 ? actorIndex - 1 : ActorInfoInit.length - 1;
|
||||||
|
showVehicle = false;
|
||||||
loadedActorKey = null;
|
loadedActorKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextActor() {
|
function nextActor() {
|
||||||
actorIndex = actorIndex < ActorInfoInit.length - 1 ? actorIndex + 1 : 0;
|
actorIndex = actorIndex < ActorInfoInit.length - 1 ? actorIndex + 1 : 0;
|
||||||
|
showVehicle = false;
|
||||||
loadedActorKey = null;
|
loadedActorKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +317,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<NavButton direction="right" onclick={nextActor} />
|
<NavButton direction="right" onclick={nextActor} />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="reset-container">
|
<div class="reset-container">
|
||||||
@ -320,6 +395,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.part-nav-wrapper {
|
.part-nav-wrapper {
|
||||||
|
position: relative;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,6 +424,31 @@
|
|||||||
color: var(--color-text-light);
|
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 {
|
.reset-container {
|
||||||
height: 1.6em;
|
height: 1.6em;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user