Initial commit: TSS Bot Web Frontend (React/Vite + production server)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { TRUNK_TOP_CSS } from "./Tree";
|
||||
|
||||
interface Leaf {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
speed: number;
|
||||
drift: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
opacity: number;
|
||||
sway: number;
|
||||
swaySpeed: number;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
export default function FallingLeaves({ treeRef }: { treeRef: React.RefObject<HTMLCanvasElement | null> }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current!;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
let animId: number;
|
||||
const leaves: Leaf[] = [];
|
||||
const MAX_LEAVES = 36;
|
||||
const frameInterval = 1000 / 30;
|
||||
let lastFrame = 0;
|
||||
let isVisible = true;
|
||||
let isRunning = false;
|
||||
let treeBounds = { left: 0, top: 0, width: 0 };
|
||||
let canvasBounds = { left: 0, top: 0 };
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
function measure() {
|
||||
const parent = canvas.parentElement;
|
||||
const rect = parent?.getBoundingClientRect();
|
||||
canvas.width = Math.max(1, Math.round(rect?.width || window.innerWidth));
|
||||
canvas.height = Math.max(1, Math.round(rect?.height || window.innerHeight));
|
||||
const treeRect = treeRef.current?.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
treeBounds = treeRect
|
||||
? { left: treeRect.left, top: treeRect.top, width: treeRect.width }
|
||||
: { left: canvasRect.left + canvas.width * 0.2, top: canvasRect.top + canvas.height * 0.4, width: canvas.width * 0.6 };
|
||||
canvasBounds = { left: canvasRect.left, top: canvasRect.top };
|
||||
}
|
||||
measure();
|
||||
const resizeObserver = new ResizeObserver(measure);
|
||||
if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
|
||||
if (treeRef.current) resizeObserver.observe(treeRef.current);
|
||||
|
||||
function spawnLeaf(): Leaf {
|
||||
const spawnY = treeBounds.top - canvasBounds.top + TRUNK_TOP_CSS;
|
||||
const spawnX = treeBounds.left - canvasBounds.left + Math.random() * treeBounds.width * 0.8 + treeBounds.width * 0.1;
|
||||
return {
|
||||
x: spawnX,
|
||||
y: spawnY,
|
||||
size: Math.random() * 4 + 2,
|
||||
speed: Math.random() * 0.3 + 0.15,
|
||||
drift: Math.random() * 0.5 - 0.25,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.02,
|
||||
opacity: Math.random() * 0.25 + 0.08,
|
||||
sway: Math.random() * 30 + 15,
|
||||
swaySpeed: Math.random() * 0.008 + 0.003,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
let spawnTimer = 0;
|
||||
|
||||
function draw(timestamp: number) {
|
||||
if (!isRunning) return;
|
||||
animId = requestAnimationFrame(draw);
|
||||
if (timestamp - lastFrame < frameInterval) return;
|
||||
lastFrame = timestamp;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
spawnTimer++;
|
||||
if (spawnTimer > 30 && leaves.length < MAX_LEAVES) {
|
||||
leaves.push(spawnLeaf());
|
||||
spawnTimer = 0;
|
||||
}
|
||||
|
||||
for (let i = leaves.length - 1; i >= 0; i--) {
|
||||
const l = leaves[i];
|
||||
l.y += l.speed;
|
||||
l.x += l.drift + Math.sin(l.phase) * l.swaySpeed * l.sway * 0.05;
|
||||
l.phase += l.swaySpeed;
|
||||
l.rotation += l.rotationSpeed;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(l.x, l.y);
|
||||
ctx.rotate(l.rotation);
|
||||
ctx.globalAlpha = l.opacity;
|
||||
ctx.fillStyle = "#fdb068";
|
||||
|
||||
const s = Math.round(l.size);
|
||||
ctx.fillRect(-s / 2, -s / 2, s, s);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (l.y > canvas.height + 10) {
|
||||
leaves.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (reducedMotion || isRunning || !isVisible || document.hidden) return;
|
||||
isRunning = true;
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isRunning = false;
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
}
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(([entry]) => {
|
||||
isVisible = entry.isIntersecting;
|
||||
if (isVisible) start();
|
||||
else stop();
|
||||
});
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) stop();
|
||||
else start();
|
||||
};
|
||||
intersectionObserver.observe(canvas);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
start();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
intersectionObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className="falling-leaves" />;
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import React, { forwardRef, useEffect, useRef } from "react";
|
||||
|
||||
const PX = 2;
|
||||
const W = 500;
|
||||
const H = 420;
|
||||
const LEAF_FLOOR = H - 60;
|
||||
|
||||
|
||||
function buildTree(seed: number) {
|
||||
const woodGrid = new Uint8Array(W * H);
|
||||
const leafGrid = new Uint8Array(W * H);
|
||||
|
||||
let s = seed;
|
||||
function rand() {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s & 0x7fffffff) / 0x7fffffff;
|
||||
}
|
||||
|
||||
function setWood(x: number, y: number, shade: number) {
|
||||
const px = Math.round(x), py = Math.round(y);
|
||||
if (px >= 0 && px < W && py >= 0 && py < H) {
|
||||
const idx = py * W + px;
|
||||
if (woodGrid[idx] === 0) woodGrid[idx] = shade;
|
||||
}
|
||||
}
|
||||
|
||||
function setLeaf(x: number, y: number, shade: number) {
|
||||
const px = Math.round(x), py = Math.round(y);
|
||||
if (px >= 0 && px < W && py >= 0 && py < LEAF_FLOOR) {
|
||||
const idx = py * W + px;
|
||||
if (shade > leafGrid[idx]) leafGrid[idx] = shade;
|
||||
}
|
||||
}
|
||||
|
||||
function drawLeaf(x: number, y: number, shade: number) {
|
||||
const px = Math.round(x), py = Math.round(y);
|
||||
setLeaf(px, py, shade);
|
||||
const r = rand();
|
||||
if (r < 0.60) setLeaf(px + (rand() > 0.5 ? 1 : -1), py, shade);
|
||||
if (r < 0.32) setLeaf(px, py + (rand() > 0.45 ? -1 : 1), shade);
|
||||
}
|
||||
|
||||
function leafScatter(cx: number, cy: number, radius: number, count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = rand() * Math.PI * 2;
|
||||
const r = Math.sqrt(rand()) * radius;
|
||||
const lx = cx + Math.cos(a) * r * 0.5;
|
||||
const vertStretch = 1.0 + (cy / H) * 3.0;
|
||||
const ly = cy + Math.abs(Math.sin(a)) * r * vertStretch;
|
||||
const yFr = (ly - cy) / radius;
|
||||
const shade = yFr < 0.5 ? 3 : yFr < 1.2 ? 2 : 1;
|
||||
drawLeaf(lx, ly, shade);
|
||||
}
|
||||
}
|
||||
|
||||
function drawLine(
|
||||
x0: number, y0: number, x1: number, y1: number,
|
||||
thickness: number, shade: number,
|
||||
) {
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const steps = Math.max(Math.ceil(dist * 2), 1);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const bx = x0 + dx * t, by = y0 + dy * t;
|
||||
const r = (thickness / 2) * (1 - t * 0.4);
|
||||
for (let oy = -Math.ceil(r); oy <= Math.ceil(r); oy++)
|
||||
for (let ox = -Math.ceil(r); ox <= Math.ceil(r); ox++)
|
||||
if (ox * ox + oy * oy <= r * r) setWood(bx + ox, by + oy, shade);
|
||||
}
|
||||
}
|
||||
|
||||
function branch(
|
||||
x: number, y: number,
|
||||
angle: number, length: number,
|
||||
thickness: number, depth: number,
|
||||
) {
|
||||
const endX = x + Math.cos(angle) * length;
|
||||
const endY = y + Math.sin(angle) * length;
|
||||
|
||||
const woodShade = thickness > 5 ? 1 : thickness > 2.5 ? 2 : 3;
|
||||
drawLine(x, y, endX, endY, thickness, woodShade);
|
||||
|
||||
if (depth <= 0 || length < 1.5) {
|
||||
const r = 4 + rand() * 6;
|
||||
|
||||
const normalised = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||||
const deg = normalised * (180 / Math.PI);
|
||||
const isNearHorizontal = deg < 30 || deg > 150;
|
||||
|
||||
if (isNearHorizontal) {
|
||||
for (let i = 0; i < Math.round(r * r * 0.8); i++) {
|
||||
const ox = (rand() - 0.5) * r * 2;
|
||||
const oy = -rand() * r * 2.5;
|
||||
const shade = oy < -r ? 3 : 2;
|
||||
drawLeaf(endX + ox, endY + oy, shade);
|
||||
}
|
||||
} else {
|
||||
leafScatter(endX, endY, r, Math.round(r * r * 0.6));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth === 1) {
|
||||
const steps = Math.ceil(length / 5);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const mx = x + Math.cos(angle) * length * t;
|
||||
const my = y + Math.sin(angle) * length * t;
|
||||
leafScatter(mx, my, 3 + rand() * 2, 8 + Math.round(rand() * 6));
|
||||
}
|
||||
}
|
||||
|
||||
const numChildren = depth > 4 ? 2 : 3 + (rand() > 0.6 ? 1 : 0);
|
||||
for (let i = 0; i < numChildren; i++) {
|
||||
const spread = 0.30 + rand() * 0.20;
|
||||
const angleOffset = (i - (numChildren - 1) / 2) * spread;
|
||||
const droopBias = 0;
|
||||
const childAngle = angle + angleOffset + (rand() - 0.5) * 0.1 + droopBias;
|
||||
const lenFactor = 0.60 + rand() * 0.12;
|
||||
const thickFactor = 0.50 + rand() * 0.10;
|
||||
branch(endX, endY, childAngle, length * lenFactor,
|
||||
Math.max(1, thickness * thickFactor), depth - 1);
|
||||
}
|
||||
|
||||
if (depth > 1 && rand() > 0.25) {
|
||||
const t = 0.25 + rand() * 0.35;
|
||||
const sx = x + Math.cos(angle) * length * t;
|
||||
const sy = y + Math.sin(angle) * length * t;
|
||||
const side = rand() > 0.5 ? 1 : -1;
|
||||
branch(sx, sy,
|
||||
angle + side * (0.30 + rand() * 0.35), length * 0.5,
|
||||
Math.max(1, thickness * 0.45), depth - 2);
|
||||
}
|
||||
}
|
||||
|
||||
const cx = W / 2;
|
||||
const trunkTop = H - 150;
|
||||
const base = H - 50;
|
||||
|
||||
|
||||
for (let ty = trunkTop; ty <= base; ty++) {
|
||||
const t = (ty - trunkTop) / (base - trunkTop);
|
||||
const width = 10 + t * t * 16;
|
||||
const hw = Math.ceil(width);
|
||||
for (let ox = -hw; ox <= hw; ox++) {
|
||||
const px = Math.round(cx + ox), py = ty;
|
||||
if (px < 0 || px >= W || py < 0 || py >= H) continue;
|
||||
woodGrid[py * W + px] = 2;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
branch(cx - 30, trunkTop + 8, -Math.PI * 0.95, 70, 11, 7);
|
||||
branch(cx - 22, trunkTop + 4, -Math.PI * 0.87, 68, 10, 7);
|
||||
branch(cx - 14, trunkTop, -Math.PI * 0.78, 66, 10, 7);
|
||||
branch(cx - 8, trunkTop - 4, -Math.PI * 0.68, 64, 9, 7);
|
||||
branch(cx - 8, trunkTop + 4, -Math.PI * 0.70, 71, 10, 7);
|
||||
branch(cx - 4, trunkTop - 7, -Math.PI * 0.60, 62, 9, 7);
|
||||
branch(cx, trunkTop - 6, -Math.PI * 0.55, 70, 9, 7);
|
||||
branch(cx, trunkTop - 7, -Math.PI * 0.50, 73, 9, 7);
|
||||
branch(cx, trunkTop - 7, -Math.PI * 0.55, 77, 9, 7);
|
||||
branch(cx, trunkTop - 6, -Math.PI * 0.45, 70, 9, 7);
|
||||
branch(cx, trunkTop - 6, -Math.PI * 0.60, 70, 9, 7);
|
||||
branch(cx, trunkTop - 6, -Math.PI * 0.40, 70, 9, 7);
|
||||
branch(cx + 30, trunkTop + 8, -Math.PI * 0.1, 65, 11, 7);
|
||||
branch(cx + 22, trunkTop + 4, -Math.PI * 0.18, 65, 10, 7);
|
||||
branch(cx + 14, trunkTop, -Math.PI * 0.22, 63, 10, 7);
|
||||
branch(cx + 14, trunkTop, -Math.PI * 0.25, 65, 10, 7);
|
||||
branch(cx + 8, trunkTop - 4, -Math.PI * 0.32, 61, 9, 7);
|
||||
branch(cx + 4, trunkTop - 7, -Math.PI * 0.40, 62, 9, 7);
|
||||
branch(cx + 2, trunkTop + 4, -Math.PI * 0.45, 67, 10, 7);
|
||||
branch(cx + 2, trunkTop + 5, -Math.PI * 0.17, 67, 10, 7);
|
||||
branch(cx - 10, trunkTop + 20, -Math.PI * 0.75, 45, 7, 6);
|
||||
branch(cx - 5, trunkTop + 20, -Math.PI * 0.62, 42, 6, 6);
|
||||
branch(cx, trunkTop + 18, -Math.PI * 0.50, 44, 7, 6);
|
||||
branch(cx + 10, trunkTop + 20, -Math.PI * 0.25, 45, 7, 6);
|
||||
branch(cx + 5, trunkTop + 20, -Math.PI * 0.38, 42, 6, 6);
|
||||
branch(cx + 3, trunkTop - 6, -Math.PI * 0.68, 10, 9, 7);
|
||||
branch(cx - 3, trunkTop - 7, -Math.PI * 0.32, 10, 9, 7);
|
||||
branch(cx + 3, trunkTop - 6, -Math.PI * 0.7, 5, 9, 7);
|
||||
branch(cx - 3, trunkTop - 7, -Math.PI * 0.3, 5, 9, 7);
|
||||
branch(cx + 3, trunkTop - 6, -Math.PI * 0.73, 20, 9, 7);
|
||||
branch(cx, trunkTop - 3, -Math.PI * 0.15, 90, 5, 3);
|
||||
|
||||
|
||||
|
||||
return { woodGrid, leafGrid };
|
||||
}
|
||||
const WOOD_COLORS = ['', '#c96303', '#c96303', '#c96303', '#321901'];
|
||||
const LEAF_COLORS = ['', '#ed5145', '#fdb068', '#fee5cd'];
|
||||
const GROUND_COLORS = ['#fee5cd', '#fdca9b', '#fdb068'];
|
||||
const GRASS_COLORS = ['#ed5145', '#fb7b04', '#c96303', '#f17c74'];
|
||||
|
||||
function hexToRgba(hex: string) {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
parseInt(hex.slice(3, 5), 16),
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
255,
|
||||
];
|
||||
}
|
||||
|
||||
const COLOR_RGBA = new Map(
|
||||
[...WOOD_COLORS, ...LEAF_COLORS, ...GROUND_COLORS, ...GRASS_COLORS]
|
||||
.filter(Boolean)
|
||||
.map((color) => [color, hexToRgba(color)]),
|
||||
);
|
||||
|
||||
export const TRUNK_TOP_CSS = (H - 150) + 10;
|
||||
|
||||
let cachedTreeCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
function renderTreeCanvas() {
|
||||
if (cachedTreeCanvas) return cachedTreeCanvas;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = W * PX;
|
||||
canvas.height = H * PX;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const { woodGrid, leafGrid } = buildTree(55);
|
||||
const pixelCanvas = document.createElement("canvas");
|
||||
const pixelCtx = pixelCanvas.getContext("2d")!;
|
||||
pixelCanvas.width = W;
|
||||
pixelCanvas.height = H;
|
||||
const image = pixelCtx.createImageData(W, H);
|
||||
|
||||
function setPixel(x: number, y: number, color: string) {
|
||||
const rgba = COLOR_RGBA.get(color)!;
|
||||
const index = (y * W + x) * 4;
|
||||
image.data[index] = rgba[0];
|
||||
image.data[index + 1] = rgba[1];
|
||||
image.data[index + 2] = rgba[2];
|
||||
image.data[index + 3] = rgba[3];
|
||||
}
|
||||
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const v = woodGrid[y * W + x];
|
||||
if (v) setPixel(x, y, WOOD_COLORS[v] ?? '#321901');
|
||||
}
|
||||
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const v = leafGrid[y * W + x];
|
||||
if (v) setPixel(x, y, LEAF_COLORS[v]);
|
||||
}
|
||||
|
||||
const GROUND_Y = H - 50;
|
||||
|
||||
let gs = 12345;
|
||||
function grassRand() {
|
||||
gs = (gs * 16807 + 0) % 2147483647;
|
||||
return (gs & 0x7fffffff) / 0x7fffffff;
|
||||
}
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
const wave = Math.sin(x * 0.05) * 3 + Math.sin(x * 0.12) * 2;
|
||||
const groundTop = Math.round(GROUND_Y + wave);
|
||||
for (let y = groundTop; y < H; y++) {
|
||||
const depth = (y - groundTop) / (H - groundTop);
|
||||
const ci = depth < 0.3 ? 0 : depth < 0.7 ? 1 : 2;
|
||||
setPixel(x, y, GROUND_COLORS[ci]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (grassRand() > 0.35) continue;
|
||||
const wave = Math.sin(x * 0.05) * 3 + Math.sin(x * 0.12) * 2;
|
||||
const surfaceY = Math.round(GROUND_Y + wave);
|
||||
const bladeH = Math.floor(grassRand() * 4) + 2;
|
||||
const color = GRASS_COLORS[Math.floor(grassRand() * GRASS_COLORS.length)];
|
||||
for (let b = 0; b < bladeH; b++) {
|
||||
setPixel(x, surfaceY - b - 1, color);
|
||||
}
|
||||
if (grassRand() > 0.6 && x + 1 < W) {
|
||||
setPixel(x + 1, surfaceY - 1, color);
|
||||
}
|
||||
}
|
||||
|
||||
pixelCtx.putImageData(image, 0, 0);
|
||||
ctx.drawImage(pixelCanvas, 0, 0, canvas.width, canvas.height);
|
||||
cachedTreeCanvas = canvas;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function prewarmTreeCanvas() {
|
||||
renderTreeCanvas();
|
||||
}
|
||||
|
||||
const Tree = forwardRef<HTMLCanvasElement>(function Tree(_, ref) {
|
||||
const internalRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasRef = (ref as React.RefObject<HTMLCanvasElement>) ?? internalRef;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
|
||||
const draw = () => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
canvas.width = W * PX;
|
||||
canvas.height = H * PX;
|
||||
ctx.drawImage(renderTreeCanvas(), 0, 0);
|
||||
};
|
||||
const requestIdle = window.requestIdleCallback ?? ((callback) => window.setTimeout(callback, 100));
|
||||
const cancelIdle = window.cancelIdleCallback ?? window.clearTimeout;
|
||||
const idleId = requestIdle(draw, { timeout: 700 });
|
||||
|
||||
return () => cancelIdle(idleId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="tree"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Tree;
|
||||
@@ -0,0 +1,150 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>__SEO_TITLE__</title>
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#e82517" />
|
||||
<meta name="application-name" content="Toothless' TSS Bot" />
|
||||
<style>
|
||||
html { background: #130d08; }
|
||||
html[data-theme="dark"] { background: #130d08; color-scheme: dark; }
|
||||
html[data-theme="dark"] body,
|
||||
html[data-theme="dark"] #root { background: #130d08; }
|
||||
html[data-theme="light"] { background: #fefde7; color-scheme: light; }
|
||||
html[data-theme="light"] body,
|
||||
html[data-theme="light"] #root { background: #fefde7; }
|
||||
</style>
|
||||
<script>
|
||||
(() => {
|
||||
const cookies = Object.fromEntries(
|
||||
document.cookie.split('; ').filter(Boolean).map((item) => {
|
||||
const separator = item.indexOf('=')
|
||||
const key = separator === -1 ? item : item.slice(0, separator)
|
||||
const value = separator === -1 ? '' : item.slice(separator + 1)
|
||||
try {
|
||||
return [decodeURIComponent(key), decodeURIComponent(value)]
|
||||
} catch {
|
||||
return [key, value]
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
let theme = cookies['tssbot_theme']
|
||||
if (!theme) {
|
||||
try {
|
||||
theme = localStorage.getItem('tssbot.theme')
|
||||
} catch {}
|
||||
}
|
||||
theme = theme === 'light' ? 'light' : 'dark'
|
||||
const bg = theme === 'dark' ? '#130d08' : '#fefde7'
|
||||
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
document.documentElement.style.colorScheme = theme
|
||||
|
||||
const customColor = cookies['tssbot_custom_color'] || localStorage.getItem('tssbot.customColor')
|
||||
if (customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)) {
|
||||
document.documentElement.style.setProperty('--color-fury-cyan', customColor)
|
||||
document.documentElement.style.setProperty('--color-ring', customColor)
|
||||
|
||||
const bigint = parseInt(customColor.replace('#', ''), 16)
|
||||
const r = (bigint >> 16) & 255
|
||||
const g = (bigint >> 8) & 255
|
||||
const b = bigint & 255
|
||||
document.documentElement.style.setProperty('--color-shadow', 'rgba(' + r + ', ' + g + ', ' + b + ', ' + (theme === 'dark' ? '0.18' : '0.12') + ')')
|
||||
|
||||
const adjust = (val, percent) => percent > 0
|
||||
? Math.max(0, Math.min(255, Math.round(val + (255 - val) * percent)))
|
||||
: Math.max(0, Math.min(255, Math.round(val * (1 + percent))))
|
||||
const formatHex = (nr, ng, nb) => '#' + ((1 << 24) + (nr << 16) + (ng << 8) + nb).toString(16).slice(1)
|
||||
|
||||
const aquaColor = formatHex(adjust(r, 0.2), adjust(g, 0.2), adjust(b, 0.2))
|
||||
const violetColor = formatHex(adjust(r, -0.2), adjust(g, -0.2), adjust(b, -0.2))
|
||||
|
||||
document.documentElement.style.setProperty('--color-fury-aqua', aquaColor)
|
||||
document.documentElement.style.setProperty('--color-fury-violet', violetColor)
|
||||
}
|
||||
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute(
|
||||
'content',
|
||||
customColor && /^#[0-9a-fA-F]{6}$/.test(customColor)
|
||||
? customColor
|
||||
: (theme === 'dark' ? '#101211' : '#e82517')
|
||||
)
|
||||
|
||||
try {
|
||||
const rawColors = cookies['tssbot_custom_colors'] || localStorage.getItem('tssbot.customColors')
|
||||
if (rawColors) {
|
||||
const colors = JSON.parse(rawColors)
|
||||
const cssVarMap = { bg: '--color-bg', surface: '--color-surface', text: '--color-text', border: '--color-border', textSoft: '--color-text-soft', textMuted: '--color-text-muted' }
|
||||
for (const [field, cssVar] of Object.entries(cssVarMap)) {
|
||||
if (colors[field] && /^#[0-9a-fA-F]{6}$/.test(colors[field])) {
|
||||
document.documentElement.style.setProperty(cssVar, colors[field])
|
||||
if (field === 'bg') document.documentElement.style.backgroundColor = colors[field]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let analyticsPreferences = null
|
||||
const serializedPreferences = cookies['tssbot_analytics_preferences']
|
||||
try {
|
||||
analyticsPreferences = JSON.parse(
|
||||
serializedPreferences || localStorage.getItem('tssbot.analyticsPreferences') || 'null',
|
||||
)
|
||||
} catch {}
|
||||
|
||||
window.__TSS_BOOT_PREFERENCES__ = { analyticsPreferences, theme }
|
||||
})()
|
||||
</script>
|
||||
<meta name="tss-turnstile-session" content="__TURNSTILE_SESSION__" />
|
||||
<meta
|
||||
name="description"
|
||||
content="__SEO_DESCRIPTION__"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="War Thunder TSS, Tournament Service, TSS leaderboard, TSS teams, TSS battle logs, War Thunder tournaments, Toothless TSS Bot"
|
||||
/>
|
||||
<meta name="author" content="Toothless' TSS Bot" />
|
||||
<meta name="robots" content="__SEO_ROBOTS__" />
|
||||
<link rel="canonical" href="__SEO_CANONICAL__" />
|
||||
|
||||
<meta property="og:site_name" content="Toothless' TSS Bot" />
|
||||
<meta property="og:locale" content="en_GB" />
|
||||
<meta property="og:type" content="__SEO_OG_TYPE__" />
|
||||
<meta property="og:url" content="__SEO_CANONICAL__" />
|
||||
<meta property="og:title" content="__SEO_TITLE__" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="__SEO_DESCRIPTION__"
|
||||
/>
|
||||
<meta property="og:image" content="__SEO_IMAGE__" />
|
||||
<meta property="og:image:secure_url" content="__SEO_IMAGE__" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Toothless' TSS Bot War Thunder TSS dashboard" />
|
||||
<meta property="article:author" content="__SEO_AUTHOR__" />
|
||||
<meta property="article:published_time" content="__SEO_PUBLISHED_TIME__" />
|
||||
<meta property="article:modified_time" content="__SEO_MODIFIED_TIME__" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="__SEO_TITLE__" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="__SEO_DESCRIPTION__"
|
||||
/>
|
||||
<meta name="twitter:image" content="__SEO_IMAGE__" />
|
||||
<link rel="icon" type="image/svg+xml" href="/embed-icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/embed-icon.svg" />
|
||||
<script type="application/ld+json" id="site-structured-data">
|
||||
__SEO_JSON_LD__
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
# Static Public Data
|
||||
|
||||
The web server serves `/data/*` from the same public data cache used by
|
||||
`/api/tss/*`, and fills missing snapshots from the matching API route with a
|
||||
short timeout. The frontend uses `/api/tss/*` by default; set
|
||||
`VITE_STATIC_DATA=true` only for static-first experiments.
|
||||
|
||||
- `/data/leaderboard-teams.json`
|
||||
- `/data/leaderboard-players.json`
|
||||
- `/data/home-teams.json`
|
||||
- `/data/recent-games.json`
|
||||
- `/data/teams/{key}.json`
|
||||
- `/data/teams/{key}.games.json`
|
||||
- `/data/players/{key}.json`
|
||||
- `/data/games/{key}.json`
|
||||
- `/data/games/{key}.logs.json`
|
||||
|
||||
`{key}` is `encodeURIComponent(value).replace(/%/g, '~')`.
|
||||
|
||||
Snapshot files should keep the same response shape as the matching `/api/tss/*`
|
||||
endpoint. Missing files are fine: the app remembers the miss for the current
|
||||
browser session and uses the existing API route instead.
|
||||
|
||||
Do not generate every possible entity forever. Keep `/data` bounded:
|
||||
|
||||
- Always generate the small global snapshots: leaderboards, home teams, recent
|
||||
games.
|
||||
- Generate team/player/game detail snapshots only for hot items, such as current
|
||||
leaderboard entries, recent games, recently viewed pages, or pinned items.
|
||||
- Prune detail snapshots by age and access. For example: keep recent games for
|
||||
7-30 days, keep leaderboard teams/players while they remain ranked, and delete
|
||||
cold files that have not been refreshed recently.
|
||||
- Write snapshots atomically (`file.tmp` then rename) so readers never see a
|
||||
partial JSON file.
|
||||
- Serve compressed responses from the web server/CDN when possible. Keep source
|
||||
JSON minified unless humans need to inspect a fixture.
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-labelledby="title">
|
||||
<title id="title">Toothless' TSS Bot tree icon</title>
|
||||
<rect width="96" height="96" rx="18" fill="#fefde7"/>
|
||||
<rect x="7" y="7" width="82" height="82" rx="14" fill="#fff2e6" stroke="#fdca9b" stroke-width="4"/>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect x="43" y="48" width="10" height="26" fill="#c96303"/>
|
||||
<rect x="39" y="62" width="18" height="10" fill="#c96303"/>
|
||||
<rect x="34" y="70" width="28" height="5" fill="#c96303"/>
|
||||
<rect x="18" y="28" width="28" height="14" fill="#ed5145"/>
|
||||
<rect x="28" y="18" width="28" height="15" fill="#ed5145"/>
|
||||
<rect x="48" y="24" width="30" height="16" fill="#ed5145"/>
|
||||
<rect x="28" y="40" width="40" height="16" fill="#ed5145"/>
|
||||
<rect x="24" y="32" width="18" height="6" fill="#fdb068"/>
|
||||
<rect x="34" y="22" width="18" height="6" fill="#fdb068"/>
|
||||
<rect x="54" y="28" width="18" height="6" fill="#fdb068"/>
|
||||
<rect x="38" y="44" width="20" height="6" fill="#fdb068"/>
|
||||
<rect x="40" y="25" width="8" height="4" fill="#fee5cd"/>
|
||||
<rect x="56" y="31" width="8" height="4" fill="#fee5cd"/>
|
||||
<rect x="46" y="47" width="8" height="4" fill="#fee5cd"/>
|
||||
<rect x="14" y="76" width="68" height="6" fill="#fdca9b"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,80 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title description">
|
||||
<title id="title">Toothless' TSS Bot</title>
|
||||
<desc id="description">A share card for Toothless' TSS Bot using the pixel tree from the home page.</desc>
|
||||
<rect width="1200" height="630" fill="#fefde7"/>
|
||||
<path d="M0 452 120 384l118 42 152-126 168 112 132-76 124 84 174-154 212 176v188H0Z" fill="#fcfbcf"/>
|
||||
<path d="M0 498 130 430l110 42 142-92 170 102 120-54 132 66 180-112 216 128v120H0Z" fill="#f9f69f"/>
|
||||
<rect x="72" y="72" width="1056" height="486" rx="28" fill="#fefde7" stroke="#fdca9b" stroke-width="6"/>
|
||||
<rect x="96" y="96" width="1008" height="438" rx="18" fill="#fff2e6" opacity=".54"/>
|
||||
|
||||
<g transform="translate(780 144) scale(.58)" shape-rendering="crispEdges">
|
||||
<rect x="0" y="0" width="500" height="420" fill="#fefde7" stroke="#fdca9b" stroke-width="6"/>
|
||||
<path d="M0 372c55-16 116-12 173 3 46 12 101 20 157 4 58-17 112-10 170 5v36H0Z" fill="#fee5cd"/>
|
||||
<path d="M0 394c54-10 110-8 172 6 54 12 108 10 160-4 58-15 112-10 168 4v20H0Z" fill="#fdca9b"/>
|
||||
|
||||
<g fill="#c96303">
|
||||
<rect x="232" y="248" width="38" height="118"/>
|
||||
<rect x="224" y="300" width="54" height="72"/>
|
||||
<rect x="214" y="346" width="76" height="34"/>
|
||||
<rect x="198" y="366" width="112" height="18"/>
|
||||
<rect x="196" y="232" width="62" height="16" transform="rotate(-28 196 232)"/>
|
||||
<rect x="248" y="222" width="64" height="14" transform="rotate(25 248 222)"/>
|
||||
<rect x="176" y="198" width="88" height="14" transform="rotate(-46 176 198)"/>
|
||||
<rect x="242" y="188" width="88" height="14" transform="rotate(42 242 188)"/>
|
||||
<rect x="150" y="164" width="92" height="12" transform="rotate(-20 150 164)"/>
|
||||
<rect x="270" y="154" width="98" height="12" transform="rotate(18 270 154)"/>
|
||||
<rect x="212" y="120" width="82" height="12" transform="rotate(-72 212 120)"/>
|
||||
<rect x="246" y="118" width="78" height="12" transform="rotate(68 246 118)"/>
|
||||
</g>
|
||||
|
||||
<g fill="#ed5145">
|
||||
<rect x="76" y="110" width="112" height="52"/>
|
||||
<rect x="114" y="70" width="128" height="62"/>
|
||||
<rect x="198" y="48" width="116" height="68"/>
|
||||
<rect x="278" y="76" width="132" height="60"/>
|
||||
<rect x="334" y="126" width="92" height="54"/>
|
||||
<rect x="102" y="160" width="116" height="58"/>
|
||||
<rect x="190" y="134" width="148" height="66"/>
|
||||
<rect x="278" y="172" width="116" height="58"/>
|
||||
</g>
|
||||
<g fill="#fdb068">
|
||||
<rect x="98" y="124" width="84" height="26"/>
|
||||
<rect x="138" y="84" width="92" height="30"/>
|
||||
<rect x="216" y="66" width="82" height="28"/>
|
||||
<rect x="292" y="92" width="96" height="30"/>
|
||||
<rect x="354" y="142" width="58" height="24"/>
|
||||
<rect x="126" y="178" width="78" height="24"/>
|
||||
<rect x="210" y="150" width="104" height="28"/>
|
||||
<rect x="300" y="190" width="76" height="24"/>
|
||||
</g>
|
||||
<g fill="#fee5cd">
|
||||
<rect x="150" y="98" width="44" height="16"/>
|
||||
<rect x="238" y="76" width="38" height="16"/>
|
||||
<rect x="314" y="104" width="48" height="16"/>
|
||||
<rect x="230" y="160" width="48" height="16"/>
|
||||
<rect x="322" y="202" width="36" height="14"/>
|
||||
</g>
|
||||
<g fill="#fb7b04">
|
||||
<rect x="74" y="360" width="6" height="18"/>
|
||||
<rect x="116" y="366" width="6" height="16"/>
|
||||
<rect x="342" y="362" width="6" height="20"/>
|
||||
<rect x="402" y="370" width="6" height="14"/>
|
||||
<rect x="444" y="364" width="6" height="18"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(122 128)">
|
||||
<text x="0" y="36" fill="#e82517" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="26" font-weight="800" letter-spacing="2">LIVE TSS INTEL</text>
|
||||
<text x="0" y="132" fill="#000" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="76" font-weight="900">Toothless'</text>
|
||||
<text x="0" y="216" fill="#000" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="76" font-weight="900">TSS Bot</text>
|
||||
<text x="0" y="282" fill="#555" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="30" font-weight="600">Leaderboards, battle logs, uptime, and viewer analytics.</text>
|
||||
</g>
|
||||
<g transform="translate(122 458)" fill="#000" font-family="Inter, Segoe UI, Arial, sans-serif" font-size="24" font-weight="800">
|
||||
<rect x="0" y="-34" width="184" height="54" rx="8" fill="#fcfbcf" stroke="#fdca9b"/>
|
||||
<text x="24" y="2">Teams</text>
|
||||
<rect x="208" y="-34" width="214" height="54" rx="8" fill="#fcfbcf" stroke="#fdca9b"/>
|
||||
<text x="232" y="2">Battle Logs</text>
|
||||
<rect x="446" y="-34" width="154" height="54" rx="8" fill="#fcfbcf" stroke="#fdca9b"/>
|
||||
<text x="470" y="2">Uptime</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,12 @@
|
||||
# 3ds Max Wavefront OBJ Exporter v0.97b - (c)2007 guruware
|
||||
# File Created: 27.04.2017 18:23:39
|
||||
|
||||
newmtl wire_022022022
|
||||
Ns 32
|
||||
d 1
|
||||
Tr 0
|
||||
Tf 1 1 1
|
||||
illum 2
|
||||
Ka 0.0863 0.0863 0.0863
|
||||
Kd 0.0863 0.0863 0.0863
|
||||
Ks 0.3500 0.3500 0.3500
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,695 @@
|
||||
// Pure 3D replay view. No DOM beyond its own <canvas>; driven entirely by an
|
||||
// external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine.
|
||||
// Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of
|
||||
// its own UI (labels, axes, guide lines, controls, hover/active-list panels).
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
|
||||
|
||||
const MODEL_PATH = '/models/t34/'
|
||||
const MINIMAP_URL = (level) => `/api/match/minimap/${level}`
|
||||
const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full`
|
||||
|
||||
const WIN_COLOR = '#00c800'
|
||||
const LOSE_COLOR = '#dc1e1e'
|
||||
const DRONE_COLOR = '#cbd5e1'
|
||||
|
||||
const DIRECTION_SAMPLE_MS = 700
|
||||
const DIRECTION_BLEND = 0.22
|
||||
const KILL_LINE_WINDOW_MS = 3000
|
||||
const FLIP_MAP_TEXTURE_Z = true
|
||||
|
||||
function coordsAreValid(c) {
|
||||
return c &&
|
||||
Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) &&
|
||||
Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) &&
|
||||
Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1)
|
||||
}
|
||||
|
||||
function normalizeCoords(c) {
|
||||
if (!coordsAreValid(c)) return null
|
||||
return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) }
|
||||
}
|
||||
|
||||
function mapSourceRect(baseCoords, renderCoords) {
|
||||
const base = normalizeCoords(baseCoords)
|
||||
const render = normalizeCoords(renderCoords)
|
||||
if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 }
|
||||
const dx = base.x1 - base.x0
|
||||
const dz = base.z1 - base.z0
|
||||
const xLo = Math.min(render.x0, render.x1)
|
||||
const xHi = Math.max(render.x0, render.x1)
|
||||
const zLo = Math.min(render.z0, render.z1)
|
||||
const zHi = Math.max(render.z0, render.z1)
|
||||
const u0 = (xLo - base.x0) / dx
|
||||
const u1 = (xHi - base.x0) / dx
|
||||
const v0 = (zLo - base.z0) / dz
|
||||
const v1 = (zHi - base.z0) / dz
|
||||
const uMin = Math.max(0, Math.min(1, Math.min(u0, u1)))
|
||||
const uMax = Math.max(0, Math.min(1, Math.max(u0, u1)))
|
||||
const vMin = Math.max(0, Math.min(1, Math.min(v0, v1)))
|
||||
const vMax = Math.max(0, Math.min(1, Math.max(v0, v1)))
|
||||
const w = uMax - uMin
|
||||
const h = vMax - vMin
|
||||
if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 }
|
||||
return { u: uMin, v: 1 - vMax, w, h }
|
||||
}
|
||||
|
||||
function fallbackBoundsCoords(bounds) {
|
||||
const pad = Math.max(bounds.planarSpan * 0.15, 250)
|
||||
return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad }
|
||||
}
|
||||
|
||||
function catmullRom(a, b, c, d, t) {
|
||||
const t2 = t * t
|
||||
const t3 = t2 * t
|
||||
return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3)
|
||||
}
|
||||
|
||||
export default class ReplayCanvas3D {
|
||||
constructor(container, data) {
|
||||
this.container = container
|
||||
this.data = data
|
||||
this.disposed = false
|
||||
this.currentT = 0
|
||||
this.selectedPlayerId = null
|
||||
this.smoothByEntity = new Map()
|
||||
|
||||
this.players = {}
|
||||
for (const p of data.players || []) this.players[p.id] = p
|
||||
this.teamWon = data.teamWon
|
||||
this.winnerSlot = Number(data.winnerSlot) || 0
|
||||
|
||||
this._buildEntities()
|
||||
this._buildCaptureModel()
|
||||
this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos)
|
||||
|
||||
this.bounds = this._computeBounds()
|
||||
this.scale = 1200 / this.bounds.planarSpan
|
||||
|
||||
this._initThree()
|
||||
|
||||
this._mode = 'ground'
|
||||
this.mapInfo = this._resolveMapInfo('ground')
|
||||
|
||||
this._buildScene()
|
||||
this._loadTankTemplate()
|
||||
this._loadMapTexture()
|
||||
|
||||
this._animate = this._animate.bind(this)
|
||||
this._raf = requestAnimationFrame(this._animate)
|
||||
this.setTime(0)
|
||||
}
|
||||
|
||||
// ---- data adaptation -----------------------------------------------------
|
||||
|
||||
_isWinner(entity) {
|
||||
if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon
|
||||
return entity.droneTeam === this.teamWon
|
||||
}
|
||||
|
||||
_colorFor(entity) {
|
||||
if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR
|
||||
return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR
|
||||
}
|
||||
|
||||
_buildEntities() {
|
||||
this.entities = []
|
||||
for (const e of this.data.entities || []) {
|
||||
if (!Array.isArray(e.path) || !e.path.length) continue
|
||||
const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) }))
|
||||
this.entities.push({
|
||||
playerId: e.playerId,
|
||||
entityIndex: e.entityIndex,
|
||||
type: e.type,
|
||||
droneTeam: e.droneTeam,
|
||||
path,
|
||||
startT: path[0].t,
|
||||
endT: path[path.length - 1].t,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_buildCaptureModel() {
|
||||
const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : []
|
||||
const state = this.data.captureState || {}
|
||||
this.captureZones = areas.map((area, index) => {
|
||||
const letter = String.fromCharCode(65 + index)
|
||||
const cap = Array.isArray(state[letter])
|
||||
? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v))
|
||||
: []
|
||||
const center = area.tm?.center || [area.x, 0, area.z]
|
||||
return {
|
||||
key: letter,
|
||||
center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) },
|
||||
radius: Math.max(1, Number(area.radius) || 1),
|
||||
cap,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_computeBounds() {
|
||||
const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
|
||||
for (const e of this.entities) {
|
||||
for (const p of e.path) {
|
||||
b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x)
|
||||
b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y)
|
||||
b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z)
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 }
|
||||
b.centerX = (b.minX + b.maxX) / 2
|
||||
b.centerZ = (b.minZ + b.maxZ) / 2
|
||||
b.spanX = Math.max(1, b.maxX - b.minX)
|
||||
b.spanZ = Math.max(1, b.maxZ - b.minZ)
|
||||
b.planarSpan = Math.max(b.spanX, b.spanZ)
|
||||
return b
|
||||
}
|
||||
|
||||
_resolveMapInfo(mode) {
|
||||
const level = this.data.mission?.level
|
||||
const full = this.data.fullMapLevel
|
||||
if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) {
|
||||
const coords = normalizeCoords(this.data.mapCoords)
|
||||
return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } }
|
||||
}
|
||||
const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds)
|
||||
const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords
|
||||
return {
|
||||
image: level ? MINIMAP_URL(level) : null,
|
||||
coords,
|
||||
baseCoords,
|
||||
sourceRect: mapSourceRect(baseCoords, coords),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- coordinate transforms ----------------------------------------------
|
||||
|
||||
_toWorld(p) {
|
||||
const b = this.bounds
|
||||
return new THREE.Vector3(
|
||||
(p.x - b.centerX) * this.scale,
|
||||
(p.y - b.minY) * this.scale,
|
||||
(p.z - b.centerZ) * this.scale,
|
||||
)
|
||||
}
|
||||
|
||||
_renderPoint(p) {
|
||||
if (!FLIP_MAP_TEXTURE_Z) return p
|
||||
const coords = normalizeCoords(this.mapInfo?.coords)
|
||||
if (!coords) return p
|
||||
return { ...p, z: coords.z0 + coords.z1 - p.z }
|
||||
}
|
||||
|
||||
// ---- three.js setup ------------------------------------------------------
|
||||
|
||||
_initThree() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.className = 'rc3d-canvas'
|
||||
this.canvasEl = canvas
|
||||
this.container.appendChild(canvas)
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.setClearColor(0x0b0d10, 1)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000)
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||
this.controls.enableDamping = true
|
||||
this.controls.dampingFactor = 0.08
|
||||
this.controls.screenSpacePanning = false
|
||||
this.controls.maxPolarAngle = Math.PI * 0.49
|
||||
this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE
|
||||
this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
|
||||
this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN
|
||||
|
||||
this.root = new THREE.Group()
|
||||
this.mapGroup = new THREE.Group()
|
||||
this.pathGroup = new THREE.Group()
|
||||
this.zoneGroup = new THREE.Group()
|
||||
this.killLineGroup = new THREE.Group()
|
||||
this.markerGroup = new THREE.Group()
|
||||
this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup)
|
||||
this.scene.add(this.root)
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.72))
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.1)
|
||||
sun.position.set(240, 500, 180)
|
||||
this.scene.add(sun)
|
||||
|
||||
this.resize()
|
||||
}
|
||||
|
||||
_worldMapExtents(coords = this.mapInfo?.coords) {
|
||||
const c = normalizeCoords(coords)
|
||||
if (!c) return null
|
||||
const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 })
|
||||
const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 })
|
||||
return {
|
||||
minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x),
|
||||
minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z),
|
||||
width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z),
|
||||
}
|
||||
}
|
||||
|
||||
_resetCamera() {
|
||||
const ext = this._worldMapExtents()
|
||||
const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100)
|
||||
this.camera.position.set(span * 0.45, span * 0.55, span * 0.72)
|
||||
this.controls.target.set(0, 0, 0)
|
||||
this.controls.minDistance = Math.max(18, span * 0.025)
|
||||
this.controls.maxDistance = Math.max(320, span * 1.8)
|
||||
this.camera.far = Math.max(9000, span * 8)
|
||||
this.camera.updateProjectionMatrix()
|
||||
this.controls.update()
|
||||
}
|
||||
|
||||
// ---- scene construction --------------------------------------------------
|
||||
|
||||
_buildScene() {
|
||||
this._clearGroup(this.mapGroup)
|
||||
this._clearGroup(this.pathGroup)
|
||||
this._clearGroup(this.markerGroup)
|
||||
this._clearGroup(this.zoneGroup)
|
||||
this._clearGroup(this.killLineGroup)
|
||||
this._buildMapPlane()
|
||||
this._buildCaptureZones()
|
||||
this._buildKillLines()
|
||||
this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e))
|
||||
this._applyHighlight()
|
||||
this._resetCamera()
|
||||
}
|
||||
|
||||
_clearGroup(group) {
|
||||
for (const child of [...group.children]) {
|
||||
group.remove(child)
|
||||
child.traverse?.((c) => {
|
||||
if (c.geometry) c.geometry.dispose()
|
||||
if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_buildMapPlane() {
|
||||
const info = this.mapInfo
|
||||
if (!info || !coordsAreValid(info.coords)) return
|
||||
const x0 = Math.min(info.coords.x0, info.coords.x1)
|
||||
const x1 = Math.max(info.coords.x0, info.coords.x1)
|
||||
const z0 = Math.min(info.coords.z0, info.coords.z1)
|
||||
const z1 = Math.max(info.coords.z0, info.coords.z1)
|
||||
const width = Math.max(1, (x1 - x0) * this.scale)
|
||||
const depth = Math.max(1, (z1 - z0) * this.scale)
|
||||
const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 })
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(width, depth, 1, 1)
|
||||
this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 })
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide,
|
||||
})
|
||||
const plane = new THREE.Mesh(geometry, material)
|
||||
plane.position.set(center.x, -0.12, center.z)
|
||||
plane.renderOrder = -10
|
||||
this.mapGroup.add(plane)
|
||||
this._mapMaterial = material
|
||||
}
|
||||
|
||||
_applyPlaneUvs(geometry, rect) {
|
||||
const u0 = rect.u
|
||||
const u1 = rect.u + rect.w
|
||||
const vTop = rect.v
|
||||
const vBottom = rect.v + rect.h
|
||||
const uv = geometry.attributes.uv
|
||||
if (FLIP_MAP_TEXTURE_Z) {
|
||||
uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop)
|
||||
uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom)
|
||||
} else {
|
||||
uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom)
|
||||
uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop)
|
||||
}
|
||||
uv.needsUpdate = true
|
||||
}
|
||||
|
||||
_loadMapTexture() {
|
||||
if (!this.mapInfo.image || !this._mapMaterial) return
|
||||
const material = this._mapMaterial
|
||||
new THREE.TextureLoader().load(
|
||||
this.mapInfo.image,
|
||||
(texture) => {
|
||||
if (this.disposed) { texture.dispose(); return }
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
|
||||
material.map = texture
|
||||
material.color.setHex(0xffffff)
|
||||
material.needsUpdate = true
|
||||
},
|
||||
undefined,
|
||||
() => { material.color.setHex(0x1f2933); material.opacity = 0.6 },
|
||||
)
|
||||
}
|
||||
|
||||
_makeVisualEntity(entity) {
|
||||
const color = this._colorFor(entity)
|
||||
const points = entity.path.map((p) => this._toWorld(this._renderPoint(p)))
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||
const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 })
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial)
|
||||
this.pathGroup.add(line)
|
||||
|
||||
const radius = 4.4
|
||||
let model
|
||||
if (this.unitTemplate && entity.type === 'ground') {
|
||||
model = this.unitTemplate.clone(true)
|
||||
model.traverse((child) => {
|
||||
if (!child.isMesh) return
|
||||
child.material = new THREE.MeshStandardMaterial({
|
||||
color, roughness: 0.72, metalness: 0.06, flatShading: true,
|
||||
emissive: new THREE.Color(color).multiplyScalar(0.22),
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const geo = new THREE.SphereGeometry(radius, 18, 12)
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08,
|
||||
})
|
||||
model = new THREE.Mesh(geo, mat)
|
||||
}
|
||||
|
||||
const arrow = new THREE.ArrowHelper(
|
||||
new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0),
|
||||
radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9,
|
||||
)
|
||||
arrow.line.material.transparent = true
|
||||
arrow.line.material.opacity = 0.78
|
||||
arrow.cone.material.transparent = true
|
||||
arrow.cone.material.opacity = 0.95
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model, arrow)
|
||||
this.markerGroup.add(group)
|
||||
|
||||
return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null }
|
||||
}
|
||||
|
||||
async _loadTankTemplate() {
|
||||
try {
|
||||
const object = await new Promise((resolve, reject) => {
|
||||
const loader = new OBJLoader()
|
||||
loader.setPath(MODEL_PATH)
|
||||
loader.load('t_34_obj.obj', resolve, undefined, reject)
|
||||
})
|
||||
if (this.disposed) return
|
||||
|
||||
const planes = []
|
||||
object.traverse((child) => {
|
||||
if (!child.isMesh) return
|
||||
child.castShadow = false
|
||||
child.receiveShadow = false
|
||||
if (/^Plane/i.test(child.name || '')) { planes.push(child); return }
|
||||
})
|
||||
for (const plane of planes) plane.removeFromParent()
|
||||
|
||||
const box = new THREE.Box3()
|
||||
object.traverse((child) => { if (child.isMesh) box.expandByObject(child) })
|
||||
if (box.isEmpty()) box.setFromObject(object)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||
const scale = 70 / maxDim
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const pivot = new THREE.Group()
|
||||
object.position.x -= center.x
|
||||
object.position.z -= center.z
|
||||
object.position.y -= box.min.y
|
||||
pivot.add(object)
|
||||
pivot.scale.setScalar(scale)
|
||||
this.unitTemplate = pivot
|
||||
|
||||
// Rebuild ground entities now that the model is available.
|
||||
this._buildScene()
|
||||
this.setTime(this.currentT)
|
||||
} catch {
|
||||
// No model -> spheres are used; nothing else to do.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- capture zones -------------------------------------------------------
|
||||
|
||||
_buildCaptureZones() {
|
||||
for (const zone of this.captureZones) {
|
||||
const center = this._toWorld(this._renderPoint(zone.center))
|
||||
const radius = Math.max(8, zone.radius * this.scale)
|
||||
const group = new THREE.Group()
|
||||
group.position.set(center.x, 1.05, center.z)
|
||||
|
||||
const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96)
|
||||
ringGeo.rotateX(-Math.PI / 2)
|
||||
const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
|
||||
color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide,
|
||||
}))
|
||||
ring.renderOrder = 8
|
||||
|
||||
const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96)
|
||||
fillGeo.rotateX(-Math.PI / 2)
|
||||
const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({
|
||||
color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
|
||||
}))
|
||||
fill.position.y = 0.02
|
||||
fill.renderOrder = 7
|
||||
|
||||
group.add(fill, ring)
|
||||
group.userData = { zone, fill }
|
||||
this.zoneGroup.add(group)
|
||||
}
|
||||
}
|
||||
|
||||
// Step interpolation, matching the 2D engine's _interpSeries(series, t, true).
|
||||
_captureValueAt(zone, t) {
|
||||
const cap = zone.cap
|
||||
if (!cap.length) return 0
|
||||
if (t <= cap[0][0]) return cap[0][1]
|
||||
const last = cap[cap.length - 1]
|
||||
if (t >= last[0]) return last[1]
|
||||
for (let i = 1; i < cap.length; i++) {
|
||||
if (cap[i][0] >= t) return cap[i - 1][1]
|
||||
}
|
||||
return last[1]
|
||||
}
|
||||
|
||||
_updateCaptureZones() {
|
||||
for (const group of this.zoneGroup.children) {
|
||||
const { zone, fill } = group.userData
|
||||
if (!zone || !fill) continue
|
||||
// Same logic as the 2D engine: owner is slot 2 if value>0 else slot 1,
|
||||
// coloured green when it matches the winning slot, red otherwise.
|
||||
const value = this._captureValueAt(zone, this.currentT)
|
||||
const frac = Math.min(1, Math.abs(value) / 100)
|
||||
if (frac <= 0.01) { fill.visible = false; continue }
|
||||
const ownerSlot = value > 0 ? 2 : 1
|
||||
fill.visible = true
|
||||
fill.material.color.set(ownerSlot === this.winnerSlot ? WIN_COLOR : LOSE_COLOR)
|
||||
fill.material.opacity = 0.18 + frac * 0.34
|
||||
const s = Math.sqrt(frac)
|
||||
fill.scale.set(s, s, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- kill lines ----------------------------------------------------------
|
||||
|
||||
_buildKillLines() {
|
||||
this.killLines = []
|
||||
for (const k of this.kills) {
|
||||
const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z }))
|
||||
const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z }))
|
||||
start.y += 12; end.y += 12
|
||||
const vec = end.clone().sub(start)
|
||||
const length = vec.length()
|
||||
if (length < 0.5) continue
|
||||
const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR)
|
||||
const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color,
|
||||
Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055)))
|
||||
for (const m of [arrow.line.material, arrow.cone.material]) {
|
||||
m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false
|
||||
}
|
||||
arrow.renderOrder = 18
|
||||
arrow.visible = false
|
||||
this.killLineGroup.add(arrow)
|
||||
this.killLines.push({ time: k.time, arrow })
|
||||
}
|
||||
}
|
||||
|
||||
_updateKillLines() {
|
||||
for (const { time, arrow } of this.killLines) {
|
||||
const age = this.currentT - time
|
||||
const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS
|
||||
arrow.visible = visible
|
||||
if (!visible) continue
|
||||
const opacity = 1 - age / KILL_LINE_WINDOW_MS
|
||||
arrow.line.material.opacity = 0.18 + opacity * 0.62
|
||||
arrow.cone.material.opacity = 0.24 + opacity * 0.72
|
||||
}
|
||||
}
|
||||
|
||||
// ---- interpolation -------------------------------------------------------
|
||||
|
||||
_findSegment(entity, t) {
|
||||
const path = entity.path
|
||||
if (t < entity.startT || t > entity.endT) return null
|
||||
let lo = 0, hi = path.length - 1
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (path[mid].t <= t) lo = mid; else hi = mid
|
||||
}
|
||||
return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] }
|
||||
}
|
||||
|
||||
_interp(entity, t) {
|
||||
const seg = this._findSegment(entity, t)
|
||||
if (!seg) return null
|
||||
const { index, a, b } = seg
|
||||
const path = entity.path
|
||||
const span = Math.max(1, b.t - a.t)
|
||||
const alpha = Math.min(1, Math.max(0, (t - a.t) / span))
|
||||
const before = path[Math.max(0, index - 1)]
|
||||
const after = path[Math.min(path.length - 1, index + 2)]
|
||||
return {
|
||||
t,
|
||||
x: catmullRom(before.x, a.x, b.x, after.x, alpha),
|
||||
y: catmullRom(before.y, a.y, b.y, after.y, alpha),
|
||||
z: catmullRom(before.z, a.z, b.z, after.z, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
_directionAt(entity, t) {
|
||||
const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS)
|
||||
const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS)
|
||||
if (afterT <= beforeT) return null
|
||||
const before = this._interp(entity, beforeT)
|
||||
const after = this._interp(entity, afterT)
|
||||
if (!before || !after) return null
|
||||
const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before)))
|
||||
if (dir.lengthSq() < 0.0001) return null
|
||||
return dir.normalize()
|
||||
}
|
||||
|
||||
_blendedDirection(visual, t) {
|
||||
const raw = this._directionAt(visual.entity, t)
|
||||
if (!raw) return visual.smoothedDirection
|
||||
const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800
|
||||
if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone()
|
||||
else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize()
|
||||
visual.lastDirectionT = t
|
||||
return visual.smoothedDirection
|
||||
}
|
||||
|
||||
// ---- highlight / focus ---------------------------------------------------
|
||||
|
||||
_applyHighlight() {
|
||||
const sel = this.selectedPlayerId
|
||||
if (!this.visualEntities) return
|
||||
for (const visual of this.visualEntities) {
|
||||
const isSel = sel != null && visual.entity.playerId === sel
|
||||
const teamColor = this._colorFor(visual.entity)
|
||||
const mat = visual.line.material
|
||||
if (sel == null) {
|
||||
mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||
} else if (isSel) {
|
||||
mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30
|
||||
} else {
|
||||
mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||
}
|
||||
if (visual.model?.isGroup) {
|
||||
visual.model.traverse((child) => {
|
||||
if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
focus(playerId) {
|
||||
this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId
|
||||
this._applyHighlight()
|
||||
if (this.selectedPlayerId == null) return
|
||||
const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible)
|
||||
|| (this.visualEntities || []).find((v) => v.entity.playerId === playerId)
|
||||
if (!visual) return
|
||||
const p = this._interp(visual.entity, this.currentT)
|
||||
if (!p) return
|
||||
const world = this._toWorld(this._renderPoint(p))
|
||||
const offset = this.camera.position.clone().sub(this.controls.target)
|
||||
this.controls.target.copy(world)
|
||||
this.camera.position.copy(world.clone().add(offset))
|
||||
this.controls.update()
|
||||
}
|
||||
|
||||
// ---- public API ----------------------------------------------------------
|
||||
|
||||
setTime(t) {
|
||||
this.currentT = t
|
||||
if (!this.visualEntities) return
|
||||
for (const visual of this.visualEntities) {
|
||||
const p = this._interp(visual.entity, t)
|
||||
visual.group.visible = Boolean(p)
|
||||
if (!p) continue
|
||||
visual.group.position.copy(this._toWorld(this._renderPoint(p)))
|
||||
const dir = this._blendedDirection(visual, t)
|
||||
visual.arrow.visible = Boolean(dir)
|
||||
if (dir) {
|
||||
visual.arrow.setDirection(dir)
|
||||
if (visual.model?.isGroup) {
|
||||
const flat = dir.clone(); flat.y = 0
|
||||
if (flat.lengthSq() > 0) {
|
||||
flat.normalize()
|
||||
visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat)
|
||||
visual.model.rotateY(Math.PI)
|
||||
visual.model.rotateY(-Math.PI / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._updateCaptureZones()
|
||||
this._updateKillLines()
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground'
|
||||
if (next === this._mode) return
|
||||
this._mode = next
|
||||
this.mapInfo = this._resolveMapInfo(next)
|
||||
this.smoothByEntity.clear()
|
||||
this._buildScene()
|
||||
this._loadMapTexture()
|
||||
this.setTime(this.currentT)
|
||||
}
|
||||
|
||||
resize() {
|
||||
const w = this.container.clientWidth || this.container.offsetWidth || 720
|
||||
const h = this.container.clientHeight || w
|
||||
this.renderer.setSize(w, h, false)
|
||||
this.camera.aspect = w / Math.max(1, h)
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
_animate() {
|
||||
if (this.disposed) return
|
||||
this.controls.update()
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this._raf = requestAnimationFrame(this._animate)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true
|
||||
if (this._raf) cancelAnimationFrame(this._raf)
|
||||
this.controls?.dispose()
|
||||
this._clearGroup(this.mapGroup)
|
||||
this._clearGroup(this.pathGroup)
|
||||
this._clearGroup(this.markerGroup)
|
||||
this._clearGroup(this.zoneGroup)
|
||||
this._clearGroup(this.killLineGroup)
|
||||
this.renderer?.dispose()
|
||||
if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Blog posts
|
||||
|
||||
Add a `.md` file in `frontend/src/blog/posts` and it will appear on `/blog` after the Vite dev server refreshes or the site is rebuilt.
|
||||
|
||||
Use this front matter at the top:
|
||||
|
||||
```md
|
||||
---
|
||||
title: Your post title
|
||||
date: 2026-06-22
|
||||
author: Heidi
|
||||
excerpt: Short one-line summary for the blog list.
|
||||
---
|
||||
|
||||
Write the post here.
|
||||
```
|
||||
|
||||
The URL slug comes from the filename, so `new-feature.md` becomes `/blog/new-feature`. You can override it with `slug: custom-url`.
|
||||
|
||||
## Markdown examples
|
||||
|
||||
Headings:
|
||||
|
||||
```md
|
||||
# Huge heading
|
||||
## Section heading
|
||||
### Small heading
|
||||
```
|
||||
|
||||
Paragraphs:
|
||||
|
||||
```md
|
||||
This is a normal paragraph.
|
||||
|
||||
Leave a blank line to start a new paragraph.
|
||||
```
|
||||
|
||||
Bullet points:
|
||||
|
||||
```md
|
||||
- First bullet
|
||||
- Second bullet
|
||||
- Third bullet
|
||||
```
|
||||
|
||||
Numbered lists:
|
||||
|
||||
```md
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Third step
|
||||
```
|
||||
|
||||
Blockquotes:
|
||||
|
||||
```md
|
||||
> This text gets shown as a quote/callout.
|
||||
```
|
||||
|
||||
Links:
|
||||
|
||||
```md
|
||||
[The homepage](/)
|
||||
[Discord](https://discord.com/)
|
||||
```
|
||||
|
||||
Inline code:
|
||||
|
||||
```md
|
||||
Use `frontend/src/blog/posts` for blog posts.
|
||||
```
|
||||
|
||||
Code blocks:
|
||||
|
||||
````md
|
||||
```js
|
||||
console.log('hello from the blog')
|
||||
```
|
||||
````
|
||||
|
||||
## Images and videos
|
||||
|
||||
Put local images and videos in `frontend/public`. Files in there are served from the site root.
|
||||
|
||||
Example local file paths:
|
||||
|
||||
```txt
|
||||
frontend/public/images/news/screenshot.png
|
||||
frontend/public/videos/news/launch.mp4
|
||||
```
|
||||
|
||||
Embed an image:
|
||||
|
||||
```md
|
||||

|
||||
```
|
||||
|
||||
Embed a local video:
|
||||
|
||||
```md
|
||||
@[video](/videos/news/launch.mp4)
|
||||
```
|
||||
|
||||
Embed YouTube or Vimeo:
|
||||
|
||||
```md
|
||||
@[youtube](https://www.youtube.com/watch?v=VIDEO_ID)
|
||||
|
||||
@[vimeo](https://vimeo.com/123456789)
|
||||
```
|
||||
|
||||
YouTube embeds use the privacy-friendly `youtube-nocookie.com` player. Direct video files support `.mp4`, `.webm`, and `.ogg`.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: Update on Preventing Abuse By Bad Actors
|
||||
date: 2026-06-28
|
||||
author: Heidi
|
||||
excerpt: A conclusion on how we plan to tackle undesireable usage of the site :3
|
||||
---
|
||||
|
||||
|
||||
# ♥ Awruff :3
|
||||
Hi all, again,
|
||||
|
||||
Today we'll be talking about actions taken to prevent and limit usage by undesireables.
|
||||
|
||||
|
||||
# Who is affected
|
||||
```md
|
||||
Team: TPC
|
||||
Users:
|
||||
F-2А, 165569402 , EstonianAviator, Bad Actor Affiliation
|
||||
kаspersky, 86157459, idk, Bad Actor Affiliation
|
||||
Tonitrus_, 33536334, idk, Bad Actor Affiliation
|
||||
xbirbx, 41808996, idk, Bad Actor Affiliation
|
||||
Dеviss, 3651161, DEVISS, PEDOPHILE!!!!!!!
|
||||
```
|
||||
|
||||
This team has been blocked
|
||||
|
||||

|
||||
|
||||
## How does this affect you?
|
||||
Honestly, it shouldn't, on their DEBUT return they set a LEGENDARY kd of 0.3 and their team captain had 0 kills, 0 assists and 0 games won with 6 consecutive deaths, its kinda funny? I don't really know.
|
||||
Love you all!!!!! ♥♥♥
|
||||
|
||||
Remember to join our [Discord](https://discord.gg/kP45rcU2Jx) :3
|
||||
Mwahhh~~
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Preventing Abuse By Bad Actors
|
||||
date: 2026-06-27
|
||||
author: Heidi
|
||||
excerpt: A brief on how we plan to tackle undesireable usage of the site :3
|
||||
---
|
||||
|
||||
# ♥ Awruff :3
|
||||
Hi all, again, sorry for the random blog post being dropped at 10pm on a Saturday, lol.
|
||||
|
||||
Just a brief overview of what we're going to be doing to prevent bad actors from being able to properly utilise the site.
|
||||
|
||||
# Why?
|
||||
A pedophile returned to TSS and I really dont like that, I dont think you should have to see their name on a page :3
|
||||
|
||||
## usage prevention.
|
||||
Currently, my working theory is that we could implement discord sign on, and block specific bad actors (TPC) from being able to utilise the site (blocking their discord accounts), however this would remove a lot of functionality from non logged in users, which I dont reeaally want..
|
||||
|
||||
Alternatively, I could just vandilise their user pages and team page, as well as blocking them from search results..
|
||||
|
||||
Open to suggestions, but I dont really want to have to make an account for everyone.. it would suck if you have to log in :(
|
||||
|
||||
Either way, if you have any ideas or such, feel free to drop them in our [Discord](https://discord.gg/kP45rcU2Jx) :3
|
||||
|
||||
Love you all!!!!!
|
||||
|
||||
Have fun!!!!!!!! ♥♥♥
|
||||
|
||||
|
||||
|
||||
|
||||
Current list of users (to be) blocked:
|
||||
|
||||
```md
|
||||
Team: TPC
|
||||
Users:
|
||||
F-2А, 165569402 , EstonianAviator, Bad Actor Affiliation
|
||||
kаspersky, 86157459, idk, Bad Actor Affiliation
|
||||
Tonitrus_, 33536334, idk, Bad Actor Affiliation
|
||||
xbirbx, 41808996, idk, Bad Actor Affiliation
|
||||
Dеviss, 3651161, DEVISS, PEDOPHILE!!!!!!!
|
||||
```
|
||||
|
||||
Dоn't be a creep :3
|
||||
|
||||

|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Our Launch & The Future
|
||||
date: 2026-06-22
|
||||
author: Heidi
|
||||
excerpt: A brief discussion about the site and the future.
|
||||
---
|
||||
|
||||
# ♥ Awruff :3
|
||||
Hi all, glad to see you all (mostly) like the page :3 (over 1000 page views in 24hrs (allegedly) + nothing but positive feedback)
|
||||
|
||||
Just a brief overview of what we're going to be adding in the future
|
||||
|
||||
## stuff:
|
||||
|
||||
- 3D maps for all played maps (in progress, should be done within the next few weeks)
|
||||
@[video](/videos/news/3d_map_preview.mp4)
|
||||
|
||||
- The ability to predict where a player might go on a map, basically just showing all their prior paths as either a heatmap or a couple lines with % probability, based on their historical games
|
||||
|
||||
- Missile timings to the viewer (time to impact, etc)
|
||||
|
||||
- Generally just more 3D viewer stuff, like chaff deployment, flare deployment
|
||||
|
||||
- accurate 3d models for vehicles, player cones of vision, etc.
|
||||
|
||||
Honestly, I dont really know what to add.. lol, if you have any suggestions for stuff you'd like to see, feel free to suggest them on our [Discord](https://discord.gg/kP45rcU2Jx), basically any suggestion will be reviewed :3
|
||||
|
||||
Have fun!!!!!!!! ♥♥♥
|
||||
@@ -0,0 +1,260 @@
|
||||
// Pure bracket-layout logic for the tournament detail page.
|
||||
//
|
||||
// The TSS API hands us authoritative matches, but `round` is numbered *per
|
||||
// `type_bracket`* — the winner tree, the loser tree, and the special
|
||||
// `LooserFinal`/`Semifinal`/`Final` stages each restart at round 0. Grouping by
|
||||
// raw round therefore collapses unrelated stages into one column (e.g. the loser
|
||||
// Semifinal landing in loser "Round 1"). This module maps each match to a
|
||||
// (side, stage, round) coordinate so columns come out in true bracket order, and
|
||||
// drops structural byes so they never render as "Team vs BYE" nodes.
|
||||
//
|
||||
// Kept framework-free so it can be unit-tested with plain node.
|
||||
|
||||
function lower(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
}
|
||||
|
||||
export function cleanName(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
// A "structural bye" is a slot that exists only to pad the bracket: a team that
|
||||
// advanced unopposed, or a placeholder whose feeders were themselves byes. We
|
||||
// hide these. A match with one known team and NO winner yet is *not* a bye —
|
||||
// it's a real pending matchup awaiting an upstream result (grand final, loser
|
||||
// bracket final), so it stays.
|
||||
export function isStructuralBye(match) {
|
||||
const a = cleanName(match.team_a_name)
|
||||
const b = cleanName(match.team_b_name)
|
||||
if (a && b) return false
|
||||
if (!a && !b) return true
|
||||
return Boolean(cleanName(match.winner_name)) || match.status === 'bye'
|
||||
}
|
||||
|
||||
// Map a TSS `type_bracket` to a display side + stage. `stage` orders the special
|
||||
// terminal columns after the numbered rounds within a side:
|
||||
// winner: Winner rounds (0) → grand/championship Final (1)
|
||||
// loser: Looser rounds (0) → LooserFinal (1) → Semifinal/lower final (2)
|
||||
export function classifyBracket(typeBracket, hasLoserSide) {
|
||||
const t = lower(typeBracket)
|
||||
if (t.includes('swiss')) return { side: 'swiss', stage: 0 }
|
||||
if (t.includes('group')) return { side: 'group', stage: 0 }
|
||||
if (t.includes('looserfinal') || t.includes('loserfinal')) return { side: 'loser', stage: 1 }
|
||||
if (t.includes('looser') || t.includes('loser')) return { side: 'loser', stage: 0 }
|
||||
if (t.includes('semifinal')) {
|
||||
// Double-elim: the Semifinal is the lower-bracket final (loser side). Single
|
||||
// elim: it's just a normal winner-side stage.
|
||||
return hasLoserSide ? { side: 'loser', stage: 2 } : { side: 'winner', stage: 0 }
|
||||
}
|
||||
if (t === 'final' || t.endsWith('final')) {
|
||||
// Terminal column of the winner side (championship / grand final).
|
||||
return { side: 'winner', stage: 1 }
|
||||
}
|
||||
if (t.includes('winner')) return { side: 'winner', stage: 0 }
|
||||
return { side: 'winner', stage: 0 }
|
||||
}
|
||||
|
||||
const SIDE_ORDER = { winner: 0, loser: 1, group: 2, swiss: 3, matches: 4 }
|
||||
const SIDE_LABELS = {
|
||||
winner: 'Winner bracket',
|
||||
loser: 'Loser bracket',
|
||||
group: 'Group matches',
|
||||
swiss: 'Swiss matches',
|
||||
matches: 'Matches',
|
||||
}
|
||||
|
||||
function asInt(value, fallback) {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function compareMatches(a, b) {
|
||||
return asInt(a.position, Number.MAX_SAFE_INTEGER) - asInt(b.position, Number.MAX_SAFE_INTEGER)
|
||||
|| String(a.match_id).localeCompare(String(b.match_id))
|
||||
}
|
||||
|
||||
// Label a bracket column. Numbered rounds read "Round N" by their position in
|
||||
// the side; the terminal winner stage reads Final / Grand Final.
|
||||
function columnLabel(side, stage, index, total, hasLoserSide) {
|
||||
if (side === 'winner' && stage === 1) {
|
||||
return hasLoserSide ? 'Grand Final' : 'Final'
|
||||
}
|
||||
return `Round ${index + 1}`
|
||||
}
|
||||
|
||||
// Build the ordered bracket model from authoritative match rows.
|
||||
// Returns { sides: [{ key, label, kind, columns: [{ id, label, matches }] }] }
|
||||
// where kind is 'bracket' (elimination, drawn as a tree) or 'list' (group/swiss,
|
||||
// drawn as a flat card list).
|
||||
export function buildBracket(matches) {
|
||||
const cleaned = (matches || []).filter((m) => !isStructuralBye(m))
|
||||
|
||||
const hasLoserSide = cleaned.some((m) => {
|
||||
const t = lower(m.type_bracket)
|
||||
return t.includes('looser') || t.includes('loser') || lower(m.side) === 'loser'
|
||||
})
|
||||
|
||||
// Group by (side, stage, round) → one column each.
|
||||
const columnMap = new Map()
|
||||
for (const match of cleaned) {
|
||||
const { side, stage } = classifyBracket(match.type_bracket, hasLoserSide)
|
||||
const round = asInt(match.round, 0)
|
||||
const key = `${side}:${stage}:${round}`
|
||||
if (!columnMap.has(key)) {
|
||||
columnMap.set(key, { side, stage, round, matches: [] })
|
||||
}
|
||||
columnMap.get(key).matches.push(match)
|
||||
}
|
||||
|
||||
// Bucket columns by side, ordered by (stage, round).
|
||||
const sideMap = new Map()
|
||||
for (const column of columnMap.values()) {
|
||||
column.matches.sort(compareMatches)
|
||||
if (!sideMap.has(column.side)) sideMap.set(column.side, [])
|
||||
sideMap.get(column.side).push(column)
|
||||
}
|
||||
|
||||
const sides = [...sideMap.entries()]
|
||||
.map(([key, columns]) => {
|
||||
columns.sort((a, b) => a.stage - b.stage || a.round - b.round)
|
||||
const isList = key === 'group' || key === 'swiss' || key === 'matches'
|
||||
return {
|
||||
key,
|
||||
label: SIDE_LABELS[key] || key,
|
||||
kind: isList ? 'list' : 'bracket',
|
||||
columns: columns.map((column, index) => ({
|
||||
id: `${key}:${column.stage}:${column.round}`,
|
||||
label: columnLabel(key, column.stage, index, columns.length, hasLoserSide),
|
||||
matches: column.matches,
|
||||
})),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (SIDE_ORDER[a.key] ?? 9) - (SIDE_ORDER[b.key] ?? 9))
|
||||
|
||||
return { sides, hasLoserSide }
|
||||
}
|
||||
|
||||
// Which match in the next column a given match feeds into, by slot position.
|
||||
// Winner tree and loser "major" rounds halve (position → floor/2); loser "minor"
|
||||
// rounds (drop-ins, same width) map identically. We infer halving vs identity
|
||||
// from the column node counts. Returns the parent position, or null.
|
||||
export function parentPosition(childPosition, currentCount, nextCount) {
|
||||
if (childPosition == null) return null
|
||||
if (nextCount >= currentCount) return childPosition
|
||||
return Math.floor(childPosition / 2)
|
||||
}
|
||||
|
||||
// The match in `columns[columnIndex + 1]` that a given match feeds into. Found by
|
||||
// slot position (faithful to the real tree even with byes hidden); falls back to
|
||||
// a proportional index if the exact parent slot was itself a hidden bye.
|
||||
export function feederParent(match, columnIndex, columns) {
|
||||
const cur = columns[columnIndex]
|
||||
const next = columns[columnIndex + 1]
|
||||
if (!next) return null
|
||||
const pPos = parentPosition(Number(match.position), cur.matches.length, next.matches.length)
|
||||
const exact = next.matches.find((m) => Number(m.position) === pPos)
|
||||
if (exact) return exact
|
||||
const order = cur.matches.indexOf(match)
|
||||
const idx = Math.min(
|
||||
next.matches.length - 1,
|
||||
Math.floor((order * next.matches.length) / cur.matches.length),
|
||||
)
|
||||
return next.matches[idx] || null
|
||||
}
|
||||
|
||||
export function layoutKey(columnIndex, match) {
|
||||
return `${columnIndex}:${match.match_id}`
|
||||
}
|
||||
|
||||
// Position every match vertically so each one sits centred between the matches
|
||||
// that feed into it — the classic bracket look. Pure: takes measured card heights,
|
||||
// returns absolute `top` offsets keyed by `layoutKey`.
|
||||
//
|
||||
// Column 0 stacks top-to-bottom. Later columns centre each match on the mean of
|
||||
// its feeders' centres. Matches whose feeders were all hidden byes (no anchor)
|
||||
// are *extrapolated* from the nearest anchored sibling using the column's slot
|
||||
// pitch — including above y=0 — rather than stacked at the top, which would shove
|
||||
// the anchored matches away from their feeders and make the connectors fan and
|
||||
// cross. A final per-side shift pulls any negative offsets back on-screen.
|
||||
export function computeBracketLayout(columns, heights, gap = 16) {
|
||||
const heightOf = (c, m) => heights.get(layoutKey(c, m)) || 92
|
||||
const centers = columns.map(() => new Map())
|
||||
|
||||
columns.forEach((column, c) => {
|
||||
const matches = column.matches
|
||||
const hs = matches.map((m) => heightOf(c, m))
|
||||
const pitch = (hs.reduce((a, b) => a + b, 0) / Math.max(1, hs.length)) + gap
|
||||
|
||||
// Anchor = mean of this match's feeders' centres (null when all were byes).
|
||||
const childCenters = new Map()
|
||||
if (c > 0) {
|
||||
for (const child of columns[c - 1].matches) {
|
||||
const parent = feederParent(child, c - 1, columns)
|
||||
if (!parent) continue
|
||||
const center = centers[c - 1].get(child.match_id)
|
||||
if (center == null) continue
|
||||
if (!childCenters.has(parent.match_id)) childCenters.set(parent.match_id, [])
|
||||
childCenters.get(parent.match_id).push(center)
|
||||
}
|
||||
}
|
||||
const center = matches.map((m) => {
|
||||
const kids = childCenters.get(m.match_id)
|
||||
return kids && kids.length ? kids.reduce((a, b) => a + b, 0) / kids.length : null
|
||||
})
|
||||
|
||||
const firstAnchor = center.findIndex((v) => v != null)
|
||||
if (firstAnchor === -1) {
|
||||
// No anchors (column 0, or a fully-bye column): plain stack.
|
||||
let cursor = 0
|
||||
matches.forEach((m, i) => {
|
||||
center[i] = cursor + hs[i] / 2
|
||||
cursor += hs[i] + gap
|
||||
})
|
||||
} else {
|
||||
// Leading byes: extrapolate upward (may go negative — fixed by the shift).
|
||||
for (let i = firstAnchor - 1; i >= 0; i -= 1) center[i] = center[i + 1] - pitch
|
||||
// Trailing / interior byes: extend down / interpolate between anchors.
|
||||
let lastAnchor = firstAnchor
|
||||
for (let i = firstAnchor + 1; i < matches.length; i += 1) {
|
||||
if (center[i] != null) { lastAnchor = i; continue }
|
||||
let next = i
|
||||
while (next < matches.length && center[next] == null) next += 1
|
||||
if (next >= matches.length) {
|
||||
center[i] = center[lastAnchor] + (i - lastAnchor) * pitch
|
||||
} else {
|
||||
const span = next - lastAnchor
|
||||
center[i] = center[lastAnchor] + ((center[next] - center[lastAnchor]) * (i - lastAnchor)) / span
|
||||
}
|
||||
}
|
||||
// Resolve any residual overlap by nudging downward (rare; honours heights).
|
||||
for (let i = 1; i < matches.length; i += 1) {
|
||||
const minC = center[i - 1] + (hs[i - 1] + hs[i]) / 2 + gap
|
||||
if (center[i] < minC) center[i] = minC
|
||||
}
|
||||
}
|
||||
|
||||
matches.forEach((m, i) => centers[c].set(m.match_id, center[i]))
|
||||
})
|
||||
|
||||
// Shift the whole side so the highest match sits at y=0, then size to content.
|
||||
let minTop = Infinity
|
||||
columns.forEach((column, c) => {
|
||||
column.matches.forEach((m) => {
|
||||
minTop = Math.min(minTop, centers[c].get(m.match_id) - heightOf(c, m) / 2)
|
||||
})
|
||||
})
|
||||
const shift = Number.isFinite(minTop) && minTop < 0 ? -minTop : 0
|
||||
|
||||
const tops = new Map()
|
||||
let height = 0
|
||||
columns.forEach((column, c) => {
|
||||
column.matches.forEach((m) => {
|
||||
const h = heightOf(c, m)
|
||||
const top = centers[c].get(m.match_id) - h / 2 + shift
|
||||
tops.set(layoutKey(c, m), top)
|
||||
height = Math.max(height, top + h)
|
||||
})
|
||||
})
|
||||
|
||||
return { tops, centers, height }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user