141 lines
7.4 KiB
TypeScript
141 lines
7.4 KiB
TypeScript
import { Euler, Quaternion, Vector3 } from "three";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { DungeonTransform, Vector3Tuple } from "./dungeonTypes";
|
|
import { RUNEWAKER_NATIVE_LIGHTING_SOURCE } from "./generated/runewakerNativeLighting.generated";
|
|
import {
|
|
resolveRunewakerNativeLighting,
|
|
runewakerRuntimePositionToSource,
|
|
runewakerSourcePositionToRuntime,
|
|
} from "./runewakerNativeLighting";
|
|
|
|
const identityTransform: DungeonTransform = {
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
scale: 1,
|
|
};
|
|
|
|
function definition(id: string, transform = identityTransform) {
|
|
return { id, transform };
|
|
}
|
|
|
|
function toTuple(vector: Vector3): Vector3Tuple {
|
|
return [vector.x, vector.y, vector.z];
|
|
}
|
|
|
|
describe("native Runewaker environment resolver", () => {
|
|
it("covers every registered RoM WDB and matches the source engine's entry trigger/settings result", () => {
|
|
const dungeons = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons;
|
|
expect(Object.keys(dungeons)).toHaveLength(30);
|
|
|
|
for (const [id, data] of Object.entries(dungeons)) {
|
|
const entry = data.authoredEntry;
|
|
expect(entry, `${id} should retain its audited source entry`).not.toBeNull();
|
|
const runtimePosition = runewakerSourcePositionToRuntime(
|
|
definition(id),
|
|
entry!.sourcePosition as Vector3Tuple,
|
|
);
|
|
expect(runtimePosition, `${id} source entry should map to runtime space`).not.toBeNull();
|
|
if (!runtimePosition) throw new Error(`Missing runtime lighting position for ${id}`);
|
|
const sourceRoundTrip = runewakerRuntimePositionToSource(definition(id), runtimePosition!);
|
|
sourceRoundTrip!.forEach((value, axis) => expect(value, `${id} axis ${axis}, runtime ${runtimePosition.join(",")}`).toBeCloseTo(entry!.sourcePosition[axis], 4));
|
|
|
|
const resolved = resolveRunewakerNativeLighting(definition(id), runtimePosition!);
|
|
const expectedTriggerId = entry!.selection.triggerZoneId;
|
|
const expectedSettingsId = entry!.selection.settingsZoneId;
|
|
if (expectedTriggerId == null || expectedSettingsId == null) {
|
|
expect(resolved, `${id} should retain its existing profile when no source zone resolves`).toBeNull();
|
|
} else {
|
|
expect(resolved, `${id} should resolve source lighting`).not.toBeNull();
|
|
expect(resolved!.triggerZone.id).toBe(expectedTriggerId);
|
|
expect(resolved!.settingsZone.id).toBe(expectedSettingsId);
|
|
expect(resolved!.timeGroup).toBe(entry!.selection.selectedTimeGroup);
|
|
expect(resolved!.weatherGroup).toBe(entry!.selection.selectedWeatherGroup);
|
|
expect(resolved!.sourceWdbSha256).toBe(data.source.wdbSha256);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("uses the reviewed Origin path node's source zone instead of the raw Lua entrance", () => {
|
|
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["the-origin"];
|
|
const reviewed = data.reviewedRuntimeEntry;
|
|
expect(reviewed?.covered).toBe(true);
|
|
const runtimePosition = runewakerSourcePositionToRuntime(
|
|
definition("the-origin"),
|
|
reviewed!.sourcePosition as Vector3Tuple,
|
|
);
|
|
const resolved = resolveRunewakerNativeLighting(definition("the-origin"), runtimePosition!);
|
|
expect(resolved?.triggerZone.id).toBe(reviewed!.selection.triggerZoneId);
|
|
expect(resolved?.settingsZone.id).toBe(3);
|
|
});
|
|
|
|
it("inherits a child trigger's parent settings and applies native time/weather fallbacks", () => {
|
|
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["grafu-castle"];
|
|
const childTrigger = data.zoneTriggers.find((trigger) => trigger.zoneId === 3)!;
|
|
const runtimePosition = runewakerSourcePositionToRuntime(definition("grafu-castle"), childTrigger.obb.center as Vector3Tuple);
|
|
const resolved = resolveRunewakerNativeLighting(definition("grafu-castle"), runtimePosition!, {
|
|
timeGroup: 2,
|
|
weatherGroup: 7,
|
|
});
|
|
expect(resolved?.triggerZone.id).toBe(3);
|
|
expect(resolved?.settingsZone.id).toBe(1);
|
|
expect(resolved?.timeGroup).toBe(2);
|
|
expect(resolved?.weatherGroup).toBe(0);
|
|
});
|
|
|
|
it("preserves Rune's byte-normalized linear colors, three diffuse directionals, and distinct specular values", () => {
|
|
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["mystic-altar"];
|
|
const runtimePosition = runewakerSourcePositionToRuntime(
|
|
definition("mystic-altar"),
|
|
data.authoredEntry!.sourcePosition as Vector3Tuple,
|
|
);
|
|
const resolved = resolveRunewakerNativeLighting(definition("mystic-altar"), runtimePosition!, {
|
|
cameraFarDistance: 1000,
|
|
})!;
|
|
const settings = data.zones.find((zone) => zone.id === resolved.settingsZone.id)!.settings[0];
|
|
if (!settings) throw new Error("Missing source lighting settings fixture");
|
|
expect(resolved.ambient.colorLinear).toEqual(settings.ambient.rgba.slice(0, 3));
|
|
expect(resolved.directionalLights).toHaveLength(3);
|
|
expect(resolved.directionalLights.map((light) => light.intensity)).toEqual([1, 0.8, 0.8]);
|
|
expect(resolved.directionalLights[0].colorLinear).toEqual(settings.primary.rgba.slice(0, 3));
|
|
expect(resolved.directionalLights[1].colorLinear).toEqual(settings.secondary.rgba.slice(0, 3));
|
|
expect(resolved.specular.colorLinear).toEqual(settings.specular.rgba.slice(0, 3));
|
|
expect(resolved.fog.startFactor).toBeCloseTo(400 / 1600);
|
|
expect(resolved.fog.endFactor).toBeCloseTo(1300 / 1600);
|
|
expect(resolved.fog.startDistance).toBeCloseTo(250);
|
|
expect(resolved.fog.endDistance).toBeCloseTo(812.5);
|
|
});
|
|
|
|
it("transforms authored directions through the inverse primary placement and runtime dungeon rotation", () => {
|
|
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["mystic-altar"];
|
|
const transform: DungeonTransform = {
|
|
position: [8, -3, 4],
|
|
rotation: [0.2, -0.35, 0.5],
|
|
scale: 1.6,
|
|
};
|
|
const runtimePosition = runewakerSourcePositionToRuntime(
|
|
definition("mystic-altar", transform),
|
|
data.authoredEntry!.sourcePosition as Vector3Tuple,
|
|
);
|
|
const resolved = resolveRunewakerNativeLighting(definition("mystic-altar", transform), runtimePosition!)!;
|
|
const settings = data.zones.find((zone) => zone.id === resolved.settingsZone.id)!.settings[0];
|
|
if (!settings) throw new Error("Missing source lighting settings fixture");
|
|
const sourceDirection = new Vector3(...settings.nativeDirectionsLeftHandedYUp.primary);
|
|
const primaryRotation = new Quaternion(...data.source.primaryPlacement.rotation).normalize();
|
|
const primaryScale = new Vector3(...data.source.primaryPlacement.scale);
|
|
sourceDirection.applyQuaternion(primaryRotation.invert()).divide(primaryScale);
|
|
sourceDirection.z *= -1;
|
|
sourceDirection.normalize();
|
|
sourceDirection.applyQuaternion(new Quaternion().setFromEuler(new Euler(...transform.rotation, "XYZ")));
|
|
const actual = new Vector3(...resolved.directionalLights[0].rayDirection);
|
|
expect(actual.distanceTo(sourceDirection.normalize())).toBeLessThan(1e-6);
|
|
expect(new Vector3(...resolved.directionalLights[0].toLightDirection).distanceTo(actual.clone().negate())).toBeLessThan(1e-8);
|
|
});
|
|
|
|
it("returns null for unknown dungeons and uncovered source points", () => {
|
|
expect(resolveRunewakerNativeLighting(definition("not-a-rom-dungeon"), [0, 0, 0])).toBeNull();
|
|
const noCoverage = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["grafu-castle"].authoredEntry!;
|
|
const runtimePosition = runewakerSourcePositionToRuntime(definition("grafu-castle"), noCoverage.sourcePosition as Vector3Tuple);
|
|
expect(resolveRunewakerNativeLighting(definition("grafu-castle"), runtimePosition!)).toBeNull();
|
|
});
|
|
});
|