Classic pacman gmae
## Using Domeleon Domeleon is a compact abstraction over any virtual DOM, including Preact (the default), React, and Vue. A Domeleon Component is a class representing application state rather than a wrapper around a DOM element. Components may define a pure view function of their state, and compose into hierarchies that can be updated, serialized, validated, or routed. ### Example **HTML** ```html <div id="app"></div> ``` **Code** ```ts import { App, Component, div, button, formField, inputRange, canvas } from "domeleon" import { inputNumber } from "domeleon/maskito" class Counter extends Component { count: number role: string #canvasEl: HTMLCanvasElement #canvasCtx: CanvasRenderingContext2D constructor(initial: number, role: string) { super() this.count = initial this.role = role } view() { return div({ class: "counter" }, formField ({ // databound input target: this, inputFn: inputRange, // inputSelect, inputText, inputTextArea, inputCheckbox etc. prop: () => this.count, label: this.role, fieldAttrs: { class: "field-class"}, labelAttrs: { class: "label-class"}, inputProps: { attrs: { min: 100, max: 250, step: 1 } } }), button({ onClick: () => this.inc(+1) }, "+"), // always use closure to bind to preserve "this" button({ onClick: () => this.inc(-1) }, "-"), this.count, canvas({ width: 400, height: 20, onMounted: el => { this.#canvasEl = el as HTMLCanvasElement this.#canvasCtx = this.#canvasEl.getContext("2d") return () => { // dispose this.#canvasEl = undefined this.#canvasCtx = undefined } } }) ) } inc(x: number) { this.count+=x this.update() // triggers render on app } // `onRendered` called after VDOM has created/updated DOM elements // useful for operations that require raw DOM access, such as canvas operations override onRendered () { this.drawOnCanvas() } drawOnCanvas() { const canvas = this.#canvasEl const cc = this.#canvasCtx cc.clearRect(0, 0, canvas.width, canvas.height) const barWidth = (this.count / 250) * canvas.width cc.fillStyle = "#4a90e2" cc.fillRect(0, 0, barWidth, canvas.height) } } class Game extends Component { wins = new Counter(10, "wins") losses = new Counter(5, "losses") view() { return div( this.wins.view(), this.losses.view() ) } } new App({ root: new Game(), id: "app"}) ```
import { App, Component, div, button, h1, canvas } from "domeleon"
const CELL_SIZE = 22;
const COLS = 19;
const ROWS = 21;
const WIDTH = COLS * CELL_SIZE;
const HEIGHT = ROWS * CELL_SIZE;
const GAME_SPEED = 4;
const SCARED_SPEED = 2.5;
const INITIAL_MAZE = [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
[1, 3, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 3, 1],
[1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
[1, 2, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 2, 1],
[1, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 2, 1],
[1, 1, 1, 1, 2, 1, 1, 1, 4, 1, 4, 1, 1, 1, 2, 1, 1, 1, 1],
[4, 4, 4, 1, 2, 1, 4, 4, 4, 4, 4, 4, 4, 1, 2, 1, 4, 4, 4],
[1, 1, 1, 1, 2, 1, 4, 1, 1, 4, 1, 1, 4, 1, 2, 1, 1, 1, 1],
[4, 4, 4, 4, 2, 4, 4, 1, 4, 4, 4, 1, 4, 4, 2, 4, 4, 4, 4],
[1, 1, 1, 1, 2, 1, 4, 1, 1, 1, 1, 1, 4, 1, 2, 1, 1, 1, 1],
[4, 4, 4, 1, 2, 1, 4, 4, 4, 4, 4, 4, 4, 1, 2, 1, 4, 4, 4],
[1, 1, 1, 1, 2, 1, 4, 1, 1, 1, 1, 1, 4, 1, 2, 1, 1, 1, 1],
[1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1],
[1, 2, 1, 1, 2, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 1, 1, 2, 1],
[1, 3, 2, 1, 2, 2, 2, 2, 2, 4, 2, 2, 2, 2, 2, 1, 2, 3, 1],
[1, 1, 2, 1, 2, 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 1],
[1, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 2, 1],
[1, 2, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 2, 1],
[1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
];
type Dir = 'up' | 'down' | 'left' | 'right';
const VECTORS: Record<Dir, { x: number, y: number }> = {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 }
};
const OPPOSITE: Record<Dir, Dir> = {
up: 'down',
down: 'up',
left: 'right',
right: 'left'
};
type GhostState = 'home' | 'exiting' | 'chase';
interface Ghost {
x: number;
y: number;
color: string;
direction: Dir;
scared: boolean;
eaten: boolean;
spawnTimer: number;
role: string;
state: GhostState;
}
interface ScorePopup {
x: number;
y: number;
score: number;
life: number;
}
class PacManGame extends Component {
#ctx: CanvasRenderingContext2D | null = null;
#obs: ResizeObserver | null = null;
#loopId = 0;
#lastTime = 0;
#scale = 1;
#mouth = 0.15;
#mouthDir = 1;
#touchStartX = 0;
#touchStartY = 0;
#powerTimer = 0;
#ghostCombo = 0;
#scorePopups: ScorePopup[] = [];
maze: number[][] = [];
pac = { x: 9, y: 15, dir: 'right' as Dir, next: 'right' as Dir };
ghosts: Ghost[] = [];
score = 0;
gameOver = false;
won = false;
powerMode = false;
constructor() {
super();
this.restart();
}
restart() {
this.maze = INITIAL_MAZE.map(row => [...row]);
this.pac = { x: 9, y: 15, dir: 'right', next: 'right' };
this.ghosts = [
{ x: 9, y: 7, color: '#ff0000', direction: 'left', scared: false, eaten: false, spawnTimer: 0, role: 'blinky', state: 'chase' },
{ x: 9, y: 9, color: '#ffb8ff', direction: 'up', scared: false, eaten: false, spawnTimer: 3, role: 'pinky', state: 'home' },
{ x: 8, y: 9, color: '#00ffff', direction: 'up', scared: false, eaten: false, spawnTimer: 6, role: 'inky', state: 'home' },
{ x: 10, y: 9, color: '#ffb852', direction: 'up', scared: false, eaten: false, spawnTimer: 9, role: 'clyde', state: 'home' },
];
this.score = 0;
this.themeReset();
this.update();
}
themeReset() {
this.gameOver = false;
this.won = false;
this.powerMode = false;
this.#powerTimer = 0;
this.#ghostCombo = 0;
this.#lastTime = 0;
this.#mouth = 0.15;
this.#mouthDir = 1;
this.#scorePopups = [];
}
canMove(x: number, y: number) {
const cy = Math.round(y);
const cx = Math.round(x);
if (cy < 0 || cy >= ROWS) return true;
if (cx < 0 || cx >= COLS) return true;
return this.maze[cy][cx] !== 1;
}
#handleKey = (e: KeyboardEvent) => {
if ((this.gameOver || this.won) && e.key === 'Enter') {
return this.restart();
}
const dir = e.key.replace('Arrow', '').toLowerCase() as Dir;
if (VECTORS[dir]) {
e.preventDefault();
this.pac.next = dir;
}
};
#handleTouchStart = (e: TouchEvent) => {
this.#touchStartX = e.touches[0].clientX;
this.#touchStartY = e.touches[0].clientY;
};
#handleTouchEnd = (e: TouchEvent) => {
const dx = e.changedTouches[0].clientX - this.#touchStartX;
const dy = e.changedTouches[0].clientY - this.#touchStartY;
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (Math.max(absX, absY) > 20) { // Threshold
if (absX > absY) {
this.pac.next = dx > 0 ? 'right' : 'left';
} else {
this.pac.next = dy > 0 ? 'down' : 'up';
}
}
};
#updateLogic(dt: number) {
const p = this.pac;
const d = GAME_SPEED * dt;
if (p.next === OPPOSITE[p.dir]) {
p.dir = p.next;
}
const gx = Math.round(p.x);
const gy = Math.round(p.y);
// Turn point logic
if (p.next !== p.dir && Math.abs(p.x - gx) < 0.2 && Math.abs(p.y - gy) < 0.2) {
if (this.canMove(gx + VECTORS[p.next].x, gy + VECTORS[p.next].y)) {
p.x = gx;
p.y = gy;
p.dir = p.next;
}
}
p.x += VECTORS[p.dir].x * d;
p.y += VECTORS[p.dir].y * d;
// Wall collision
if (!this.canMove(p.x + VECTORS[p.dir].x * 0.5, p.y + VECTORS[p.dir].y * 0.5)) {
p.x = Math.round(p.x);
p.y = Math.round(p.y);
}
// Tunnel wrap
if (p.x < -0.8) {
p.x = COLS - 0.2;
} else if (p.x > COLS - 0.2) {
p.x = -0.8;
}
// Eating pellets
const fx = Math.round(p.x);
const fy = Math.round(p.y);
if (Math.abs(p.x - fx) < 0.3 && Math.abs(p.y - fy) < 0.3 && fy >= 0 && fy < ROWS) {
const cell = this.maze[fy][fx];
if (cell === 2 || cell === 3) {
this.maze[fy][fx] = 4;
this.score += cell === 2 ? 10 : 50;
if (cell === 3) {
this.powerMode = true;
this.#powerTimer = 7000;
this.#ghostCombo = 0;
this.ghosts.forEach(g => {
if (!g.eaten) {
g.scared = true;
g.direction = OPPOSITE[g.direction];
}
});
}
}
}
this.won = !this.maze.some(r => r.some(c => c === 2 || c === 3));
this.#updateGhosts(dt);
}
#updateGhosts(dt: number) {
for (const g of this.ghosts) {
if (g.eaten) {
g.x = 9; g.y = 9;
if ((g.spawnTimer -= dt) <= 0) {
g.eaten = false;
g.state = 'home';
g.spawnTimer = 2;
}
continue;
}
// House logic
if (g.state === 'home') {
g.y = 9 + Math.sin(Date.now() / 200) * 0.2;
if ((g.spawnTimer -= dt) <= 0) g.state = 'exiting';
continue;
}
if (g.state === 'exiting') {
if (Math.abs(g.x - 9) > 0.1) g.x += (9 - g.x) * 0.1;
else {
g.x = 9;
g.y -= GAME_SPEED * dt;
if (g.y <= 7) { g.y = 7; g.state = 'chase'; g.direction = 'left'; }
}
continue;
}
const dist = (g.scared ? SCARED_SPEED : GAME_SPEED) * dt;
// Move decision at tile center
const nextX = g.x + VECTORS[g.direction].x * dist;
const nextY = g.y + VECTORS[g.direction].y * dist;
const crossedCenter =
(VECTORS[g.direction].x > 0 && Math.floor(g.x) !== Math.floor(nextX)) ||
(VECTORS[g.direction].x < 0 && Math.ceil(g.x) !== Math.ceil(nextX)) ||
(VECTORS[g.direction].y > 0 && Math.floor(g.y) !== Math.floor(nextY)) ||
(VECTORS[g.direction].y < 0 && Math.ceil(g.y) !== Math.ceil(nextY));
if (crossedCenter) {
const gx = Math.round(nextX);
const gy = Math.round(nextY);
g.x = gx;
g.y = gy;
// Determine target
let tx = Math.round(this.pac.x), ty = Math.round(this.pac.y);
if (g.role === 'pinky') { tx += VECTORS[this.pac.dir].x * 4; ty += VECTORS[this.pac.dir].y * 4; }
else if (g.role === 'inky') { const b = this.ghosts[0]; tx = b.x + (tx - b.x) * 2; ty = b.y + (ty - b.y) * 2; }
else if (g.role === 'clyde' && Math.hypot(gx - tx, gy - ty) < 8) { tx = 0; ty = ROWS; }
// Pick best available direction (cannot reverse)
const valid = (Object.keys(VECTORS) as Dir[]).filter(d =>
d !== OPPOSITE[g.direction] && this.canMove(gx + VECTORS[d].x, gy + VECTORS[d].y)
);
if (valid.length) {
valid.sort((a, b) => {
const getD = (dir: Dir) => Math.hypot(gx + VECTORS[dir].x - tx, gy + VECTORS[dir].y - ty);
return g.scared ? getD(b) - getD(a) : getD(a) - getD(b);
});
g.direction = valid[0];
} else if (!this.canMove(gx + VECTORS[g.direction].x, gy + VECTORS[g.direction].y)) {
g.direction = OPPOSITE[g.direction];
}
} else {
g.x = nextX;
g.y = nextY;
}
// Wall safety
if (!this.canMove(g.x + VECTORS[g.direction].x * 0.4, g.y + VECTORS[g.direction].y * 0.4)) {
g.x = Math.round(g.x);
g.y = Math.round(g.y);
}
// Tunnel wrap (Ghost)
if (g.x < -0.8) {
g.x = COLS - 0.2;
} else if (g.x > COLS - 0.2) {
g.x = -0.8;
}
if (Math.hypot(g.x - this.pac.x, g.y - this.pac.y) < 0.6) {
if (this.powerMode && g.scared) {
const s = Math.min(200 * Math.pow(2, this.#ghostCombo++), 1600);
this.score += s;
this.#scorePopups.push({ x: g.x * CELL_SIZE + 11, y: g.y * CELL_SIZE, score: s, life: 60 });
g.eaten = true;
g.scared = false;
g.spawnTimer = 3;
} else {
this.gameOver = true;
}
}
}
}
#loop = (t: number) => {
const dt = Math.min((t - (this.#lastTime || t)) / 1000, 0.1);
this.#lastTime = t;
// Mouth animation
this.#mouth += dt * 1.8 * this.#mouthDir;
if (this.#mouth >= 0.3) {
this.#mouth = 0.3;
this.#mouthDir = -1;
} else if (this.#mouth <= 0.01) {
this.#mouth = 0.01;
this.#mouthDir = 1;
}
if (this.powerMode && (this.#powerTimer -= dt * 1000) <= 0) {
this.powerMode = false;
this.ghosts.forEach(g => g.scared = false);
}
this.#scorePopups = this.#scorePopups.filter(p => {
p.y -= 0.5;
return --p.life > 0;
});
if (!this.gameOver && !this.won) {
this.#updateLogic(dt);
}
this.#draw();
this.update();
this.#loopId = requestAnimationFrame(this.#loop);
};
#draw() {
const ctx = this.#ctx;
if (!ctx) return;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
this.maze.forEach((row, y) => {
row.forEach((cell, x) => {
const cx = x * 22 + 11;
const cy = y * 22 + 11;
if (cell === 1) {
ctx.strokeStyle = '#2121de';
ctx.lineWidth = 3;
ctx.beginPath();
(Object.keys(VECTORS) as Dir[]).forEach(d => {
const v = VECTORS[d];
if (this.maze[y + v.y]?.[x + v.x] === 1) {
ctx.moveTo(cx, cy);
ctx.lineTo(cx + v.x * 11, cy + v.y * 11);
}
});
ctx.stroke();
} else if (cell === 2 || cell === 3) {
ctx.fillStyle = '#ffb8ae';
ctx.beginPath();
const radius = cell === 2 ? 2 : Math.sin(Date.now() / 200) * 2 + 6;
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
}
});
});
this.#drawPacman(ctx);
this.ghosts.forEach(g => this.#drawGhost(ctx, g));
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
this.#scorePopups.forEach(p => {
ctx.fillStyle = `rgba(0, 255, 255, ${p.life / 60})`;
ctx.fillText(p.score + '', p.x, p.y);
});
}
#drawPacman(ctx: CanvasRenderingContext2D) {
const p = this.pac;
const px = p.x * 22 + 11;
const py = p.y * 22 + 11;
const angles: Record<Dir, number> = { right: 0, down: 0.5, left: 1, up: 1.5 };
ctx.fillStyle = '#ff0';
ctx.beginPath();
const mouthOffset = 4 * (this.#mouth / 0.3);
const vx = px - VECTORS[p.dir].x * mouthOffset;
const vy = py - VECTORS[p.dir].y * mouthOffset;
ctx.moveTo(vx, vy);
ctx.arc(
px,
py,
11,
(angles[p.dir] + this.#mouth) * Math.PI,
(angles[p.dir] - this.#mouth + 2) * Math.PI
);
ctx.fill();
}
#drawGhost(ctx: CanvasRenderingContext2D, g: Ghost) {
const gx = g.x * 22;
const gy = g.y * 22;
const isWhiteFlash = this.#powerTimer < 2000 && Math.floor(Date.now() / 150) % 2 === 0;
if (!g.eaten) {
ctx.fillStyle = g.scared
? (isWhiteFlash ? '#fff' : '#2121de')
: g.color;
ctx.beginPath();
ctx.arc(gx + 11, gy + 11, 11, Math.PI, 0);
ctx.lineTo(gx + 22, gy + 22);
for (let i = 3; i > 0; i--) {
ctx.quadraticCurveTo(
gx + (22 / 3) * (i - 0.5),
gy + 18,
gx + (22 / 3) * (i - 1),
gy + 22
);
}
ctx.fill();
}
// Color state for facial features
const featureColor = g.scared ? (isWhiteFlash ? '#ff0000' : '#fff') : '#fff';
const pupilColor = g.scared ? (isWhiteFlash ? '#fff' : '#ff0000') : '#2121de';
// Eyes
ctx.fillStyle = featureColor;
[6, 14].forEach(ox => {
ctx.beginPath();
ctx.arc(gx + ox, gy + 8, 3.5, 0, Math.PI * 2);
ctx.fill();
});
// Pupils (moving or scared static)
ctx.fillStyle = pupilColor;
const v = VECTORS[g.direction];
[6, 14].forEach(ox => {
ctx.beginPath();
const px = g.scared ? gx + ox : gx + ox + v.x * 1.5;
const py = g.scared ? gy + 8 : gy + 8 + v.y * 1.5;
ctx.arc(px, py, 1.8, 0, Math.PI * 2);
ctx.fill();
});
// Scared mouth
if (g.scared && !g.eaten) {
ctx.strokeStyle = featureColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const mx = gx + 6 + i * 1.1;
const my = gy + 16 + (i % 2 === 0 ? 0 : 2);
if (i === 0) ctx.moveTo(mx, my);
else ctx.lineTo(mx, my);
}
ctx.stroke();
}
}
initCanvas(el: HTMLCanvasElement) {
this.#ctx = el.getContext('2d');
window.addEventListener('keydown', this.#handleKey);
el.addEventListener('touchstart', this.#handleTouchStart, { passive: true });
el.addEventListener('touchend', this.#handleTouchEnd, { passive: true });
this.#obs = new ResizeObserver(() => {
const wrapper = el.closest('.maze-wrapper');
if (!wrapper) return;
const b = wrapper.getBoundingClientRect();
this.#scale = Math.min((b.width - 10) / WIDTH, (b.height - 10) / HEIGHT);
this.update();
});
this.#obs.observe(el.closest('.maze-wrapper')!);
this.#loopId = requestAnimationFrame(this.#loop);
return () => {
window.removeEventListener('keydown', this.#handleKey);
el.removeEventListener('touchstart', this.#handleTouchStart);
el.removeEventListener('touchend', this.#handleTouchEnd);
cancelAnimationFrame(this.#loopId);
this.#obs?.disconnect();
};
}
view() {
return div({ class: 'game-container' },
div({ class: 'header' },
h1('PAC-MAN'),
div({ class: 'score' }, `Score: ${this.score}`)
),
div({ class: 'maze-wrapper' },
div({
class: 'maze-container',
style: { transform: `scale(${this.#scale})` }
},
canvas({
width: WIDTH,
height: HEIGHT,
onMounted: el => this.initCanvas(el as HTMLCanvasElement)
}),
(this.gameOver || this.won) && div({ class: 'overlay' },
div({ class: 'message' }, this.gameOver ? 'GAME OVER' : 'YOU WIN!'),
button({ onClick: () => this.restart() }, 'Play Again')
)
)
),
div({ class: 'instructions' }, 'Use Arrow Keys or Swipe to Move')
);
}
}
new App({ root: new PacManGame(), id: 'app' });