mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-08 21:24:32 +00:00
fix additive animations
This commit is contained in:
parent
bb654b3667
commit
814b0af9c6
205 changed files with 279 additions and 73 deletions
|
|
@ -34,6 +34,10 @@
|
|||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.Button:not(:has(.ButtonLabel)) svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.Button:disabled {
|
||||
opacity: 0.6;
|
||||
box-shadow: inset 0 0 12px 1px rgba(54, 54, 54, 0.5);
|
||||
|
|
@ -48,11 +52,13 @@
|
|||
border-right: 1px solid rgba(200, 200, 200, 0.3);
|
||||
border-bottom: 1px solid rgba(200, 200, 200, 0.3);
|
||||
background: rgba(3, 82, 147, 0.6);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.Button:not(:disabled):hover {
|
||||
background: rgba(0, 98, 179, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.Button svg {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ export function CopyCoordinatesButton({
|
|||
if (!camera) return;
|
||||
const hash = encodeViewHash(camera);
|
||||
const params = new URLSearchParams();
|
||||
params.set("mission", `${missionName}~${missionType}`);
|
||||
const missionString = missionType
|
||||
? `${missionName}~${missionType}`
|
||||
: missionName;
|
||||
params.set("mission", missionString);
|
||||
params.set("fog", fogEnabled.toString());
|
||||
const fullPath = `${window.location.pathname}?${params}${hash}`;
|
||||
const fullUrl = `${window.location.origin}${fullPath}`;
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@
|
|||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
background: rgba(3, 82, 147, 0.6);
|
||||
color: #fff;
|
||||
cursor: pointer; */
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
MeshBasicMaterial,
|
||||
MeshLambertMaterial,
|
||||
AdditiveBlending,
|
||||
AdditiveAnimationBlendMode,
|
||||
AnimationMixer,
|
||||
AnimationClip,
|
||||
LoopOnce,
|
||||
|
|
@ -46,8 +47,25 @@ import {
|
|||
} from "../stream/playbackUtils";
|
||||
import type { ThreadState as StreamThreadState } from "../stream/types";
|
||||
|
||||
/** Shape entity data readable in useFrame for streaming mode. */
|
||||
interface StreamShapeEntity {
|
||||
threads?: StreamThreadState[];
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
steeringYaw?: number;
|
||||
frozen?: boolean;
|
||||
maxSteeringAngle?: number;
|
||||
}
|
||||
|
||||
const log = createLogger("GenericShape");
|
||||
|
||||
/** WheeledVehicle per-wheel animation state (position-controlled, not threaded). */
|
||||
interface WheelAnimState {
|
||||
wheelAction?: AnimationAction;
|
||||
springAction?: AnimationAction;
|
||||
turnAction?: AnimationAction;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
/** Returns pausable time in seconds for demo mode, real time otherwise. */
|
||||
function shapeNowSec(): number {
|
||||
const { recording } = engineStore.getState().playback;
|
||||
|
|
@ -461,8 +479,8 @@ export const ShapeRenderer = memo(function ShapeRenderer({
|
|||
children,
|
||||
}: {
|
||||
loadingColor?: string;
|
||||
/** Stable entity reference whose `.threads` field is mutated in-place. */
|
||||
streamEntity?: { threads?: StreamThreadState[] };
|
||||
/** Stable entity reference whose fields are mutated in-place. */
|
||||
streamEntity?: StreamShapeEntity;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { object, shapeName } = useShapeInfo();
|
||||
|
|
@ -514,8 +532,8 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
streamEntity,
|
||||
}: {
|
||||
gltf: ReturnType<typeof useStaticShape>;
|
||||
/** Stable entity reference whose `.threads` field is mutated in-place. */
|
||||
streamEntity?: { threads?: StreamThreadState[] };
|
||||
/** Stable entity reference whose fields are mutated in-place. */
|
||||
streamEntity?: StreamShapeEntity;
|
||||
}) {
|
||||
const { object, shapeName } = useShapeInfo();
|
||||
const { debugMode } = useDebug();
|
||||
|
|
@ -656,6 +674,8 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
const animationEnabledRef = useRef(animationEnabled);
|
||||
animationEnabledRef.current = animationEnabled;
|
||||
|
||||
const wheelAnimsRef = useRef<WheelAnimState[] | null>(null);
|
||||
|
||||
// Stream entity reference for imperative thread reads in useFrame.
|
||||
// The entity is mutated in-place, so reading streamEntity?.threads
|
||||
// always returns the latest value without requiring React re-renders.
|
||||
|
|
@ -698,24 +718,28 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
}, [iflMeshes]);
|
||||
|
||||
// DTS cyclic flags by sequence name. Cyclic sequences loop; non-cyclic
|
||||
// play once and clamp. Falls back to assuming cyclic if data is absent.
|
||||
const seqCyclicByName = useMemo(() => {
|
||||
const map = new Map<string, boolean>();
|
||||
// DTS sequence flags by name, parsed from glTF extras.
|
||||
const { seqCyclicByName, seqBlendByName } = useMemo(() => {
|
||||
const cycMap = new Map<string, boolean>();
|
||||
const blendMap = new Map<string, boolean>();
|
||||
const rawNames = gltf.scene.userData?.dts_sequence_names;
|
||||
const rawCyclic = gltf.scene.userData?.dts_sequence_cyclic;
|
||||
if (typeof rawNames === "string" && typeof rawCyclic === "string") {
|
||||
const rawBlend = gltf.scene.userData?.dts_sequence_blend;
|
||||
if (typeof rawNames === "string") {
|
||||
try {
|
||||
const names: string[] = JSON.parse(rawNames);
|
||||
const cyclic: boolean[] = JSON.parse(rawCyclic);
|
||||
const cyclic: boolean[] = typeof rawCyclic === "string" ? JSON.parse(rawCyclic) : [];
|
||||
const blend: boolean[] = typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
map.set(names[i].toLowerCase(), cyclic[i] ?? true);
|
||||
const lower = names[i].toLowerCase();
|
||||
cycMap.set(lower, cyclic[i] ?? true);
|
||||
if (blend[i]) blendMap.set(lower, true);
|
||||
}
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
}
|
||||
return map;
|
||||
return { seqCyclicByName: cycMap, seqBlendByName: blendMap };
|
||||
}, [gltf]);
|
||||
|
||||
// Animation setup.
|
||||
|
|
@ -768,6 +792,12 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
}
|
||||
// Blend sequences (DTS flag 0x8) are delta transforms multiplied
|
||||
// onto the existing pose. Use Three.js additive blending so they
|
||||
// composite on top of non-blend threads (e.g. Deploy on Ambient).
|
||||
if (seqBlendByName.has(seqLower)) {
|
||||
action.blendMode = AdditiveAnimationBlendMode;
|
||||
}
|
||||
action.reset().play();
|
||||
thread.action = action;
|
||||
}
|
||||
|
|
@ -857,11 +887,57 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
}
|
||||
|
||||
// Set up WheeledVehicle wheel/spring/turn animations.
|
||||
// These are position-controlled (setPos) not thread-controlled.
|
||||
// Detect by checking if wheel0/spring0 clips exist.
|
||||
if (mixer && clipsByName.has("wheel0")) {
|
||||
const wheelAnims: WheelAnimState[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const state: WheelAnimState = { rotation: 0 };
|
||||
const wheelClip = clipsByName.get(`wheel${i}`);
|
||||
if (wheelClip) {
|
||||
const action = mixer.clipAction(wheelClip);
|
||||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
action.paused = true;
|
||||
action.play();
|
||||
state.wheelAction = action;
|
||||
}
|
||||
const springClip = clipsByName.get(`spring${i}`);
|
||||
if (springClip) {
|
||||
const action = mixer.clipAction(springClip);
|
||||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
action.paused = true;
|
||||
action.play();
|
||||
// Rest position: springs at full extension (pos=0 in Torque).
|
||||
action.time = 0;
|
||||
state.springAction = action;
|
||||
}
|
||||
const turnClip = clipsByName.get(`turn${i}`);
|
||||
if (turnClip) {
|
||||
const action = mixer.clipAction(turnClip);
|
||||
action.setLoop(LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
action.paused = true;
|
||||
action.play();
|
||||
// Center (straight ahead).
|
||||
action.time = turnClip.duration * 0.5;
|
||||
state.turnAction = action;
|
||||
}
|
||||
wheelAnims.push(state);
|
||||
}
|
||||
wheelAnimsRef.current = wheelAnims;
|
||||
} else {
|
||||
wheelAnimsRef.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((fn) => fn());
|
||||
handlePlayThreadRef.current = null;
|
||||
handleStopThreadRef.current = null;
|
||||
prevDemoThreadsRef.current = undefined;
|
||||
wheelAnimsRef.current = null;
|
||||
for (const slot of [...threads.keys()]) handleStopThread(slot);
|
||||
};
|
||||
}, [
|
||||
|
|
@ -869,6 +945,7 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
clipsByName,
|
||||
visNodesBySequence,
|
||||
seqCyclicByName,
|
||||
seqBlendByName,
|
||||
object,
|
||||
runtime,
|
||||
]);
|
||||
|
|
@ -963,6 +1040,19 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
if (!seqName) continue;
|
||||
if (t.state === 0) {
|
||||
playThread(slot, seqName);
|
||||
// If the animation is already finished (atEnd=true on first
|
||||
// appearance — e.g. a deployed MPB entering scope), snap to
|
||||
// the end pose immediately instead of replaying it.
|
||||
if (t.atEnd) {
|
||||
const thread = threads.get(slot);
|
||||
if (thread?.action) {
|
||||
const clip = thread.action.getClip();
|
||||
thread.action.time = t.forward ? clip.duration : 0;
|
||||
thread.action.setLoop(LoopOnce, 1);
|
||||
thread.action.clampWhenFinished = true;
|
||||
thread.action.paused = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopThread(slot);
|
||||
}
|
||||
|
|
@ -1005,6 +1095,46 @@ export const ShapeModel = memo(function ShapeModel({
|
|||
}
|
||||
}
|
||||
|
||||
// Drive WheeledVehicle wheel/spring/turn animations from ghost state.
|
||||
const wheelAnims = wheelAnimsRef.current;
|
||||
if (wheelAnims && animationEnabled) {
|
||||
const entity = streamEntityRef.current;
|
||||
const wheels = entity?.wheels;
|
||||
const steeringYaw = entity?.steeringYaw ?? 0;
|
||||
// From VehicleData datablock (e.g. MPB = 0.3 rad).
|
||||
const maxSteeringAngle = entity?.maxSteeringAngle ?? 0.3;
|
||||
|
||||
for (let i = 0; i < wheelAnims.length; i++) {
|
||||
const wa = wheelAnims[i];
|
||||
const wheel = wheels?.[i];
|
||||
|
||||
// Wheel rotation: accumulate from speed, matching Torque's
|
||||
// advanceTime: rotation += wheelSpeed * dt * TWO_PI, then
|
||||
// wrap to [0,1) and flip negative to 1-rotation.
|
||||
if (wa.wheelAction && wheel) {
|
||||
wa.rotation += wheel.speed * effectDelta * Math.PI * 2;
|
||||
wa.rotation -= Math.floor(wa.rotation); // wrap to [0,1)
|
||||
wa.wheelAction.time =
|
||||
wa.rotation * wa.wheelAction.getClip().duration;
|
||||
}
|
||||
|
||||
// Spring: ghost vehicles stay at rest (fully extended = pos 0).
|
||||
// The server already accounts for spring height in the ghost position.
|
||||
// (Spring animation would only change with client-side raycasts.)
|
||||
|
||||
// Turn: steering angle → animation position.
|
||||
// Torque: pos = 0.5 - t * 0.5 where t = steerAngle² / maxSteeringAngle
|
||||
if (wa.turnAction) {
|
||||
const t =
|
||||
(steeringYaw * Math.abs(steeringYaw)) / maxSteeringAngle;
|
||||
const pos = 0.5 - t * 0.5;
|
||||
wa.turnAction.time =
|
||||
Math.max(0, Math.min(1, pos)) *
|
||||
wa.turnAction.getClip().duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance IFL texture atlases.
|
||||
// Matches Torque's animateIfls():
|
||||
// time = th->pos * th->sequence->duration + th->sequence->toolBegin
|
||||
|
|
|
|||
|
|
@ -213,13 +213,15 @@
|
|||
}
|
||||
|
||||
.CloseButton {
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
z-index: 101;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
background: transparent;
|
||||
margin: 0 0 0 -8px;
|
||||
padding: 6px;
|
||||
padding: 6px 12px 6px 8px;
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
.Button {
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 2px;
|
||||
margin: 0 0 0 8px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
margin: 0 0 0 2px;
|
||||
font-size: 24px;
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
|
|
|
|||
|
|
@ -132,6 +132,14 @@ export interface ShapeEntity extends PositionedBase {
|
|||
targetRenderFlags?: number;
|
||||
iffColor?: { r: number; g: number; b: number };
|
||||
weaponShape?: string;
|
||||
/** WheeledVehicle per-wheel state (speed, slip). */
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
/** Vehicle steering angle (radians). */
|
||||
steeringYaw?: number;
|
||||
/** Vehicle frozen state (deployed). */
|
||||
frozen?: boolean;
|
||||
/** Vehicle max steering angle (radians), from datablock. */
|
||||
maxSteeringAngle?: number;
|
||||
}
|
||||
|
||||
export interface PlayerEntity extends PositionedBase {
|
||||
|
|
|
|||
|
|
@ -126,6 +126,14 @@ export interface MutableEntity {
|
|||
audioMinLoopGap?: number;
|
||||
audioMaxLoopGap?: number;
|
||||
sceneData?: SceneObject;
|
||||
/** WheeledVehicle per-wheel state from ghost data. */
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
/** Vehicle steering angle (radians), from ghost data. */
|
||||
steeringYaw?: number;
|
||||
/** Vehicle frozen state (deployed MPB, etc.). */
|
||||
frozen?: boolean;
|
||||
/** Vehicle max steering angle (radians), from VehicleData datablock. */
|
||||
maxSteeringAngle?: number;
|
||||
/** Force field visual data extracted from ForceFieldBareData datablock. */
|
||||
forceFieldData?: {
|
||||
textures: string[];
|
||||
|
|
@ -961,6 +969,14 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
}
|
||||
|
||||
// Vehicle maxSteeringAngle from VehicleData datablock.
|
||||
if (
|
||||
entity.className === "WheeledVehicle" &&
|
||||
typeof blockData?.maxSteeringAngle === "number"
|
||||
) {
|
||||
entity.maxSteeringAngle = blockData.maxSteeringAngle;
|
||||
}
|
||||
|
||||
// Force field visual data from ForceFieldBareData datablock.
|
||||
if (entity.className === "ForceFieldBare" && blockData) {
|
||||
const color1 = blockData.color1 as
|
||||
|
|
@ -993,6 +1009,35 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
}
|
||||
}
|
||||
|
||||
// WheeledVehicle per-wheel state. Mutate in-place to avoid allocation
|
||||
// on every ghost update (~32Hz).
|
||||
if (Array.isArray(data.wheels)) {
|
||||
const incoming = data.wheels as Array<{
|
||||
avel: number;
|
||||
Dy: number;
|
||||
Dx: number;
|
||||
}>;
|
||||
if (!entity.wheels || entity.wheels.length !== incoming.length) {
|
||||
entity.wheels = incoming.map((w) => ({
|
||||
speed: w.avel,
|
||||
lateralSlip: w.Dx,
|
||||
longitudinalSlip: w.Dy,
|
||||
}));
|
||||
} else {
|
||||
for (let i = 0; i < incoming.length; i++) {
|
||||
entity.wheels[i].speed = incoming[i].avel;
|
||||
entity.wheels[i].lateralSlip = incoming[i].Dx;
|
||||
entity.wheels[i].longitudinalSlip = incoming[i].Dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof data.steeringYaw === "number") {
|
||||
entity.steeringYaw = data.steeringYaw;
|
||||
}
|
||||
if (typeof data.frozen === "boolean") {
|
||||
entity.frozen = data.frozen;
|
||||
}
|
||||
|
||||
// Weapon images (Player)
|
||||
if (entity.type === "Player") {
|
||||
const images = data.images as
|
||||
|
|
@ -2253,6 +2298,10 @@ export abstract class StreamEngine implements StreamingPlayback {
|
|||
audioMaxDistance: entity.audioMaxDistance,
|
||||
audioMinLoopGap: entity.audioMinLoopGap,
|
||||
audioMaxLoopGap: entity.audioMaxLoopGap,
|
||||
wheels: entity.wheels,
|
||||
steeringYaw: entity.steeringYaw,
|
||||
frozen: entity.frozen,
|
||||
maxSteeringAngle: entity.maxSteeringAngle,
|
||||
sceneData: entity.sceneData,
|
||||
forceFieldData: entity.forceFieldData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -223,5 +223,9 @@ export function streamEntityToGameEntity(
|
|||
threads: entity.threads,
|
||||
targetRenderFlags: entity.targetRenderFlags,
|
||||
iffColor: entity.iffColor,
|
||||
wheels: entity.wheels,
|
||||
steeringYaw: entity.steeringYaw,
|
||||
frozen: entity.frozen,
|
||||
maxSteeringAngle: entity.maxSteeringAngle,
|
||||
} satisfies ShapeEntity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ export interface StreamEntity {
|
|||
audioMaxDistance?: number;
|
||||
audioMinLoopGap?: number;
|
||||
audioMaxLoopGap?: number;
|
||||
/** WheeledVehicle per-wheel state. */
|
||||
wheels?: Array<{ speed: number; lateralSlip: number; longitudinalSlip: number }>;
|
||||
/** Vehicle steering angle (radians). */
|
||||
steeringYaw?: number;
|
||||
/** Vehicle frozen state (deployed). */
|
||||
frozen?: boolean;
|
||||
/** Vehicle max steering angle (radians). */
|
||||
maxSteeringAngle?: number;
|
||||
/** Scene infrastructure data (terrain, interior, sky, etc.). */
|
||||
sceneData?: SceneObject;
|
||||
/** Force field visual data from ForceFieldBareData datablock. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue