diff --git a/FUTURE_IMPLEMENTATION.md b/FUTURE_IMPLEMENTATION.md new file mode 100644 index 0000000..d27d3d0 --- /dev/null +++ b/FUTURE_IMPLEMENTATION.md @@ -0,0 +1,156 @@ +# Future Implementation Steps + +The map editor and collision system provide a foundation, but competitive multiplayer requires the server—not the browser—to own the match state. The phases below are ordered to establish trustworthy networking before expanding the game content. + +## Phase 5: Server-Authoritative Multiplayer + +This is the highest-priority milestone. + +- Have clients send destinations or inputs instead of positions. +- Make the server own player positions, movement speed, collisions, health, damage, and cooldowns. +- Run the server simulation at a fixed tick rate. +- Broadcast periodic state snapshots to clients. +- Add client-side prediction, interpolation, and server reconciliation. +- Associate players with their socket or authenticated player ID. +- Send connection IDs only to the corresponding socket. +- Remove players and release match resources when they disconnect. +- Load map boundaries and walls into the server simulation. +- Share collision rules between the editor preview, client, and server without trusting client results. + +The current position event must be replaced before the game can be competitive. Accepting coordinates reported by clients allows a modified client to teleport or ignore collisions. + +## Phase 6: Matches and Rooms + +- Create a separate Socket.IO room and state container for each match. +- Add a lobby, ready state, and match countdown. +- Assign players to teams. +- Add team-specific spawn points. +- Define match start, active, finished, and abandoned states. +- Add a match timer, scoreboard, and win/loss condition. +- Support rematches or returning to the lobby. +- Add a reconnection grace period and restore the player's current match state. +- Add spectator handling after the basic match lifecycle works. + +Start with `1v1` or `2v2`. Larger matches will make networking, balancing, and testing substantially harder. + +## Phase 7: Minimal MOBA Gameplay + +- Add two teams with visually distinct players. +- Add health, death, respawning, and temporary invulnerability after spawning. +- Add a basic attack. +- Add two or three abilities with server-controlled cooldowns. +- Add server-side hit detection and damage validation. +- Add one clear objective, such as destroying a base or reaching a score target. +- Add a HUD for health, cooldowns, score, teams, and match time. +- Establish movement speed, attack range, damage, and cooldown configuration. + +Items, minions, progression systems, and multiple heroes should wait until a small match with one character is consistently fun. + +## Phase 8: Simple In-Game Chat + +- Add match-wide and team chat channels. +- Attach usernames on the server rather than accepting a username with each message. +- Limit message length and validate every message. +- Render messages as text, never executable HTML. +- Add per-user chat rate limits and spam protection. +- Add mute controls. +- Add basic profanity filtering and player reporting. +- Optionally retain a short recent-message history for reconnecting players. +- Keep chat events separate from gameplay events and rate limits. + +## Phase 9: Accounts and Competitive Identity + +- Add registration and login, or use an external identity provider. +- Give every account a stable, non-sequential public player ID. +- Add unique display names and moderation rules. +- Store match history, wins, losses, and abandonment records. +- Add an initial MMR or Elo-style rating. +- Separate ranked and unranked matchmaking. +- Add leaver penalties only after reconnection is reliable. +- Add password recovery, session expiration, and logout invalidation if passwords are managed locally. + +## Phase 10: Persistence and Matchmaking + +Use a relational database such as PostgreSQL for: + +- Accounts and authentication identities. +- Ratings and match results. +- Map definitions and map versions. +- Reports, mutes, suspensions, and bans. + +Redis can be introduced when needed for: + +- Matchmaking queues. +- Presence and short-lived session data. +- Active match discovery. +- Coordinating Socket.IO across multiple game-server processes. + +Avoid making a database the source of truth for every simulation tick. Active match state should remain in the authoritative game process and persist only meaningful events or final results. + +## Phase 11: Security and Anti-Cheat + +- Use HTTPS and secure WebSockets in production. +- Authenticate every socket connection. +- Validate the allowed origin during connection setup. +- Authorize every event against the player's identity, team, and current match. +- Treat all client messages as untrusted input. +- Validate message schemas, types, ranges, and sizes. +- Rate-limit movement, abilities, matchmaking, authentication, and chat separately. +- Reject impossible movement, cooldown use, damage, and match-state transitions. +- Limit simultaneous connections per account and IP. +- Add heartbeat and idle-connection cleanup. +- Log authentication failures, invalid commands, rate-limit events, and abnormal disconnects without logging credentials or session tokens. + +Relevant guidance: [OWASP WebSocket Security](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html). + +## Phase 12: Reliability and Competitive Quality + +- Display network latency and connection state. +- Recover gracefully from temporary disconnections. +- Add AFK detection. +- Handle server shutdowns without silently losing match results. +- Add structured logs and production error reporting. +- Monitor simulation tick duration, active matches, message rates, memory, and disconnects. +- Add automated load tests with simulated players. +- Test under latency, jitter, packet loss, and reconnect scenarios. +- Add regional servers only when player latency measurements justify them. +- Add replay or command-history storage for debugging disputed matches and cheating reports. + +## Testing Requirements + +Automated tests should cover at least: + +- Movement and collision agreement between client and server. +- Map boundaries, individual walls, intersections, endpoints, and sliding. +- Invalid or malicious movement commands. +- Ability range, damage, cooldowns, death, and respawn. +- Match state transitions and win conditions. +- Joining, leaving, disconnecting, and reconnecting. +- Team and match chat isolation. +- Chat validation, escaping, muting, and rate limiting. +- Concurrent matches without state leaking between rooms. +- Server behavior under expected and excessive message rates. + +## Immediate Server Cleanup + +Before implementing new game content: + +- Replace the global players array with per-match state. +- Stop accepting client-provided positions as truth. +- Replace sequential broadcast IDs with socket-scoped or authenticated IDs. +- Remove players on the Socket.IO `disconnect` event. +- Validate every incoming socket payload. +- Remove continuous position logging from production. +- Keep map editing endpoints restricted to authorized users before public deployment. + +## Recommended Delivery Order + +1. Authoritative movement and server-side map collision. +2. Client snapshots, interpolation, prediction, and reconciliation. +3. Match rooms, teams, lifecycle, and reconnect support. +4. One complete combat objective with health and respawning. +5. Simple team and match chat. +6. Accounts, match history, and unranked matchmaking. +7. Rating, ranked matchmaking, moderation, and operations. + +Socket.IO remains suitable for this stage because it provides bidirectional low-latency communication, automatic reconnection, and options for scaling across servers. See the [Socket.IO documentation](https://socket.io/) for its current deployment and adapter guidance. diff --git a/README.md b/README.md index 71169fd..f1c932a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,49 @@ ## Todo List -- [ ] Harita Editoru +- [X] Map Editor +- [ ] [Future implementation steps](FUTURE_IMPLEMENTATION.md) - [ ] Path Finding -- [ ] Chat to build backend for sockets. [Chat location](https://raw.githubusercontent.com/pegasuspect/simpleMOBA/main/screenshots/2023-04-25_17-08-40.png) -- [ ] If chat is working use the logic to build mulltiplayer moving circles +- [ ] In-game Chat + - [Chat location](https://raw.githubusercontent.com/pegasuspect/simpleMOBA/main/screenshots/2023-04-25_17-08-40.png) + +### Map Editor TODOS +#### Phase-1: Create the Editor Page & Preview +1. Implement the same UI with camera movement, with a different route for map editor. +1. Place a default spawn point Indicated as (X) where the player spawns in the actual game. +1. Allow moving the map with arrow keys. +1. Put a toggle button wth label; Preview, to run the game with the map in the editor. Essentially, starting the game on the editor's page. +1. Once the toggle is switched back, ensure the game stops, player(s) are removed and previos state of the editor is shown with the spawn point. + +#### Phase-2: Create Endpoints for Map Data +1. Create `GET /map-state` that reads and returns the contents of `map-state.json` +1. Create an enpoint for `POST /save-map` which overrides `map-state.json` + +#### Phase-3: Add Editor Toolbar with Default Objects; Spawn, Map Area +1. Add a toolbar on the top, outside the game area. +1. Toolbar should have 1 button and an input for map area, initially. There will be about 4-5 buttons/controls at least later on so account for that space. +1. Read the map state from the server. Place the map's objects on the editor. There will be spawn locaiton and map size initially. If they don't exist set them to whatever is used in the game. +1. Add a button called [Spawn]. + 1. If spawn data was returned from `GET /map-state` replace this button's text with 'Remove Spawn'. If "Remove Spawn" is pressed: + 1. Remove the spawn location from current map state. + 1. Disable all other controls. + 1. Replace button label "Remove Spawn" with "Place Spawn". + 1. While moving the mouse over the map area move the spawn location with mouse. + 1. Once clicked place spaw location and restore the button label as "Remove Spawn" again. + 1. Otherwise, if there wasn't spawn data from the GET endpoint, and follow steps at Phase-3 4.1.1-5 +1. Add a text input. + 1. If map size data is returned from `GET /map-state` then set the inputs value to it. Otherwise, set it's value to a default. Set the default to whatever current game runs in pixels. + 1. Validate that it always has 2 integers devided by 'x'. + 1. The size reads `${length}x${width}`. + +#### Phase-4: Add Wall Control +1. Add a button caller [Walls] +1. If this button is pushed swith to wall placement mode, and keep the button pressed. +1. Wall Placement Mode: + 1. This mode should add walls to the map by drawing a line. When moving the mouse nothing happens. After one click it places a start location, then moving the mouse around should draw a line between start location and the current mouse location until a second click is pressed which sets the end location. Save this onto map state object as a new wall between start location and end location. + 1. Save the new wall object to the server with `POST /save-map` +1. Once the Preview is toggled make sure the player cannot go over the wall boundaries. So update the collision logic for that if it needs update. + # Next Meeting Create a chat client for the game using sockets on diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..bc76084 --- /dev/null +++ b/editor.html @@ -0,0 +1,155 @@ + + + + + + simpleMOBA Map Editor + + + +
+
+

