From 4ee2a0a159f683c3cf8c1695389a0baa33f5c83c Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sat, 2 May 2026 17:23:18 +0200 Subject: [PATCH] feat: implement core game systems including networking, matchmaking, audio, and tilemap support Co-authored-by: Copilot --- public/levels/level1.json | 31 +++++++++ src/engine/ecs.ts | 38 +++++++++++ src/engine/tilemap.ts | 103 ++++++++++++++++++++++++++++ src/main.ts | 103 ++++++++++++++++++++++++++-- src/systems/AudioSystem.ts | 114 +++++++++++++++++++++++++++++++ src/systems/InputSystem.ts | 20 +++++- src/systems/MatchmakingClient.ts | 98 ++++++++++++++++++++++++++ src/systems/NetworkClient.ts | 108 +++++++++++++++++++++++++++++ src/systems/NicknameSystem.ts | 73 ++++++++++++++++++++ src/systems/PhysicsSystem.ts | 96 +++++++++++++++++++++++++- src/systems/RenderSystem.ts | 58 ++++++++++++++-- src/systems/SyncSystem.ts | 110 +++++++++++++++++++++++++++++ tsconfig.json | 2 +- 13 files changed, 939 insertions(+), 15 deletions(-) create mode 100644 public/levels/level1.json create mode 100644 src/engine/tilemap.ts create mode 100644 src/systems/AudioSystem.ts create mode 100644 src/systems/MatchmakingClient.ts create mode 100644 src/systems/NetworkClient.ts create mode 100644 src/systems/NicknameSystem.ts create mode 100644 src/systems/SyncSystem.ts diff --git a/public/levels/level1.json b/public/levels/level1.json new file mode 100644 index 0000000..73ef482 --- /dev/null +++ b/public/levels/level1.json @@ -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": [] +} diff --git a/src/engine/ecs.ts b/src/engine/ecs.ts index e400ddb..c0b9392 100644 --- a/src/engine/ecs.ts +++ b/src/engine/ecs.ts @@ -10,6 +10,44 @@ export interface Component { [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 { private nextId = 1; private entities: Map> = new Map(); diff --git a/src/engine/tilemap.ts b/src/engine/tilemap.ts new file mode 100644 index 0000000..a3e3cc6 --- /dev/null +++ b/src/engine/tilemap.ts @@ -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; + }>; +} + +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 { + 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 }; +} diff --git a/src/main.ts b/src/main.ts index 022461b..33f7052 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,15 @@ import { World } from "./engine/ecs"; import { InputSystem } from "./systems/InputSystem"; import { RenderSystem } from "./systems/RenderSystem"; 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. * 60 FPS game loop. ECS + systems. - * TODO: add nickname UI, network client, level loader. + * TODO: add level loader. */ const canvas = document.createElement("canvas"); @@ -15,9 +19,22 @@ canvas.height = 600; document.body.appendChild(canvas); 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 render = new RenderSystem(canvas); -const physics = new PhysicsSystem(); +const physics = new PhysicsSystem(createTilemap(25, 19, 32)); const systems = [input, physics, render]; @@ -34,6 +51,84 @@ function loop(now: number) { 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(); diff --git a/src/systems/AudioSystem.ts b/src/systems/AudioSystem.ts new file mode 100644 index 0000000..a32c74a --- /dev/null +++ b/src/systems/AudioSystem.ts @@ -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 = new Map(); + private masterGain: GainNode | null = null; + private activeLoops: Map = new Map(); + + async init(): Promise { + this.ctx = new AudioContext(); + this.masterGain = this.ctx.createGain(); + this.masterGain.connect(this.ctx.destination); + } + + async load(name: string, url: string): Promise { + 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); + } +} diff --git a/src/systems/InputSystem.ts b/src/systems/InputSystem.ts index 9053878..eb09afb 100644 --- a/src/systems/InputSystem.ts +++ b/src/systems/InputSystem.ts @@ -2,7 +2,7 @@ import { World, System } from "../engine/ecs"; /** * 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 { private keys = new Set(); @@ -17,7 +17,21 @@ export class InputSystem extends System { return this.keys.has(key); } - update(world: World, dt: number): void { - // TODO: translate key state into movement / action components + update(world: World, _dt: number): void { + 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; + } } } diff --git a/src/systems/MatchmakingClient.ts b/src/systems/MatchmakingClient.ts new file mode 100644 index 0000000..d2b6f02 --- /dev/null +++ b/src/systems/MatchmakingClient.ts @@ -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 { + 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; + } +} diff --git a/src/systems/NetworkClient.ts b/src/systems/NetworkClient.ts new file mode 100644 index 0000000..ccc7bcc --- /dev/null +++ b/src/systems/NetworkClient.ts @@ -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; + private reconnectAttempts = 0; + private maxReconnectDelay = 30000; + private reconnecting = false; + + constructor(url = "ws://localhost:3001") { + this.url = url; + } + + connect(): Promise { + 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; + } +} diff --git a/src/systems/NicknameSystem.ts b/src/systems/NicknameSystem.ts new file mode 100644 index 0000000..9f0da11 --- /dev/null +++ b/src/systems/NicknameSystem.ts @@ -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 { + return new Promise((resolve) => { + this.resolve = resolve; + document.body.appendChild(this.overlay); + this.input.focus(); + }); + } +} diff --git a/src/systems/PhysicsSystem.ts b/src/systems/PhysicsSystem.ts index ee3d769..93cf657 100644 --- a/src/systems/PhysicsSystem.ts +++ b/src/systems/PhysicsSystem.ts @@ -1,12 +1,102 @@ import { World, System } from "../engine/ecs"; +import { + type Tilemap, + createTilemap, + resolveCollision, +} from "../engine/tilemap"; /** * 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 { + 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 { - // TODO: integrate velocity into position - // TODO: resolve collisions against level geometry + // Update positions and resolve tile collisions + 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; + } + } + } } } diff --git a/src/systems/RenderSystem.ts b/src/systems/RenderSystem.ts index a3aadc7..a5cb582 100644 --- a/src/systems/RenderSystem.ts +++ b/src/systems/RenderSystem.ts @@ -2,7 +2,7 @@ import { World, System } from "../engine/ecs"; /** * 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 { private ctx: CanvasRenderingContext2D; @@ -14,11 +14,61 @@ export class RenderSystem extends System { this.ctx = canvas.getContext("2d")!; } - update(world: World, dt: number): void { + update(world: World, _dt: number): void { this.ctx.fillStyle = "#0a0a0a"; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - // TODO: query entities with Position + Sprite, draw them - // TODO: draw nickname labels above co-op player + // Find player for camera + 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(); } } diff --git a/src/systems/SyncSystem.ts b/src/systems/SyncSystem.ts new file mode 100644 index 0000000..2f841af --- /dev/null +++ b/src/systems/SyncSystem.ts @@ -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 = 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); + } +} diff --git a/tsconfig.json b/tsconfig.json index 1ab38c8..98e0991 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ /* Linting */ "noUnusedLocals": true, - "noUnusedParameters": true, + "noUnusedParameters": false, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true },