170 lines
4.6 KiB
TypeScript
170 lines
4.6 KiB
TypeScript
import { createCanvas } from "@common/display/canvas";
|
|
import { gameLoop } from "@common/game";
|
|
import { circleGen } from "@common/navigation/bresenham";
|
|
import Physics from "@common/physics";
|
|
import { mapNumber } from "@common/utils";
|
|
|
|
interface Ball {
|
|
body: number;
|
|
damage: number;
|
|
health: number;
|
|
}
|
|
|
|
interface Peg {
|
|
body: number;
|
|
health: number;
|
|
}
|
|
|
|
interface State extends Record<string, unknown> {
|
|
canvas: HTMLCanvasElement;
|
|
ctx: CanvasRenderingContext2D;
|
|
bottom: number;
|
|
balls: Set<Ball>;
|
|
pegs: Set<Peg>;
|
|
|
|
pegHealth: number;
|
|
ballHealth: number;
|
|
ballDamage: number;
|
|
}
|
|
|
|
let lastTick = performance.now();
|
|
function update() {
|
|
const dt = (performance.now() - lastTick) / 1000;
|
|
if (dt < 1) {
|
|
Physics.update(dt);
|
|
}
|
|
lastTick = performance.now();
|
|
setTimeout(update, 5);
|
|
}
|
|
|
|
const killBody = (state: State, body: Peg | Ball) => {
|
|
state.balls.delete(body as Ball);
|
|
state.pegs.delete(body as Peg);
|
|
Physics.deleteBody(body.body);
|
|
}
|
|
|
|
const onCollision = (state: State, b1: number, b2: number) => {
|
|
const ball = state.balls.values().find(b => b.body === b1 || b.body === b2);
|
|
const peg = state.pegs.values().find(b => b.body === b1 || b.body === b2);
|
|
|
|
if (ball && (b1 === state.bottom || b2 === state.bottom)) {
|
|
killBody(state, ball);
|
|
} else if (peg && ball) {
|
|
peg.health -= ball.damage;
|
|
ball.health -= 1;
|
|
if (peg.health <= 0) {
|
|
killBody(state, peg);
|
|
}
|
|
if (ball.health <= 0) {
|
|
killBody(state, ball);
|
|
}
|
|
}
|
|
}
|
|
|
|
const generatePegs = (state: State) => {
|
|
const { canvas } = state;
|
|
state.pegs.clear();
|
|
|
|
const gap = 45;
|
|
const vgap = gap * Math.cos(Math.PI / 6);
|
|
let offset = 0;
|
|
for (let y = canvas.height / 3; y <= canvas.height - gap; y += vgap) {
|
|
for (let i = offset; i <= canvas.width / 2; i += gap) {
|
|
const x1 = canvas.width / 2 + i;
|
|
state.pegs.add({
|
|
body: Physics.newCircle(x1, y, 3, Number.POSITIVE_INFINITY),
|
|
health: state.pegHealth,
|
|
});
|
|
|
|
const x2 = canvas.width / 2 - i;
|
|
if (Math.abs(x1 - x2) < gap) continue;
|
|
state.pegs.add({
|
|
body: Physics.newCircle(x2, y, 3, Number.POSITIVE_INFINITY),
|
|
health: state.pegHealth,
|
|
});
|
|
}
|
|
offset = gap / 2 - offset;
|
|
}
|
|
}
|
|
|
|
const setup = (): State => {
|
|
const canvas = createCanvas(640, 480);
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new Error('Failed to get canvas context');
|
|
}
|
|
|
|
const state: State = {
|
|
canvas,
|
|
ctx,
|
|
bottom: 0,
|
|
pegs: new Set(),
|
|
balls: new Set(),
|
|
pegHealth: 3,
|
|
ballHealth: 10,
|
|
ballDamage: 1,
|
|
};
|
|
|
|
ctx.imageSmoothingEnabled = false;
|
|
canvas.addEventListener('contextmenu', e => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
});
|
|
canvas.addEventListener('mouseup', () => {
|
|
const body = Physics.newCircle(canvas.width / 2 + Math.random() - 0.5, 50, 10, 1);
|
|
const health = state.ballHealth;
|
|
const damage = state.ballDamage;
|
|
const ball: Ball = {
|
|
body,
|
|
health,
|
|
damage,
|
|
}
|
|
state.balls.add(ball);
|
|
return false;
|
|
});
|
|
|
|
generatePegs(state);
|
|
|
|
state.bottom = Physics.newPlane(0, canvas.height, 0, -1);
|
|
Physics.newPlane(0, 0, 1, 0);
|
|
Physics.newPlane(0, 0, 0, 1);
|
|
Physics.newPlane(canvas.width, 0, -1, 0);
|
|
|
|
Physics.setCollisionCallback((a, b) => onCollision(state, a, b));
|
|
|
|
update();
|
|
|
|
return state;
|
|
}
|
|
|
|
const frame = (dt: number, state: State) => {
|
|
Physics.addGlobalForce(0, 100);
|
|
Physics.update(dt);
|
|
|
|
const { ctx, canvas } = state;
|
|
ctx.fillStyle = `#111`;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
for (const peg of state.pegs) {
|
|
const { x, y, radius } = Physics.getBody(peg.body);
|
|
if (!radius) continue;
|
|
|
|
ctx.fillStyle = `hsl(0, 0%, ${mapNumber(peg.health, 0, state.pegHealth, 0, 100)}%)`;
|
|
fillCircle(ctx, x, y, radius);
|
|
}
|
|
for (const ball of state.balls) {
|
|
const { id, x, y, radius } = Physics.getBody(ball.body);
|
|
if (!radius) continue;
|
|
|
|
ctx.fillStyle = `hsl(${id % 360}, 100%, 50%)`;
|
|
fillCircle(ctx, x, y, radius);
|
|
}
|
|
}
|
|
|
|
function fillCircle(ctx: CanvasRenderingContext2D, xc: number, yc: number, r: number) {
|
|
// because default circle fill is antialiased
|
|
for (const { x, y } of circleGen(xc, yc, r, { fill: true })) {
|
|
ctx.fillRect(x, y, 1, 1);
|
|
}
|
|
}
|
|
|
|
export default gameLoop(setup, frame); |