mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 04:27:14 +00:00
refactor: convert to npm-workspaces monorepo (engine/core-public/shared/client/server)
Restructure the single src/ tree into an npm-workspaces monorepo under
packages/, rename core -> engine, extract a types-only core-public layer,
and break the pre-existing engine -> client dependency cycle.
Structure (packages/):
core-public public API/wire schemas + shared enums (clean leaf)
shared framework-agnostic helpers (clean leaf)
engine deterministic simulation (was src/core)
client rendering/UI (was src/client)
server coordination (was src/server)
Dependency DAG: engine -> {core-public, shared}; client -> {core-public,
shared, engine}; server -> {core-public, engine}.
- npm workspaces: root package.json workspaces + per-package package.json;
tsconfig.base.json holds shared options + path aliases
(core-public/* shared/* engine/* client/* server/*) resolved uniformly by
tsc, Vite (resolve.tsconfigPaths), Vitest, and tsx. Lockfile regenerated.
- core-public: moved Schemas/ApiSchemas/CosmeticSchemas/StatsSchemas/
ClanApiSchemas/WorkerSchemas/Base64/PatternDecoder; extracted the enums
(GameTypes), GameEvent type, emoji table, and GraphicsOverrides schema.
Engine re-exports the moved enums/types so existing imports keep working.
- Broke engine -> client cycle:
- renderNumber/renderTroops -> shared/format
- NameBoxCalculator moved into engine
- username validation returns translation key + params; client translates
- applyStateUpdate moved to client (operates on the render-only PlayerState)
- Config/UnitGrid/execution-Util/GameImpl now use structural read
interfaces (engine/game/ReadViews: PlayerLike/UnitLike/GameLike) instead
of importing client view classes; client imports view classes from a new
client/view barrel; deleted the engine/game/GameView re-export shim.
- Build/deploy updated: vite.config, index.html, eslint, Dockerfile
(copies packages/ + tsconfig.base.json before npm ci), .vscode, tests.
Verified: tsc --noEmit clean; 1364 + 65 tests pass; production vite build
succeeds; engine has zero client/server imports; core-public and shared are
dependency leaves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cb9cab9aca
commit
a7f992e9b0
@@ -0,0 +1,245 @@
|
||||
import { Colord } from "colord";
|
||||
import { base64url } from "jose";
|
||||
import { assetUrl } from "engine/AssetUrls";
|
||||
import { decodePatternData } from "core-public/PatternDecoder";
|
||||
import { PlayerType } from "engine/game/Game";
|
||||
import { GameView } from "client/view";
|
||||
import { uploadFrameData } from "./render/frame/Upload";
|
||||
import {
|
||||
PlayerStatic,
|
||||
SpawnCenter,
|
||||
GameView as WebGLGameView,
|
||||
} from "./render/gl";
|
||||
|
||||
const PALETTE_SIZE = 4096;
|
||||
|
||||
/**
|
||||
* The renderer-side glue between GameView (which already builds the full
|
||||
* FrameData each tick) and the WebGL view. Two responsibilities:
|
||||
*
|
||||
* 1. Palette management — translate PlayerView colors into a Float32Array
|
||||
* the renderer uploads to a 1D texture, and call view.addPlayers() when
|
||||
* new players appear (this is a renderer-side lifecycle event, not part
|
||||
* of FrameData).
|
||||
* 2. Per-tick upload — pass the FrameData to the renderer's uploadFrameData
|
||||
* helper, which dispatches to all the view.update*() methods.
|
||||
*/
|
||||
export class WebGLFrameBuilder {
|
||||
private readonly palette: Float32Array;
|
||||
private readonly patternMeta: Float32Array;
|
||||
private readonly patternData: Uint8Array;
|
||||
|
||||
private readonly knownSmallIDs = new Set<number>();
|
||||
/**
|
||||
* Last spawn tile pushed to the renderer per smallID. Players can re-pick
|
||||
* spawn during the spawn phase, so this tracks the latest value rather than
|
||||
* just first-seen — re-uploads only when the tile actually changes.
|
||||
*/
|
||||
private readonly lastSpawnTile = new Map<number, number>();
|
||||
/** Skin atlas allocated once on first syncPlayers — player set is locked at game start. */
|
||||
private skinsInitialized = false;
|
||||
// The renderer needs to know which player is "me" so affiliation tint,
|
||||
// unit colors, and SAM-radius perspective work. Push it once the local
|
||||
// player's update arrives (may take several ticks during join).
|
||||
private localPlayerSmallID = 0;
|
||||
// Scratch buffer for terrain-delta uploads (parallel to the refs list).
|
||||
private terrainDeltaBytes: Uint8Array = new Uint8Array(0);
|
||||
|
||||
constructor(private readonly view: WebGLGameView) {
|
||||
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
|
||||
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
|
||||
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
|
||||
}
|
||||
|
||||
/** Drop internal caches to force a full re-upload of state on the next update(). */
|
||||
clearCaches(): void {
|
||||
this.knownSmallIDs.clear();
|
||||
this.lastSpawnTile.clear();
|
||||
this.localPlayerSmallID = 0;
|
||||
this.skinsInitialized = false;
|
||||
}
|
||||
|
||||
update(gameView: GameView): void {
|
||||
this.syncPlayers(gameView);
|
||||
this.syncPlayerSpawns(gameView);
|
||||
this.syncLocalPlayer(gameView);
|
||||
this.syncSpawnOverlay(gameView);
|
||||
this.syncTerrainDeltas(gameView);
|
||||
uploadFrameData(this.view, gameView.frameData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Push each player's current spawn tile to the renderer as the skin anchor
|
||||
* (image center lines up with this tile). Players re-pick spawn during the
|
||||
* spawn phase, so we re-upload whenever the tile changes, not just on first
|
||||
* sighting. Once spawn phase ends, spawnTile is locked and this becomes a
|
||||
* no-op via the cache check.
|
||||
*/
|
||||
private syncPlayerSpawns(gameView: GameView): void {
|
||||
for (const p of gameView.players()) {
|
||||
const smallID = p.smallID();
|
||||
const spawnTile = p.state.spawnTile;
|
||||
if (spawnTile === undefined) continue;
|
||||
if (this.lastSpawnTile.get(smallID) === spawnTile) continue;
|
||||
this.lastSpawnTile.set(smallID, spawnTile);
|
||||
this.view.setPlayerSpawn(
|
||||
smallID,
|
||||
gameView.x(spawnTile),
|
||||
gameView.y(spawnTile),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Water-nuke conversions (land → water) mutate the underlying terrain.
|
||||
* Forward this tick's terrain-changed refs to the renderer so it can
|
||||
* re-upload those texels in both the RGBA color texture and the R8UI
|
||||
* water-detection texture used by railroads/bridges.
|
||||
*/
|
||||
private syncTerrainDeltas(gameView: GameView): void {
|
||||
const refs = gameView.recentlyUpdatedTerrainTiles();
|
||||
if (refs.length === 0) return;
|
||||
if (this.terrainDeltaBytes.length < refs.length) {
|
||||
this.terrainDeltaBytes = new Uint8Array(refs.length);
|
||||
}
|
||||
for (let i = 0; i < refs.length; i++) {
|
||||
this.terrainDeltaBytes[i] = gameView.terrainByte(refs[i]);
|
||||
}
|
||||
this.view.applyTerrainDelta(refs, this.terrainDeltaBytes);
|
||||
}
|
||||
|
||||
private syncLocalPlayer(gameView: GameView): void {
|
||||
const sid = gameView.myPlayer()?.smallID() ?? 0;
|
||||
if (sid === this.localPlayerSmallID) return;
|
||||
this.localPlayerSmallID = sid;
|
||||
this.view.setLocalPlayerID(sid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn-phase highlights: each already-spawned human player gets a colored
|
||||
* ring + tile glow around their starting territory. Pushed every tick
|
||||
* during spawn phase; the pass animates locally from the snapshot.
|
||||
*/
|
||||
private syncSpawnOverlay(gameView: GameView): void {
|
||||
const inSpawnPhase = gameView.inSpawnPhase();
|
||||
if (!inSpawnPhase) {
|
||||
this.view.updateSpawnOverlay(false, []);
|
||||
return;
|
||||
}
|
||||
const me = gameView.myPlayer();
|
||||
const myTeam = me?.team() ?? null;
|
||||
const centers: SpawnCenter[] = [];
|
||||
for (const p of gameView.players()) {
|
||||
if (!p.isPlayer() || p.type() !== PlayerType.Human) continue;
|
||||
const spawnTile = p.state.spawnTile;
|
||||
if (spawnTile === undefined) continue;
|
||||
const isSelf = me !== null && p.smallID() === me.smallID();
|
||||
// myPlayer reads as a near-white with a faint gold/silver tint so the
|
||||
// local-player ring is visually distinct from any team color; everyone
|
||||
// else uses their territory tint.
|
||||
const c = isSelf
|
||||
? { r: 248, g: 218, b: 140 }
|
||||
: p.territoryColor().toRgb();
|
||||
centers.push({
|
||||
// spawnTile tracks the player's currently-selected spawn directly —
|
||||
// updates the same tick the player picks a new location (faster than
|
||||
// the nameData centroid which only refreshes every 2 ticks).
|
||||
x: gameView.x(spawnTile),
|
||||
y: gameView.y(spawnTile),
|
||||
r: c.r / 255,
|
||||
g: c.g / 255,
|
||||
b: c.b / 255,
|
||||
isSelf,
|
||||
isTeammate:
|
||||
myTeam !== null &&
|
||||
p.team() === myTeam &&
|
||||
p.smallID() !== me?.smallID(),
|
||||
});
|
||||
}
|
||||
this.view.updateSpawnOverlay(true, centers);
|
||||
}
|
||||
|
||||
private syncPlayers(gameView: GameView): void {
|
||||
if (!this.skinsInitialized) {
|
||||
this.skinsInitialized = true;
|
||||
const urls = new Set<string>();
|
||||
for (const p of gameView.players()) {
|
||||
const url = p.cosmetics.skin?.url;
|
||||
if (url) urls.add(assetUrl(url));
|
||||
}
|
||||
this.view.initSkinAtlas([...urls]);
|
||||
}
|
||||
const newPlayers: PlayerStatic[] = [];
|
||||
for (const p of gameView.players()) {
|
||||
const smallID = p.smallID();
|
||||
if (this.knownSmallIDs.has(smallID)) continue;
|
||||
this.knownSmallIDs.add(smallID);
|
||||
|
||||
this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor());
|
||||
|
||||
// p.cosmetics.flag has already been server-resolved to either a full URL
|
||||
// or a relative asset path (e.g. "/flags/US.svg" or a CDN URL for a
|
||||
// custom flag). assetUrl() passes URLs through and rewrites paths.
|
||||
const flagRef = p.cosmetics.flag;
|
||||
const flagUrl = flagRef ? assetUrl(flagRef) : undefined;
|
||||
|
||||
const skin = p.cosmetics.skin;
|
||||
if (skin?.url) {
|
||||
this.view.setPlayerSkin(smallID, assetUrl(skin.url));
|
||||
}
|
||||
|
||||
const pattern = p.cosmetics.pattern;
|
||||
if (pattern && pattern.patternData) {
|
||||
try {
|
||||
const decoded = decodePatternData(
|
||||
pattern.patternData,
|
||||
base64url.decode,
|
||||
);
|
||||
const metaOff = smallID * 4;
|
||||
this.patternMeta[metaOff] = 1.0; // hasPattern = true
|
||||
this.patternMeta[metaOff + 1] = decoded.width;
|
||||
this.patternMeta[metaOff + 2] = decoded.height;
|
||||
this.patternMeta[metaOff + 3] = decoded.scale;
|
||||
|
||||
this.patternData.set(decoded.bytes.slice(3), smallID * 1024);
|
||||
} catch (e) {
|
||||
console.warn("Failed to decode territory pattern", e);
|
||||
}
|
||||
}
|
||||
|
||||
newPlayers.push({
|
||||
...p.static,
|
||||
flag: flagUrl,
|
||||
color: p.territoryColor().toHex(),
|
||||
});
|
||||
}
|
||||
if (newPlayers.length > 0) {
|
||||
this.view.addPlayers(
|
||||
newPlayers,
|
||||
this.palette,
|
||||
this.patternMeta,
|
||||
this.patternData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private writePaletteEntry(
|
||||
smallID: number,
|
||||
fill: Colord,
|
||||
border: Colord,
|
||||
): void {
|
||||
const fillRgba = fill.toRgb();
|
||||
const fillOff = smallID * 4;
|
||||
this.palette[fillOff] = fillRgba.r / 255;
|
||||
this.palette[fillOff + 1] = fillRgba.g / 255;
|
||||
this.palette[fillOff + 2] = fillRgba.b / 255;
|
||||
this.palette[fillOff + 3] = 150 / 255;
|
||||
|
||||
const borderRgba = border.toRgb();
|
||||
const borderOff = PALETTE_SIZE * 4 + smallID * 4;
|
||||
this.palette[borderOff] = borderRgba.r / 255;
|
||||
this.palette[borderOff + 1] = borderRgba.g / 255;
|
||||
this.palette[borderOff + 2] = borderRgba.b / 255;
|
||||
this.palette[borderOff + 3] = 1.0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user