mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-06 05:50:34 +00:00
add IFL texture animation
This commit is contained in:
parent
25449af198
commit
af17b43584
2506 changed files with 393603 additions and 6536 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, Suspense, useMemo } from "react";
|
||||
import { memo, Suspense, useMemo, useRef, useEffect } from "react";
|
||||
import { useGLTF, useTexture } from "@react-three/drei";
|
||||
import {
|
||||
FALLBACK_TEXTURE_URL,
|
||||
|
|
@ -15,6 +15,7 @@ import { setupColor } from "../textureUtils";
|
|||
import { useDebug } from "./SettingsProvider";
|
||||
import { useShapeInfo } from "./ShapeInfoProvider";
|
||||
import { FloatingLabel } from "./FloatingLabel";
|
||||
import { useIflTexture } from "./useIflTexture";
|
||||
|
||||
/**
|
||||
* Load a .glb file that was converted from a .dts, used for static shapes.
|
||||
|
|
@ -24,28 +25,57 @@ export function useStaticShape(shapeName: string) {
|
|||
return useGLTF(url);
|
||||
}
|
||||
|
||||
export function ShapeTexture({
|
||||
/**
|
||||
* Animated IFL (Image File List) material component. Creates a sprite sheet
|
||||
* from all frames and animates via texture offset.
|
||||
*/
|
||||
export function IflTexture({
|
||||
material,
|
||||
shapeName,
|
||||
}: {
|
||||
material?: MeshStandardMaterial;
|
||||
material: MeshStandardMaterial;
|
||||
shapeName?: string;
|
||||
}) {
|
||||
const resourcePath = material.userData.resource_path;
|
||||
// Convert resource_path (e.g., "skins/blue00") to IFL path
|
||||
const iflPath = `textures/${resourcePath}.ifl`;
|
||||
|
||||
const texture = useIflTexture(iflPath);
|
||||
|
||||
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
|
||||
|
||||
const customMaterial = useMemo(() => {
|
||||
if (!isOrganic) {
|
||||
const shaderMaterial = createAlphaAsRoughnessMaterial();
|
||||
shaderMaterial.map = texture;
|
||||
return shaderMaterial;
|
||||
}
|
||||
|
||||
const clonedMaterial = material.clone();
|
||||
clonedMaterial.map = texture;
|
||||
clonedMaterial.transparent = true;
|
||||
clonedMaterial.alphaTest = 0.9;
|
||||
clonedMaterial.side = 2; // DoubleSide
|
||||
return clonedMaterial;
|
||||
}, [material, texture, isOrganic]);
|
||||
|
||||
return <primitive object={customMaterial} attach="material" />;
|
||||
}
|
||||
|
||||
function StaticTexture({ material, shapeName }) {
|
||||
const resourcePath = material.userData.resource_path;
|
||||
|
||||
const url = useMemo(() => {
|
||||
const flagNames = new Set(material.userData.flag_names ?? []);
|
||||
const isIflMaterial = flagNames.has("IflMaterial");
|
||||
const resourcePath = material.userData.resource_path;
|
||||
if (!resourcePath) {
|
||||
console.warn(
|
||||
`Material index out of range on shape "${shapeName}" - rendering fallback.`,
|
||||
);
|
||||
}
|
||||
return resourcePath && !isIflMaterial
|
||||
return resourcePath
|
||||
? // Use custom `resource_path` added by forked io_dts3d Blender add-on
|
||||
shapeTextureToUrl(resourcePath)
|
||||
: // Not supported yet
|
||||
FALLBACK_TEXTURE_URL;
|
||||
}, [material]);
|
||||
: FALLBACK_TEXTURE_URL;
|
||||
}, [material, resourcePath, shapeName]);
|
||||
|
||||
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
|
||||
|
||||
|
|
@ -76,6 +106,25 @@ export function ShapeTexture({
|
|||
return <primitive object={customMaterial} attach="material" />;
|
||||
}
|
||||
|
||||
export function ShapeTexture({
|
||||
material,
|
||||
shapeName,
|
||||
}: {
|
||||
material?: MeshStandardMaterial;
|
||||
shapeName?: string;
|
||||
}) {
|
||||
const flagNames = new Set(material.userData.flag_names ?? []);
|
||||
const isIflMaterial = flagNames.has("IflMaterial");
|
||||
const resourcePath = material.userData.resource_path;
|
||||
|
||||
// Use IflTexture for animated materials
|
||||
if (isIflMaterial && resourcePath) {
|
||||
return <IflTexture material={material} shapeName={shapeName} />;
|
||||
} else {
|
||||
return <StaticTexture material={material} shapeName={shapeName} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function ShapePlaceholder({
|
||||
color,
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -10,31 +10,48 @@ const excludeMissions = new Set([
|
|||
]);
|
||||
|
||||
const sourceGroupNames = {
|
||||
"Classic_maps_v1.vl2": "Classic",
|
||||
"DynamixFinalPack.vl2": "Official",
|
||||
"missions.vl2": "Official",
|
||||
"S5maps.vl2": "S5",
|
||||
"S8maps.vl2": "S8",
|
||||
"SkiFreeGameType.vl2": "SkiFree",
|
||||
"TR2final105-client.vl2": "Team Rabbit 2",
|
||||
"TWL-MapPack.vl2": "TWL",
|
||||
"TWL2-MapPack.vl2": "TWL2",
|
||||
"z_DMP2-V0.6.vl2": "DMP2 (Discord Map Pack)",
|
||||
"zAddOnsVL2s/TWL_T2arenaOfficialMaps.vl2": "Arena",
|
||||
"zAddOnsVL2s/zDiscord-Map-Pack-4.7.1.vl2": "DMP (Discord Map Pack)",
|
||||
"z_mappacks/CTF/Classic_maps_v1.vl2": "Classic",
|
||||
"z_mappacks/CTF/DynamixFinalPack.vl2": "Official",
|
||||
"z_mappacks/CTF/KryMapPack_b3EDIT.vl2": "KryMapPack",
|
||||
"z_mappacks/CTF/S5maps.vl2": "S5",
|
||||
"z_mappacks/CTF/S8maps.vl2": "S8",
|
||||
"z_mappacks/CTF/TWL-MapPack.vl2": "TWL",
|
||||
"z_mappacks/CTF/TWL-MapPackEDIT.vl2": "TWL",
|
||||
"z_mappacks/CTF/TWL2-MapPack.vl2": "TWL2",
|
||||
"z_mappacks/CTF/TWL2-MapPackEDIT.vl2": "TWL2",
|
||||
"z_mappacks/TWL_T2arenaOfficialMaps.vl2": "Arena",
|
||||
"z_mappacks/z_DMP2-V0.6.vl2": "DMP2 (Discord Map Pack)",
|
||||
"z_mappacks/zDMP-4.7.3DX.vl2": "DMP (Discord Map Pack)",
|
||||
// "SkiFreeGameType.vl2": "SkiFree",
|
||||
};
|
||||
|
||||
const dirGroupNames = {
|
||||
"z_mappacks/DM": "DM",
|
||||
"z_mappacks/LCTF": "LCTF",
|
||||
"z_mappacks/Lak": "LakRabbit",
|
||||
};
|
||||
|
||||
const getDirName = (sourcePath: string) => {
|
||||
const match = sourcePath.match(/^(.*)(\/[^/]+)$/);
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const groupedMissions = getMissionList().reduce(
|
||||
(groupMap, missionName) => {
|
||||
const missionInfo = getMissionInfo(missionName);
|
||||
const [sourcePath] = getSourceAndPath(missionInfo.resourcePath);
|
||||
const groupName = sourceGroupNames[sourcePath] ?? null;
|
||||
const sourceDir = getDirName(sourcePath);
|
||||
const groupName =
|
||||
sourceGroupNames[sourcePath] ?? dirGroupNames[sourceDir] ?? null;
|
||||
const groupMissions = groupMap.get(groupName) ?? [];
|
||||
if (!excludeMissions.has(missionName)) {
|
||||
groupMissions.push({
|
||||
resourcePath: missionInfo.resourcePath,
|
||||
missionName,
|
||||
displayName: missionInfo.displayName,
|
||||
sourcePath,
|
||||
});
|
||||
groupMap.set(groupName, groupMissions);
|
||||
}
|
||||
|
|
@ -46,6 +63,7 @@ const groupedMissions = getMissionList().reduce(
|
|||
resourcePath: string;
|
||||
missionName: string;
|
||||
displayName: string;
|
||||
sourcePath: string;
|
||||
}>
|
||||
>(),
|
||||
);
|
||||
|
|
@ -78,6 +96,8 @@ export function InspectorControls({
|
|||
setFov,
|
||||
audioEnabled,
|
||||
setAudioEnabled,
|
||||
animationEnabled,
|
||||
setAnimationEnabled,
|
||||
} = useSettings();
|
||||
const { speedMultiplier, setSpeedMultiplier } = useControls();
|
||||
const { debugMode, setDebugMode } = useDebug();
|
||||
|
|
@ -150,6 +170,17 @@ export function InspectorControls({
|
|||
/>
|
||||
<label htmlFor="audioInput">Audio?</label>
|
||||
</div>
|
||||
<div className="CheckboxField">
|
||||
<input
|
||||
id="animationInput"
|
||||
type="checkbox"
|
||||
checked={animationEnabled}
|
||||
onChange={(event) => {
|
||||
setAnimationEnabled(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="animationInput">Animation?</label>
|
||||
</div>
|
||||
<div className="CheckboxField">
|
||||
<input
|
||||
id="debugInput"
|
||||
|
|
@ -164,7 +195,7 @@ export function InspectorControls({
|
|||
<div className="Field">
|
||||
<label htmlFor="fovInput">FOV</label>
|
||||
<input
|
||||
id="speedInput"
|
||||
id="fovInput"
|
||||
type="range"
|
||||
min={75}
|
||||
max={120}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import { createScriptLoader } from "../torqueScript/scriptLoader.browser";
|
||||
import { renderObject } from "./renderObject";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { TickProvider } from "./TickProvider";
|
||||
|
||||
const loadScript = createScriptLoader();
|
||||
|
||||
|
|
@ -70,5 +71,9 @@ export const Mission = memo(function Mission({ name }: { name: string }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
return executedMission.objects.map((object, i) => renderObject(object, i));
|
||||
return (
|
||||
<TickProvider>
|
||||
{executedMission.objects.map((object, i) => renderObject(object, i))}
|
||||
</TickProvider>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type PersistedSettings = {
|
|||
speedMultiplier?: number;
|
||||
fov?: number;
|
||||
audioEnabled?: boolean;
|
||||
animationEnabled?: boolean;
|
||||
debugMode?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
const [speedMultiplier, setSpeedMultiplier] = useState(1);
|
||||
const [fov, setFov] = useState(90);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [animationEnabled, setAnimationEnabled] = useState(true);
|
||||
const [debugMode, setDebugMode] = useState(false);
|
||||
|
||||
const settingsContext = useMemo(
|
||||
|
|
@ -47,8 +49,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
setFov,
|
||||
audioEnabled,
|
||||
setAudioEnabled,
|
||||
animationEnabled,
|
||||
setAnimationEnabled,
|
||||
}),
|
||||
[fogEnabled, speedMultiplier, fov, audioEnabled],
|
||||
[fogEnabled, speedMultiplier, fov, audioEnabled, animationEnabled],
|
||||
);
|
||||
|
||||
const debugContext = useMemo(
|
||||
|
|
@ -75,6 +79,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
if (savedSettings.audioEnabled != null) {
|
||||
setAudioEnabled(savedSettings.audioEnabled);
|
||||
}
|
||||
if (savedSettings.animationEnabled != null) {
|
||||
setAnimationEnabled(savedSettings.animationEnabled);
|
||||
}
|
||||
if (savedSettings.fogEnabled != null) {
|
||||
setFogEnabled(savedSettings.fogEnabled);
|
||||
}
|
||||
|
|
@ -102,6 +109,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
speedMultiplier,
|
||||
fov,
|
||||
audioEnabled,
|
||||
animationEnabled,
|
||||
debugMode,
|
||||
};
|
||||
try {
|
||||
|
|
@ -116,7 +124,14 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||
clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [fogEnabled, speedMultiplier, fov, audioEnabled, debugMode]);
|
||||
}, [
|
||||
fogEnabled,
|
||||
speedMultiplier,
|
||||
fov,
|
||||
audioEnabled,
|
||||
animationEnabled,
|
||||
debugMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settingsContext}>
|
||||
|
|
|
|||
80
src/components/TickProvider.tsx
Normal file
80
src/components/TickProvider.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
|
||||
export const TICK_RATE = 32;
|
||||
const TICK_INTERVAL = 1 / TICK_RATE;
|
||||
|
||||
type TickCallback = (tick: number) => void;
|
||||
|
||||
type TickContextValue = {
|
||||
subscribe: (callback: TickCallback) => () => void;
|
||||
getTick: () => number;
|
||||
};
|
||||
|
||||
const TickContext = createContext<TickContextValue | null>(null);
|
||||
|
||||
export function TickProvider({ children }: { children: ReactNode }) {
|
||||
const callbacksRef = useRef<Set<TickCallback> | undefined>(undefined);
|
||||
const accumulatorRef = useRef(0);
|
||||
const tickRef = useRef(0);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
accumulatorRef.current += delta;
|
||||
|
||||
while (accumulatorRef.current >= TICK_INTERVAL) {
|
||||
accumulatorRef.current -= TICK_INTERVAL;
|
||||
tickRef.current++;
|
||||
|
||||
if (callbacksRef.current) {
|
||||
for (const callback of callbacksRef.current) {
|
||||
callback(tickRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const subscribe = useCallback((callback: TickCallback) => {
|
||||
callbacksRef.current ??= new Set();
|
||||
callbacksRef.current.add(callback);
|
||||
return () => {
|
||||
callbacksRef.current!.delete(callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getTick = useCallback(() => tickRef.current, []);
|
||||
|
||||
const context = useMemo(() => ({ subscribe, getTick }), [subscribe, getTick]);
|
||||
|
||||
return (
|
||||
<TickContext.Provider value={context}>{children}</TickContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTick(callback: TickCallback) {
|
||||
const context = useContext(TickContext);
|
||||
if (!context) {
|
||||
throw new Error("useTick must be used within a TickProvider");
|
||||
}
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
return context.subscribe((tick) => callbackRef.current(tick));
|
||||
}, [context]);
|
||||
}
|
||||
|
||||
export function useGetTick() {
|
||||
const context = useContext(TickContext);
|
||||
if (!context) {
|
||||
throw new Error("useGetTick must be used within a TickProvider");
|
||||
}
|
||||
return context.getTick;
|
||||
}
|
||||
144
src/components/useIflTexture.ts
Normal file
144
src/components/useIflTexture.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTexture } from "@react-three/drei";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CanvasTexture,
|
||||
ClampToEdgeWrapping,
|
||||
NearestFilter,
|
||||
SRGBColorSpace,
|
||||
Texture,
|
||||
} from "three";
|
||||
import { loadImageFrameList, textureFrameToUrl } from "../loaders";
|
||||
import { useTick } from "./TickProvider";
|
||||
import { useSettings } from "./SettingsProvider";
|
||||
|
||||
interface IflAtlas {
|
||||
texture: CanvasTexture;
|
||||
columns: number;
|
||||
rows: number;
|
||||
frameCount: number;
|
||||
/** Tick at which each frame starts (cumulative). */
|
||||
frameStartTicks: number[];
|
||||
/** Total ticks for one complete animation cycle. */
|
||||
totalTicks: number;
|
||||
/** Last rendered frame index, to avoid redundant offset updates. */
|
||||
lastFrame: number;
|
||||
}
|
||||
|
||||
// Module-level cache for atlas textures, shared across all components.
|
||||
const atlasCache = new Map<string, IflAtlas>();
|
||||
|
||||
function createAtlas(textures: Texture[]): IflAtlas {
|
||||
const frameWidth = textures[0].image.width;
|
||||
const frameHeight = textures[0].image.height;
|
||||
const frameCount = textures.length;
|
||||
|
||||
// Arrange frames in a roughly square grid.
|
||||
const columns = Math.ceil(Math.sqrt(frameCount));
|
||||
const rows = Math.ceil(frameCount / columns);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = frameWidth * columns;
|
||||
canvas.height = frameHeight * rows;
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
textures.forEach((tex, i) => {
|
||||
const col = i % columns;
|
||||
const row = Math.floor(i / columns);
|
||||
ctx.drawImage(tex.image, col * frameWidth, row * frameHeight);
|
||||
});
|
||||
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.generateMipmaps = false;
|
||||
texture.minFilter = NearestFilter;
|
||||
texture.magFilter = NearestFilter;
|
||||
texture.wrapS = ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.repeat.set(1 / columns, 1 / rows);
|
||||
|
||||
return {
|
||||
texture,
|
||||
columns,
|
||||
rows,
|
||||
frameCount,
|
||||
frameStartTicks: [],
|
||||
totalTicks: 0,
|
||||
lastFrame: -1,
|
||||
};
|
||||
}
|
||||
|
||||
function computeTiming(
|
||||
atlas: IflAtlas,
|
||||
frames: { name: string; frameCount: number }[],
|
||||
) {
|
||||
let totalTicks = 0;
|
||||
atlas.frameStartTicks = frames.map((frame) => {
|
||||
const start = totalTicks;
|
||||
totalTicks += frame.frameCount;
|
||||
return start;
|
||||
});
|
||||
atlas.totalTicks = totalTicks;
|
||||
}
|
||||
|
||||
function updateAtlasFrame(atlas: IflAtlas, frameIndex: number) {
|
||||
if (frameIndex === atlas.lastFrame) return;
|
||||
atlas.lastFrame = frameIndex;
|
||||
|
||||
const col = frameIndex % atlas.columns;
|
||||
// Flip row: canvas Y=0 is top, but texture V=0 is bottom.
|
||||
const row = atlas.rows - 1 - Math.floor(frameIndex / atlas.columns);
|
||||
atlas.texture.offset.set(col / atlas.columns, row / atlas.rows);
|
||||
}
|
||||
|
||||
function getFrameIndexForTick(atlas: IflAtlas, tick: number): number {
|
||||
if (atlas.totalTicks === 0) return 0;
|
||||
|
||||
const cycleTick = tick % atlas.totalTicks;
|
||||
const { frameStartTicks } = atlas;
|
||||
|
||||
// Binary search would be faster for many frames, but linear is fine for typical IFLs.
|
||||
for (let i = frameStartTicks.length - 1; i >= 0; i--) {
|
||||
if (cycleTick >= frameStartTicks[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an IFL (Image File List) and returns an animated texture.
|
||||
* The texture atlas is shared across all components using the same IFL path.
|
||||
*/
|
||||
export function useIflTexture(iflPath: string): Texture {
|
||||
const { animationEnabled } = useSettings();
|
||||
|
||||
const { data: frames } = useSuspenseQuery({
|
||||
queryKey: ["ifl", iflPath],
|
||||
queryFn: () => loadImageFrameList(iflPath),
|
||||
});
|
||||
|
||||
const textureUrls = useMemo(
|
||||
() => frames.map((frame) => textureFrameToUrl(frame.name)),
|
||||
[frames],
|
||||
);
|
||||
|
||||
const textures = useTexture(textureUrls);
|
||||
|
||||
const atlas = useMemo(() => {
|
||||
let cached = atlasCache.get(iflPath);
|
||||
if (!cached) {
|
||||
cached = createAtlas(textures);
|
||||
atlasCache.set(iflPath, cached);
|
||||
}
|
||||
computeTiming(cached, frames);
|
||||
return cached;
|
||||
}, [iflPath, textures, frames]);
|
||||
|
||||
useTick((tick) => {
|
||||
const frameIndex = animationEnabled ? getFrameIndexForTick(atlas, tick) : 0;
|
||||
updateAtlasFrame(atlas, frameIndex);
|
||||
});
|
||||
|
||||
return atlas.texture;
|
||||
}
|
||||
|
|
@ -1,3 +1,11 @@
|
|||
/**
|
||||
* Parse an IFL file, for frame based image animation.
|
||||
*
|
||||
* See:
|
||||
* - https://help.autodesk.com/view/3DSMAX/2025/ENU/?guid=GUID-CA63616D-9E87-42FC-8E84-D67E1990EE71
|
||||
* - https://docs.torque3d.org/for-artists/materials/material-animation
|
||||
* - http://wiki.torque3d.org/artist:mapping-materials-to-textures#toc4
|
||||
*/
|
||||
export function parseImageFileList(source: string) {
|
||||
const lines = source
|
||||
.split(/(?:\r\n|\r|\n)/g)
|
||||
|
|
|
|||
|
|
@ -73,19 +73,11 @@ export function createBuiltins(
|
|||
isobject(obj: any): boolean {
|
||||
return runtime().$.isObject(obj);
|
||||
},
|
||||
typeof(obj: any): string {
|
||||
if (obj == null) return "";
|
||||
if (typeof obj === "object" && obj._class) return obj._className;
|
||||
return typeof obj;
|
||||
},
|
||||
|
||||
// Object lookup
|
||||
nametoid(name: string): number {
|
||||
return runtime().$.nameToId(name);
|
||||
},
|
||||
isfunction(name: string): boolean {
|
||||
return runtime().$.isFunction(name);
|
||||
},
|
||||
|
||||
// String functions
|
||||
strlen(str: any): number {
|
||||
|
|
@ -310,12 +302,6 @@ export function createBuiltins(
|
|||
const max = Number(b) || 0;
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
},
|
||||
getrandomseed(): number {
|
||||
throw new Error("getRandomSeed() not implemented");
|
||||
},
|
||||
setrandomseed(_seed: any): void {
|
||||
throw new Error("setRandomSeed() not implemented");
|
||||
},
|
||||
mdegtorad(deg: any): number {
|
||||
return (Number(deg) || 0) * (Math.PI / 180);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRuntime } from "./runtime";
|
||||
import type { TorqueRuntimeOptions } from "./types";
|
||||
import { parse, transpile } from "./index";
|
||||
|
|
@ -7,9 +6,11 @@ import { parse, transpile } from "./index";
|
|||
function run(script: string, options?: TorqueRuntimeOptions) {
|
||||
const { $, $f, $g, state } = createRuntime(options);
|
||||
const { code } = transpile(script);
|
||||
const fn = new Function("$", "$f", "$g", code);
|
||||
fn($, $f, $g);
|
||||
return { $, $f, $g, state };
|
||||
// Provide $l (locals) at module scope for top-level local variable access
|
||||
const $l = $.locals();
|
||||
const fn = new Function("$", "$f", "$g", "$l", code);
|
||||
fn($, $f, $g, $l);
|
||||
return { $, $f, $g, $l, state };
|
||||
}
|
||||
|
||||
describe("TorqueScript Runtime", () => {
|
||||
|
|
@ -78,6 +79,33 @@ describe("TorqueScript Runtime", () => {
|
|||
`);
|
||||
expect($g.get("result")).toBe(120);
|
||||
});
|
||||
|
||||
it("handles top-level local variables", () => {
|
||||
const { $g, $l } = run(`
|
||||
%x = 5;
|
||||
%y = 10;
|
||||
$result = %x * %y;
|
||||
%name = "test";
|
||||
`);
|
||||
expect($g.get("result")).toBe(50);
|
||||
expect($l.get("x")).toBe(5);
|
||||
expect($l.get("y")).toBe(10);
|
||||
expect($l.get("name")).toBe("test");
|
||||
});
|
||||
|
||||
it("top-level locals are separate from function locals", () => {
|
||||
const { $g } = run(`
|
||||
%x = 100;
|
||||
function getX() {
|
||||
%x = 42;
|
||||
return %x;
|
||||
}
|
||||
$funcResult = getX();
|
||||
$topResult = %x;
|
||||
`);
|
||||
expect($g.get("funcResult")).toBe(42);
|
||||
expect($g.get("topResult")).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("string operations", () => {
|
||||
|
|
@ -725,86 +753,95 @@ describe("TorqueScript Runtime", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("real TorqueScript files", () => {
|
||||
const emptyAST = parse("");
|
||||
describe("complex scripts", () => {
|
||||
it("sets globals and registers methods", () => {
|
||||
const { $g, state } = run(`
|
||||
$Game::numRoles = 3;
|
||||
$Game::role0 = "Goalie";
|
||||
$Game::role1 = "Defense";
|
||||
$Game::role2 = "Offense";
|
||||
|
||||
async function runFile(filepath: string) {
|
||||
const source = readFileSync(filepath, "utf8");
|
||||
// Provide a loader that returns empty scripts for known dependencies
|
||||
const runtime = createRuntime({
|
||||
loadScript: async (path) => {
|
||||
// Return empty script for known dependencies
|
||||
if (path.toLowerCase().includes("aitdm")) return "";
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const script = await runtime.loadFromSource(source);
|
||||
script.execute();
|
||||
return {
|
||||
$: runtime.$,
|
||||
$f: runtime.$f,
|
||||
$g: runtime.$g,
|
||||
state: runtime.state,
|
||||
};
|
||||
}
|
||||
function MyGame::onStart(%game) {
|
||||
return "started";
|
||||
}
|
||||
`);
|
||||
|
||||
it("transpiles and executes TR2Roles.cs", async () => {
|
||||
const { $g, state } = await runFile(
|
||||
"docs/base/@vl2/TR2final105-server.vl2/scripts/TR2Roles.cs",
|
||||
);
|
||||
|
||||
// Verify globals were set
|
||||
expect($g.get("TR2::numRoles")).toBe(3);
|
||||
expect($g.get("TR2::role0")).toBe("Goalie");
|
||||
expect($g.get("TR2::role1")).toBe("Defense");
|
||||
expect($g.get("TR2::role2")).toBe("Offense");
|
||||
|
||||
// Verify methods were registered
|
||||
expect(state.methods.has("TR2Game")).toBe(true);
|
||||
expect($g.get("Game::numRoles")).toBe(3);
|
||||
expect($g.get("Game::role0")).toBe("Goalie");
|
||||
expect($g.get("Game::role1")).toBe("Defense");
|
||||
expect($g.get("Game::role2")).toBe("Offense");
|
||||
expect(state.methods.has("MyGame")).toBe(true);
|
||||
});
|
||||
|
||||
it("transpiles and executes a .mis mission file", async () => {
|
||||
const { $, state } = await runFile(
|
||||
"docs/base/@vl2/4thGradeDropout.vl2/missions/4thGradeDropout.mis",
|
||||
);
|
||||
it("creates nested object hierarchies like mission files", () => {
|
||||
const { $, state } = run(`
|
||||
new SimGroup(MissionGroup) {
|
||||
new TerrainBlock(Terrain) {
|
||||
size = 1024;
|
||||
};
|
||||
new Sky(Sky) {
|
||||
cloudSpeed = 1.5;
|
||||
};
|
||||
new SimGroup(PlayerDropPoints) {
|
||||
new SpawnSphere(Spawn1) { position = "0 0 100"; };
|
||||
new SpawnSphere(Spawn2) { position = "100 0 100"; };
|
||||
};
|
||||
};
|
||||
`);
|
||||
|
||||
// Verify MissionGroup was created
|
||||
const missionGroup = $.deref("MissionGroup");
|
||||
expect(missionGroup).toBeDefined();
|
||||
expect(missionGroup._className).toBe("SimGroup");
|
||||
|
||||
// Verify child objects were created
|
||||
const terrain = $.deref("Terrain");
|
||||
expect(terrain).toBeDefined();
|
||||
|
||||
const sky = $.deref("Sky");
|
||||
expect(sky).toBeDefined();
|
||||
|
||||
// Verify object count
|
||||
expect(state.objectsByName.size).toBeGreaterThan(10);
|
||||
expect($.deref("Terrain")).toBeDefined();
|
||||
expect($.deref("Sky")).toBeDefined();
|
||||
expect($.deref("Spawn1")).toBeDefined();
|
||||
expect($.deref("Spawn2")).toBeDefined();
|
||||
expect(state.objectsByName.size).toBe(6);
|
||||
});
|
||||
|
||||
it("transpiles TDMGame.cs with methods and parent calls", async () => {
|
||||
const source = readFileSync(
|
||||
"docs/base/@vl2/z_DMP2-V0.6.vl2/scripts/TDMGame.cs",
|
||||
"utf-8",
|
||||
);
|
||||
const runtime = createRuntime({
|
||||
loadScript: async (path) => {
|
||||
if (path.toLowerCase().includes("aitdm")) return "";
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const script = await runtime.loadFromSource(source);
|
||||
script.execute();
|
||||
it("supports namespace method calls", () => {
|
||||
const { $g } = run(`
|
||||
function BaseGame::getValue(%game) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
// Verify methods were registered on TDMGame
|
||||
expect(runtime.state.methods.has("TDMGame")).toBe(true);
|
||||
const tdmMethods = runtime.state.methods.get("TDMGame");
|
||||
expect(tdmMethods).toBeDefined();
|
||||
function BaseGame::getDoubled(%game) {
|
||||
return BaseGame::getValue(%game) * 2;
|
||||
}
|
||||
|
||||
// Verify transpiled code contains parent calls
|
||||
const { code } = transpile(source);
|
||||
$base = BaseGame::getValue(0);
|
||||
$doubled = BaseGame::getDoubled(0);
|
||||
`);
|
||||
|
||||
expect($g.get("base")).toBe(10);
|
||||
expect($g.get("doubled")).toBe(20);
|
||||
});
|
||||
|
||||
it("supports parent:: in package overrides", () => {
|
||||
const { $g } = run(`
|
||||
function doSomething() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
package MyOverride {
|
||||
function doSomething() {
|
||||
return Parent::doSomething() + 5;
|
||||
}
|
||||
};
|
||||
|
||||
$result = doSomething();
|
||||
`);
|
||||
|
||||
expect($g.get("result")).toBe(15);
|
||||
});
|
||||
|
||||
it("generates parent calls in transpiled code", () => {
|
||||
const { code } = transpile(`
|
||||
function MyGame::onEnd(%game) {
|
||||
Parent::onEnd(%game);
|
||||
}
|
||||
`);
|
||||
expect(code).toContain("$.parent(");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -758,8 +758,10 @@ export function createRuntime(
|
|||
|
||||
function executeAST(ast: Program): void {
|
||||
const code = getOrGenerateCode(ast);
|
||||
const execFn = new Function("$", "$f", "$g", code);
|
||||
execFn($, $f, $g);
|
||||
// Provide $l (locals) at module scope for top-level local variable access
|
||||
const $l = createLocals();
|
||||
const execFn = new Function("$", "$f", "$g", "$l", code);
|
||||
execFn($, $f, $g, $l);
|
||||
}
|
||||
|
||||
function createLoadedScript(ast: Program, path?: string): LoadedScript {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue