From 00bb43036d786c20cafed3e3469c794f1229606e Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Fri, 29 Aug 2025 00:20:40 -0700 Subject: [PATCH] Refactoring --- static/character.js | 176 ++++++++ static/inputhandler.js | 259 +++++++++++ static/physics.js | 44 ++ static/script.js | 981 +++++++++++++++-------------------------- static/stickfigure.js | 193 ++++++++ 5 files changed, 1025 insertions(+), 628 deletions(-) create mode 100644 static/character.js create mode 100644 static/inputhandler.js create mode 100644 static/physics.js create mode 100644 static/stickfigure.js diff --git a/static/character.js b/static/character.js new file mode 100644 index 0000000..a07fc7d --- /dev/null +++ b/static/character.js @@ -0,0 +1,176 @@ +export class StickFigureRenderer { + draw(player, ctx, time) { + const headRadius = 10; + const x = player.x; + let y = player.y; + + const crouch = !!player.crouching; + const bodyLength = crouch ? 20 : 30; + const legLength = crouch ? 20 : 25; + if (crouch) y += 15; // shift down a touch so crouch looks grounded + + // Set drawing styles + ctx.strokeStyle = "black"; + ctx.fillStyle = "black"; + ctx.lineWidth = 2; + + // head + ctx.beginPath(); + ctx.arc(x, y, headRadius, 0, Math.PI * 2); + ctx.stroke(); + + // eye + ctx.beginPath(); + ctx.arc(x + player.facing * 5, y - 3, 2, 0, Math.PI * 2); + ctx.fill(); + + // body + const chestY = y + headRadius; + const hipY = y + headRadius + bodyLength; + ctx.beginPath(); + ctx.moveTo(x, chestY); + ctx.lineTo(x, hipY); + ctx.stroke(); + + // arms + ctx.beginPath(); + ctx.moveTo(x, y + 20); + if (player.action === "punch" && player.hitFrame < 10) { + ctx.lineTo(x + 30 * player.facing, y + 10); + } else { + ctx.lineTo(x + 20 * player.facing, y + 20); + } + ctx.moveTo(x, y + 20); + ctx.lineTo(x - 20 * player.facing, y + 20); + ctx.stroke(); + + // legs + ctx.beginPath(); + if (player.vx !== 0) { + const step = Math.sin(time * 0.75); + const stride = 14; + const lift = 0; + + // right leg + ctx.moveTo(x, hipY); + ctx.lineTo(x + stride * step, hipY + legLength + (-lift * Math.abs(step))); + + // left leg + ctx.moveTo(x, hipY); + ctx.lineTo(x - stride * step, hipY + legLength + (-lift * Math.abs(step))); + } else { + const idleSpread = 10; + ctx.moveTo(x, hipY); + ctx.lineTo(x + idleSpread, hipY + legLength); + ctx.moveTo(x, hipY); + ctx.lineTo(x - idleSpread, hipY + legLength); + } + ctx.stroke(); + ctx.strokeStyle = "black"; + + // speech bubble + if (player.talking) { + const padding = 12; + const maxMessageWidth = 100; + + // set font BEFORE measuring + ctx.font = "12px sans-serif"; + + const lines = this._wrapText(ctx, player.message, maxMessageWidth); + const textWidth = ctx.measureText(player.message).width; + const widths = lines.map(line => ctx.measureText(line).width); + const contentWidth = Math.max(...widths, textWidth); + const bubbleWidth = Math.min(contentWidth + padding + 10, maxMessageWidth + padding + 10); + const lineHeight = 14; + const bubbleHeight = lines.length * lineHeight + padding; + const bubbleX = x - bubbleWidth - 15; + const bubbleY = y - 20; + + ctx.beginPath(); + ctx.fillStyle = "white"; + ctx.strokeStyle = "black"; + if (typeof ctx.roundRect === "function") { + ctx.roundRect(bubbleX, bubbleY, bubbleWidth, bubbleHeight, 4); + } else { + ctx.rect(bubbleX, bubbleY, bubbleWidth, bubbleHeight); + } + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = "black"; + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], bubbleX + padding, bubbleY + padding + i * lineHeight); + } + } + + let nameY = y - 80; + if (player.isAlive) { + nameY = this._drawHealthBar(ctx, player) + } + + // name tag + ctx.save(); + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.fillStyle = "black"; + ctx.fillText(player.id, x, nameY); + ctx.restore(); + } + + _drawHealthBar(ctx, player) { + const x = player.x; + const headCenterY = player.y; + const topOfHead = headCenterY - 10; + const barWidth = 40; + const barHeight = 5; + + const barX = x - barWidth / 2; + const barY = topOfHead - 10; + + ctx.fillStyle = "#ddd"; + ctx.fillRect(barX, barY, barWidth, barHeight); + + const pct = Math.max(0, Math.min(1, player.hp / player.maxHp)); + let color = '#27ae60'; + + if (pct <= 0.33) { + color = '#e74c3c'; + } else if (pct <= 0.66) { + color = '#f1c40f'; + } + + ctx.fillStyle = color; + ctx.fillRect(barX, barY, barWidth * pct, barHeight); + + + ctx.strokeStyle = '#000'; + ctx.lineWidth = 1; + ctx.strokeRect(barX, barY, barWidth, barHeight); + + return barY; + } + + _wrapText(ctx, text, maxWidth) { + const words = text.split(" "); + const lines = []; + let line = ""; + + for (let i = 0; i < words.length; i++) { + const testLine = line + words[i] + " "; + const testWidth = ctx.measureText(testLine).width; + + if (testWidth > maxWidth && i > 0) { + lines.push(line.trimEnd()); + line = words[i] + " "; + } else { + line = testLine; + } + } + + lines.push(line.trim()); + return lines; + } + +} + diff --git a/static/inputhandler.js b/static/inputhandler.js new file mode 100644 index 0000000..add3ad7 --- /dev/null +++ b/static/inputhandler.js @@ -0,0 +1,259 @@ +class Command { + execute() { } + toNetwork() { + return null; + } +} + +class PunchCmd extends Command { + execute(p) { + p.punch(); + } + toNetwork(p) { + return { + Id: p.id, + Action: ' ', + ActionType: "keyDown" + }; + } +} + +class JumpCmd extends Command { + execute(p) { + p.jump(); + } + toNetwork(p) { + return { + Id: p.id, + Action: 'w', + ActionType: 'keyDown' + }; + } +} + +class CrouchPressCmd extends Command { + execute(p) { + p.crouching = true; + } + toNetwork(p) { + return { + Id: p.id, + Action: 's', + ActionType: 'keyDown' + }; + } +} + +class CrouchUnpressCmd extends Command { + execute(p) { + p.crouching = false; + } + toNetwork(p) { + return { + Id: p.id, + Action: 's', + ActionType: 'keyUp' + }; + } +} + +class MoveLeftPressCmd extends Command { + execute(p) { + p.keys.a = true; + } + toNetwork(p) { + return { + Id: p.id, + Action: 'a', + ActionType: 'keyDown' + }; + + } +} + +class MoveLeftUnpressCmd extends Command { + execute(p) { + p.keys.a = false; + } + toNetwork(p) { + return { + Id: p.id, + Action: 'a', + ActionType: 'keyUp' + }; + + } +} + +class MoveRightPressCmd extends Command { + execute(p) { + p.keys.d = true; + } + toNetwork(p) { + return { + Id: p.id, + Action: 'd', + ActionType: 'keyDown' + }; + } +} + +class MoveRightUnpressCmd extends Command { + execute(p) { + p.keys.d = false; + } + toNetwork(p) { + return { + Id: p.id, + Action: 'd', + ActionType: 'keyUp' + }; + } +} + +class TalkCmd extends Command { + constructor(message) { + super(); + this.message = message; + } + execute(p) { + p.talk(this.message); + } + toNetwork(p) { + return { + Id: p.id, + Action: 't', + ActionType: '', + Message: this.message + } + } +} + +class DisconnectedCmd extends Command { + execute() { } + toNetwork(p) { + return { + Id: p.id, + Action: "disconnected", + ActionType: "" + }; + } +} + +export class InputHandler { + constructor(game) { + this.game = game; + + this.downMap = new Map([ + [' ', () => new PunchCmd()], + ['w', () => new JumpCmd()], + ['s', () => new CrouchPressCmd()], + ['a', () => new MoveLeftPressCmd()], + ['d', () => new MoveRightPressCmd()], + ]); + + this.upMap = new Map([ + ['s', () => new CrouchUnpressCmd()], + ['a', () => new MoveLeftUnpressCmd()], + ['d', () => new MoveRightUnpressCmd()], + ]); + } + + handleKeyDown(e, player) { + const key = e.code === 'Space' ? ' ' : (e.key || '').toLowerCase(); + const k = this.downMap.get(key); + if (!k) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + this.dispatch(k(), player) + } + + handleKeyUp(e, player) { + const key = e.code === "Space" ? ' ' : e.key; + const k = this.upMap.get(key); + if (!k) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.dispatch(k(), player) + } + + dispatch(cmd, player) { + if (!player) return; + cmd.execute(player, this.game); + if (player.isLocal) { + const msg = cmd.toNetwork(player); + if (msg) { + this.game._send({ + ...msg, + PosX: Math.floor(player.x), + PosY: Math.floor(player.y), + Facing: player.facing, + Ts: Date.now(), + }); + } + } + } + + handleNetworkMessage(data) { + this.game._ensurePlayer(data.Id); + const p = this.game.players?.[data.Id]; + if (!p) { + return; + } + + if (typeof data.PosX === "number") { + p._lastTx = p._tx ?? data.PosX; + p._tx = data.PosX; + } + if (typeof data.PosY === "number") { + p._lastTy = p._ty ?? data.PosY; + p._ty = data.PosY; + } + if (typeof data.Facing === "number") { + p._tFacing = data.Facing; + } + + p._lastUpdateTime = Date.now(); + + const action = data.Action; + const actionType = data.ActionType; + + const apply = (cmd) => cmd && cmd.execute(p, this.game); + + switch (action) { + case 'disconnected': + delete this.game.players[data.Id]; + break; + case 't': + apply(new TalkCmd(data.Message ?? "")); + break; + case ' ': + apply(new PunchCmd()); + break; + case 'w': + apply(new JumpCmd()); + break; + case 's': + apply(actionType === 'keyDown' ? new CrouchPressCmd() : new CrouchUnpressCmd()); + break; + case 'a': + apply(actionType === 'keyDown' ? new MoveLeftPressCmd() : new MoveLeftUnpressCmd()); + break; + case 'd': + apply(actionType === 'keyDown' ? new MoveRightPressCmd() : new MoveRightUnpressCmd()); + break; + } + } + releaseAll(player) { + const cmds = []; + if (player.keys?.a) cmds.push(new MoveLeftUnpressCmd()); + if (player.keys?.d) cmds.push(new MoveRightUnpressCmd()); + if (player.crouching) cmds.push(new CrouchUnpressCmd()); + for (const c of cmds) this.dispatch(c, player); + } +} diff --git a/static/physics.js b/static/physics.js new file mode 100644 index 0000000..a2b00c9 --- /dev/null +++ b/static/physics.js @@ -0,0 +1,44 @@ +export class PhysicsSystems { + resolveCollisions(player, platforms, groundY) { + const totalHeight = 60; + const halfFootW = 12; + const EPS = 0.5; + + if (player._prevY === undefined) { + player._prevY = player.y; + } + + let landedOnPlatform = false; + + // one-way platforms (land only when falling and crossing from above) + if (player.vy >= 0) { + for (const pf of platforms) { + const feetLeft = player.x - halfFootW; + const feetRight = player.x + halfFootW; + const pfLeft = pf.x, pfRight = pf.x + pf.w; + + const horizOverlap = feetRight > pfLeft && feetLeft < pfRight; + const wasAbove = (player._prevY + totalHeight) <= pf.y + EPS; + const nowBelowTop = (player.y + totalHeight) >= pf.y - EPS; + const dropping = player.crouching === true; // hold 's' to drop through + + if (horizOverlap && wasAbove && nowBelowTop && !dropping) { + player.y = pf.y - totalHeight; + player.vy = 0; + landedOnPlatform = true; + break; + } + } + } + // --- ground after platforms --- + if (!landedOnPlatform && player.y + totalHeight >= groundY) { + player.y = groundY - totalHeight; + player.vy = 0; + player.onGround = true; + } else if (landedOnPlatform) { + player.onGround = true; + } else { + player.onGround = false; + } + } +} diff --git a/static/script.js b/static/script.js index 9048800..4fb0d62 100644 --- a/static/script.js +++ b/static/script.js @@ -1,717 +1,442 @@ -window.addEventListener("DOMContentLoaded", function() { - class StickFigure { - constructor(x, facing, id, canvas) { - this.canvas = canvas; - this.id = id; - this.x = x; - this.y = canvas.height - 60; - this.facing = facing; - this.action = "idle"; - this.hitFrame = 0; - this.speed = 2; - this.vx = 0; - this.vy = 0; - this.gravity = 0.5; - this.jumpStrength = -10; - this.onGround = true; - this.talking = false; - this.talkTimer = 0; - this.message = ""; - this.keys = { - a: false, - d: false, - }; - this.crouching = false; - this.isPunching = false; - this.punchHasHit = false; - this.sentJoin = false; - this.hp = 10; - this.isAlive = true; - this._tx = x; - this._ty = canvas.height - 60; - this._lastTs = 0; - this._tFacing = undefined; - this._lastUpdateTime = Date.now(); - this._updateInterval = 100; - } +import { StickFigureRenderer } from "./character.js"; +import { InputHandler } from "./inputhandler.js"; +import { PhysicsSystems } from "./physics.js"; +import { StickFigure } from "./stickfigure.js" + +class Game { + constructor({ canvasId = 'canvas', chatInputId = 'msg', sendBtnId = "send", leaveBtnId = "leave" }) { + this.renderer = new StickFigureRenderer(); + this.physics = new PhysicsSystems(); + this.input = new InputHandler(this); + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas.getContext('2d'); + + this.chatInput = document.getElementById(chatInputId); + this.sendBtn = document.getElementById(sendBtnId); + this.leaveBtn = document.getElementById(leaveBtnId); + + this.time = 0; + this.players = { + }; + this.pageData = this._getPageData(); + + this.ws = null; + + this.loop = this.loop.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); + this._onSendClick = this._onSendClick.bind(this); + this._leaveGame = this._leaveGame.bind(this); + + this._started = false; + this._sentJoin = false; + this._stopped = false; + + this._reconnectDelay = 1000; + this._maxReconnectDelay = 15000; + this._reconnectTimer = null; + + this.platform = [ + { x: 300, y: this.canvas.height - 90, w: 240, h: 12 }, + { x: 200, y: this.canvas.height - 160, w: 240, h: 12 }, + { x: 280, y: this.canvas.height - 240, w: 240, h: 12 }, + { x: 460, y: this.canvas.height - 320, w: 240, h: 12 }, + ] + + this.pickups = [ + { id: 'p1', x: 160, y: this.canvas.height - 15, r: 15, type: 'atk', amount: 10 }, + + { id: 'p2', x: 350, y: this.canvas.height - 15, r: 15, type: 'def', amount: 20 }, + ] + } - update() { - this._prevY = this.y; - this.vx = 0; + start() { + if (this._started) return; + this._started = true; // FIXED: was "this.started = true" + this._stopped = false; + + document.addEventListener("keydown", this._onKeyDown, { passive: false }); + document.addEventListener("keyup", this._onKeyUp, { passive: false }); + window.addEventListener("blur", this._onBlur); + // Send disconnect message on page unload + window.addEventListener("beforeunload", () => { + this._sendAction("disconnected", "", ""); + }); + if (this.sendBtn) this.sendBtn.addEventListener("click", this._onSendClick); + if (this.leaveBtn) this.leaveBtn.addEventListener("click", this._leaveGame); + + this._ensurePlayer(this.pageData.username); + this._initWebSocket(); + + requestAnimationFrame(this.loop); + } - if (this.keys.a) { - this.vx = -this.speed; - this.facing = -1; - } + destroy() { + this._stopped = true; + this._started = false; + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + document.removeEventListener("keydown", this._onKeyDown); + document.removeEventListener("keyup", this._onKeyUp); + if (this.sendBtn) this.sendBtn.removeEventListener("click", this._onSendClick); + if (this.ws) this.ws.close(); + } - if (this.crouching && this.keys.d) { - this.speed = 0.5; - this.vx = this.speed; - } - if (this.crouching && this.keys.a) { - this.speed = 0.5; - this.vx = -this.speed; - } + _scheduleReconnect() { + if (this._stopped) return; - if (this.keys.d) { - this.vx = this.speed; - this.facing = 1; - } + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer) + this._reconnectTimer = null; + } - this.x += this.vx; + const jitter = this._reconnectDelay * (Math.random() * 0.4 - 0.2); + const delay = Math.max(250, Math.min(this._maxReconnectDelay, this._reconnectDelay + jitter)); - if (this.isLocal) { - this.vy += this.gravity; - this.y += this.vy; - } + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + this._initWebSocket(); + }, delay); - this.x = Math.max(20, Math.min(this.canvas.width - 20, this.x)); + this._reconnectDelay = Math.min(this._maxReconnectDelay, Math.round(this._reconnectDelay * 1.8)); + } - if (this.action === "punch") { - this.hitFrame++; - if (this.hitFrame > 15) { - this.action = 'idle'; - this.hitFrame = 0; - this.isPunching = false; - this.punchHasHit = false; - } - } + _getPlayerArray() { + if (!this._cachedPlayerArray || this._playersChanged) { + this._cachedPlayerArray = Object.values(this.players); + this._playersChanged = false; + } - if (this.talking) { - this.talkTimer--; - if (this.talkTimer <= 0) { - this.talking = false; - } - } + return this._cachedPlayerArray; + } - if (!this.isLocal && typeof this._tx === "number" - && typeof this._ty === "number") { - const now = Date.now(); - const timeSinceUpdate = now - this._lastUpdateTime; + loop() { + if (this._stopped) return; + this.time += 0.1; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - const targetLerpTime = 100; - const deltaTime = 16.67; - const lerpFactor = Math.min(1, deltaTime / targetLerpTime); - const stalenessFactor = Math.min(2, timeSinceUpdate / this._updateInterval); - const adjustedLerpFactor = lerpFactor * stalenessFactor; + const playerArray = this._getPlayerArray() - const lerp = (a, b, t) => a + (b - a) * Math.min(1, t); + this.ctx.fillStyle = "#333" - this.x = lerp(this.x, this._tx, adjustedLerpFactor); - this.y = lerp(this.y, this._ty, adjustedLerpFactor); - this.facing = this._tFacing ?? this.facing; - } + for (const p of this.platform) { + this.ctx.fillRect(p.x, p.y, p.w, p.h) } - draw(ctx, time) { - const headRadius = 10; - const x = this.x; - let y = this.y; - - const crouch = !!this.crouching; - const bodyLength = crouch ? 20 : 30; - const legLength = crouch ? 20 : 25; - if (crouch) y += 15; // shift down a touch so crouch looks grounded + playerArray.forEach(player => { + player.update(); // Call the update method for each player + if (player.isLocal) { + this.physics.resolveCollisions(player, this.platform, this.canvas.height); + } + }); - // Set drawing styles - ctx.strokeStyle = "black"; - ctx.fillStyle = "black"; - ctx.lineWidth = 2; + const alivePlayers = playerArray.filter(p => p.isAlive); - // head - ctx.beginPath(); - ctx.arc(x, y, headRadius, 0, Math.PI * 2); - ctx.stroke(); + for (let i = 0; i < alivePlayers.length; i++) { + for (let j = i + 1; j < alivePlayers.length; j++) { + const playerA = playerArray[i]; + const playerB = playerArray[j]; - // eye - ctx.beginPath(); - ctx.arc(x + this.facing * 5, y - 3, 2, 0, Math.PI * 2); - ctx.fill(); + const playerAPunch = playerA.getPunchHitbox(); + const playerBPunch = playerB.getPunchHitbox(); + const playerABody = playerA.getBodyHitbox(); + const playerBBody = playerB.getBodyHitbox(); - // body - const chestY = y + headRadius; - const hipY = y + headRadius + bodyLength; - ctx.beginPath(); - ctx.moveTo(x, chestY); - ctx.lineTo(x, hipY); - ctx.stroke(); - // arms - ctx.beginPath(); - ctx.moveTo(x, y + 20); - const walking = this.vx !== 0; - const armSwing = walking ? Math.sin(time * 0.2) * 4 : 0; // subtle swing - if (this.action === "punch" && this.hitFrame < 10) { - ctx.lineTo(x + 30 * this.facing, y + 10); - } else { - ctx.lineTo(x + (20 + armSwing) * this.facing, y + 20); - } - ctx.moveTo(x, y + 20); - ctx.lineTo(x - (20 + armSwing) * this.facing, y + 20); - ctx.stroke(); - - // legs - ctx.beginPath(); - if (this.vx !== 0) { - const step = Math.sin(time * 0.75); // cadence; lower = slower - const stride = 14; // how far forward/back feet go - const lift = 0; // little foot lift - - // right leg - ctx.moveTo(x, hipY); - ctx.lineTo(x + stride * step, hipY + legLength + (-lift * Math.abs(step))); - - // left leg (opposite phase) - ctx.moveTo(x, hipY); - ctx.lineTo(x - stride * step, hipY + legLength + (-lift * Math.abs(step))); - } else { - const idleSpread = 10; - ctx.moveTo(x, hipY); - ctx.lineTo(x + idleSpread, hipY + legLength); - ctx.moveTo(x, hipY); - ctx.lineTo(x - idleSpread, hipY + legLength); - } - ctx.stroke(); - - // speech bubble - if (this.talking) { - const padding = 12; - const maxMessageWidth = 100; - - // set font BEFORE measuring - ctx.font = "12px sans-serif"; - - const lines = this._wrapText(ctx, this.message, maxMessageWidth); - const textWidth = ctx.measureText(this.message).width; - const widths = lines.map(line => ctx.measureText(line).width); - const contentWidth = Math.max(...widths, textWidth); - const bubbleWidth = Math.min(contentWidth + padding + 10, maxMessageWidth + padding + 10); - const lineHeight = 14; - const bubbleHeight = lines.length * lineHeight + padding; - const bubbleX = x - bubbleWidth - 15; - const bubbleY = y - 20; - - ctx.beginPath(); - ctx.fillStyle = "white"; - ctx.strokeStyle = "black"; - if (typeof ctx.roundRect === "function") { - ctx.roundRect(bubbleX, bubbleY, bubbleWidth, bubbleHeight, 4); - } else { - ctx.rect(bubbleX, bubbleY, bubbleWidth, bubbleHeight); + if (playerAPunch && !playerA.punchHasHit && rectangleOverlap(playerAPunch, playerBBody)) { + playerA.punchHasHit = true; + playerB.hp -= Math.pow(playerA.baseAttackPower, 2) / (playerA.baseAttackPower + playerB.baseDefense); + if (playerB.hp <= 0) { + playerB.isAlive = false; + delete this.players[playerB.id] + this._playersChanged = true; + } } - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = "black"; - for (let i = 0; i < lines.length; i++) { - ctx.fillText(lines[i], bubbleX + padding, bubbleY + padding + i * lineHeight); + if (playerBPunch && !playerB.punchHasHit && rectangleOverlap(playerBPunch, playerABody)) { + playerB.punchHasHit = true; + playerA.hp -= Math.pow(playerB.baseAttackPower, 2) / (playerB.baseAttackPower + playerA.baseDefense); + + if (playerA.hp <= 0) { + playerA.isAlive = false; + delete this.players[playerA.id] + this._playersChanged = true; + } } } - - // name tag - ctx.save(); - ctx.font = "10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.fillStyle = "black"; - ctx.fillText(this.id, x, y - 19); - ctx.restore(); } - punch() { - this.isPunching = true; - this.punchHasHit = false; - - if (this.action === 'idle') { - this.action = 'punch'; - this.hitFrame = 0; + playerArray.forEach(player => { + this.renderer.draw(player, this.ctx, this.time); // Call the draw method for each player + }) + + const me = this._me(); + if (me && me.isLocal) { + if (this.time % 0.2 < 0.1) { + this._send({ + Id: this.pageData.username, + Action: "position_update", + PosX: Math.floor(me.x), + PosY: Math.floor(me.y), + Facing: me.facing, + Ts: Date.now(), + }) } } - talk(message) { - this.talking = true; - this.talkTimer = 120; - this.message = message; - } + this._drawPickups(this.ctx); - jump() { - if (this.onGround) { - this.vy = this.jumpStrength; - this.onGround = false; - } + if (me && me.isLocal) { + this._checkPickupCollision(me) } - _wrapText(ctx, text, maxWidth) { - const words = text.split(" "); - const lines = []; - let line = ""; + requestAnimationFrame(this.loop) + } - for (let i = 0; i < words.length; i++) { - const testLine = line + words[i] + " "; - const testWidth = ctx.measureText(testLine).width; + _drawPickups(ctx) { + for (const p of this.pickups) { + ctx.beginPath(); - if (testWidth > maxWidth && i > 0) { - lines.push(line.trimEnd()); - line = words[i] + " "; - } else { - line = testLine; - } - } + ctx.fillStyle = p.type === 'atk' ? '#e74c3c' : '#3498db'; + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fill(); - lines.push(line.trim()); - return lines; + ctx.fillStyle = 'white'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(p.type === 'atk' ? 'A' : 'D', p.x, p.y + 0.5); } + } - getBodyHitbox() { - const w = 24; - const h = 60 - (this.crouching ? 10 : 0); - return { - x: this.x - w / 2, - y: this.y - 10, - w, - h - }; - } + _checkPickupCollision(player) { - getPunchHitbox() { - if (this.action === 'punch' && this.hitFrame < 10) { - const w = 18; - const h = 14; - const frontX = this.x + this.facing * 22; - const x = this.facing === 1 ? frontX : frontX - w; - const y = this.y + (this.crouching ? 18 : 10); - return { - x, - y, - w, - h - }; - } + const headRadius = 10; + const bodyLength = player.crouching ? 20 : 30; + const legLength = player.crouching ? 20 : 25; - return null; - } + const px = Math.floor(player.x); + const py = Math.floor(player.y + headRadius + bodyLength + legLength); + for (let i = this.pickups.length - 1; i >= 0; i--) { + const p = this.pickups[i]; + const dx = px - p.x; + const dy = py - p.y; + const dist2 = dx * dx + dy * dy; + const playerPickupRadius = 8; + const r = p.r + playerPickupRadius; - resolveCollisions(platforms, groundY) { - const totalHeight = 60; - const halfFootW = 12; - const EPS = 0.5; + if (dist2 <= r * r) { + this._send({ + Id: this.pageData.username, + Action: 'pickup', + PickupId: p.id, + PickupIndex: i, + Type: p.type, + Amount: p.amount, + Ts: Date.now() + }) - if (this._prevY === undefined) { - this._prevY = this.y; - } - let landedOnPlatform = false; - - // one-way platforms (land only when falling and crossing from above) - if (this.vy >= 0) { - for (const pf of platforms) { - const feetLeft = this.x - halfFootW; - const feetRight = this.x + halfFootW; - const pfLeft = pf.x, pfRight = pf.x + pf.w; - - const horizOverlap = feetRight > pfLeft && feetLeft < pfRight; - const wasAbove = (this._prevY + totalHeight) <= pf.y + EPS; - const nowBelowTop = (this.y + totalHeight) >= pf.y - EPS; - const dropping = this.crouching === true; // hold 's' to drop through - - if (horizOverlap && wasAbove && nowBelowTop && !dropping) { - this.y = pf.y - totalHeight; - this.vy = 0; - landedOnPlatform = true; - break; - } - } - } - // --- ground after platforms --- - if (!landedOnPlatform && this.y + totalHeight >= groundY) { - this.y = groundY - totalHeight; - this.vy = 0; - this.onGround = true; - } else if (landedOnPlatform) { - this.onGround = true; - } else { - this.onGround = false; } + } } - class Game { - constructor({ canvasId = 'canvas', chatInputId = 'msg', sendBtnId = "send", leaveBtnId = "leave" }) { - this.canvas = document.getElementById(canvasId); - this.ctx = this.canvas.getContext('2d'); - - this.chatInput = document.getElementById(chatInputId); - this.sendBtn = document.getElementById(sendBtnId); - this.leaveBtn = document.getElementById(leaveBtnId); - - this.time = 0; - this.players = { - }; - this.pageData = this._getPageData(); - - this.ws = null; - - this.loop = this.loop.bind(this); - this._onKeyDown = this._onKeyDown.bind(this); - this._onKeyUp = this._onKeyUp.bind(this); - this._onSendClick = this._onSendClick.bind(this); - this._leaveGame = this._leaveGame.bind(this); - - this._started = false; - this._sentJoin = false; - this._stopped = false; - - this._reconnectDelay = 1000; - this._maxReconnectDelay = 15000; - this._reconnectTimer = null; - - this.platform = [ - { x: 120, y: this.canvas.height - 60, w: 240, h: 12 }, - { x: 200, y: this.canvas.height - 120, w: 240, h: 12 }, - { x: 280, y: this.canvas.height - 180, w: 240, h: 12 }, - { x: 460, y: this.canvas.height - 240, w: 240, h: 12 }, - - - - ] - } + _getPageData() { + const metaTag = document.querySelector('meta[name="pagedata"]'); + const json = JSON.parse(metaTag.getAttribute("content")); + return json; + } - start() { - if (this._started) return; - this._started = true; // FIXED: was "this.started = true" - this._stopped = false; - - document.addEventListener("keydown", this._onKeyDown); - document.addEventListener("keyup", this._onKeyUp); - // Send disconnect message on page unload - window.addEventListener("beforeunload", () => { - this._sendAction("disconnected", "", ""); - }); - if (this.sendBtn) this.sendBtn.addEventListener("click", this._onSendClick); - if (this.leaveBtn) this.leaveBtn.addEventListener("click", this._leaveGame); - - this._ensurePlayer(this.pageData.username); - this._initWebSocket(); + _isTypingInChat() { + return document.activeElement === this.chatInput; + } - requestAnimationFrame(this.loop); - } + _onKeyDown(e) { + if (this._isTypingInChat()) return; - destroy() { - this._stopped = true; - this._started = false; - if (this._reconnectTimer) { - clearTimeout(this._reconnectTimer); - this._reconnectTimer = null; - } - document.removeEventListener("keydown", this._onKeyDown); - document.removeEventListener("keyup", this._onKeyUp); - if (this.sendBtn) this.sendBtn.removeEventListener("click", this._onSendClick); - if (this.ws) this.ws.close(); - } + const me = this._me() + if (!me) return; - _scheduleReconnect() { - if (this._stopped) return; - - if (this._reconnectTimer) { - clearTimeout(this._reconnectTimer) - this._reconnectTimer = null; - } + this.input.handleKeyDown(e, me); + } - const jitter = this._reconnectDelay * (Math.random() * 0.4 - 0.2); - const delay = Math.max(250, Math.min(this._maxReconnectDelay, this._reconnectDelay + jitter)); + _me() { + return this.players[this.pageData.username]; + } - this._reconnectTimer = setTimeout(() => { - this._reconnectTimer = null; - this._initWebSocket(); - }, delay); + _onKeyUp(e) { + const me = this._me(); + if (!me) return; - this._reconnectDelay = Math.min(this._maxReconnectDelay, Math.round(this._reconnectDelay * 1.8)); + if (e.key === 't') { + this.chatInput.focus(); } - loop() { - if (this._stopped) return; - this.time += 0.1; - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - - const playerArray = Object.values(this.players); - - this.ctx.fillStyle = "#333" - - for (const p of this.platform) { - this.ctx.fillRect(p.x, p.y, p.w, p.h) + if (this._isTypingInChat()) { + if (e.key === 'Enter') { + this._onSendClick() } - - playerArray.forEach(player => { - player.update(); // Call the update method for each player - if (player.isLocal) { - player.resolveCollisions(this.platform, this.canvas.height); - } - }); - - for (let i = 0; i < playerArray.length; i++) { - for (let j = i + 1; j < playerArray.length; j++) { - const playerA = playerArray[i]; - const playerB = playerArray[j]; - - const playerAPunch = playerA.getPunchHitbox(); - const playerBPunch = playerB.getPunchHitbox(); - const playerABody = playerA.getBodyHitbox(); - const playerBBody = playerB.getBodyHitbox(); - - if (playerAPunch && !playerA.punchHasHit && rectangleOverlap(playerAPunch, playerBBody)) { - playerA.punchHasHit = true; - playerB.hp -= 1; - if (playerB.hp == 0) { - playerB.isAlive = false; - delete this.players[playerB.id] - } - } - - if (playerBPunch && !playerB.punchHasHit && rectangleOverlap(playerBPunch, playerABody)) { - playerB.punchHasHit = true; - playerA.hp -= 1; - if (playerA.hp == 0) { - playerA.isAlive = false; - delete this.players[playerA.id] - } - } - } + if (e.key === "Escape" && this._isTypingInChat()) { + this.chatInput.blur(); } - - playerArray.forEach(player => { - player.draw(this.ctx, this.time); // Call the draw method for each player - }) - - const me = this._me(); - if (me && me.isLocal) { - if (this.time % 0.2 < 0.1) { - this._send({ - Id: this.pageData.username, - Action: "position_update", - PosX: Math.floor(me.x), - PosY: Math.floor(me.y), - Facing: me.facing, - Ts: Date.now(), - }) - } - } - - requestAnimationFrame(this.loop) + return; } - _getPageData() { - const metaTag = document.querySelector('meta[name="pagedata"]'); - const json = JSON.parse(metaTag.getAttribute("content")); - return json; - } + this.input.handleKeyUp(e, me); + } - _isTypingInChat() { - return document.activeElement === this.chatInput; + _ensurePlayer(id) { + if (!this.players[id] && id !== "") { + this.players[id] = new StickFigure(250, 1, id, this.canvas) + this._playersChanged = true; + this.players[id].isLocal = (id === this.pageData.username) } + } - _onKeyDown(e) { - if (this._isTypingInChat()) return; + _onBlur() { + const me = this._me(); + if (!me) return; + // ensure local state matches network: clear any stuck presses + this.input.releaseAll(me); + } - const me = this._me() - if (!me) return; + _onSendClick() { + const inputEl = this.chatInput; + if (!inputEl) return; - switch (e.key) { - case 'e': - case ' ': - this._sendAction('e', "keyDown") - break; - case 'w': - this._sendAction('k', "keyDown") - break; - case 's': - this._sendAction('j', 'keyDown') - break; - case 'a': - case 'd': - this._sendAction(e.key, 'keyDown') - break; - default: - break; - } - } + const text = inputEl.value.trim(); + if (!text) return; - _me() { - return this.players[this.pageData.username]; - } + const me = this._me(); + if (me) me.talk(text); - _onKeyUp(e) { - const me = this._me(); - if (!me) return; + this._sendAction("t", "", text); - if (e.key === 't') { - this.chatInput.focus(); - } - - if (e.key === 's') { - this._sendAction('j', 'keyUp') - } + inputEl.value = ""; + } - if (e.key === 'a' || e.key === 'd') { - this._sendAction(e.key, 'keyUp') - } + _initWebSocket() { + if (this._stopped) return; - if (this._isTypingInChat()) { - if (e.key === 'Enter') { - this._onSendClick() - } - if (e.key === "Escape" && this._isTypingInChat()) { - this.chatInput.blur(); - } - } + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { + return; } + const scheme = location.protocol === "https:" ? "wss://" : "ws://"; + this.ws = new WebSocket(scheme + location.host + "/ws"); - _ensurePlayer(id) { - if (!this.players[id] && id !== "") { - this.players[id] = new StickFigure(250, 1, id, this.canvas) - this.players[id].isLocal = (id === this.pageData.username) - } - } - - _onSendClick() { - const inputEl = this.chatInput; - if (!inputEl) return; - - const text = inputEl.value.trim(); - if (!text) return; - - const me = this._me(); - if (me) me.talk(text); - - this._sendAction("t", "", text); - - inputEl.value = ""; - } + this.ws.onopen = () => { + this._reconnectDelay = 1000; - _initWebSocket() { - if (this._stopped) return; + if (!this._sentJoin && this.pageData.username !== "") { + this._send({ + Id: this.pageData.username, + Action: "join", + Message: "" + }); + this._sentJoin = true; + + } + }; + + this.ws.onmessage = (e) => { + if (!e.data) return; + let data; + try { + data = JSON.parse(e.data); + } catch (error) { + console.error('JSON parse error:', error); + console.error('Message length:', e.data.length); + console.error('Raw message:', e.data); + + // Check character codes around position 106 + const around106 = e.data.substring(100, 115); + console.error('Around position 106:', around106); + console.error('Character codes around 106:', + Array.from(around106).map((char, i) => `${i + 100}: '${char}' (${char.charCodeAt(0)})`)); + + // Try to find the actual error position + for (let i = 0; i < e.data.length; i++) { + try { + JSON.parse(e.data.substring(0, i + 1)); + } catch (partialError) { + if (i > 105 && i < 110) { + console.error(`Parse fails at position ${i}: character '${e.data[i]}' (code: ${e.data.charCodeAt(i)})`); + } + } + } - if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } - const scheme = location.protocol === "https:" ? "wss://" : "ws://"; - this.ws = new WebSocket(scheme + location.host + "/ws"); - this.ws.onopen = () => { - this._reconnectDelay = 1000; + this._ensurePlayer(data.Id); + this.input.handleNetworkMessage(data); - if (!this._sentJoin && this.pageData.username !== "") { - this._send({ - Id: this.pageData.username, - Action: "join", - Message: "" - }); - this._sentJoin = true; - - } - }; - - this.ws.onmessage = (e) => { - if (!e.data) return; - const data = JSON.parse(e.data); - - this._ensurePlayer(data.Id) - const p = this.players[data.Id]; + switch (data.Action) { + case 'pickup': + const idx = this.pickups.findIndex(pp => pp.id === data.PickupId); + if (idx !== -1) { + this.pickups.splice(idx, 1); + } - if (typeof data.PosX === "number" && typeof data.PosY === "number") { - p._tx = data.PosX; - p._ty = data.PosY; - p._lastUpdateTime = Date.now(); - } + const who = this.players[data.Id]; - if (typeof data.Facing === "number") { - p._tFacing = data.Facing; - } + if (who) { + const kind = data.Type; + who.applyBuff(kind, data.Amount) + } + break; + } + }; + this.ws.onerror = (err) => { + console.log("ws error:", err); + this._scheduleReconnect() + }; - switch (data.Action) { - case "disconnected": - delete this.players[data.Id] - break; - case "t": - p.talk(data.Message); - break; - case "e": - p.punch(); - break; - case "k": - case "w": - p.jump(); - break; - case "j": - case "s": - p.crouching = data.ActionType === "keyDown" - p.speed = 2; - break; - case "h": - case "a": - p.keys[data.Action === "h" ? "h" : "a"] = data.ActionType === "keyDown"; - break; - case "l": - case "d": - p.keys[data.Action === "l" ? "l" : "d"] = data.ActionType === "keyDown"; - break; - } - }; - - this.ws.onerror = (err) => { - console.log("ws error:", err); - this._scheduleReconnect() - }; - - this.ws.onclose = () => { - console.log("leaving game...") - this.ws = null; - this._sentJoin = false; - delete this.players[this.pageData.username] - this._scheduleReconnect() - } + this.ws.onclose = () => { + console.log("leaving game...") + this.ws = null; + this._sentJoin = false; + delete this.players[this.pageData.username] + this._playersChanged = true; + this._scheduleReconnect() } + } - _sendAction(action, actionType = "", message = "") { - const me = this._me(); - this._send({ - Id: this.pageData.username, - Action: action, - ActionType: actionType, - Message: message, - PosX: me ? Math.floor(me.x) : undefined, - PosY: me ? Math.floor(me.y) : undefined, - Facing: me ? me.facing : undefined, - Ts: Date.now(), - }); - } + _sendAction(action, actionType = "", message = "") { + const me = this._me(); + this._send({ + Id: this.pageData.username, + Action: action, + ActionType: actionType, + Message: message, + PosX: me ? Math.floor(me.x) : undefined, + PosY: me ? Math.floor(me.y) : undefined, + Facing: me ? me.facing : undefined, + Ts: Date.now(), + }); + } - _send(obj) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(obj)); - } + _send(obj) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); } - _leaveGame() { - console.log('leave button was clicked'); - this.ws.close(); - } - } + _leaveGame() { + console.log('leave button was clicked'); + this.ws.close(); + } + +} - const game = new Game({ - canvasId: 'canvas', - chatInputId: 'msg', - sendBtnId: 'send', - leaveBtnId: 'leave', - }); - game.start(); +const game = new Game({ + canvasId: 'canvas', + chatInputId: 'msg', + sendBtnId: 'send', + leaveBtnId: 'leave', }); +game.start(); -function rectangleOverlap(a, b) { - return ( - a.x < b.x + b.w && - a.x + a.w > b.x && - a.y < b.y + b.h && - a.y + a.h > b.y - ) -} diff --git a/static/stickfigure.js b/static/stickfigure.js new file mode 100644 index 0000000..2921d63 --- /dev/null +++ b/static/stickfigure.js @@ -0,0 +1,193 @@ +export class StickFigure { + constructor(x, facing, id, canvas) { + this.canvas = canvas; + this.id = id; + this.x = x; + this.y = canvas.height - 60; + this.facing = facing; + this._lastTx = x; + this._lastTy = this.y; + this._lastUpdateTime = 0; + this._walkTTL = 0; + this.action = "idle"; + this.hitFrame = 0; + this.speed = 2; + this.vx = 0; + this.vy = 0; + this.gravity = 0.5; + this.jumpStrength = -10; + this.onGround = true; + this.talking = false; + this.talkTimer = 0; + this.message = ""; + this.keys = { + a: false, + d: false, + } + this.crouching = false; + this.isPunching = false; + this.punchHasHit = false; + this.sentJoin = false; + this.hp = 100; + this.maxHp = 100; + this.isAlive = true; + this._tx = x; + this._ty = canvas.height - 60; + this._lastTs = 0; + this._tFacing = undefined; + this._lastUpdateTime = Date.now(); + this._updateInterval = 10; + this.baseAttackPower = 14; + this.baseDefense = 14; + } + + update() { + const prevX = this.x; + this._prevY = this.y; + this.vx = 0; + + if (this.keys.a) { + this.vx = -this.speed; + this.facing = -1; + } + + if (this.crouching && this.keys.d) { + this.speed = 0.5; + this.vx = this.speed; + } + if (this.crouching && this.keys.a) { + this.vx = -this.speed; + } + + if (this.keys.d) { + this.vx = this.speed; + this.facing = 1; + } + + this.x += this.vx; + + if (this.isLocal) { + this.vy += this.gravity; + this.y += this.vy; + } + + this.x = Math.max(20, Math.min(this.canvas.width - 20, this.x)); + + if (this.action === "punch") { + this.hitFrame++; + if (this.hitFrame > 15) { + this.action = 'idle'; + this.hitFrame = 0; + this.isPunching = false; + this.punchHasHit = false; + } + } + + if (this.talking) { + this.talkTimer--; + if (this.talkTimer <= 0) { + this.talking = false; + } + } + + if (!this.isLocal + && typeof this._tx === "number" + && typeof this._ty === "number") { + const now = Date.now(); + const timeSinceUpdate = now - this._lastUpdateTime; + + const targetLerpTime = 200; + const deltaTime = 16.67; + const lerpFactor = Math.min(1, deltaTime / targetLerpTime); + const stalenessFactor = Math.min(2, timeSinceUpdate / this._updateInterval); + const adjustedLerpFactor = lerpFactor * stalenessFactor; + + const deltaY = Math.abs(this.y - this._ty); + + if (deltaY > 8 || !this.onGround) { + this.x = this._tx; + this.y = this._ty; + } else { + this.x = this.lerp(this.x, this._tx, adjustedLerpFactor); + this.y = this.lerp(this.y, this._ty, adjustedLerpFactor); + } + this.facing = this._tFacing ?? this.facing; + + this.vx = this.x - prevX; + if (Math.abs(this.vx) < 0.5) { + this.vx = 0; + } + } + } + + lerp(a, b, t) { + return a + (b - a) * Math.min(1, t); + } + + applyBuff(type, amount) { + if (type === 'atk') { + this.baseAttackPower += amount; + } + + if (type === 'def') { + this.baseDefense += amount; + } + } + + punch() { + this.isPunching = true; + this.punchHasHit = false; + + if (this.action === 'idle') { + this.action = 'punch'; + this.hitFrame = 0; + } + } + + talk(message) { + this.talking = true; + this.talkTimer = 120; + this.message = message; + } + + jump() { + if (this.onGround) { + this.vy = this.jumpStrength; + this.onGround = false; + } + } + + + + getBodyHitbox() { + const w = 24; + const h = 60 - (this.crouching ? 10 : 0); + return { + x: this.x - w / 2, + y: this.y - 10, + w, + h + }; + } + + getPunchHitbox() { + if (this.action === 'punch' && this.hitFrame < 10) { + const w = 18; + const h = 14; + const frontX = this.x + this.facing * 22; + const x = this.facing === 1 ? frontX : frontX - w; + const y = this.y + (this.crouching ? 18 : 10); + return { + x, + y, + w, + h + }; + } + + return null; + } + + +} +