From 1f67b7c0fa2c92a484df38ac16678b5bab0a4d62 Mon Sep 17 00:00:00 2001 From: pegasuspect <1279406+pegasuspect@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:24:07 -0400 Subject: [PATCH 1/7] Wrote detailed roadmap for Map Editor. --- README.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 71169fd..26fdfa7 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,49 @@ ## Todo List -- [ ] Harita Editoru +- [ ] Map Editor - [ ] 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 +### 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 1. AWS From c4d0605f4e01e30dbd714a0591a0418fe5c405a1 Mon Sep 17 00:00:00 2001 From: pegasuspect <1279406+pegasuspect@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:24:12 -0400 Subject: [PATCH 2/7] Add map editor functionality with UI and preview mode --- editor.html | 86 +++++++++++++++++++++++++++++++++ index.js | 4 ++ public/editor.js | 120 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 editor.html create mode 100644 public/editor.js diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..eb80d0b --- /dev/null +++ b/editor.html @@ -0,0 +1,86 @@ + + + + + + simpleMOBA Map Editor + + + +
+
+

Map Editor

+ +
+

Editor mode · use the arrow keys to move the camera

+ +
+ + + + + diff --git a/index.js b/index.js index cdaf736..6a419a3 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,10 @@ app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); +app.get(['/editor', '/editor/'], (req, res) => { + res.sendFile(__dirname + '/editor.html'); +}); + io.on('connection', (socket) => { socket.on('position', player => { let p = players.find(x => x.id === player.id); diff --git a/public/editor.js b/public/editor.js new file mode 100644 index 0000000..82c8a13 --- /dev/null +++ b/public/editor.js @@ -0,0 +1,120 @@ +(() => { + const SPAWN = Object.freeze({ x: 400, y: 300 }); + + class EditorController extends Controller { + rightMouseDown() {} + middleMouseDown() {} + } + + class MapEditor { + constructor(canvas, toggle, status) { + this.canvas = canvas; + this.toggle = toggle; + this.status = status; + this.ctx = 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.onMouseDown = (event) => this.activeController.mouseDown(event); + 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); + }; + + canvas.addEventListener('mousedown', this.onMouseDown); + canvas.addEventListener('contextmenu', this.onContextMenu); + document.addEventListener('keydown', this.onKeyDown); + document.addEventListener('keyup', this.onKeyUp); + toggle.addEventListener('change', () => this.setPreview(toggle.checked)); + + this.frame = this.frame.bind(this); + requestAnimationFrame(this.frame); + } + + get cam() { + return this.camera; + } + + isArrowKey(event) { + return ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key); + } + + resetCameraDirection() { + this.camera.direction = [0, 0]; + } + + setPreview(enabled) { + this.resetCameraDirection(); + + if (enabled) { + 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.p1.x = SPAWN.x; + this.previewGame.p1.y = SPAWN.y; + this.previewGame.p1.translation = null; + this.activeController = this.previewGame.controller; + 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.status.textContent = 'Editor mode · use the arrow keys to move the camera'; + } + + drawEditor() { + this.utils.clear(); + const x = this.utils.vpx(SPAWN.x); + const y = this.utils.vpy(SPAWN.y); + const size = 14; + + this.ctx.save(); + 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(); + } + + frame() { + if (this.previewGame) { + this.previewGame.update(); + this.previewGame.draw(); + } else { + this.camera.update(); + this.drawEditor(); + } + requestAnimationFrame(this.frame); + } + } + + document.addEventListener('DOMContentLoaded', () => { + window.mapEditor = new MapEditor( + document.getElementById('editor-canvas'), + document.getElementById('preview-toggle'), + document.getElementById('status') + ); + }); +})(); From 3e6ddb05e75a935b7052888f2c6b20ead56b9f2e Mon Sep 17 00:00:00 2001 From: pegasuspect <1279406+pegasuspect@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:28:28 -0400 Subject: [PATCH 3/7] Implement map state management with save and load functionality --- index.js | 36 ++++++++++++++++++++++++++++++++++++ map-state.json | 1 + 2 files changed, 37 insertions(+) create mode 100644 map-state.json diff --git a/index.js b/index.js index 6a419a3..cd4d22b 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,15 @@ 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(express.json({ limit: '1mb' })); app.use(express.static('public')); app.get('/', (req, res) => { @@ -16,6 +20,38 @@ 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..0967ef4 --- /dev/null +++ b/map-state.json @@ -0,0 +1 @@ +{} From b6d23f8d66fb957a2357a433ee8d9b67070299eb Mon Sep 17 00:00:00 2001 From: pegasuspect <1279406+pegasuspect@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:54:28 -0400 Subject: [PATCH 4/7] Enhance map editor with toolbar, spawn placement, and map state loading functionality --- editor.html | 63 +++++++++- index.html | 10 +- index.js | 11 ++ map-state.json | 8 +- public/editor.js | 292 +++++++++++++++++++++++++++++++++++++++++++---- public/lib.js | 90 ++++++++++++++- 6 files changed, 446 insertions(+), 28 deletions(-) diff --git a/editor.html b/editor.html index eb80d0b..444ec9e 100644 --- a/editor.html +++ b/editor.html @@ -34,6 +34,60 @@ font-size: 1.35rem; } + .toolbar { + display: flex; + align-items: end; + gap: 12px; + min-height: 52px; + margin-bottom: 10px; + padding: 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + } + + .toolbar button, + .toolbar input { + box-sizing: border-box; + height: 34px; + border: 1px solid #9ca3af; + border-radius: 4px; + font: inherit; + } + + .toolbar button { + padding: 0 14px; + background: #f8fafc; + cursor: pointer; + } + + .toolbar button:hover:not(:disabled) { + background: #e5e7eb; + } + + .toolbar label { + display: grid; + gap: 4px; + color: #4b5563; + font-size: .78rem; + font-weight: 600; + } + + .toolbar input { + width: 130px; + padding: 0 9px; + } + + .toolbar input.invalid { + border-color: #dc2626; + outline-color: #dc2626; + } + + :disabled { + cursor: not-allowed; + opacity: .55; + } + .preview-control { display: inline-flex; align-items: center; @@ -73,7 +127,14 @@

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 cd4d22b..8244acb 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,17 @@ 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')); diff --git a/map-state.json b/map-state.json index 0967ef4..e38ddc1 100644 --- a/map-state.json +++ b/map-state.json @@ -1 +1,7 @@ -{} +{ + "size": "400x400", + "spawn": { + "x": 224, + "y": 380 + } +} diff --git a/public/editor.js b/public/editor.js index 82c8a13..7bdfeb4 100644 --- a/public/editor.js +++ b/public/editor.js @@ -1,25 +1,60 @@ (() => { - const SPAWN = Object.freeze({ x: 400, y: 300 }); + 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.placeSpawn(event); + } + rightMouseDown() {} middleMouseDown() {} } class MapEditor { - constructor(canvas, toggle, status) { - this.canvas = canvas; - this.toggle = toggle; - this.status = status; - this.ctx = canvas.getContext('2d'); + constructor(elements) { + this.canvas = elements.canvas; + this.toggle = elements.toggle; + this.status = elements.status; + this.spawnButton = elements.spawnButton; + 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.saveQueue = Promise.resolve(); this.onMouseDown = (event) => this.activeController.mouseDown(event); + this.onMouseMove = (event) => { + if (!this.placingSpawn) return; + const point = this.worldPoint(event); + if (this.pointWithinBoundaries(point, SPAWN_INSET)) this.spawnPreview = point; + }; this.onContextMenu = (event) => this.activeController.contextMenu(event); this.onKeyDown = (event) => { if (this.isArrowKey(event)) event.preventDefault(); @@ -30,20 +65,194 @@ this.activeController.keyUp(event); }; - canvas.addEventListener('mousedown', this.onMouseDown); - canvas.addEventListener('contextmenu', this.onContextMenu); + 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); - toggle.addEventListener('change', () => this.setPreview(toggle.checked)); + this.toggle.addEventListener('change', () => this.setPreview(this.toggle.checked)); + this.spawnButton.addEventListener('click', () => this.beginSpawnPlacement()); + 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.sizeInput.value = this.mapSize; + this.status.textContent = 'Editor mode · use the arrow keys to move the camera'; + if (adjustedSpawn) { + 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.status.textContent = 'Could not load map data; using game defaults.'; + } finally { + this.toggle.disabled = false; + this.spawnButton.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.clampCamera(); + this.saveMapState('Map boundaries saved.'); + } + + beginSpawnPlacement() { + if (this.placingSpawn) { + this.canvas.focus(); + return; + } + + this.spawn = null; + delete this.mapState.spawn; + this.placingSpawn = true; + this.spawnPreview = null; + this.toggle.disabled = true; + this.sizeInput.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.updateSpawnButton(); + this.saveMapState('Spawn point saved.'); + } + + 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); } @@ -52,18 +261,38 @@ 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.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.p1.x = SPAWN.x; - this.previewGame.p1.y = SPAWN.y; - this.previewGame.p1.translation = null; + this.previewGame.applyMapState({ size: this.mapSize, spawn: this.spawn }); this.activeController = this.previewGame.controller; + this.spawnButton.disabled = true; + this.sizeInput.disabled = true; this.status.textContent = 'Preview mode · right-click to move · arrow keys move the camera'; this.canvas.focus(); return; @@ -73,16 +302,19 @@ 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.status.textContent = 'Editor mode · use the arrow keys to move the camera'; } - drawEditor() { - this.utils.clear(); - const x = this.utils.vpx(SPAWN.x); - const y = this.utils.vpy(SPAWN.y); + 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(); @@ -98,12 +330,28 @@ 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.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); @@ -111,10 +359,12 @@ } document.addEventListener('DOMContentLoaded', () => { - window.mapEditor = new MapEditor( - document.getElementById('editor-canvas'), - document.getElementById('preview-toggle'), - document.getElementById('status') - ); + window.mapEditor = new MapEditor({ + canvas: document.getElementById('editor-canvas'), + toggle: document.getElementById('preview-toggle'), + status: document.getElementById('status'), + spawnButton: document.getElementById('spawn-button'), + sizeInput: document.getElementById('map-size') + }); }); })(); diff --git a/public/lib.js b/public/lib.js index e030dea..247c8c0 100644 --- a/public/lib.js +++ b/public/lib.js @@ -76,19 +76,81 @@ class Game { cam = new Camera() otherPlayers = [] id = -1 + mapWidth = null + mapHeight = null 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.translation = null + } + + this.constrainPlayer() + 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.translation = null + } + } + + 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) + return [ + Math.min(maxX, Math.max(minX, x)), + Math.min(maxY, Math.max(minY, y)) + ] + } + + 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)) + } + update() { this.p1.update(); this.cam.update(); + this.constrainPlayer(); + this.constrainCamera(); } draw() { this.utils.clear() + this.drawMapBoundary() this.p1.draw(this.utils) for (let i = 0; i < this.otherPlayers.length; i++) { const player = this.otherPlayers[i]; @@ -97,6 +159,20 @@ 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() + } } class Player { @@ -113,9 +189,12 @@ class Player { 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]] + if(h === 0) { + this.translation = null + return + } + let i = Math.ceil(h/this.speed) + this.translation = [i,[(x-this.x)/i, (y-this.y)/i],[x,y]] } move() { @@ -125,6 +204,8 @@ class Player { this.y += this.translation[1][1] if(this.translation[0] == 0) { + this.x = this.translation[2][0] + this.y = this.translation[2][1] this.translation = null } } @@ -168,7 +249,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) { From b7f618227a41dcdb48d1a8cdc83a693a9585a6c5 Mon Sep 17 00:00:00 2001 From: pegasuspect <1279406+pegasuspect@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:20:32 -0400 Subject: [PATCH 5/7] Add wall placement and eraser functionality to map editor --- editor.html | 8 ++ map-state.json | 226 ++++++++++++++++++++++++++++++++++++++++++++++- public/editor.js | 206 ++++++++++++++++++++++++++++++++++++++++-- public/lib.js | 160 +++++++++++++++++++++++++++++++++ 4 files changed, 593 insertions(+), 7 deletions(-) diff --git a/editor.html b/editor.html index 444ec9e..bc76084 100644 --- a/editor.html +++ b/editor.html @@ -65,6 +65,12 @@ background: #e5e7eb; } + .toolbar button[aria-pressed="true"] { + border-color: #2563eb; + background: #2563eb; + color: #fff; + } + .toolbar label { display: grid; gap: 4px; @@ -132,6 +138,8 @@

Map Editor

+ +