import { Application, Graphics, Ticker } from "pixi.js";
import { Box, Circle, Line, System } from "check2d";
const PLAY_AREA = { x: 0, y: 0, width: 600, height: 600 };
const CIRCLE_RADIUS = 50;
const SQUARE_SIZE = 200;
const PLAYER_RADIUS = 16;
const PLAYER_ROTATE_SPEED = 0.05;
const PLAYER_THRUST = 0.2;
const PLAYER_DRAG = 0.98;
const PLAYER_TIP_DIST = 18;
const BULLET_SPEED = 8;
const BULLET_RADIUS = 1.5;
const COLOR_CIRCLE = 0xff3333;
const COLOR_SQUARE = 0x33ff66;
const COLOR_OVERLAP = 0xffff00;
const COLOR_PLAYER_OVERLAP = 0x880000;
const COLOR_PLAYER = 0xaaaaff;
const COLOR_BULLET = 0xffffff;
// angle=0 points up; tip and two rear corners of the triangle
const PLAYER_TIP = [0, -18] as const;
const PLAYER_REAR_L = [-11, 10] as const;
const PLAYER_REAR_R = [11, 10] as const;
interface Body {
x: number;
y: number;
vx: number;
vy: number;
isCollided: boolean;
}
interface Player extends Body {
angle: number; // radians, 0 = pointing up
}
interface Bullet {
x: number;
y: number;
vx: number;
vy: number;
}
interface World {
circle: Body;
square: Body;
player: Player;
bullets: Bullet[];
}
interface Colliders {
system: System;
circleCollider: Circle;
squareCollider: Box;
playerCollider: Circle;
walls: Line[];
}
interface SceneGraphics {
circleGfx: Graphics;
squareGfx: Graphics;
playerGfx: Graphics;
bulletGfx: Graphics;
offsetX: number;
offsetY: number;
}
function createWorld(): World {
return {
circle: { x: 150, y: 200, vx: 2.5, vy: 1.8, isCollided: false },
square: { x: 350, y: 350, vx: -1.2, vy: 2.0, isCollided: false },
player: { x: 300, y: 300, vx: 0, vy: 0, angle: 0, isCollided: false },
bullets: [],
};
}
function buildColliders(world: World): Colliders {
const system = new System();
const circleCollider = new Circle(
{ x: world.circle.x, y: world.circle.y },
CIRCLE_RADIUS,
);
const squareCollider = new Box(
{ x: world.square.x, y: world.square.y },
SQUARE_SIZE,
SQUARE_SIZE,
{ isCentered: true },
);
const playerCollider = new Circle(
{ x: world.player.x, y: world.player.y },
PLAYER_RADIUS,
);
const { x, y, width, height } = PLAY_AREA;
const walls = [
new Line({ x, y }, { x, y: y + height }, { isStatic: true }), // left
new Line(
{ x: x + width, y },
{ x: x + width, y: y + height },
{ isStatic: true },
), // right
new Line({ x, y }, { x: x + width, y }, { isStatic: true }), // top
new Line(
{ x, y: y + height },
{ x: x + width, y: y + height },
{ isStatic: true },
), // bottom
];
for (const body of [
circleCollider,
squareCollider,
playerCollider,
...walls,
]) {
system.insert(body);
}
return { system, circleCollider, squareCollider, playerCollider, walls };
}
function resolveWallBounce(
body: Body,
collider: Circle | Box,
wall: Line,
system: System,
) {
if (system.checkCollision(collider, wall)) {
const r = system.response;
body.x -= r.overlapV.x;
body.y -= r.overlapV.y;
collider.setPosition(body.x, body.y);
if (Math.abs(r.overlapN.x) > Math.abs(r.overlapN.y)) {
body.vx *= -1;
} else {
body.vy *= -1;
}
}
}
function tickPlayer(player: Player, keys: Set<string>, dt: number) {
if (keys.has("ArrowLeft")) player.angle -= PLAYER_ROTATE_SPEED * dt;
if (keys.has("ArrowRight")) player.angle += PLAYER_ROTATE_SPEED * dt;
if (keys.has("ArrowUp")) {
player.vx += Math.sin(player.angle) * PLAYER_THRUST * dt;
player.vy -= Math.cos(player.angle) * PLAYER_THRUST * dt;
}
if (keys.has("ArrowDown")) {
player.vx -= Math.sin(player.angle) * PLAYER_THRUST * dt;
player.vy += Math.cos(player.angle) * PLAYER_THRUST * dt;
}
player.vx *= PLAYER_DRAG;
player.vy *= PLAYER_DRAG;
player.x += player.vx * dt;
player.y += player.vy * dt;
}
function spawnBullet(world: World) {
const { player } = world;
world.bullets.push({
x: player.x + Math.sin(player.angle) * PLAYER_TIP_DIST,
y: player.y - Math.cos(player.angle) * PLAYER_TIP_DIST,
vx: player.vx + Math.sin(player.angle) * BULLET_SPEED,
vy: player.vy - Math.cos(player.angle) * BULLET_SPEED,
});
}
function tickBullets(world: World, colliders: Colliders, dt: number) {
const { system, walls } = colliders;
const wallSet = new Set(walls);
const hit = new Set<Bullet>();
for (const b of world.bullets) {
const prevX = b.x;
const prevY = b.y;
b.x += b.vx * dt;
b.y += b.vy * dt;
const result = system.raycast(
{ x: prevX, y: prevY },
{ x: b.x, y: b.y },
(body) => wallSet.has(body as Line),
);
if (result) hit.add(b);
}
world.bullets = world.bullets.filter((b) => !hit.has(b));
}
function worldTick(
world: World,
colliders: Colliders,
keys: Set<string>,
dt: number,
) {
const { circle, square, player } = world;
const { circleCollider, squareCollider, playerCollider, walls, system } =
colliders;
circle.x += circle.vx * dt;
circle.y += circle.vy * dt;
square.x += square.vx * dt;
square.y += square.vy * dt;
tickPlayer(player, keys, dt);
circleCollider.setPosition(circle.x, circle.y);
squareCollider.setPosition(square.x, square.y);
playerCollider.setPosition(player.x, player.y);
for (const wall of walls) {
resolveWallBounce(circle, circleCollider, wall, system);
resolveWallBounce(square, squareCollider, wall, system);
resolveWallBounce(player, playerCollider, wall, system);
}
const circleSquare = system.checkCollision(circleCollider, squareCollider);
const circlePlayer = system.checkCollision(circleCollider, playerCollider);
const squarePlayer = system.checkCollision(squareCollider, playerCollider);
world.circle.isCollided = circleSquare || circlePlayer;
world.square.isCollided = circleSquare || squarePlayer;
world.player.isCollided = circlePlayer || squarePlayer;
tickBullets(world, colliders, dt);
}
function buildWorldGraphics(app: Application): SceneGraphics {
const offsetX = (app.screen.width - PLAY_AREA.width) / 2;
const offsetY = (app.screen.height - PLAY_AREA.height) / 2;
const playAreaGfx = new Graphics();
playAreaGfx
.rect(0, 0, PLAY_AREA.width, PLAY_AREA.height)
.stroke({ color: 0xffffff, width: 2 });
playAreaGfx.x = offsetX;
playAreaGfx.y = offsetY;
app.stage.addChild(playAreaGfx);
const circleGfx = new Graphics();
app.stage.addChild(circleGfx);
const squareGfx = new Graphics();
app.stage.addChild(squareGfx);
const playerGfx = new Graphics();
app.stage.addChild(playerGfx);
const bulletGfx = new Graphics();
app.stage.addChild(bulletGfx);
return { circleGfx, squareGfx, playerGfx, bulletGfx, offsetX, offsetY };
}
function graphicsTick(world: World, gfx: SceneGraphics) {
gfx.circleGfx.clear();
gfx.circleGfx
.circle(0, 0, CIRCLE_RADIUS)
.fill(world.circle.isCollided ? COLOR_OVERLAP : COLOR_CIRCLE);
gfx.circleGfx.x = gfx.offsetX + world.circle.x;
gfx.circleGfx.y = gfx.offsetY + world.circle.y;
gfx.squareGfx.clear();
gfx.squareGfx
.rect(-SQUARE_SIZE / 2, -SQUARE_SIZE / 2, SQUARE_SIZE, SQUARE_SIZE)
.fill(world.square.isCollided ? COLOR_OVERLAP : COLOR_SQUARE);
gfx.squareGfx.x = gfx.offsetX + world.square.x;
gfx.squareGfx.y = gfx.offsetY + world.square.y;
gfx.playerGfx.clear();
gfx.playerGfx
.poly([...PLAYER_TIP, ...PLAYER_REAR_L, ...PLAYER_REAR_R])
.stroke({
color: world.player.isCollided ? COLOR_PLAYER_OVERLAP : COLOR_PLAYER,
width: 2,
});
gfx.playerGfx.x = gfx.offsetX + world.player.x;
gfx.playerGfx.y = gfx.offsetY + world.player.y;
gfx.playerGfx.rotation = world.player.angle;
gfx.bulletGfx.clear();
for (const b of world.bullets) {
gfx.bulletGfx
.circle(gfx.offsetX + b.x, gfx.offsetY + b.y, BULLET_RADIUS)
.fill(COLOR_BULLET);
}
}
(async () => {
const app = new Application();
await app.init({ background: "#111111", resizeTo: window, antialias: true });
document.getElementById("pixi-container")!.appendChild(app.canvas);
const keys = new Set<string>();
const world = createWorld();
const colliders = buildColliders(world);
window.addEventListener("keydown", (e) => {
if (e.key.startsWith("Arrow")) e.preventDefault();
if (e.key === " ") {
e.preventDefault();
spawnBullet(world);
}
keys.add(e.key);
});
window.addEventListener("keyup", (e) => keys.delete(e.key));
const gfx = buildWorldGraphics(app);
app.ticker.add((ticker: Ticker) => {
worldTick(world, colliders, keys, ticker.deltaTime);
graphicsTick(world, gfx);
});
})();