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.
 
 
 

717 lines
23 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 = {
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;
}
update() {
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.speed = 0.5;
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 = 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 lerp = (a, b, t) => a + (b - a) * Math.min(1, t);
this.x = lerp(this.x, this._tx, adjustedLerpFactor);
this.y = lerp(this.y, this._ty, adjustedLerpFactor);
this.facing = this._tFacing ?? this.facing;
}
}
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;
}
resolveCollisions(platforms, groundY) {
const totalHeight = 60;
const halfFootW = 12;
const EPS = 0.5;
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 },
]
}
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();
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));
}
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)
}
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]
}
}
}
}
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)
}
_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':
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;
}
}
_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 === 's') {
this._sendAction('j', 'keyUp')
}
if (e.key === 'a' || e.key === 'd') {
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)
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 = "";
}
_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;
const data = JSON.parse(e.data);
this._ensurePlayer(data.Id)
const p = this.players[data.Id];
if (typeof data.PosX === "number" && typeof data.PosY === "number") {
p._tx = data.PosX;
p._ty = data.PosY;
p._lastUpdateTime = Date.now();
}
if (typeof data.Facing === "number") {
p._tFacing = data.Facing;
}
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()
}
}
_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();
}
}
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
)
}