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;
|
||||
}
|
||||
|
||||
// --- 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<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 { 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();
|
||||
|
||||
@@ -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.
|
||||
* 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<string>();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user