Map Editor

+ +
+
+ + + + +
+

Editor mode · use the arrow keys to move the camera

+ +
+ + + + + diff --git a/index.html b/index.html index 9efb831..4f5e00b 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,14 @@ var socket = io(); window.game = new Game(ctx, socket, -1); + fetch('/map-state') + .then(response => { + if(!response.ok) throw new Error(`Map state request failed (${response.status})`); + return response.json(); + }) + .then(mapState => window.game.applyMapState(mapState)) + .catch(error => console.error('Unable to load map boundaries:', error)); + socket.on('id', (id) => { if (window.game && window.game.id === -1) window.game.id = id; }); @@ -70,4 +78,4 @@ - \ No newline at end of file + diff --git a/index.js b/index.js index cdaf736..8244acb 100644 --- a/index.js +++ b/index.js @@ -1,17 +1,68 @@ const express = require('express'); +const fs = require('fs').promises; +const path = require('path'); const app = express(); const http = require('http').Server(app); const io = require('socket.io')(http); const port = process.env.PORT || 3000; +const mapStatePath = path.join(__dirname, 'map-state.json'); let id = 0; let players = []; +app.use((req, res, next) => { + const origin = req.headers.origin; + if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { + res.set('Access-Control-Allow-Origin', origin); + res.set('Access-Control-Allow-Headers', 'Content-Type'); + res.set('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + } + next(); +}); + +app.use(express.json({ limit: '1mb' })); app.use(express.static('public')); app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); +app.get(['/editor', '/editor/'], (req, res) => { + res.sendFile(__dirname + '/editor.html'); +}); + +app.get('/map-state', async (req, res) => { + try { + const mapState = await fs.readFile(mapStatePath, 'utf8'); + res.type('json').send(mapState); + } catch (error) { + console.error('Unable to read map state:', error); + res.status(500).json({ error: 'Unable to read map state.' }); + } +}); + +app.post('/save-map', async (req, res) => { + if (!req.body || Array.isArray(req.body) || typeof req.body !== 'object') { + return res.status(400).json({ error: 'Map state must be a JSON object.' }); + } + + try { + await fs.writeFile(mapStatePath, `${JSON.stringify(req.body, null, 2)}\n`, 'utf8'); + res.json({ saved: true, mapState: req.body }); + } catch (error) { + console.error('Unable to save map state:', error); + res.status(500).json({ error: 'Unable to save map state.' }); + } +}); + +app.use((error, req, res, next) => { + if (error instanceof SyntaxError && error.status === 400 && 'body' in error) { + return res.status(400).json({ error: 'Request body must contain valid JSON.' }); + } + + next(error); +}); + io.on('connection', (socket) => { socket.on('position', player => { let p = players.find(x => x.id === player.id); diff --git a/map-state.json b/map-state.json new file mode 100644 index 0000000..873d7c3 --- /dev/null +++ b/map-state.json @@ -0,0 +1,229 @@ +{ + "size": "800x800", + "spawn": { + "x": 224, + "y": 380 + }, + "walls": [ + { + "start": { + "x": 38, + "y": 51 + }, + "end": { + "x": 38, + "y": 467 + } + }, + { + "start": { + "x": 40, + "y": 49 + }, + "end": { + "x": 287, + "y": 47 + } + }, + { + "start": { + "x": 289, + "y": 50 + }, + "end": { + "x": 152, + "y": 228 + } + }, + { + "start": { + "x": 350, + "y": 226 + }, + "end": { + "x": 260, + "y": 175 + } + }, + { + "start": { + "x": 262, + "y": 175 + }, + "end": { + "x": 578, + "y": 182 + } + }, + { + "start": { + "x": 578, + "y": 182 + }, + "end": { + "x": 550, + "y": 241 + } + }, + { + "start": { + "x": 550, + "y": 241 + }, + "end": { + "x": 569, + "y": 247 + } + }, + { + "start": { + "x": 574, + "y": 249 + }, + "end": { + "x": 605, + "y": 248 + } + }, + { + "start": { + "x": 605, + "y": 248 + }, + "end": { + "x": 603, + "y": 279 + } + }, + { + "start": { + "x": 603, + "y": 280 + }, + "end": { + "x": 606, + "y": 314 + } + }, + { + "start": { + "x": 606, + "y": 314 + }, + "end": { + "x": 635, + "y": 313 + } + }, + { + "start": { + "x": 635, + "y": 313 + }, + "end": { + "x": 551, + "y": 391 + } + }, + { + "start": { + "x": 551, + "y": 391 + }, + "end": { + "x": 609, + "y": 377 + } + }, + { + "start": { + "x": 608, + "y": 378 + }, + "end": { + "x": 580, + "y": 451 + } + }, + { + "start": { + "x": 580, + "y": 451 + }, + "end": { + "x": 662, + "y": 451 + } + }, + { + "start": { + "x": 662, + "y": 451 + }, + "end": { + "x": 679, + "y": 485 + } + }, + { + "start": { + "x": 679, + "y": 485 + }, + "end": { + "x": 720, + "y": 481 + } + }, + { + "start": { + "x": 720, + "y": 481 + }, + "end": { + "x": 574, + "y": 113 + } + }, + { + "start": { + "x": 574, + "y": 113 + }, + "end": { + "x": 290, + "y": 117 + } + }, + { + "start": { + "x": 290, + "y": 117 + }, + "end": { + "x": 560, + "y": 66 + } + }, + { + "start": { + "x": 560, + "y": 66 + }, + "end": { + "x": 576, + "y": 116 + } + }, + { + "start": { + "x": 158, + "y": 231 + }, + "end": { + "x": 344, + "y": 231 + } + } + ] +} diff --git a/public/editor.js b/public/editor.js new file mode 100644 index 0000000..a1f2d88 --- /dev/null +++ b/public/editor.js @@ -0,0 +1,566 @@ +(() => { + const DEFAULT_SPAWN = Object.freeze({ x: 400, y: 300 }); + const DEFAULT_SIZE = '800x600'; + const SIZE_PATTERN = /^(\d+)x(\d+)$/; + const SPAWN_INSET = 20; + + async function apiFetch(path, options) { + try { + const response = await fetch(path, options); + if (response.status !== 404 && response.status !== 405) return response; + } catch (error) { + if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) throw error; + } + + if (!['localhost', '127.0.0.1'].includes(window.location.hostname) || window.location.port === '3000') { + throw new Error('The game server is unavailable.'); + } + + return fetch(`http://${window.location.hostname}:3000${path}`, options); + } + + class EditorController extends Controller { + leftMouseDown(event) { + this.game.handleEditorClick(event); + } + + rightMouseDown() {} + middleMouseDown() {} + } + + class MapEditor { + constructor(elements) { + this.canvas = elements.canvas; + this.toggle = elements.toggle; + this.status = elements.status; + this.spawnButton = elements.spawnButton; + this.wallsButton = elements.wallsButton; + this.eraserButton = elements.eraserButton; + this.sizeInput = elements.sizeInput; + this.ctx = this.canvas.getContext('2d'); + this.camera = new Camera(); + this.utils = new Util(this.ctx, this.camera); + this.editorController = new EditorController(this); + this.activeController = this.editorController; + this.previewGame = null; + this.editorCameraState = { x: 0, y: 0 }; + this.mapState = {}; + this.spawn = { ...DEFAULT_SPAWN }; + this.mapSize = DEFAULT_SIZE; + this.placingSpawn = false; + this.spawnPreview = null; + this.walls = []; + this.wallMode = false; + this.wallStart = null; + this.wallPreview = null; + this.eraserMode = false; + this.saveQueue = Promise.resolve(); + + this.onMouseDown = (event) => this.activeController.mouseDown(event); + this.onMouseMove = (event) => { + const point = this.worldPoint(event); + if (this.placingSpawn && this.pointWithinBoundaries(point, SPAWN_INSET)) { + this.spawnPreview = point; + } else if (this.wallMode && this.wallStart && this.pointWithinBoundaries(point)) { + this.wallPreview = point; + } + }; + this.onContextMenu = (event) => this.activeController.contextMenu(event); + this.onKeyDown = (event) => { + if (this.isArrowKey(event)) event.preventDefault(); + this.activeController.keyDown(event); + }; + this.onKeyUp = (event) => { + if (this.isArrowKey(event)) event.preventDefault(); + this.activeController.keyUp(event); + }; + + this.canvas.addEventListener('mousedown', this.onMouseDown); + this.canvas.addEventListener('mousemove', this.onMouseMove); + this.canvas.addEventListener('contextmenu', this.onContextMenu); + document.addEventListener('keydown', this.onKeyDown); + document.addEventListener('keyup', this.onKeyUp); + this.toggle.addEventListener('change', () => this.setPreview(this.toggle.checked)); + this.spawnButton.addEventListener('click', () => this.beginSpawnPlacement()); + this.wallsButton.addEventListener('click', () => this.toggleWallMode()); + this.eraserButton.addEventListener('click', () => this.toggleEraserMode()); + this.sizeInput.addEventListener('input', () => this.validateSizeInput()); + this.sizeInput.addEventListener('change', () => this.applyMapSize()); + this.sizeInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') this.sizeInput.blur(); + }); + + this.frame = this.frame.bind(this); + requestAnimationFrame(this.frame); + this.loadMapState(); + } + + get cam() { + return this.camera; + } + + async loadMapState() { + try { + const response = await apiFetch('/map-state'); + if (!response.ok) throw new Error(`Map state request failed (${response.status})`); + const data = await response.json(); + if (!data || Array.isArray(data) || typeof data !== 'object') { + throw new Error('Map state is not an object'); + } + this.mapState = data; + this.mapSize = this.validSize(data.size) ? data.size : DEFAULT_SIZE; + const loadedSpawn = this.validSpawn(data.spawn) ? data.spawn : DEFAULT_SPAWN; + this.spawn = this.clampMapPoint(loadedSpawn, SPAWN_INSET); + const adjustedSpawn = this.validSpawn(data.spawn) && + (this.spawn.x !== data.spawn.x || this.spawn.y !== data.spawn.y); + this.mapState.spawn = { ...this.spawn }; + this.mapState.size = this.mapSize; + this.walls = this.normalizeWalls(data.walls); + const adjustedWalls = JSON.stringify(this.walls) !== JSON.stringify(data.walls || []); + this.mapState.walls = this.walls.map(wall => this.copyWall(wall)); + this.sizeInput.value = this.mapSize; + this.status.textContent = 'Editor mode · use the arrow keys to move the camera'; + if (adjustedSpawn || adjustedWalls) { + this.saveMapState('Out-of-bounds objects were moved inside the map boundaries.'); + } + } catch (error) { + console.error(error); + this.mapState = {}; + this.spawn = { ...DEFAULT_SPAWN }; + this.mapSize = DEFAULT_SIZE; + this.walls = []; + this.status.textContent = 'Could not load map data; using game defaults.'; + } finally { + this.toggle.disabled = false; + this.spawnButton.disabled = false; + this.wallsButton.disabled = false; + this.eraserButton.disabled = false; + this.sizeInput.disabled = false; + this.updateSpawnButton(); + } + } + + validSpawn(spawn) { + return spawn && Number.isFinite(spawn.x) && Number.isFinite(spawn.y); + } + + validSize(value) { + const match = typeof value === 'string' && value.match(SIZE_PATTERN); + return Boolean(match && Number(match[1]) > 0 && Number(match[2]) > 0); + } + + validateSizeInput() { + const valid = this.validSize(this.sizeInput.value); + this.sizeInput.classList.toggle('invalid', !valid); + this.sizeInput.setCustomValidity(valid ? '' : 'Use two positive integers separated by x, for example 800x600.'); + return valid; + } + + applyMapSize() { + if (!this.validateSizeInput()) { + this.status.textContent = 'Map boundaries must look like 800x600.'; + return; + } + + this.mapSize = this.sizeInput.value; + this.mapState.size = this.mapSize; + if (this.spawn) { + this.spawn = this.clampMapPoint(this.spawn, SPAWN_INSET); + this.mapState.spawn = { ...this.spawn }; + } + this.walls = this.normalizeWalls(this.walls); + this.mapState.walls = this.walls.map(wall => this.copyWall(wall)); + this.clampCamera(); + this.saveMapState('Map boundaries saved.'); + } + + beginSpawnPlacement() { + if (this.placingSpawn) { + this.canvas.focus(); + return; + } + + this.setWallMode(false); + this.spawn = null; + delete this.mapState.spawn; + this.placingSpawn = true; + this.spawnPreview = null; + this.toggle.disabled = true; + this.sizeInput.disabled = true; + this.wallsButton.disabled = true; + this.eraserButton.disabled = true; + this.updateSpawnButton(); + this.status.textContent = 'Move over the map and click to place the spawn point.'; + this.saveMapState(); + this.canvas.focus(); + } + + placeSpawn(event) { + if (!this.placingSpawn) return; + + const point = this.worldPoint(event); + if (!this.pointWithinBoundaries(point, SPAWN_INSET)) { + this.status.textContent = 'The spawn point must be placed inside the map boundaries.'; + return; + } + + this.spawn = point; + this.mapState.spawn = { ...this.spawn }; + this.placingSpawn = false; + this.spawnPreview = null; + this.toggle.disabled = false; + this.sizeInput.disabled = false; + this.wallsButton.disabled = false; + this.eraserButton.disabled = false; + this.updateSpawnButton(); + this.saveMapState('Spawn point saved.'); + } + + handleEditorClick(event) { + if (this.placingSpawn) { + this.placeSpawn(event); + } else if (this.wallMode) { + this.placeWallPoint(event); + } else if (this.eraserMode) { + this.eraseObject(event); + } + } + + toggleWallMode() { + this.setWallMode(!this.wallMode); + if (this.wallMode) this.canvas.focus(); + } + + setWallMode(enabled) { + if (enabled) this.setEraserMode(false); + this.wallMode = enabled; + this.wallStart = null; + this.wallPreview = null; + this.wallsButton.setAttribute('aria-pressed', String(enabled)); + this.spawnButton.disabled = enabled; + this.sizeInput.disabled = enabled; + if (enabled) { + this.status.textContent = 'Wall mode · click a start point, then click an end point.'; + } else if (!this.previewGame && !this.placingSpawn) { + this.status.textContent = 'Editor mode · use the arrow keys to move the camera'; + } + } + + toggleEraserMode() { + this.setEraserMode(!this.eraserMode); + if (this.eraserMode) this.canvas.focus(); + } + + setEraserMode(enabled) { + if (enabled && this.wallMode) { + this.wallMode = false; + this.wallStart = null; + this.wallPreview = null; + this.wallsButton.setAttribute('aria-pressed', 'false'); + } + this.eraserMode = enabled; + this.eraserButton.setAttribute('aria-pressed', String(enabled)); + this.spawnButton.disabled = enabled; + this.sizeInput.disabled = enabled; + if (enabled) { + this.status.textContent = 'Eraser mode · click a wall or spawn point to remove it.'; + } else if (!this.wallMode && !this.previewGame && !this.placingSpawn) { + this.status.textContent = 'Editor mode · use the arrow keys to move the camera'; + } + } + + placeWallPoint(event) { + const point = this.worldPoint(event); + if (!this.pointWithinBoundaries(point)) { + this.status.textContent = 'Wall points must be inside the map boundaries.'; + return; + } + + if (!this.wallStart) { + this.wallStart = point; + this.wallPreview = point; + this.status.textContent = 'Move the mouse and click to set the wall end point.'; + return; + } + + if (point.x === this.wallStart.x && point.y === this.wallStart.y) { + this.status.textContent = 'A wall needs two different points.'; + return; + } + + this.walls.push({ start: this.wallStart, end: point }); + this.mapState.walls = this.walls.map(wall => this.copyWall(wall)); + this.wallStart = null; + this.wallPreview = null; + this.saveMapState('Wall saved · click to start another wall.'); + } + + copyWall(wall) { + return { + start: { x: wall.start.x, y: wall.start.y }, + end: { x: wall.end.x, y: wall.end.y } + }; + } + + normalizeWalls(walls) { + if (!Array.isArray(walls)) return []; + return walls + .filter(wall => wall && this.validSpawn(wall.start) && this.validSpawn(wall.end)) + .map(wall => ({ + start: this.clampMapPoint(wall.start), + end: this.clampMapPoint(wall.end) + })) + .filter(wall => wall.start.x !== wall.end.x || wall.start.y !== wall.end.y); + } + + distanceToWall(point, wall) { + const dx = wall.end.x - wall.start.x; + const dy = wall.end.y - wall.start.y; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared === 0) return Math.hypot(point.x - wall.start.x, point.y - wall.start.y); + const projection = Math.max(0, Math.min(1, + ((point.x - wall.start.x) * dx + (point.y - wall.start.y) * dy) / lengthSquared + )); + return Math.hypot( + point.x - (wall.start.x + projection * dx), + point.y - (wall.start.y + projection * dy) + ); + } + + eraseObject(event) { + const point = this.worldPoint(event); + const candidates = this.walls.map((wall, index) => ({ + type: 'wall', index, distance: this.distanceToWall(point, wall) + })); + if (this.spawn) { + candidates.push({ + type: 'spawn', + distance: Math.hypot(point.x - this.spawn.x, point.y - this.spawn.y) + }); + } + candidates.sort((a, b) => a.distance - b.distance); + const target = candidates[0]; + if (!target || target.distance > 16) { + this.status.textContent = 'No object found there. Click closer to a wall or spawn point.'; + return; + } + + if (target.type === 'wall') { + this.walls.splice(target.index, 1); + this.mapState.walls = this.walls.map(wall => this.copyWall(wall)); + this.saveMapState('Wall removed.'); + } else { + this.spawn = null; + delete this.mapState.spawn; + this.updateSpawnButton(); + this.saveMapState('Spawn point removed.'); + } + } + + updateSpawnButton() { + this.spawnButton.textContent = this.spawn ? 'Remove Spawn' : 'Place Spawn'; + } + + worldPoint(event) { + const scaleX = this.canvas.width / this.canvas.clientWidth; + const scaleY = this.canvas.height / this.canvas.clientHeight; + return { + x: Math.round(event.offsetX * scaleX + this.camera.x), + y: Math.round(event.offsetY * scaleY + this.camera.y) + }; + } + + mapDimensions() { + const dimensions = this.mapSize.match(SIZE_PATTERN); + return { width: Number(dimensions[1]), height: Number(dimensions[2]) }; + } + + pointWithinBoundaries(point, inset = 0) { + const { width, height } = this.mapDimensions(); + if (width < inset * 2 || height < inset * 2) return false; + const minX = Math.min(inset, width / 2); + const maxX = Math.max(width - inset, width / 2); + const minY = Math.min(inset, height / 2); + const maxY = Math.max(height - inset, height / 2); + + return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY; + } + + clampMapPoint(point, inset = 0) { + const { width, height } = this.mapDimensions(); + const minX = Math.min(inset, width / 2); + const maxX = Math.max(width - inset, width / 2); + const minY = Math.min(inset, height / 2); + const maxY = Math.max(height - inset, height / 2); + + return { + x: Math.round(Math.min(maxX, Math.max(minX, point.x))), + y: Math.round(Math.min(maxY, Math.max(minY, point.y))) + }; + } + + saveMapState(successMessage) { + const snapshot = JSON.parse(JSON.stringify(this.mapState)); + this.saveQueue = this.saveQueue + .catch(() => {}) + .then(async () => { + const response = await apiFetch('/save-map', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(snapshot) + }); + if (!response.ok) throw new Error(`Save failed (${response.status})`); + if (successMessage) this.status.textContent = successMessage; + }) + .catch((error) => { + console.error(error); + this.status.textContent = 'Could not save map data. Your edits remain in this tab.'; + }); + return this.saveQueue; + } + + isArrowKey(event) { + return ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key); + } + + resetCameraDirection() { + this.camera.direction = [0, 0]; + } + + clampCamera() { + const { width: mapWidth, height: mapHeight } = this.mapDimensions(); + const halfViewWidth = this.canvas.width / 2; + const halfViewHeight = this.canvas.height / 2; + + this.camera.x = Math.min( + mapWidth - halfViewWidth, + Math.max(-halfViewWidth, this.camera.x) + ); + this.camera.y = Math.min( + mapHeight - halfViewHeight, + Math.max(-halfViewHeight, this.camera.y) + ); + } + + setPreview(enabled) { + this.resetCameraDirection(); + + if (enabled) { + if (!this.spawn) { + this.toggle.checked = false; + this.status.textContent = 'Place a spawn point before starting Preview.'; + return; + } + this.setWallMode(false); + this.setEraserMode(false); + this.editorCameraState = { x: this.camera.x, y: this.camera.y }; + this.previewGame = new Game(this.ctx); + this.previewGame.cam = this.camera; + this.previewGame.utils = new Util(this.ctx, this.camera); + this.previewGame.applyMapState({ + size: this.mapSize, + spawn: this.spawn, + walls: this.walls + }); + this.activeController = this.previewGame.controller; + this.spawnButton.disabled = true; + this.sizeInput.disabled = true; + this.wallsButton.disabled = true; + this.eraserButton.disabled = true; + this.status.textContent = 'Preview mode · right-click to move · arrow keys move the camera'; + this.canvas.focus(); + return; + } + + this.previewGame = null; + this.camera.x = this.editorCameraState.x; + this.camera.y = this.editorCameraState.y; + this.activeController = this.editorController; + this.spawnButton.disabled = false; + this.sizeInput.disabled = false; + this.wallsButton.disabled = false; + this.eraserButton.disabled = false; + this.status.textContent = 'Editor mode · use the arrow keys to move the camera'; + } + + drawSpawn(spawn, preview = false) { + if (!spawn) return; + const x = this.utils.vpx(spawn.x); + const y = this.utils.vpy(spawn.y); + const size = 14; + + this.ctx.save(); + this.ctx.globalAlpha = preview ? .55 : 1; + this.ctx.strokeStyle = '#dc2626'; + this.ctx.lineWidth = 3; + this.ctx.beginPath(); + this.ctx.moveTo(x - size, y - size); + this.ctx.lineTo(x + size, y + size); + this.ctx.moveTo(x + size, y - size); + this.ctx.lineTo(x - size, y + size); + this.ctx.stroke(); + this.ctx.fillStyle = '#991b1b'; + this.ctx.font = '600 13px system-ui, sans-serif'; + this.ctx.textAlign = 'center'; + this.ctx.fillText('Spawn', x, y - 22); + this.ctx.restore(); + } + + drawWall(wall, preview = false) { + if (!wall) return; + this.ctx.save(); + this.ctx.globalAlpha = preview ? .55 : 1; + this.ctx.strokeStyle = '#111827'; + this.ctx.lineWidth = 4; + this.ctx.lineCap = 'round'; + this.ctx.beginPath(); + this.ctx.moveTo(this.utils.vpx(wall.start.x), this.utils.vpy(wall.start.y)); + this.ctx.lineTo(this.utils.vpx(wall.end.x), this.utils.vpy(wall.end.y)); + this.ctx.stroke(); + this.ctx.restore(); + } + + drawEditor() { + this.utils.clear(); + const dimensions = this.mapSize.match(SIZE_PATTERN); + const width = Number(dimensions[1]); + const height = Number(dimensions[2]); + + this.ctx.save(); + this.ctx.strokeStyle = '#94a3b8'; + this.ctx.setLineDash([8, 6]); + this.ctx.strokeRect(this.utils.vpx(0), this.utils.vpy(0), width, height); + this.ctx.restore(); + this.walls.forEach(wall => this.drawWall(wall)); + if (this.wallStart && this.wallPreview) { + this.drawWall({ start: this.wallStart, end: this.wallPreview }, true); + } + this.drawSpawn(this.spawn); + this.drawSpawn(this.spawnPreview, true); + } + + frame() { + if (this.previewGame) { + this.previewGame.update(); + this.previewGame.draw(); + } else { + this.camera.update(); + this.clampCamera(); + this.drawEditor(); + } + requestAnimationFrame(this.frame); + } + } + + document.addEventListener('DOMContentLoaded', () => { + window.mapEditor = new MapEditor({ + canvas: document.getElementById('editor-canvas'), + toggle: document.getElementById('preview-toggle'), + status: document.getElementById('status'), + spawnButton: document.getElementById('spawn-button'), + wallsButton: document.getElementById('walls-button'), + eraserButton: document.getElementById('eraser-button'), + sizeInput: document.getElementById('map-size') + }); + }); +})(); diff --git a/public/lib.js b/public/lib.js index e030dea..fc47227 100644 --- a/public/lib.js +++ b/public/lib.js @@ -76,19 +76,256 @@ class Game { cam = new Camera() otherPlayers = [] id = -1 + mapWidth = null + mapHeight = null + walls = [] constructor(ctx, socket, id) { + this.ctx = ctx this.utils = new Util(ctx, this.cam) this.controller = new Controller(this); } + applyMapState(mapState) { + const size = mapState && typeof mapState.size === 'string' + ? mapState.size.match(/^(\d+)x(\d+)$/) + : null + if(size && Number(size[1]) > 0 && Number(size[2]) > 0) { + this.mapWidth = Number(size[1]) + this.mapHeight = Number(size[2]) + } + + const spawn = mapState && mapState.spawn + if(spawn && Number.isFinite(spawn.x) && Number.isFinite(spawn.y)) { + this.p1.x = spawn.x + this.p1.y = spawn.y + this.p1.stop() + } + + this.walls = Array.isArray(mapState && mapState.walls) + ? mapState.walls.filter(wall => + wall && + wall.start && Number.isFinite(wall.start.x) && Number.isFinite(wall.start.y) && + wall.end && Number.isFinite(wall.end.x) && Number.isFinite(wall.end.y) + ) + : [] + + this.constrainPlayer() + this.resolveWallOverlaps() + this.constrainCamera() + } + + constrainPlayer() { + if(this.mapWidth === null || this.mapHeight === null) return + const minX = Math.min(this.p1.r, this.mapWidth / 2) + const maxX = Math.max(this.mapWidth - this.p1.r, this.mapWidth / 2) + const minY = Math.min(this.p1.r, this.mapHeight / 2) + const maxY = Math.max(this.mapHeight - this.p1.r, this.mapHeight / 2) + const x = Math.min(maxX, Math.max(minX, this.p1.x)) + const y = Math.min(maxY, Math.max(minY, this.p1.y)) + + if(x !== this.p1.x || y !== this.p1.y) { + this.p1.x = x + this.p1.y = y + this.p1.stop() + } + } + + constrainDestination(x, y) { + if(this.mapWidth === null || this.mapHeight === null) return [x, y] + const minX = Math.min(this.p1.r, this.mapWidth / 2) + const maxX = Math.max(this.mapWidth - this.p1.r, this.mapWidth / 2) + const minY = Math.min(this.p1.r, this.mapHeight / 2) + const maxY = Math.max(this.mapHeight - this.p1.r, this.mapHeight / 2) + const boundaryTarget = [ + Math.min(maxX, Math.max(minX, x)), + Math.min(maxY, Math.max(minY, y)) + ] + return boundaryTarget + } + + constrainCamera() { + if(this.mapWidth === null || this.mapHeight === null) return + const halfWidth = this.ctx.canvas.width / 2 + const halfHeight = this.ctx.canvas.height / 2 + this.cam.x = Math.min(this.mapWidth - halfWidth, Math.max(-halfWidth, this.cam.x)) + this.cam.y = Math.min(this.mapHeight - halfHeight, Math.max(-halfHeight, this.cam.y)) + } + + closestPointOnWall(point, wall) { + const dx = wall.end.x - wall.start.x + const dy = wall.end.y - wall.start.y + const lengthSquared = dx * dx + dy * dy + if(lengthSquared === 0) { + return {x: wall.start.x, y: wall.start.y} + } + const projection = Math.max(0, Math.min(1, + ((point.x - wall.start.x) * dx + (point.y - wall.start.y) * dy) / lengthSquared + )) + return { + x: wall.start.x + projection * dx, + y: wall.start.y + projection * dy + } + } + + distanceToWall(point, wall) { + const closest = this.closestPointOnWall(point, wall) + return Math.hypot(point.x - closest.x, point.y - closest.y) + } + + wallSide(point, wall) { + return (wall.end.x - wall.start.x) * (point.y - wall.start.y) - + (wall.end.y - wall.start.y) * (point.x - wall.start.x) + } + + resolveWallOverlaps() { + for(const wall of this.walls) { + const closest = this.closestPointOnWall(this.p1, wall) + let dx = this.p1.x - closest.x + let dy = this.p1.y - closest.y + let distance = Math.hypot(dx, dy) + if(distance >= this.p1.r) continue + + if(distance === 0) { + dx = -(wall.end.y - wall.start.y) + dy = wall.end.x - wall.start.x + distance = Math.hypot(dx, dy) + if(distance === 0) { + dx = 1 + dy = 0 + distance = 1 + } + } + this.p1.x = closest.x + dx / distance * this.p1.r + this.p1.y = closest.y + dy / distance * this.p1.r + this.p1.stop() + } + this.constrainPlayer() + } + + resolveWallMovement(previousPosition) { + const intended = {x: this.p1.x - previousPosition.x, y: this.p1.y - previousPosition.y} + const constraints = [] + + for(const wall of this.walls) { + if(this.distanceToWall(this.p1, wall) >= this.p1.r) continue + const closest = this.closestPointOnWall(previousPosition, wall) + let nx = previousPosition.x - closest.x + let ny = previousPosition.y - closest.y + let distance = Math.hypot(nx, ny) + if(distance === 0) { + nx = -(wall.end.y - wall.start.y) + ny = wall.end.x - wall.start.x + distance = Math.hypot(nx, ny) || 1 + } + nx /= distance + ny /= distance + constraints.push({nx, ny, minimum: -(Math.max(0, distance - this.p1.r))}) + } + + if(!constraints.length) return + let movement = this.solveConstrainedMovement(intended, constraints) + const restingOnWalls = constraints.every(constraint => constraint.minimum > -0.01) + if(restingOnWalls && this.p1.destination) { + const remainingGoal = { + x: this.p1.destination.x - previousPosition.x, + y: this.p1.destination.y - previousPosition.y + } + const contactConstraints = constraints.map(constraint => ({...constraint, minimum: 0})) + const allowedGoal = this.solveConstrainedMovement(remainingGoal, contactConstraints) + const allowedDistance = Math.hypot(allowedGoal.x, allowedGoal.y) + if(allowedDistance > 0) { + const step = Math.min(this.p1.speed, allowedDistance) + movement = { + x: allowedGoal.x / allowedDistance * step, + y: allowedGoal.y / allowedDistance * step + } + } + } + this.p1.x = previousPosition.x + movement.x + this.p1.y = previousPosition.y + movement.y + this.resolveWallPenetration(previousPosition) + } + + resolveWallPenetration(previousPosition) { + for(let iteration = 0; iteration < 4; iteration++) { + const constraints = [] + for(const wall of this.walls) { + const closest = this.closestPointOnWall(this.p1, wall) + let nx = this.p1.x - closest.x + let ny = this.p1.y - closest.y + let distance = Math.hypot(nx, ny) + if(distance >= this.p1.r - 1e-7) continue + if(distance === 0) { + const previousClosest = this.closestPointOnWall(previousPosition, wall) + nx = previousPosition.x - previousClosest.x + ny = previousPosition.y - previousClosest.y + distance = Math.hypot(nx, ny) + if(distance === 0) { + nx = -(wall.end.y - wall.start.y) + ny = wall.end.x - wall.start.x + distance = Math.hypot(nx, ny) || 1 + } + } + constraints.push({ + nx: nx / distance, + ny: ny / distance, + minimum: this.p1.r - distance + }) + } + if(!constraints.length) return + const correction = this.solveConstrainedMovement({x: 0, y: 0}, constraints) + this.p1.x += correction.x + this.p1.y += correction.y + } + } + + solveConstrainedMovement(desired, constraints) { + const candidates = [desired, {x: 0, y: 0}] + for(const constraint of constraints) { + const dot = constraint.nx * desired.x + constraint.ny * desired.y + const adjustment = constraint.minimum - dot + candidates.push({ + x: desired.x + adjustment * constraint.nx, + y: desired.y + adjustment * constraint.ny + }) + } + for(let first = 0; first < constraints.length; first++) { + for(let second = first + 1; second < constraints.length; second++) { + const a = constraints[first] + const b = constraints[second] + const determinant = a.nx * b.ny - a.ny * b.nx + if(Math.abs(determinant) < 1e-9) continue + candidates.push({ + x: (a.minimum * b.ny - a.ny * b.minimum) / determinant, + y: (a.nx * b.minimum - a.minimum * b.nx) / determinant + }) + } + } + + const feasible = candidates.filter(candidate => constraints.every(constraint => + constraint.nx * candidate.x + constraint.ny * candidate.y >= constraint.minimum - 1e-7 + )) + feasible.sort((a, b) => + (a.x - desired.x) ** 2 + (a.y - desired.y) ** 2 - + ((b.x - desired.x) ** 2 + (b.y - desired.y) ** 2) + ) + return feasible[0] || {x: 0, y: 0} + } + update() { + const previousPosition = {x: this.p1.x, y: this.p1.y} this.p1.update(); this.cam.update(); + this.constrainPlayer(); + this.resolveWallMovement(previousPosition); + this.constrainCamera(); } draw() { this.utils.clear() + this.drawMapBoundary() + this.drawWalls() this.p1.draw(this.utils) for (let i = 0; i < this.otherPlayers.length; i++) { const player = this.otherPlayers[i]; @@ -97,6 +334,34 @@ class Game { } } } + + drawMapBoundary() { + if(this.mapWidth === null || this.mapHeight === null) return + this.ctx.save() + this.ctx.strokeStyle = '#334155' + this.ctx.lineWidth = 3 + this.ctx.strokeRect( + this.utils.vpx(0), + this.utils.vpy(0), + this.mapWidth, + this.mapHeight + ) + this.ctx.restore() + } + + drawWalls() { + this.ctx.save() + this.ctx.strokeStyle = '#111827' + this.ctx.lineWidth = 4 + this.ctx.lineCap = 'round' + for(const wall of this.walls) { + this.ctx.beginPath() + this.ctx.moveTo(this.utils.vpx(wall.start.x), this.utils.vpy(wall.start.y)) + this.ctx.lineTo(this.utils.vpx(wall.end.x), this.utils.vpy(wall.end.y)) + this.ctx.stroke() + } + this.ctx.restore() + } } class Player { @@ -109,25 +374,29 @@ class Player { // business-logic speed = 4 - translation = null + destination = null setDestination(x,y) { - let h = Math.pow((y-this.y)**2+(x-this.x)**2, 0.5) - let i = Math.round(h/this.speed) - let r = Math.atan2((y-this.y),(x-this.x)) - this.translation = [i,[Math.cos(r)*this.speed, Math.sin(r)*this.speed]] + this.destination = {x, y} } - move() { - if(this.translation) { - this.translation[0]-- - this.x += this.translation[1][0] - this.y += this.translation[1][1] + stop() { + this.destination = null + } - if(this.translation[0] == 0) { - this.translation = null - } + move() { + if(!this.destination) return + const dx = this.destination.x - this.x + const dy = this.destination.y - this.y + const distance = Math.hypot(dx, dy) + if(distance <= this.speed) { + this.x = this.destination.x + this.y = this.destination.y + this.destination = null + return } + this.x += dx / distance * this.speed + this.y += dy / distance * this.speed } update() { @@ -168,7 +437,8 @@ class Controller { } rightMouseDown(e) { - this.game.p1.setDestination(...this.game.cam.translate(e.offsetX, e.offsetY)) + const destination = this.game.cam.translate(e.offsetX, e.offsetY) + this.game.p1.setDestination(...this.game.constrainDestination(...destination)) } leftMouseDown(e) {