Initial commit

This commit is contained in:
Brian Beck 2025-09-11 16:48:23 -07:00
commit 2211ed7650
10117 changed files with 735995 additions and 0 deletions

133
.gitignore vendored Normal file
View file

@ -0,0 +1,133 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store
.tshy

18
app/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import "./style.css";
export const metadata = {
title: "Next.js",
description: "Generated by Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

353
app/page.tsx Normal file
View file

@ -0,0 +1,353 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import {
getActualResourcePath,
getResourceList,
getSource,
} from "@/src/manifest";
import { parseTerrainBuffer } from "@/src/terrain";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { getTerrainFile, parseMissionScript } from "@/src/mission";
const BASE_URL = "/t2-mapper";
const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
function getUrlForPath(resourcePath: string) {
resourcePath = getActualResourcePath(resourcePath);
const sourcePath = getSource(resourcePath);
if (!sourcePath) {
return `${RESOURCE_ROOT_URL}${resourcePath}`;
} else {
return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${resourcePath}`;
}
}
function terrainTextureToUrl(name: string) {
name = name.replace(/^terrain\./, "");
try {
return getUrlForPath(`textures/terrain/${name}.png`);
} catch (err) {
return `${BASE_URL}/black.png`;
}
}
function uint16ToFloat32(src: Uint16Array) {
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
out[i] = src[i] / 65535;
}
return out;
}
async function loadMission(name: string) {
const res = await fetch(getUrlForPath(`missions/${name}.mis`));
const missionScript = await res.text();
return parseMissionScript(missionScript);
}
async function loadTerrain(fileName: string) {
const res = await fetch(getUrlForPath(`terrains/${fileName}`));
const terrainBuffer = await res.arrayBuffer();
return parseTerrainBuffer(terrainBuffer);
}
function resizeRendererToDisplaySize(renderer) {
const canvas = renderer.domElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const needResize = canvas.width !== width || canvas.height !== height;
if (needResize) {
renderer.setSize(width, height, false);
}
return needResize;
}
const excludeMissions = new Set([
"SkiFree",
"SkiFree_Daily",
"SkiFree_Randomizer",
]);
const missions = getResourceList()
.map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/))
.filter(Boolean)
.map((match) => match[1])
.filter((name) => !excludeMissions.has(name));
export default function HomePage() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [missionName, setMissionName] = useState("TWL_Raindance");
useEffect(() => {
const canvas = canvasRef.current;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
});
const textureLoader = new THREE.TextureLoader();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
canvas.clientWidth / canvas.clientHeight,
0.1,
2000
);
function setupColor(tex) {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; // Still need this for tiling to work
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 16;
tex.generateMipmaps = true;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}
function setupMask(data) {
const tex = new THREE.DataTexture(
data,
256,
256,
THREE.RedFormat, // 1 channel
THREE.UnsignedByteType // 8-bit
);
// Masks should stay linear
tex.colorSpace = THREE.NoColorSpace;
// Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.generateMipmaps = false; // if width/height are not powers of two
tex.minFilter = THREE.LinearFilter; // avoid mips if generateMipmaps=false
tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
return tex;
}
async function loadMap() {
const mission = await loadMission(missionName);
const terrainFile = getTerrainFile(mission);
const terrain = await loadTerrain(terrainFile);
const layerCount = terrain.textureNames.length;
console.log({ terrain });
const baseTextures = terrain.textureNames.map((name) => {
return setupColor(textureLoader.load(terrainTextureToUrl(name)));
});
const alphaTextures = terrain.alphaMaps.map((data) => setupMask(data));
// Geometry: a simple plane (512x512 meters to match Tribes 2 scale)
const planeSize = 512;
const geom = new THREE.PlaneGeometry(planeSize, planeSize, 256, 256);
geom.rotateX(-Math.PI / 2);
const f32HeightMap = uint16ToFloat32(terrain.heightMap);
const heightMap = new THREE.DataTexture(
f32HeightMap,
256,
256,
THREE.RedFormat,
THREE.FloatType
);
heightMap.colorSpace = THREE.NoColorSpace;
heightMap.generateMipmaps = false;
heightMap.needsUpdate = true;
// Start with a standard material; assign map to trigger USE_MAP/vMapUv
const mat = new THREE.MeshStandardMaterial({
// map: base0,
displacementMap: heightMap,
map: heightMap,
// In Tribes 2, heightmap values are 0-0xFFFF (65535),
// already converted to 0-1 range by uint16ToFloat32.
// Scale by 2048 to match Tribes 2's height units in meters
displacementScale: 512,
displacementBias: -32,
});
// Inject our 4-layer blend before lighting
mat.onBeforeCompile = (shader) => {
// uniforms for 4 albedo maps + 3 alpha masks
baseTextures.forEach((tex, i) => {
shader.uniforms[`albedo${i}`] = { value: tex };
});
alphaTextures.forEach((tex, i) => {
if (i > 0) {
shader.uniforms[`mask${i}`] = { value: tex };
}
});
// Add per-texture tiling uniforms
baseTextures.forEach((tex, i) => {
shader.uniforms[`tiling${i}`] = {
value: Math.min(32, Math.pow(2, i + 2)),
};
});
// Declare our uniforms at the top of the fragment shader
shader.fragmentShader =
`
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
uniform sampler2D albedo3;
uniform sampler2D albedo4;
uniform sampler2D albedo5;
uniform sampler2D mask1;
uniform sampler2D mask2;
uniform sampler2D mask3;
uniform sampler2D mask4;
uniform sampler2D mask5;
uniform float tiling0;
uniform float tiling1;
uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
` + shader.fragmentShader;
// Replace the default map sampling block with our layered blend.
// We rely on vMapUv provided by USE_MAP.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <map_fragment>",
`
// Sample base albedo layers (sRGB textures auto-decoded to linear)
vec2 baseUv = vMapUv;
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
${
layerCount > 1
? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;`
: ""
}
${
layerCount > 2
? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;`
: ""
}
${
layerCount > 3
? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;`
: ""
}
${
layerCount > 4
? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;`
: ""
}
${
layerCount > 5
? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;`
: ""
}
// Sample linear masks (use R channel)
float a1 = texture2D(mask1, baseUv).r;
${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
// Bottom-up compositing: each mask tells how much the higher layer replaces lower
${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
// Assign to diffuseColor before lighting
diffuseColor.rgb = ${layerCount > 1 ? "blended" : "c0"};
`
);
};
// for (let gx = -1; gx <= 1; gx++) {
// for (let gz = -1; gz <= 1; gz++) {
// }
// }
const mesh = new THREE.Mesh(geom, mat);
// mesh.position.set(gx * planeSize, 0, gz * planeSize);
scene.add(mesh);
}
// const displacementMap = textureLoader.load("/heightmap.png");
// const planeMesh = new THREE.Mesh(
// new THREE.PlaneGeometry(256, 256, 256, 256),
// new THREE.MeshPhongMaterial({
// side: THREE.DoubleSide,
// displacementMap: displacementMap,
// map: displacementMap,
// displacementScale: 50,
// })
// );
// scene.add(planeMesh);
const controls = new OrbitControls(camera, renderer.domElement);
// const geometry = new THREE.BoxGeometry(1, 1, 1);
// const material = new THREE.MeshPhongMaterial({
// color: "rgba(255, 255, 255, 1)",
// });
// const cube = new THREE.Mesh(geometry, material);
// scene.add(cube);
const skyColor = "rgba(209, 237, 255, 1)";
const groundColor = "rgba(186, 200, 181, 1)";
const intensity = 2;
const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
scene.add(light);
// const loader = new GLTFLoader();
// loader.load("/flagstand.gltf", (gltf) => {
// scene.add(gltf.scene); // gltf.scene is a THREE.Group containing the model
// camera.position.set(0, 0, 300);
// controls.update();
// });
camera.position.set(100, 15, 100);
camera.lookAt(0, 0, -200);
controls.update();
const animate = () => {
// cube.rotation.x += 0.01;
// cube.rotation.y += 0.01;
if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
controls.update();
renderer.render(scene, camera);
};
renderer.setAnimationLoop(animate);
loadMap();
}, [missionName]);
return (
<main>
<canvas ref={canvasRef} id="canvas" />
<select
id="missionList"
value={missionName}
onChange={(e) => setMissionName(e.target.value)}
>
{missions.map((missionName) => (
<option key={missionName}>{missionName}</option>
))}
</select>
</main>
);
}

