feat: implement core game systems including networking, matchmaking, audio, and tilemap support

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-02 17:23:18 +02:00
parent 5499b90390
commit 4ee2a0a159
13 changed files with 939 additions and 15 deletions
+31
View File
@@ -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": []
}
+38
View File
@@ -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();
+103
View File
@@ -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
View File
@@ -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();
+114
View File
@@ -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);
}
}
+17 -3
View File
@@ -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;
}
} }
} }
+98
View File
@@ -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;
}
}
+108
View File
@@ -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;
}
}
+73
View File
@@ -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();
});
}
}
+93 -3
View File
@@ -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;
}
}
}
} }
} }
+54 -4
View File
@@ -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();
} }
} }
+110
View File
@@ -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
View File
@@ -15,7 +15,7 @@
/* Linting */ /* Linting */
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": false,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },