You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
605 lines
19 KiB
605 lines
19 KiB
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 = { |
|
h: false, |
|
l: false, |
|
}; |
|
this.crouching = false; |
|
this.isPunching = false; |
|
this.punchHasHit = false; |
|
this.sentJoin = false; |
|
this.hp = 10; |
|
this.isAlive = true; |
|
} |
|
|
|
|
|
|
|
update() { |
|
this.vx = 0; |
|
if (this.keys.h) { |
|
this.vx = -this.speed; |
|
this.facing = -1; |
|
} |
|
|
|
if (this.crouching && this.keys.l) { |
|
this.speed = 0.5; |
|
this.vx = this.speed; |
|
} |
|
if (this.crouching && this.keys.h) { |
|
this.speed = 0.5; |
|
this.vx = -this.speed; |
|
} |
|
|
|
if (this.keys.l) { |
|
this.vx = this.speed; |
|
this.facing = 1; |
|
} |
|
|
|
this.x += this.vx; |
|
|
|
this.vy += this.gravity; |
|
this.y += this.vy; |
|
|
|
const groundY = this.canvas.height; |
|
const totalHeight = 60; |
|
|
|
if (this.y + totalHeight >= groundY) { |
|
this.y = groundY - totalHeight; |
|
this.vy = 0; |
|
this.onGround = true; |
|
} else { |
|
this.onGround = false; |
|
} |
|
|
|
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; |
|
} |
|
} |
|
} |
|
|
|
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 |
|
|
|
|
|
// head |
|
ctx.beginPath(); |
|
ctx.arc(x, y, headRadius, 0, Math.PI * 2); |
|
ctx.stroke(); |
|
|
|
// eye |
|
ctx.beginPath(); |
|
ctx.arc(x + this.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); |
|
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); |
|
} |
|
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); |
|
} |
|
} |
|
|
|
// 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; |
|
} |
|
} |
|
|
|
talk(message) { |
|
this.talking = true; |
|
this.talkTimer = 120; |
|
this.message = message; |
|
} |
|
|
|
jump() { |
|
if (this.onGround) { |
|
this.vy = this.jumpStrength; |
|
this.onGround = false; |
|
} |
|
} |
|
|
|
_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; |
|
} |
|
|
|
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; |
|
} |
|
} |
|
|
|
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 = { |
|
"player": new StickFigure(10, 1, "player", this.canvas), |
|
"player1": new StickFigure(100, 1, "player1", this.canvas) |
|
|
|
}; |
|
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; |
|
} |
|
|
|
start() { |
|
if (this._started) return; |
|
this.started = true; |
|
this._stopped = false; |
|
|
|
document.addEventListener("keydown", this._onKeyDown); |
|
document.addEventListener("keyup", this._onKeyUp); |
|
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); |
|
} |
|
|
|
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(); |
|
} |
|
|
|
_scheduleReconnect() { |
|
if (this._stopped) return; |
|
if (this._reconnectTimer) { |
|
this._reconnectTimer = null; |
|
} |
|
|
|
const jitter = this._reconnectDelay * (Math.random() * 0.4 - 0.2); |
|
const delay = Math.max(250, Math.min(this._maxReconnectDelay, this._reconnectDelay + jitter)); |
|
console.log(`Reconnecting websocket...`) |
|
|
|
this._reconnectTimer = setTimeout(() => { |
|
this._reconnectTimer = null; |
|
this._initWebSocket(); |
|
}, delay); |
|
|
|
this._reconnectDelay = Math.min(this._maxReconnectDelay, Math.round(this._reconnectDelay * 1.8)); |
|
} |
|
|
|
loop() { |
|
this.time += 0.1; |
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); |
|
|
|
const playerArray = Object.values(this.players); |
|
|
|
playerArray.forEach(player => { |
|
player.update(); // Call the update method for each player |
|
}); |
|
|
|
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; |
|
console.log("Player A hits player B"); |
|
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; |
|
console.log("Player B hits player A") |
|
playerA.hp -= 1; |
|
if (playerA.hp == 0) { |
|
playerA.isAlive = false; |
|
delete this.players[playerA.id] |
|
} |
|
} |
|
} |
|
} |
|
|
|
playerArray.forEach(player => { |
|
player.draw(this.ctx, this.time); // Call the draw method for each player |
|
|
|
}) |
|
|
|
requestAnimationFrame(this.loop) |
|
} |
|
|
|
_getPageData() { |
|
const metaTag = document.querySelector('meta[name="pagedata"]'); |
|
const json = JSON.parse(metaTag.getAttribute("content")); |
|
return json; |
|
} |
|
|
|
_isTypingInChat() { |
|
return document.activeElement === this.chatInput; |
|
} |
|
|
|
_onKeyDown(e) { |
|
if (this._isTypingInChat()) return; |
|
|
|
const me = this._me() |
|
if (!me) return; |
|
|
|
switch (e.key) { |
|
case 'e': |
|
me.punch() |
|
this._sendAction('e', "keyDown") |
|
break; |
|
case 'k': |
|
me.jump(); |
|
this._sendAction('k', "keyDown") |
|
break; |
|
case 'j': |
|
me.crouching = true; |
|
this._sendAction('j', 'keyDown') |
|
break; |
|
case 'h': |
|
case 'l': |
|
if (!me.keys) me.keys = {}; |
|
if (!me.keys[e.key]) me.keys[e.key] = true; |
|
this._sendAction(e.key, 'keyDown') |
|
break; |
|
default: |
|
break; |
|
} |
|
} |
|
|
|
_me() { |
|
return this.players[this.pageData.username]; |
|
} |
|
|
|
_onKeyUp(e) { |
|
const me = this._me(); |
|
if (!me) return; |
|
|
|
if (e.key === 't') { |
|
this.chatInput.focus(); |
|
} |
|
|
|
if (e.key === 'j') { |
|
me.crouching = false; |
|
me.speed = 2; |
|
this._sendAction('j', 'keyUp') |
|
} |
|
|
|
if (e.key === 'h' || e.key === 'l') { |
|
if (!me.keys) me.keys = {}; |
|
me.keys[e.key] = false; |
|
this._sendAction(e.key, 'keyUp') |
|
} |
|
|
|
if (this._isTypingInChat()) { |
|
if (e.key === 'Enter') { |
|
this._onSendClick() |
|
} |
|
if (e.key === "Escape" && this._isTypingInChat()) { |
|
this.chatInput.blur(); |
|
} |
|
} |
|
} |
|
|
|
_ensurePlayer(id) { |
|
if (!this.players[id] && id !== "") { |
|
this.players[id] = new StickFigure(250, 1, id, this.canvas) |
|
} |
|
} |
|
|
|
_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 = ""; |
|
} |
|
|
|
_initWebSocket() { |
|
if (this._stopped) return; |
|
|
|
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 = () => { |
|
console.log('i am opening') |
|
this._reconnectDelay = 1000; |
|
|
|
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) { |
|
const data = JSON.parse(e.data); |
|
this._ensurePlayer(data.Id); |
|
|
|
// ignore echo |
|
if (data.Id === this.pageData.username) return; |
|
|
|
const p = this.players[data.Id]; |
|
switch (data.Action) { |
|
case "t": |
|
p.talk(data.Message); |
|
break; |
|
case "e": |
|
p.punch(); |
|
break; |
|
case "k": |
|
p.jump(); |
|
break; |
|
case "h": |
|
case "l": |
|
p.keys[data.Action] = data.ActionType === "keyDown"; |
|
break; |
|
} |
|
} |
|
}; |
|
|
|
this.ws.onerror = (err) => { |
|
console.log("ws error:", err); |
|
this._scheduleReconnect() |
|
}; |
|
|
|
this.ws.onclose = (event) => { |
|
console.log(event.code) |
|
console.log("leaving game...") |
|
console.log(this.pageData) |
|
this.ws = null; |
|
this._sentJoin = false; |
|
this._scheduleReconnect() |
|
} |
|
} |
|
|
|
_sendAction(action, actionType = "", message = "") { |
|
this._send({ |
|
Id: this.pageData.username, |
|
Action: action, |
|
ActionType: actionType, |
|
Message: message |
|
}); |
|
} |
|
|
|
_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(); |
|
} |
|
|
|
} |
|
|
|
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 |
|
) |
|
}
|
|
|