5 changed files with 1025 additions and 628 deletions
@ -0,0 +1,176 @@
@@ -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; |
||||
} |
||||
|
||||
} |
||||
|
||||
@ -0,0 +1,259 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@
@@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,193 @@
@@ -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; |
||||
} |
||||
|
||||
|
||||
} |
||||
|
||||
Loading…
Reference in new issue