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.
 
 
 

603 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
// 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 + 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 = {
};
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; // FIXED: was "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
)
}