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.
457 lines
14 KiB
457 lines
14 KiB
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._onBlur = this._onBlur.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 }, |
|
] |
|
} |
|
|
|
start() { |
|
if (this._started) return; |
|
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); |
|
} |
|
|
|
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) { |
|
clearTimeout(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)); |
|
|
|
this._reconnectTimer = setTimeout(() => { |
|
this._reconnectTimer = null; |
|
this._initWebSocket(); |
|
}, delay); |
|
|
|
this._reconnectDelay = Math.min(this._maxReconnectDelay, Math.round(this._reconnectDelay * 1.8)); |
|
} |
|
|
|
_getPlayerArray() { |
|
if (!this._cachedPlayerArray || this._playersChanged) { |
|
console.log('players changed...') |
|
this._cachedPlayerArray = Object.values(this.players); |
|
this._playersChanged = false; |
|
} |
|
|
|
return this._cachedPlayerArray; |
|
} |
|
|
|
loop(now = performance.now()) { |
|
if (this._stopped) return; |
|
|
|
if (!this._last) this._last = now; |
|
|
|
const dt = (now - this._last) / 100; |
|
this._last = now; |
|
|
|
this.time += dt; |
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); |
|
|
|
const playerArray = this._getPlayerArray() |
|
|
|
this.ctx.fillStyle = "#333" |
|
|
|
for (const p of this.platform) { |
|
this.ctx.fillRect(p.x, p.y, p.w, p.h) |
|
} |
|
|
|
playerArray.forEach(player => { |
|
player.update(dt); // Call the update method for each player |
|
if (player.isLocal) { |
|
this.physics.resolveCollisions(player, this.platform, this.canvas.height); |
|
} |
|
}); |
|
|
|
const alivePlayers = playerArray.filter(p => p.isAlive); |
|
|
|
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]; |
|
|
|
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 -= Math.pow(playerA.baseAttackPower, 2) / (playerA.baseAttackPower + playerB.baseDefense); |
|
if (playerB.hp <= 0) { |
|
playerB.isAlive = false; |
|
delete this.players[playerB.id] |
|
this._playersChanged = true; |
|
} |
|
} |
|
|
|
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; |
|
} |
|
} |
|
} |
|
} |
|
|
|
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(), |
|
}) |
|
} |
|
} |
|
|
|
this._drawPickups(this.ctx); |
|
|
|
if (me && me.isLocal) { |
|
this._checkPickupCollision(me) |
|
} |
|
|
|
requestAnimationFrame(this.loop) |
|
} |
|
|
|
_drawPickups(ctx) { |
|
for (const p of this.pickups) { |
|
ctx.beginPath(); |
|
|
|
ctx.fillStyle = p.type === 'atk' ? '#e74c3c' : '#3498db'; |
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); |
|
ctx.fill(); |
|
|
|
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); |
|
} |
|
} |
|
|
|
_checkPickupCollision(player) { |
|
|
|
const headRadius = 10; |
|
const bodyLength = player.crouching ? 20 : 30; |
|
const legLength = player.crouching ? 20 : 25; |
|
|
|
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; |
|
|
|
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() |
|
}) |
|
|
|
|
|
} |
|
|
|
} |
|
} |
|
|
|
_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; |
|
|
|
this.input.handleKeyDown(e, me); |
|
} |
|
|
|
_me() { |
|
return this.players[this.pageData.username]; |
|
} |
|
|
|
_onKeyUp(e) { |
|
const me = this._me(); |
|
if (!me) return; |
|
|
|
if (e.key === 't') { |
|
this.chatInput.focus(); |
|
} |
|
|
|
if (this._isTypingInChat()) { |
|
if (e.key === 'Enter') { |
|
this._onSendClick() |
|
} |
|
if (e.key === "Escape" && this._isTypingInChat()) { |
|
this.chatInput.blur(); |
|
} |
|
return; |
|
} |
|
|
|
this.input.handleKeyUp(e, me); |
|
} |
|
|
|
_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) |
|
this.players['dummy'] = new StickFigure(200, 1, 'dummy', this.canvas) |
|
} |
|
} |
|
|
|
_onBlur() { |
|
const me = this._me(); |
|
if (!me) return; |
|
// ensure local state matches network: clear any stuck presses |
|
this.input.releaseAll(me); |
|
} |
|
|
|
_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 = () => { |
|
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) 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)})`); |
|
} |
|
} |
|
} |
|
|
|
return; |
|
} |
|
|
|
this._ensurePlayer(data.Id); |
|
this.input.handleNetworkMessage(data); |
|
|
|
switch (data.Action) { |
|
case 'pickup': |
|
const idx = this.pickups.findIndex(pp => pp.id === data.PickupId); |
|
if (idx !== -1) { |
|
this.pickups.splice(idx, 1); |
|
} |
|
|
|
const who = this.players[data.Id]; |
|
|
|
if (who) { |
|
const kind = data.Type; |
|
who.applyBuff(kind, data.Amount) |
|
} |
|
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._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(), |
|
}); |
|
} |
|
|
|
_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(); |
|
} |
|
|
|
} |
|
|
|
function rectangleOverlap(rect1, rect2) { |
|
return rect1.x < rect2.x + rect2.w && |
|
rect1.x + rect1.w > rect2.x && |
|
rect1.y < rect2.y + rect2.h && |
|
rect1.y + rect1.h > rect2.y; |
|
} |
|
|
|
const game = new Game({ |
|
canvasId: 'canvas', |
|
chatInputId: 'msg', |
|
sendBtnId: 'send', |
|
leaveBtnId: 'leave', |
|
}); |
|
game.start(); |
|
|
|
|