17
app/style.css Normal file
View file

@ -0,0 +1,17 @@
html,
body {
margin: 0;
padding: 0;
}
#canvas {
display: block;
width: 100vw;
height: 100vh;
}
#missionList {
position: fixed;
top: 20px;
left: 20px;
}

2736
generated/hxDif.cjs Normal file

File diff suppressed because it is too large Load diff

1465
generated/mission.cjs Normal file

File diff suppressed because it is too large Load diff

95
mission.pegjs Normal file
View file

@ -0,0 +1,95 @@
start
= document
document
= body:statement* !. { return body.filter(Boolean); }
statement
= comment
/ instance
/ definition
/ datablock
/ space+ { return null; }
comment
= "//" text:$[^\n\r]* { return { type: 'comment', text }; }
datablock
= "datablock " space* className:identifier space*
"(" space* instanceName:objectName? space* ")" space*
(":" space* baseName:objectName)? space*
"{" body:body* "}" sep*
{
return {
type: 'datablock',
className,
instanceName,
body: body.filter(Boolean)
}
}
instance
= "new " space* className:identifier space*
"(" space* instanceName:objectName? space* ")" space*
"{" body:body* "}" sep*
{
return {
type: 'instance',
className,
instanceName,
body: body.filter(Boolean)
}
}
body
= space+ { return null; }
/ definition
/ instance
/ comment
definition
= target:lhs space* "=" space* value:rhs ";"?
{ return { type: 'definition', target, value }; }
string
= "\"" values:(escape / notDoubleQuote)* "\"" { return { type: 'string', value: values.join('') }; }
/ "'" values:(escape / notSingleQuote)* "'" { return { type: 'string', value: values.join('') }; }
escape = "\\" char:. { return char}
notDoubleQuote = $[^\\"]+
notSingleQuote = $[^\\']+
space = [ \t\n\r] { return null; }
sep = ";"
identifier = $([$%]?[a-zA-Z][a-zA-Z0-9_]*)
objectName
= identifier
/ number
lhs = name:identifier index:index* { return { name, index }; }
rhs
= string
/ number
/ instance
/ boolean
/ ref:identifier { return { type: 'reference', value: ref }; }
index = arrayIndex / propertyIndex
arrayIndex = "[" space* index:accessor space* "]" { return index; }
propertyIndex = "." index:identifier { return index; }
accessor
= number
/ identifier
number = digits:$[0-9.]+ { return { type: 'number', value: parseFloat(digits) }; }
boolean = literal:("true" / "false") { return { type: 'boolean', value: literal === "true" }; }
eol = "\n" / "\r\n" / "\r"

6
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.ts Normal file
View file

@ -0,0 +1,7 @@
module.exports = {
output: "export",
distDir: process.env.NODE_ENV === "production" ? "./docs" : undefined,
basePath: "/t2-mapper",
assetPrefix: "/t2-mapper/",
trailingSlash: true,
};

2015
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "t2-mapper",
"version": "1.0.0",
"description": "",
"keywords": [],
"author": "Brian Beck <exogen@gmail.com>",
"license": "MIT",
"type": "module",
"scripts": {
"build:parser": "peggy mission.pegjs -o generated/mission.cjs",
"find": "find public -iname",
"start": "next dev"
},
"dependencies": {
"@react-three/fiber": "^9.3.0",
"next": "^15.5.2",
"peggy": "^5.0.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"three": "^0.180.0",
"unzipper": "^0.12.3"
},
"devDependencies": {
"@types/node": "24.3.1",
"@types/react": "19.1.12",
"@types/three": "^0.180.0",
"@types/unzipper": "^0.10.11",
"tsx": "^4.20.5",
"typescript": "5.9.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
Tribes2 Classic map pack version 2
10/31/02
The "classic_maps_v1.vl2" file should be put into
your "...\Tribes2\GameData\base" directory, overwriting
any existing earlier version.
See the "classic_readme.txt" file for a list of changes.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more