feat: implement core game systems including networking, matchmaking, audio, and tilemap support
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"width": 25,
|
||||||
|
"height": 19,
|
||||||
|
"tileSize": 32,
|
||||||
|
"tiles": [
|
||||||
|
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,
|
||||||
|
1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,
|
||||||
|
1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,
|
||||||
|
1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,
|
||||||
|
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
|
||||||
|
],
|
||||||
|
"spawnPoints": [
|
||||||
|
{ "x": 2, "y": 2 },
|
||||||
|
{ "x": 22, "y": 16 }
|
||||||
|
],
|
||||||
|
"entities": []
|
||||||
|
}
|
||||||
@@ -10,6 +10,44 @@ export interface Component {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Component types ---
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Velocity {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sprite {
|
||||||
|
src: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Health {
|
||||||
|
current: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {} // marker component
|
||||||
|
|
||||||
|
export interface Nickname {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Enemy {} // marker component
|
||||||
|
|
||||||
|
export interface Weapon {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
damage: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class World {
|
export class World {
|
||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private entities: Map<Entity, Set<string>> = new Map();
|
private entities: Map<Entity, Set<string>> = new Map();
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Tilemap - grid-based level geometry.
|
||||||
|
* Tile types: 0=walkable, 1=wall, 2=water, 3=door
|
||||||
|
* Supports loading from JSON level files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Tilemap {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
tileSize: number;
|
||||||
|
tiles: number[]; // flat array, row-major
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelData {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
tileSize: number;
|
||||||
|
tiles: number[];
|
||||||
|
spawnPoints?: { x: number; y: number }[];
|
||||||
|
entities?: Array<{
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
props?: Record<string, any>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTilemap(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
tileSize: number,
|
||||||
|
data?: number[],
|
||||||
|
): Tilemap {
|
||||||
|
const size = width * height;
|
||||||
|
const tiles = data ?? new Array(size).fill(0);
|
||||||
|
return { width, height, tileSize, tiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLevel(url: string): Promise<LevelData> {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`Failed to load level: ${resp.status}`);
|
||||||
|
const data: LevelData = await resp.json();
|
||||||
|
|
||||||
|
// Validate schema
|
||||||
|
if (!data.width || !data.height || !data.tileSize || !data.tiles) {
|
||||||
|
throw new Error("Invalid level schema: missing required fields");
|
||||||
|
}
|
||||||
|
if (data.tiles.length !== data.width * data.height) {
|
||||||
|
throw new Error("Invalid level schema: tiles length mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTileType(map: Tilemap, x: number, y: number): number {
|
||||||
|
const tx = Math.floor(x / map.tileSize);
|
||||||
|
const ty = Math.floor(y / map.tileSize);
|
||||||
|
if (tx < 0 || tx >= map.width || ty < 0 || ty >= map.height) return 1; // out of bounds = wall
|
||||||
|
return map.tiles[ty * map.width + tx];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWall(map: Tilemap, x: number, y: number): boolean {
|
||||||
|
return getTileType(map, x, y) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImpassable(map: Tilemap, x: number, y: number): boolean {
|
||||||
|
const type = getTileType(map, x, y);
|
||||||
|
return type === 1 || type === 2; // wall or water
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCollision(
|
||||||
|
map: Tilemap,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
halfW: number,
|
||||||
|
halfH: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
// Check all 4 corners
|
||||||
|
const corners = [
|
||||||
|
{ x: x - halfW, y: y - halfH },
|
||||||
|
{ x: x + halfW, y: y - halfH },
|
||||||
|
{ x: x - halfW, y: y + halfH },
|
||||||
|
{ x: x + halfW, y: y + halfH },
|
||||||
|
];
|
||||||
|
|
||||||
|
let finalX = x;
|
||||||
|
let finalY = y;
|
||||||
|
|
||||||
|
for (const c of corners) {
|
||||||
|
if (isImpassable(map, c.x, c.y)) {
|
||||||
|
// Push entity out of impassable tile
|
||||||
|
const tx = Math.floor(c.x / map.tileSize);
|
||||||
|
const ty = Math.floor(c.y / map.tileSize);
|
||||||
|
|
||||||
|
if (c.x < x) finalX = Math.max(finalX, (tx + 1) * map.tileSize + halfW);
|
||||||
|
else finalX = Math.min(finalX, tx * map.tileSize - halfW);
|
||||||
|
if (c.y < y) finalY = Math.max(finalY, (ty + 1) * map.tileSize + halfH);
|
||||||
|
else finalY = Math.min(finalY, ty * map.tileSize - halfH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: finalX, y: finalY };
|
||||||
|
}
|
||||||
+99
-4
@@ -2,11 +2,15 @@ import { World } from "./engine/ecs";
|
|||||||
import { InputSystem } from "./systems/InputSystem";
|
import { InputSystem } from "./systems/InputSystem";
|
||||||
import { RenderSystem } from "./systems/RenderSystem";
|
import { RenderSystem } from "./systems/RenderSystem";
|
||||||
import { PhysicsSystem } from "./systems/PhysicsSystem";
|
import { PhysicsSystem } from "./systems/PhysicsSystem";
|
||||||
|
import { NicknameSystem } from "./systems/NicknameSystem";
|
||||||
|
import { NetworkClient } from "./systems/NetworkClient";
|
||||||
|
import { MatchmakingClient } from "./systems/MatchmakingClient";
|
||||||
|
import { loadLevel, createTilemap } from "./engine/tilemap";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ZAMNY Browser Edition - main entry.
|
* ZAMNY Browser Edition - main entry.
|
||||||
* 60 FPS game loop. ECS + systems.
|
* 60 FPS game loop. ECS + systems.
|
||||||
* TODO: add nickname UI, network client, level loader.
|
* TODO: add level loader.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@@ -15,9 +19,22 @@ canvas.height = 600;
|
|||||||
document.body.appendChild(canvas);
|
document.body.appendChild(canvas);
|
||||||
|
|
||||||
const world = new World();
|
const world = new World();
|
||||||
|
|
||||||
|
// Create player entity
|
||||||
|
const player = world.createEntity();
|
||||||
|
world.addComponent(player, "Player", {});
|
||||||
|
world.addComponent(player, "Position", { x: 400, y: 300 });
|
||||||
|
world.addComponent(player, "Velocity", { dx: 0, dy: 0 });
|
||||||
|
world.addComponent(player, "Sprite", {
|
||||||
|
src: "player.png",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
});
|
||||||
|
world.addComponent(player, "Health", { current: 100, max: 100 });
|
||||||
|
|
||||||
const input = new InputSystem();
|
const input = new InputSystem();
|
||||||
const render = new RenderSystem(canvas);
|
const render = new RenderSystem(canvas);
|
||||||
const physics = new PhysicsSystem();
|
const physics = new PhysicsSystem(createTilemap(25, 19, 32));
|
||||||
|
|
||||||
const systems = [input, physics, render];
|
const systems = [input, physics, render];
|
||||||
|
|
||||||
@@ -34,6 +51,84 @@ function loop(now: number) {
|
|||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(loop);
|
// Show nickname UI before starting game loop
|
||||||
|
async function start() {
|
||||||
|
const nickname = await new NicknameSystem().show();
|
||||||
|
console.log(`[ZAMNY] Player nickname: ${nickname}`);
|
||||||
|
|
||||||
console.log("[ZAMNY] Game loop started. ECS ready.");
|
// Load level
|
||||||
|
try {
|
||||||
|
const levelData = await loadLevel("/levels/level1.json");
|
||||||
|
const tilemap = createTilemap(
|
||||||
|
levelData.width,
|
||||||
|
levelData.height,
|
||||||
|
levelData.tileSize,
|
||||||
|
levelData.tiles,
|
||||||
|
);
|
||||||
|
physics.setMap(tilemap);
|
||||||
|
|
||||||
|
// Set player spawn
|
||||||
|
if (levelData.spawnPoints && levelData.spawnPoints.length > 0) {
|
||||||
|
const spawn = levelData.spawnPoints[0];
|
||||||
|
world.addComponent(player, "Position", {
|
||||||
|
x: spawn.x * tilemap.tileSize + tilemap.tileSize / 2,
|
||||||
|
y: spawn.y * tilemap.tileSize + tilemap.tileSize / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log("[ZAMNY] Level loaded:", levelData);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[ZAMNY] Failed to load level, using default tilemap");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to connect to server (non-blocking - game works offline)
|
||||||
|
const network = new NetworkClient();
|
||||||
|
try {
|
||||||
|
await network.connect();
|
||||||
|
network.sendNickname(nickname);
|
||||||
|
|
||||||
|
const matchmaking = new MatchmakingClient(network);
|
||||||
|
|
||||||
|
// Handle server messages
|
||||||
|
network.onMessageHandler((msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "nickname_ok":
|
||||||
|
console.log(`[ZAMNY] Nickname confirmed: ${msg.nickname}`);
|
||||||
|
break;
|
||||||
|
case "player_list":
|
||||||
|
console.log(`[ZAMNY] Available players:`, msg.players);
|
||||||
|
break;
|
||||||
|
case "room_created":
|
||||||
|
console.log(`[ZAMNY] Room created: ${msg.roomId}`);
|
||||||
|
break;
|
||||||
|
case "room_joined":
|
||||||
|
console.log(
|
||||||
|
`[ZAMNY] Joined room ${msg.roomId} with ${msg.peerNickname}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "peer_joined":
|
||||||
|
console.log(
|
||||||
|
`[ZAMNY] Peer ${msg.peerNickname} joined room ${msg.roomId}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "peer_left":
|
||||||
|
console.log(`[ZAMNY] Peer left room ${msg.roomId}`);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
console.warn(`[ZAMNY] Server error: ${msg.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose matchmaking to console for testing
|
||||||
|
(window as any).matchmaking = matchmaking;
|
||||||
|
console.log(
|
||||||
|
"[ZAMNY] Use window.matchmaking.createRoom() or .joinRoom(targetId)",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[ZAMNY] Server unavailable, running offline");
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* AudioSystem - Web Audio API wrapper for sound effects.
|
||||||
|
* Loads audio files, plays them by name.
|
||||||
|
* Supports background music loops, volume control, spatial audio.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AudioSystem {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private sounds: Map<string, AudioBuffer> = new Map();
|
||||||
|
private masterGain: GainNode | null = null;
|
||||||
|
private activeLoops: Map<string, AudioBufferSourceNode> = new Map();
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
this.ctx = new AudioContext();
|
||||||
|
this.masterGain = this.ctx.createGain();
|
||||||
|
this.masterGain.connect(this.ctx.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(name: string, url: string): Promise<void> {
|
||||||
|
if (!this.ctx) await this.init();
|
||||||
|
if (!this.ctx) return;
|
||||||
|
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
|
const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer);
|
||||||
|
this.sounds.set(name, audioBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
play(name: string, volume = 1.0): void {
|
||||||
|
if (!this.ctx || !this.masterGain) return;
|
||||||
|
const buffer = this.sounds.get(name);
|
||||||
|
if (!buffer) return;
|
||||||
|
|
||||||
|
const source = this.ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.value = volume;
|
||||||
|
|
||||||
|
source.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
source.start(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
playLoop(name: string, volume = 0.5): void {
|
||||||
|
if (!this.ctx || !this.masterGain) return;
|
||||||
|
const buffer = this.sounds.get(name);
|
||||||
|
if (!buffer) return;
|
||||||
|
|
||||||
|
// Stop existing loop if playing
|
||||||
|
this.stopLoop(name);
|
||||||
|
|
||||||
|
const source = this.ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = true;
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.value = volume;
|
||||||
|
|
||||||
|
source.connect(gain);
|
||||||
|
gain.connect(this.masterGain);
|
||||||
|
source.start(0);
|
||||||
|
|
||||||
|
this.activeLoops.set(name, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoop(name: string): void {
|
||||||
|
const source = this.activeLoops.get(name);
|
||||||
|
if (source) {
|
||||||
|
source.stop();
|
||||||
|
this.activeLoops.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(level: number): void {
|
||||||
|
if (!this.masterGain) return;
|
||||||
|
this.masterGain.gain.value = Math.max(0, Math.min(1, level));
|
||||||
|
}
|
||||||
|
|
||||||
|
playAt(
|
||||||
|
name: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
listenerX = 0,
|
||||||
|
listenerY = 0,
|
||||||
|
maxDist = 500,
|
||||||
|
): void {
|
||||||
|
if (!this.ctx || !this.masterGain) return;
|
||||||
|
const buffer = this.sounds.get(name);
|
||||||
|
if (!buffer) return;
|
||||||
|
|
||||||
|
const dx = x - listenerX;
|
||||||
|
const dy = y - listenerY;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Volume drops off with distance (inverse square law, clamped)
|
||||||
|
const volume = Math.max(0, 1 - dist / maxDist);
|
||||||
|
|
||||||
|
const source = this.ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.value = volume;
|
||||||
|
|
||||||
|
// Panning based on relative x position
|
||||||
|
const panner = this.ctx.createStereoPanner();
|
||||||
|
panner.pan.value = Math.max(-1, Math.min(1, dx / maxDist));
|
||||||
|
|
||||||
|
source.connect(gain);
|
||||||
|
gain.connect(panner);
|
||||||
|
panner.connect(this.masterGain);
|
||||||
|
source.start(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { World, System } from "../engine/ecs";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* InputSystem - captures keyboard + touch input.
|
* InputSystem - captures keyboard + touch input.
|
||||||
* TODO: map to actions (move, shoot, use item).
|
* Maps WASD/arrows to Velocity on player entities.
|
||||||
*/
|
*/
|
||||||
export class InputSystem extends System {
|
export class InputSystem extends System {
|
||||||
private keys = new Set<string>();
|
private keys = new Set<string>();
|
||||||
@@ -17,7 +17,21 @@ export class InputSystem extends System {
|
|||||||
return this.keys.has(key);
|
return this.keys.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(world: World, dt: number): void {
|
update(world: World, _dt: number): void {
|
||||||
// TODO: translate key state into movement / action components
|
const speed = 200;
|
||||||
|
const players = world.query("Player", "Velocity");
|
||||||
|
|
||||||
|
for (const entity of players) {
|
||||||
|
const vel = world.getComponent(entity, "Velocity");
|
||||||
|
if (!vel) continue;
|
||||||
|
|
||||||
|
vel.dx = 0;
|
||||||
|
vel.dy = 0;
|
||||||
|
|
||||||
|
if (this.keys.has("ArrowLeft") || this.keys.has("a")) vel.dx = -speed;
|
||||||
|
if (this.keys.has("ArrowRight") || this.keys.has("d")) vel.dx = speed;
|
||||||
|
if (this.keys.has("ArrowUp") || this.keys.has("w")) vel.dy = -speed;
|
||||||
|
if (this.keys.has("ArrowDown") || this.keys.has("s")) vel.dy = speed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* MatchmakingClient - high-level matchmaking over NetworkClient.
|
||||||
|
* Lists available players, creates/joins rooms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NetworkClient } from "./NetworkClient";
|
||||||
|
|
||||||
|
export interface PlayerInfo {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatchmakingClient {
|
||||||
|
private network: NetworkClient;
|
||||||
|
private players: PlayerInfo[] = [];
|
||||||
|
private onPlayerListCallback?: (players: PlayerInfo[]) => void;
|
||||||
|
private onRoomCreatedCallback?: (roomId: string) => void;
|
||||||
|
private onRoomJoinedCallback?: (
|
||||||
|
roomId: string,
|
||||||
|
peerId: string,
|
||||||
|
peerNickname: string,
|
||||||
|
) => void;
|
||||||
|
private onPeerJoinedCallback?: (
|
||||||
|
roomId: string,
|
||||||
|
peerId: string,
|
||||||
|
peerNickname: string,
|
||||||
|
) => void;
|
||||||
|
private onPeerLeftCallback?: (roomId: string, peerId: string) => void;
|
||||||
|
|
||||||
|
constructor(network: NetworkClient) {
|
||||||
|
this.network = network;
|
||||||
|
this.setupMessageHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMessageHandler() {
|
||||||
|
this.network.onMessageHandler((msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "player_list":
|
||||||
|
this.players = msg.players ?? [];
|
||||||
|
this.onPlayerListCallback?.(this.players);
|
||||||
|
break;
|
||||||
|
case "room_created":
|
||||||
|
this.onRoomCreatedCallback?.(msg.roomId);
|
||||||
|
break;
|
||||||
|
case "room_joined":
|
||||||
|
this.onRoomJoinedCallback?.(msg.roomId, msg.peerId, msg.peerNickname);
|
||||||
|
break;
|
||||||
|
case "peer_joined":
|
||||||
|
this.onPeerJoinedCallback?.(msg.roomId, msg.peerId, msg.peerNickname);
|
||||||
|
break;
|
||||||
|
case "peer_left":
|
||||||
|
this.onPeerLeftCallback?.(msg.roomId, msg.peerId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Pass through to other handlers
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshPlayers(): Promise<PlayerInfo[]> {
|
||||||
|
this.network.listPlayers();
|
||||||
|
// Return cached list (async update via onPlayerList)
|
||||||
|
return this.players;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom(): void {
|
||||||
|
this.network.sendRaw(JSON.stringify({ type: "create_room" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(targetId: string): void {
|
||||||
|
this.network.sendRaw(JSON.stringify({ type: "join_room", targetId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayerListUpdate(handler: (players: PlayerInfo[]) => void): void {
|
||||||
|
this.onPlayerListCallback = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoomCreated(handler: (roomId: string) => void): void {
|
||||||
|
this.onRoomCreatedCallback = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRoomJoined(
|
||||||
|
handler: (roomId: string, peerId: string, peerNickname: string) => void,
|
||||||
|
): void {
|
||||||
|
this.onRoomJoinedCallback = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPeerJoined(
|
||||||
|
handler: (roomId: string, peerId: string, peerNickname: string) => void,
|
||||||
|
): void {
|
||||||
|
this.onPeerJoinedCallback = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPeerLeft(handler: (roomId: string, peerId: string) => void): void {
|
||||||
|
this.onPeerLeftCallback = handler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* NetworkClient - WebSocket client for DiscoveryServer.
|
||||||
|
* Connects, sends nickname, receives server messages.
|
||||||
|
* Auto-reconnects on drop with exponential backoff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ServerMessage {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetworkClient {
|
||||||
|
private ws?: WebSocket;
|
||||||
|
private url: string;
|
||||||
|
private nickname?: string;
|
||||||
|
private onMessage?: (msg: ServerMessage) => void;
|
||||||
|
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectDelay = 30000;
|
||||||
|
private reconnecting = false;
|
||||||
|
|
||||||
|
constructor(url = "ws://localhost:3001") {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.ws = new WebSocket(this.url);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log("[NetworkClient] Connected");
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.reconnecting = false;
|
||||||
|
// Resend nickname on reconnect
|
||||||
|
if (this.nickname) {
|
||||||
|
this.sendNickname(this.nickname);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
this.onMessage?.(msg);
|
||||||
|
} catch {
|
||||||
|
console.warn("[NetworkClient] Bad JSON from server");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
if (!this.reconnecting && this.nickname) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (err) => {
|
||||||
|
console.error("[NetworkClient] Error", err);
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
this.reconnecting = true;
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * Math.pow(2, this.reconnectAttempts),
|
||||||
|
this.maxReconnectDelay,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[NetworkClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
this.connect().catch(() => {
|
||||||
|
// Reconnect failed, will retry on next close
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNickname(nickname: string): void {
|
||||||
|
this.nickname = nickname;
|
||||||
|
this.ws?.send(JSON.stringify({ type: "set_nickname", nickname }));
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRaw(data: string): void {
|
||||||
|
this.ws?.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
listPlayers(): void {
|
||||||
|
this.ws?.send(JSON.stringify({ type: "list_players" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessageHandler(handler: (msg: ServerMessage) => void): void {
|
||||||
|
this.onMessage = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnecting = false;
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* NicknameSystem - UI overlay for nickname input.
|
||||||
|
* Validates length (1-16 chars), then hides overlay and returns nickname.
|
||||||
|
*/
|
||||||
|
export class NicknameSystem {
|
||||||
|
private overlay: HTMLDivElement;
|
||||||
|
private input: HTMLInputElement;
|
||||||
|
private submitBtn: HTMLButtonElement;
|
||||||
|
private resolve?: (nickname: string) => void;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.overlay = document.createElement("div");
|
||||||
|
this.overlay.style.cssText = `
|
||||||
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.85); display: flex; align-items: center;
|
||||||
|
justify-content: center; z-index: 1000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.style.cssText =
|
||||||
|
"background: #222; padding: 2rem; border-radius: 8px; text-align: center;";
|
||||||
|
form.onsubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.textContent = "Zombies Ate My Neighbors";
|
||||||
|
title.style.color = "#ff4444";
|
||||||
|
title.style.marginBottom = "1rem";
|
||||||
|
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.textContent = "Enter Nickname: ";
|
||||||
|
label.style.color = "#fff";
|
||||||
|
label.style.display = "block";
|
||||||
|
label.style.marginBottom = "0.5rem";
|
||||||
|
|
||||||
|
this.input = document.createElement("input");
|
||||||
|
this.input.type = "text";
|
||||||
|
this.input.maxLength = 16;
|
||||||
|
this.input.required = true;
|
||||||
|
this.input.style.cssText =
|
||||||
|
"padding: 0.5rem; font-size: 1rem; width: 200px; margin-bottom: 1rem;";
|
||||||
|
|
||||||
|
this.submitBtn = document.createElement("button");
|
||||||
|
this.submitBtn.type = "submit";
|
||||||
|
this.submitBtn.textContent = "Start Game";
|
||||||
|
this.submitBtn.style.cssText =
|
||||||
|
"padding: 0.5rem 1rem; font-size: 1rem; background: #ff4444; color: #fff; border: none; cursor: pointer;";
|
||||||
|
|
||||||
|
form.appendChild(title);
|
||||||
|
form.appendChild(label);
|
||||||
|
form.appendChild(this.input);
|
||||||
|
form.appendChild(this.submitBtn);
|
||||||
|
this.overlay.appendChild(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSubmit() {
|
||||||
|
const nickname = this.input.value.trim();
|
||||||
|
if (nickname.length >= 1 && nickname.length <= 16) {
|
||||||
|
this.overlay.remove();
|
||||||
|
this.resolve?.(nickname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show(): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.resolve = resolve;
|
||||||
|
document.body.appendChild(this.overlay);
|
||||||
|
this.input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,102 @@
|
|||||||
import { World, System } from "../engine/ecs";
|
import { World, System } from "../engine/ecs";
|
||||||
|
import {
|
||||||
|
type Tilemap,
|
||||||
|
createTilemap,
|
||||||
|
resolveCollision,
|
||||||
|
} from "../engine/tilemap";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PhysicsSystem - movement + collision.
|
* PhysicsSystem - movement + collision.
|
||||||
* TODO: tile collision, enemy pushback, weapon hitboxes.
|
* Integrates Velocity into Position each tick.
|
||||||
|
* Resolves collisions against tilemap walls.
|
||||||
|
* TODO: enemy pushback, weapon hitboxes.
|
||||||
*/
|
*/
|
||||||
export class PhysicsSystem extends System {
|
export class PhysicsSystem extends System {
|
||||||
|
private map: Tilemap;
|
||||||
|
|
||||||
|
constructor(tilemap?: Tilemap) {
|
||||||
|
super();
|
||||||
|
// Default: 25x25 grid, 32px tiles, all walkable
|
||||||
|
this.map = tilemap ?? createTilemap(25, 25, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMap(map: Tilemap): void {
|
||||||
|
this.map = map;
|
||||||
|
}
|
||||||
|
|
||||||
update(world: World, dt: number): void {
|
update(world: World, dt: number): void {
|
||||||
// TODO: integrate velocity into position
|
// Update positions and resolve tile collisions
|
||||||
// TODO: resolve collisions against level geometry
|
const entities = world.query("Position", "Velocity", "Sprite");
|
||||||
|
for (const entity of entities) {
|
||||||
|
const pos = world.getComponent(entity, "Position");
|
||||||
|
const vel = world.getComponent(entity, "Velocity");
|
||||||
|
const sprite = world.getComponent(entity, "Sprite");
|
||||||
|
if (!pos || !vel || !sprite) continue;
|
||||||
|
|
||||||
|
pos.x += vel.dx * dt;
|
||||||
|
pos.y += vel.dy * dt;
|
||||||
|
|
||||||
|
// Resolve wall collisions
|
||||||
|
const resolved = resolveCollision(
|
||||||
|
this.map,
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
sprite.width / 2,
|
||||||
|
sprite.height / 2,
|
||||||
|
);
|
||||||
|
pos.x = resolved.x;
|
||||||
|
pos.y = resolved.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle weapon attacks
|
||||||
|
this.handleWeaponAttacks(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWeaponAttacks(world: World): void {
|
||||||
|
const weapons = world.query("Position", "Weapon");
|
||||||
|
const enemies = world.query("Position", "Enemy", "Velocity");
|
||||||
|
|
||||||
|
for (const weaponEntity of weapons) {
|
||||||
|
const weaponPos = world.getComponent(weaponEntity, "Position");
|
||||||
|
const weapon = world.getComponent(weaponEntity, "Weapon");
|
||||||
|
if (!weaponPos || !weapon || !weapon.active) continue;
|
||||||
|
|
||||||
|
for (const enemyEntity of enemies) {
|
||||||
|
const enemyPos = world.getComponent(enemyEntity, "Position");
|
||||||
|
const enemyVel = world.getComponent(enemyEntity, "Velocity");
|
||||||
|
if (!enemyPos || !enemyVel) continue;
|
||||||
|
|
||||||
|
// Check AABB overlap
|
||||||
|
const weaponLeft = weaponPos.x - weapon.width / 2;
|
||||||
|
const weaponRight = weaponPos.x + weapon.width / 2;
|
||||||
|
const weaponTop = weaponPos.y - weapon.height / 2;
|
||||||
|
const weaponBottom = weaponPos.y + weapon.height / 2;
|
||||||
|
|
||||||
|
const enemyLeft = enemyPos.x - 16; // assume 32px enemy
|
||||||
|
const enemyRight = enemyPos.x + 16;
|
||||||
|
const enemyTop = enemyPos.y - 16;
|
||||||
|
const enemyBottom = enemyPos.y + 16;
|
||||||
|
|
||||||
|
if (
|
||||||
|
weaponLeft < enemyRight &&
|
||||||
|
weaponRight > enemyLeft &&
|
||||||
|
weaponTop < enemyBottom &&
|
||||||
|
weaponBottom > enemyTop
|
||||||
|
) {
|
||||||
|
// Hit! Apply pushback velocity
|
||||||
|
const dx = enemyPos.x - weaponPos.x;
|
||||||
|
const dy = enemyPos.y - weaponPos.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist > 0) {
|
||||||
|
const pushStrength = 300;
|
||||||
|
enemyVel.dx += (dx / dist) * pushStrength;
|
||||||
|
enemyVel.dy += (dy / dist) * pushStrength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate weapon after hit
|
||||||
|
weapon.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { World, System } from "../engine/ecs";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RenderSystem - draws entities with Position + Sprite to canvas.
|
* RenderSystem - draws entities with Position + Sprite to canvas.
|
||||||
* TODO: implement camera, sprite batching, neighbor name labels.
|
* Camera follows player entity. Draws nickname labels above entities.
|
||||||
*/
|
*/
|
||||||
export class RenderSystem extends System {
|
export class RenderSystem extends System {
|
||||||
private ctx: CanvasRenderingContext2D;
|
private ctx: CanvasRenderingContext2D;
|
||||||
@@ -14,11 +14,61 @@ export class RenderSystem extends System {
|
|||||||
this.ctx = canvas.getContext("2d")!;
|
this.ctx = canvas.getContext("2d")!;
|
||||||
}
|
}
|
||||||
|
|
||||||
update(world: World, dt: number): void {
|
update(world: World, _dt: number): void {
|
||||||
this.ctx.fillStyle = "#0a0a0a";
|
this.ctx.fillStyle = "#0a0a0a";
|
||||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
// TODO: query entities with Position + Sprite, draw them
|
// Find player for camera
|
||||||
// TODO: draw nickname labels above co-op player
|
const playerEntities = world.query("Player", "Position");
|
||||||
|
const playerPos =
|
||||||
|
playerEntities.length > 0
|
||||||
|
? world.getComponent(playerEntities[0], "Position")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
this.ctx.save();
|
||||||
|
|
||||||
|
// Apply camera offset (center player on screen)
|
||||||
|
if (playerPos) {
|
||||||
|
const cx = this.canvas.width / 2 - playerPos.x;
|
||||||
|
const cy = this.canvas.height / 2 - playerPos.y;
|
||||||
|
this.ctx.translate(cx, cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw all entities with Position + Sprite
|
||||||
|
const entities = world.query("Position", "Sprite");
|
||||||
|
for (const entity of entities) {
|
||||||
|
const pos = world.getComponent(entity, "Position");
|
||||||
|
const sprite = world.getComponent(entity, "Sprite");
|
||||||
|
if (!pos || !sprite) continue;
|
||||||
|
|
||||||
|
// Placeholder: colored rect (no sprite loaded yet)
|
||||||
|
this.ctx.fillStyle = "#44ff44";
|
||||||
|
this.ctx.fillRect(
|
||||||
|
pos.x - sprite.width / 2,
|
||||||
|
pos.y - sprite.height / 2,
|
||||||
|
sprite.width,
|
||||||
|
sprite.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw nickname label if entity has Nickname component
|
||||||
|
const nickname = world.getComponent(entity, "Nickname");
|
||||||
|
if (nickname?.text) {
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.font = "12px sans-serif";
|
||||||
|
this.ctx.textAlign = "center";
|
||||||
|
this.ctx.strokeStyle = "#000";
|
||||||
|
this.ctx.lineWidth = 3;
|
||||||
|
this.ctx.strokeText(
|
||||||
|
nickname.text,
|
||||||
|
pos.x,
|
||||||
|
pos.y - sprite.height / 2 - 8,
|
||||||
|
);
|
||||||
|
this.ctx.fillStyle = "#fff";
|
||||||
|
this.ctx.fillText(nickname.text, pos.x, pos.y - sprite.height / 2 - 8);
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* SyncSystem - prediction + reconciliation for multiplayer.
|
||||||
|
*
|
||||||
|
* Prediction: assume local input is correct, apply immediately.
|
||||||
|
* Reconciliation: when server state arrives, correct drift if needed.
|
||||||
|
*
|
||||||
|
* Sends local player state at 20 Hz via NetworkClient.
|
||||||
|
* On receiving remote state, interpolates toward remote position if drift > threshold.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { World, System } from "../engine/ecs";
|
||||||
|
import { NetworkClient } from "./NetworkClient";
|
||||||
|
|
||||||
|
export interface RemoteState {
|
||||||
|
entityId: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tick: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncSystem extends System {
|
||||||
|
private remoteStates: Map<number, RemoteState> = new Map();
|
||||||
|
private localTick = 0;
|
||||||
|
private lastSync = 0;
|
||||||
|
private syncInterval = 50; // 20 Hz
|
||||||
|
private network?: NetworkClient;
|
||||||
|
private driftThreshold = 10; // pixels
|
||||||
|
private interpolationFrames = 3;
|
||||||
|
private interpolationCounter = 0;
|
||||||
|
private interpolationTarget?: { x: number; y: number };
|
||||||
|
|
||||||
|
constructor(network?: NetworkClient) {
|
||||||
|
super();
|
||||||
|
this.network = network;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNetwork(network: NetworkClient): void {
|
||||||
|
this.network = network;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(world: World, dt: number): void {
|
||||||
|
this.localTick++;
|
||||||
|
this.lastSync += dt * 1000;
|
||||||
|
|
||||||
|
// Send local player state at 20 Hz
|
||||||
|
if (this.lastSync >= this.syncInterval && this.network?.connected) {
|
||||||
|
this.lastSync = 0;
|
||||||
|
this.sendLocalState(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle interpolation
|
||||||
|
if (this.interpolationCounter > 0 && this.interpolationTarget) {
|
||||||
|
const players = world.query("Player", "Position");
|
||||||
|
if (players.length > 0) {
|
||||||
|
const pos = world.getComponent(players[0], "Position");
|
||||||
|
if (pos) {
|
||||||
|
const t = 1 / this.interpolationFrames;
|
||||||
|
pos.x += (this.interpolationTarget.x - pos.x) * t;
|
||||||
|
pos.y += (this.interpolationTarget.y - pos.y) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.interpolationCounter--;
|
||||||
|
if (this.interpolationCounter <= 0) {
|
||||||
|
this.interpolationTarget = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendLocalState(world: World): void {
|
||||||
|
const players = world.query("Player", "Position");
|
||||||
|
if (players.length === 0) return;
|
||||||
|
|
||||||
|
const pos = world.getComponent(players[0], "Position");
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
this.network?.sendRaw(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "state_update",
|
||||||
|
tick: this.localTick,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRemoteState(world: World, state: RemoteState): void {
|
||||||
|
this.remoteStates.set(state.entityId, state);
|
||||||
|
|
||||||
|
// Check drift against local position
|
||||||
|
const players = world.query("Player", "Position");
|
||||||
|
if (players.length === 0) return;
|
||||||
|
|
||||||
|
const pos = world.getComponent(players[0], "Position");
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
const dx = state.x - pos.x;
|
||||||
|
const dy = state.y - pos.y;
|
||||||
|
const drift = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (drift > this.driftThreshold) {
|
||||||
|
// Start interpolation toward remote position
|
||||||
|
this.interpolationTarget = { x: state.x, y: state.y };
|
||||||
|
this.interpolationCounter = this.interpolationFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteState(entityId: number): RemoteState | undefined {
|
||||||
|
return this.remoteStates.get(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": false,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user