Add face detection character pipeline and trump-mog example

Introduces the Node.js character build pipeline using face-api.js for
face detection and @imgly/background-removal-node for ML background
removal. Includes the trump-mog example game demonstrating South Park
photo-composite characters with Trump and Biden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-02-24 14:52:09 -05:00
parent 1906802d8c
commit 150fda32e7
71 changed files with 7457 additions and 48 deletions
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Trump Mog</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#game-container { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="game-container"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "trump-mog",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@imgly/background-removal-node": "^1.4.5",
"phaser": "^3.90.0",
"sharp": "^0.34.5"
},
"devDependencies": {
"vite": "^7.3.1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* crop-head.mjs Crop a bg-removed image down to just the head.
*
* Finds the bounding box of non-transparent pixels, then takes
* the top portion as the head (approx top 40% of the figure).
*
* Usage:
* node scripts/crop-head.mjs <input> [output]
*
* Example:
* node scripts/crop-head.mjs public/assets/test-photo-clean.png public/assets/test-photo-clean-head.png
*/
import sharp from 'sharp';
import { resolve, basename } from 'node:path';
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: node scripts/crop-head.mjs <input> [output]');
process.exit(1);
}
const inputPath = resolve(args[0]);
const outputPath = resolve(args[1] || inputPath.replace(/\.png$/, '-head.png'));
console.log(`Cropping head from: ${basename(inputPath)}`);
const image = sharp(inputPath);
const { width, height, channels } = await image.metadata();
const { data } = await image.raw().ensureAlpha().toBuffer({ resolveWithObject: true });
// Find bounding box of non-transparent pixels
let minX = width, maxX = 0, minY = height, maxY = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const alpha = data[(y * width + x) * 4 + 3];
if (alpha > 10) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const figureW = maxX - minX;
const figureH = maxY - minY;
const centerX = minX + figureW / 2;
console.log(` Figure bounds: ${figureW}x${figureH} at (${minX},${minY})`);
// Head = top ~40% of figure height, centered on figure center X
const headRatio = 0.40;
const headH = Math.round(figureH * headRatio);
const headW = Math.round(headH * 0.9); // heads are roughly as wide as tall
const pad = Math.round(headW * 0.15); // add some padding
const cropX = Math.max(0, Math.round(centerX - headW / 2) - pad);
const cropY = Math.max(0, minY - pad);
const cropW = Math.min(width - cropX, headW + pad * 2);
const cropH = Math.min(height - cropY, headH + pad * 2);
console.log(` Head crop: ${cropW}x${cropH} at (${cropX},${cropY})`);
await sharp(inputPath)
.extract({ left: cropX, top: cropY, width: cropW, height: cropH })
.toFile(outputPath);
console.log(`${basename(outputPath)}`);
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* process-head.mjs Strip background from a head/face image using ML.
*
* Uses @imgly/background-removal-node (ISNet ONNX model) to produce a
* clean transparent PNG. No halos, no color threshold hacks.
*
* Usage:
* node scripts/process-head.mjs <input> [output]
*
* Examples:
* node scripts/process-head.mjs public/assets/trump-head.png
* node scripts/process-head.mjs photo.jpg public/assets/cleaned.png
*
* If output is omitted, overwrites the input file.
* First run downloads ~40 MB of model files (cached for future runs).
*/
import { removeBackground } from '@imgly/background-removal-node';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve, basename } from 'node:path';
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: node scripts/process-head.mjs <input> [output]');
process.exit(1);
}
const inputPath = resolve(args[0]);
const outputPath = resolve(args[1] || inputPath);
console.log(`Processing: ${basename(inputPath)}`);
console.log('(First run downloads ~40 MB model — cached after that)\n');
const startTime = Date.now();
try {
// Read source image — detect MIME from magic bytes
const inputBuffer = await readFile(inputPath);
let mime = 'image/png';
if (inputBuffer[0] === 0xFF && inputBuffer[1] === 0xD8) mime = 'image/jpeg';
else if (inputBuffer[0] === 0x89 && inputBuffer[1] === 0x50) mime = 'image/png';
else if (inputBuffer[0] === 0x52 && inputBuffer[1] === 0x49) mime = 'image/webp';
const inputBlob = new Blob([inputBuffer], { type: mime });
console.log(` Detected: ${mime} (${(inputBuffer.length / 1024).toFixed(0)} KB)`);
// Run ML background removal
const resultBlob = await removeBackground(inputBlob, {
model: 'medium', // Good balance of quality/speed (~80 MB)
output: {
format: 'image/png',
quality: 1.0,
type: 'foreground', // Keep the person, remove everything else
},
progress: (key, current, total) => {
if (total > 0) {
const pct = Math.round((current / total) * 100);
process.stdout.write(`\r ${key}: ${pct}%`);
if (current >= total) process.stdout.write('\n');
}
},
});
// Write transparent PNG
const resultBuffer = Buffer.from(await resultBlob.arrayBuffer());
await writeFile(outputPath, resultBuffer);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nDone in ${elapsed}s → ${basename(outputPath)} (${(resultBuffer.length / 1024).toFixed(0)} KB)`);
} catch (err) {
console.error('Background removal failed:', err.message);
process.exit(1);
}
+189
View File
@@ -0,0 +1,189 @@
// --- Display ---
export const DPR = Math.min(window.devicePixelRatio || 1, 2);
const _isPortrait = window.innerHeight > window.innerWidth;
const _designW = _isPortrait ? 540 : 960;
const _designH = _isPortrait ? 960 : 540;
const _designAspect = _designW / _designH;
const _deviceW = window.innerWidth * DPR;
const _deviceH = window.innerHeight * DPR;
let _canvasW, _canvasH;
if (_deviceW / _deviceH > _designAspect) {
_canvasW = _deviceW;
_canvasH = Math.round(_deviceW / _designAspect);
} else {
_canvasW = Math.round(_deviceH * _designAspect);
_canvasH = _deviceH;
}
export const PX = _canvasW / _designW;
export const GAME = {
WIDTH: _canvasW,
HEIGHT: _canvasH,
IS_PORTRAIT: _isPortrait,
};
// --- Characters ---
// Unit: "u" = CHARACTER.U (base unit derived from game width)
// All body measurements scale from this single value.
const _U = GAME.WIDTH * 0.012;
export const CHARACTER = {
U: _U,
// Overall proportions — BOBBLEHEAD: giant head, stubby body
TORSO_W: _U * 6,
TORSO_H: _U * 5,
SHOULDER_W: _U * 7, // wider than torso for jacket shape
WAIST_W: _U * 5,
NECK_W: _U * 2.5,
NECK_H: _U * 1,
// Head (spritesheet) sizing — dominates the character
HEAD_H: _canvasH * 0.35, // ~35% of screen height
// Expression spritesheet
FRAME_W: 200,
FRAME_H: 300,
// Arms (stubby)
UPPER_ARM_W: _U * 1.8,
UPPER_ARM_H: _U * 3,
LOWER_ARM_W: _U * 1.6,
LOWER_ARM_H: _U * 2.5,
HAND_W: _U * 1.8,
HAND_H: _U * 1.5,
// Legs (stubby)
LEG_W: _U * 2.4,
LEG_H: _U * 3,
LEG_GAP: _U * 1.2, // gap between legs
SHOE_W: _U * 3,
SHOE_H: _U * 1.2,
// Suit details (proportional to smaller body)
LAPEL_W: _U * 1.4,
TIE_W: _U * 1,
TIE_H: _U * 3,
BUTTON_R: _U * 0.3,
POCKET_W: _U * 1.5,
POCKET_H: _U * 0.25,
// Body height (torso + legs + shoes + neck — excludes head)
BODY_HEIGHT: _U * 5 + _U * 3 + _U * 1.2 + _U * 1,
// Outline
OUTLINE: Math.max(1, Math.round(_U * 0.3)),
// Expression system
EXPRESSION: { NORMAL: 0, HAPPY: 1, ANGRY: 2, SURPRISED: 3, FRAME_COUNT: 4 },
EXPRESSION_HOLD: 1500, // ms before reverting to normal
// Positions
TRUMP_X: GAME.WIDTH * 0.3,
TRUMP_Y: GAME.HEIGHT * 0.65,
BIDEN_X: GAME.WIDTH * 0.7,
BIDEN_Y: GAME.HEIGHT * 0.65,
// Trump colors
TRUMP_SUIT: 0x1a1a2e,
TRUMP_SUIT_LIGHT: 0x282845,
TRUMP_TIE: 0xcc0000,
TRUMP_SHIRT: 0xf0f0f0,
TRUMP_PANTS: 0x141428,
TRUMP_SHOES: 0x1a1a1a,
TRUMP_SKIN: 0xf5c68a,
// Biden/opponent colors
BIDEN_SUIT: 0x1a2744,
BIDEN_SUIT_LIGHT: 0x253560,
BIDEN_TIE: 0x2244aa,
BIDEN_SHIRT: 0xf0f0f0,
BIDEN_PANTS: 0x142038,
BIDEN_SHOES: 0x1a1a1a,
BIDEN_SKIN: 0xf5d0a9,
};
// --- Mog Mechanics ---
export const MOG = {
// Timing bar
BAR_WIDTH: GAME.WIDTH * 0.5,
BAR_HEIGHT: GAME.HEIGHT * 0.04,
BAR_X: GAME.WIDTH * 0.5,
BAR_Y: GAME.HEIGHT * 0.12,
BAR_BG: 0x333333,
BAR_FILL: 0x44ff44,
BAR_PERFECT: 0xffd700,
// Cursor speed (oscillates left-right)
CURSOR_SPEED_START: 300 * PX,
CURSOR_SPEED_INCREMENT: 30 * PX,
CURSOR_WIDTH: 4 * PX,
CURSOR_COLOR: 0xffffff,
// Sweet spot (center zone for perfect mog)
SWEET_SPOT_RATIO: 0.15, // 15% of bar width
// Scoring
PERFECT_MOG_POINTS: 100,
GOOD_MOG_POINTS: 50,
MISS_POINTS: 0,
// Mog meter
METER_MAX: 1000,
METER_WIDTH: GAME.WIDTH * 0.6,
METER_HEIGHT: GAME.HEIGHT * 0.03,
METER_X: GAME.WIDTH * 0.5,
METER_Y: GAME.HEIGHT * 0.2,
METER_BG: 0x222222,
METER_FILL: 0xff4444,
METER_BORDER: 0xffffff,
// Scale changes on mog
TRUMP_GROW: 0.03,
BIDEN_SHRINK: 0.03,
MAX_TRUMP_SCALE: 1.8,
MIN_BIDEN_SCALE: 0.4,
// Rounds
MOGS_PER_ROUND: 10,
};
// --- Colors ---
export const COLORS = {
SKY_TOP: 0x1a0a2e,
SKY_BOTTOM: 0x16213e,
GROUND: 0x2d4a22,
GROUND_DARK: 0x1e3316,
UI_TEXT: '#ffffff',
UI_SHADOW: '#000000',
TITLE_GOLD: '#ffd700',
SCORE_TEXT: '#00ff88',
COMBO_TEXT: '#ff4444',
BTN_PRIMARY: 0xff4444,
BTN_PRIMARY_HOVER: 0xff6666,
BTN_TEXT: '#ffffff',
};
// --- UI ---
export const UI = {
FONT: '"Impact", "Arial Black", sans-serif',
TITLE_RATIO: 0.09,
HEADING_RATIO: 0.055,
BODY_RATIO: 0.04,
SMALL_RATIO: 0.028,
BTN_W_RATIO: 0.4,
BTN_H_RATIO: 0.08,
BTN_RADIUS: 12 * PX,
MIN_TOUCH: 44 * PX,
};
// --- Transitions ---
export const TRANSITION = {
FADE_DURATION: 350,
};
+68
View File
@@ -0,0 +1,68 @@
export const Events = {
// Game lifecycle
GAME_START: 'game:start',
GAME_OVER: 'game:over',
GAME_RESTART: 'game:restart',
// Mog actions
MOG_ATTEMPT: 'mog:attempt',
MOG_PERFECT: 'mog:perfect',
MOG_GOOD: 'mog:good',
MOG_MISS: 'mog:miss',
MOG_METER_FULL: 'mog:meter_full',
// Score
SCORE_CHANGED: 'score:changed',
// Particles
PARTICLES_EMIT: 'particles:emit',
// Expressions
EXPRESSION_CHANGE: 'expression:change',
EXPRESSION_RESET: 'expression:reset',
// Audio
AUDIO_INIT: 'audio:init',
MUSIC_GAMEPLAY: 'music:gameplay',
MUSIC_GAMEOVER: 'music:gameover',
MUSIC_STOP: 'music:stop',
};
class EventBus {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
return this;
}
off(event, callback) {
if (!this.listeners[event]) return this;
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
return this;
}
emit(event, data) {
if (!this.listeners[event]) return this;
this.listeners[event].forEach(callback => {
try {
callback(data);
} catch (err) {
console.error(`EventBus error in ${event}:`, err);
}
});
return this;
}
removeAll() {
this.listeners = {};
return this;
}
}
export const eventBus = new EventBus();
+24
View File
@@ -0,0 +1,24 @@
import Phaser from 'phaser';
import { GAME, COLORS, DPR } from './Constants.js';
import { BootScene } from '../scenes/BootScene.js';
import { GameScene } from '../scenes/GameScene.js';
import { UIScene } from '../scenes/UIScene.js';
export const GameConfig = {
type: Phaser.AUTO,
width: GAME.WIDTH,
height: GAME.HEIGHT,
parent: 'game-container',
backgroundColor: COLORS.SKY_TOP,
roundPixels: true,
antialias: true,
render: {
preserveDrawingBuffer: true,
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
zoom: 1 / DPR,
},
scene: [BootScene, GameScene, UIScene],
};
+43
View File
@@ -0,0 +1,43 @@
import { MOG } from './Constants.js';
class GameState {
constructor() {
this.reset();
}
reset() {
this.score = 0;
this.bestScore = this.bestScore || 0;
this.started = false;
this.gameOver = false;
this.mogMeter = 0;
this.mogCount = 0;
this.perfectCount = 0;
this.goodCount = 0;
this.missCount = 0;
this.combo = 0;
this.maxCombo = 0;
this.trumpScale = 1.0;
this.bidenScale = 1.0;
this.trumpExpression = 'normal';
this.bidenExpression = 'normal';
this.round = 1;
}
addScore(points = 1) {
this.score += points;
if (this.score > this.bestScore) {
this.bestScore = this.score;
}
}
addMogPower(amount) {
this.mogMeter = Math.min(this.mogMeter + amount, MOG.METER_MAX);
}
isMogMeterFull() {
return this.mogMeter >= MOG.METER_MAX;
}
}
export const gameState = new GameState();
@@ -0,0 +1,396 @@
import { CHARACTER, PX } from '../core/Constants.js';
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
const C = CHARACTER;
const OL = C.OUTLINE;
const EXP = C.EXPRESSION;
// Smart fallback chains — NEVER default to NORMAL if alternatives exist
const FALLBACK = {
[EXP.HAPPY]: [EXP.SURPRISED, EXP.NORMAL, EXP.ANGRY],
[EXP.ANGRY]: [EXP.SURPRISED, EXP.HAPPY, EXP.NORMAL],
[EXP.SURPRISED]: [EXP.ANGRY, EXP.HAPPY, EXP.NORMAL],
[EXP.NORMAL]: [EXP.HAPPY, EXP.ANGRY, EXP.SURPRISED],
};
const EXPRESSION_NAMES = ['normal', 'happy', 'angry', 'surprised'];
/**
* Bobblehead character with expression spritesheet head.
* Giant photo head (~35% of screen) on a tiny South Park body.
*/
export class Character {
constructor(scene, x, y, type) {
this.scene = scene;
this.type = type;
this.baseX = x;
this.baseY = y;
this.currentExpression = EXP.NORMAL;
this.revertTimer = null;
const pal = type === 'trump'
? { suit: C.TRUMP_SUIT, suitL: C.TRUMP_SUIT_LIGHT, tie: C.TRUMP_TIE, shirt: C.TRUMP_SHIRT, pants: C.TRUMP_PANTS, shoes: C.TRUMP_SHOES, skin: C.TRUMP_SKIN }
: { suit: C.BIDEN_SUIT, suitL: C.BIDEN_SUIT_LIGHT, tie: C.BIDEN_TIE, shirt: C.BIDEN_SHIRT, pants: C.BIDEN_PANTS, shoes: C.BIDEN_SHOES, skin: C.BIDEN_SKIN };
this.pal = pal;
this.container = scene.add.container(x, y);
// Draw the body (stubby bobblehead proportions)
this.bodyGfx = scene.add.graphics();
this.drawBody(this.bodyGfx, pal, type);
// Arms as separate graphics (for animation)
this.leftArmGfx = scene.add.graphics();
this.rightArmGfx = scene.add.graphics();
this.drawArm(this.leftArmGfx, pal, -1);
this.drawArm(this.rightArmGfx, pal, 1);
// Position arms pinned to shoulders (store home positions for animation reset)
const armX = C.SHOULDER_W * 0.5 + C.UPPER_ARM_W * 0.1;
const armY = -C.TORSO_H * 0.38;
this.armHomeLeft = { x: -armX, y: armY };
this.armHomeRight = { x: armX, y: armY };
this.leftArmGfx.setPosition(this.armHomeLeft.x, this.armHomeLeft.y);
this.rightArmGfx.setPosition(this.armHomeRight.x, this.armHomeRight.y);
// Head — spritesheet with 4 expression frames
const sheetKey = type === 'trump' ? 'trump-expressions' : 'biden-expressions';
const headY = -C.TORSO_H * 0.5 - C.NECK_H - C.HEAD_H * 0.35;
// Detect available frames by checking spritesheet texture
this.availableFrames = this._detectAvailableFrames(sheetKey);
this.headSprite = scene.add.sprite(0, headY, sheetKey, EXP.NORMAL);
// Scale head to bobblehead size, preserving aspect ratio
const headScale = C.HEAD_H / C.FRAME_H;
this.headSprite.setScale(headScale);
// Layer order: arms behind body, head on top
this.container.add([
this.leftArmGfx,
this.rightArmGfx,
this.bodyGfx,
this.headSprite,
]);
// Idle breathing
this.bobTween = scene.tweens.add({
targets: this.container,
y: y - 2 * PX,
duration: 1400 + Math.random() * 400,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
// Listen for expression changes
this._onExpressionChange = (data) => {
if (data.target === this.type) {
this.setExpression(data.expression);
}
};
this._onExpressionReset = (data) => {
if (!data || data.target === this.type) {
this.setExpression(EXP.NORMAL);
}
};
eventBus.on(Events.EXPRESSION_CHANGE, this._onExpressionChange);
eventBus.on(Events.EXPRESSION_RESET, this._onExpressionReset);
}
/**
* Detect which frames have actual pixel content (not fully transparent).
* Returns a Set of frame indices that are usable.
*/
_detectAvailableFrames(sheetKey) {
const available = new Set();
try {
const texture = this.scene.textures.get(sheetKey);
if (!texture || texture.key === '__MISSING') return available;
// Check each frame exists in the texture
for (let i = 0; i < EXP.FRAME_COUNT; i++) {
const frame = texture.get(i);
// Frame exists if it's not the base frame (which means fallback)
if (frame && frame.width > 0 && frame.cutX !== undefined) {
available.add(i);
}
}
} catch {
// If texture detection fails, assume all frames available
for (let i = 0; i < EXP.FRAME_COUNT; i++) available.add(i);
}
// Always assume NORMAL is available as ultimate fallback
if (available.size === 0) available.add(EXP.NORMAL);
return available;
}
/**
* Set expression with smart fallback chain and juice.
* Auto-reverts to NORMAL after EXPRESSION_HOLD ms.
*/
setExpression(expression) {
// Resolve frame — use fallback chain if requested frame is missing
let resolvedFrame = expression;
if (!this.availableFrames.has(expression)) {
const chain = FALLBACK[expression] || [EXP.NORMAL];
resolvedFrame = chain.find(f => this.availableFrames.has(f));
if (resolvedFrame === undefined) resolvedFrame = EXP.NORMAL;
}
this.currentExpression = expression;
this.headSprite.setFrame(resolvedFrame);
// Track in game state
const exprName = EXPRESSION_NAMES[expression] || 'normal';
if (this.type === 'trump') gameState.trumpExpression = exprName;
else gameState.bidenExpression = exprName;
// Squash-stretch juice on expression change
if (expression !== EXP.NORMAL) {
this.scene.tweens.add({
targets: this.headSprite,
scaleX: this.headSprite.scaleX * 1.15,
scaleY: this.headSprite.scaleY * 0.9,
duration: 80,
yoyo: true,
ease: 'Quad.easeOut',
});
}
// Auto-revert to NORMAL after hold time (unless already NORMAL)
if (this.revertTimer) {
this.revertTimer.destroy();
this.revertTimer = null;
}
if (expression !== EXP.NORMAL) {
this.revertTimer = this.scene.time.delayedCall(C.EXPRESSION_HOLD, () => {
this.setExpression(EXP.NORMAL);
this.revertTimer = null;
});
}
}
drawBody(g, pal, type) {
g.clear();
// === SHOES (bottom) ===
const shoeY = C.TORSO_H * 0.5 + C.LEG_H;
g.fillStyle(pal.shoes);
g.lineStyle(OL, 0x000000);
g.fillRoundedRect(-C.LEG_GAP - C.SHOE_W, shoeY - C.SHOE_H * 0.3, C.SHOE_W, C.SHOE_H, C.SHOE_H * 0.3);
g.strokeRoundedRect(-C.LEG_GAP - C.SHOE_W, shoeY - C.SHOE_H * 0.3, C.SHOE_W, C.SHOE_H, C.SHOE_H * 0.3);
g.fillRoundedRect(C.LEG_GAP, shoeY - C.SHOE_H * 0.3, C.SHOE_W, C.SHOE_H, C.SHOE_H * 0.3);
g.strokeRoundedRect(C.LEG_GAP, shoeY - C.SHOE_H * 0.3, C.SHOE_W, C.SHOE_H, C.SHOE_H * 0.3);
// === LEGS (pants) ===
const legTop = C.TORSO_H * 0.5 - C.U;
g.fillStyle(pal.pants);
g.lineStyle(OL, 0x000000);
g.fillRoundedRect(-C.LEG_GAP - C.LEG_W, legTop, C.LEG_W, C.LEG_H + C.U, C.U * 0.5);
g.strokeRoundedRect(-C.LEG_GAP - C.LEG_W, legTop, C.LEG_W, C.LEG_H + C.U, C.U * 0.5);
g.fillRoundedRect(C.LEG_GAP, legTop, C.LEG_W, C.LEG_H + C.U, C.U * 0.5);
g.strokeRoundedRect(C.LEG_GAP, legTop, C.LEG_W, C.LEG_H + C.U, C.U * 0.5);
// === TORSO (suit jacket — smaller for bobblehead) ===
g.fillStyle(pal.suit);
g.lineStyle(OL, 0x000000);
g.beginPath();
const sW = C.SHOULDER_W * 0.5;
const wW = C.WAIST_W * 0.5;
const tH = C.TORSO_H * 0.5;
g.moveTo(-sW, -tH);
g.lineTo(sW, -tH);
g.lineTo(sW, -tH + C.U * 1.2);
g.lineTo(wW + C.U * 0.5, tH * 0.3);
g.lineTo(wW, tH);
g.lineTo(-wW, tH);
g.lineTo(-wW - C.U * 0.5, tH * 0.3);
g.lineTo(-sW, -tH + C.U * 1.2);
g.closePath();
g.fillPath();
g.strokePath();
// === SUIT JACKET DETAIL — lighter panel ===
g.fillStyle(pal.suitL);
g.beginPath();
const pW = C.TORSO_W * 0.15;
g.moveTo(-sW + C.U * 0.5, -tH + C.U * 0.3);
g.lineTo(-pW, -tH + C.U * 0.3);
g.lineTo(-pW, tH - C.U * 0.5);
g.lineTo(-wW + C.U * 0.5, tH - C.U * 0.5);
g.lineTo(-wW - C.U * 0.3, tH * 0.3);
g.lineTo(-sW + C.U * 0.5, -tH + C.U * 1.5);
g.closePath();
g.fillPath();
// === SHIRT / COLLAR V ===
g.fillStyle(pal.shirt);
g.lineStyle(OL, 0x000000);
g.beginPath();
g.moveTo(-C.NECK_W * 0.8, -tH);
g.lineTo(C.NECK_W * 0.8, -tH);
g.lineTo(C.TIE_W * 0.8, tH * 0.2);
g.lineTo(0, tH * 0.45);
g.lineTo(-C.TIE_W * 0.8, tH * 0.2);
g.closePath();
g.fillPath();
g.strokePath();
// === LAPELS ===
g.fillStyle(pal.suit);
g.lineStyle(OL, 0x000000);
g.beginPath();
g.moveTo(-C.NECK_W * 0.7, -tH);
g.lineTo(-C.LAPEL_W, -tH + C.U * 0.3);
g.lineTo(-C.TIE_W, tH * 0.35);
g.lineTo(-C.TIE_W * 0.6, tH * 0.15);
g.closePath();
g.fillPath();
g.strokePath();
g.beginPath();
g.moveTo(C.NECK_W * 0.7, -tH);
g.lineTo(C.LAPEL_W, -tH + C.U * 0.3);
g.lineTo(C.TIE_W, tH * 0.35);
g.lineTo(C.TIE_W * 0.6, tH * 0.15);
g.closePath();
g.fillPath();
g.strokePath();
// === TIE ===
g.fillStyle(pal.tie);
g.lineStyle(OL * 0.7, 0x000000);
// Knot
g.beginPath();
g.moveTo(-C.TIE_W * 0.4, -tH + C.U * 0.2);
g.lineTo(C.TIE_W * 0.4, -tH + C.U * 0.2);
g.lineTo(C.TIE_W * 0.5, -tH + C.U * 0.8);
g.lineTo(-C.TIE_W * 0.5, -tH + C.U * 0.8);
g.closePath();
g.fillPath();
g.strokePath();
// Blade (tapers down)
g.beginPath();
g.moveTo(-C.TIE_W * 0.45, -tH + C.U * 0.8);
g.lineTo(C.TIE_W * 0.45, -tH + C.U * 0.8);
g.lineTo(C.TIE_W * 0.3, tH * 0.1);
g.lineTo(0, tH * 0.4);
g.lineTo(-C.TIE_W * 0.3, tH * 0.1);
g.closePath();
g.fillPath();
g.strokePath();
// === BUTTONS ===
g.fillStyle(0x888888);
g.lineStyle(OL * 0.5, 0x000000);
g.fillCircle(wW * 0.3, tH * 0.05, C.BUTTON_R);
g.strokeCircle(wW * 0.3, tH * 0.05, C.BUTTON_R);
g.fillCircle(wW * 0.25, tH * 0.3, C.BUTTON_R);
g.strokeCircle(wW * 0.25, tH * 0.3, C.BUTTON_R);
// === NECK ===
g.fillStyle(pal.skin);
g.lineStyle(OL, 0x000000);
g.fillRoundedRect(-C.NECK_W * 0.5, -tH - C.NECK_H, C.NECK_W, C.NECK_H + C.U * 0.5, C.U * 0.3);
g.strokeRoundedRect(-C.NECK_W * 0.5, -tH - C.NECK_H, C.NECK_W, C.NECK_H + C.U * 0.5, C.U * 0.3);
}
drawArm(g, pal, side) {
g.clear();
// Upper arm (suit sleeve)
g.fillStyle(pal.suit);
g.lineStyle(OL, 0x000000);
g.fillRoundedRect(-C.UPPER_ARM_W * 0.5, 0, C.UPPER_ARM_W, C.UPPER_ARM_H, C.U * 0.4);
g.strokeRoundedRect(-C.UPPER_ARM_W * 0.5, 0, C.UPPER_ARM_W, C.UPPER_ARM_H, C.U * 0.4);
// Shirt cuff
g.fillStyle(pal.shirt);
g.lineStyle(OL * 0.7, 0x000000);
g.fillRoundedRect(-C.LOWER_ARM_W * 0.5, C.UPPER_ARM_H - C.U * 0.3, C.LOWER_ARM_W, C.U * 0.8, C.U * 0.2);
g.strokeRoundedRect(-C.LOWER_ARM_W * 0.5, C.UPPER_ARM_H - C.U * 0.3, C.LOWER_ARM_W, C.U * 0.8, C.U * 0.2);
// Hand (mitten shape — South Park style)
g.fillStyle(pal.skin);
g.lineStyle(OL, 0x000000);
const handY = C.UPPER_ARM_H + C.U * 0.3;
g.fillRoundedRect(-C.HAND_W * 0.5, handY, C.HAND_W, C.HAND_H, C.HAND_H * 0.4);
g.strokeRoundedRect(-C.HAND_W * 0.5, handY, C.HAND_W, C.HAND_H, C.HAND_H * 0.4);
}
// === ANIMATIONS ===
mogPose(duration = 600) {
// Reset arms to shoulder home position before animating (prevents drift)
this.leftArmGfx.setPosition(this.armHomeLeft.x, this.armHomeLeft.y);
this.rightArmGfx.setPosition(this.armHomeRight.x, this.armHomeRight.y);
this.leftArmGfx.angle = 0;
this.rightArmGfx.angle = 0;
this.scene.tweens.add({
targets: this.leftArmGfx,
angle: 50,
duration: duration * 0.25,
yoyo: true,
hold: duration * 0.4,
ease: 'Back.easeOut',
});
this.scene.tweens.add({
targets: this.rightArmGfx,
angle: -50,
duration: duration * 0.25,
yoyo: true,
hold: duration * 0.4,
ease: 'Back.easeOut',
});
this.scene.tweens.add({
targets: this.bodyGfx,
scaleX: 1.05,
duration: duration * 0.2,
yoyo: true,
hold: duration * 0.5,
ease: 'Quad.easeOut',
});
}
cowerPose(duration = 600) {
// Reset arms to shoulder home position before animating (prevents drift)
this.leftArmGfx.setPosition(this.armHomeLeft.x, this.armHomeLeft.y);
this.rightArmGfx.setPosition(this.armHomeRight.x, this.armHomeRight.y);
this.leftArmGfx.angle = 0;
this.rightArmGfx.angle = 0;
this.scene.tweens.add({
targets: this.container,
y: this.baseY + C.U * 1.5,
duration: duration * 0.25,
yoyo: true,
hold: duration * 0.4,
ease: 'Quad.easeOut',
});
this.scene.tweens.add({
targets: this.leftArmGfx,
angle: -15,
duration: duration * 0.25,
yoyo: true,
hold: duration * 0.4,
});
this.scene.tweens.add({
targets: this.rightArmGfx,
angle: 15,
duration: duration * 0.25,
yoyo: true,
hold: duration * 0.4,
});
}
destroy() {
if (this.bobTween) this.bobTween.destroy();
if (this.revertTimer) this.revertTimer.destroy();
eventBus.off(Events.EXPRESSION_CHANGE, this._onExpressionChange);
eventBus.off(Events.EXPRESSION_RESET, this._onExpressionReset);
this.container.destroy();
}
}
+51
View File
@@ -0,0 +1,51 @@
import Phaser from 'phaser';
import { GameConfig } from './core/GameConfig.js';
import { eventBus, Events } from './core/EventBus.js';
import { gameState } from './core/GameState.js';
const game = new Phaser.Game(GameConfig);
// Expose for testing
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
window.__EVENT_BUS__ = eventBus;
window.__EVENTS__ = Events;
// AI-readable game state snapshot
window.render_game_to_text = () => {
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
const activeScenes = game.scene.getScenes(true).map(s => s.scene.key);
const payload = {
coords: 'origin:top-left x:right y:down',
mode: gameState.gameOver ? 'game_over' : gameState.started ? 'playing' : 'menu',
scene: activeScenes[0] || null,
scenes: activeScenes,
score: gameState.score,
bestScore: gameState.bestScore,
mogMeter: gameState.mogMeter,
mogPercent: Math.round((gameState.mogMeter / 1000) * 100),
combo: gameState.combo,
maxCombo: gameState.maxCombo,
trumpScale: gameState.trumpScale,
bidenScale: gameState.bidenScale,
trumpExpression: gameState.trumpExpression || 'normal',
bidenExpression: gameState.bidenExpression || 'normal',
perfects: gameState.perfectCount,
totalMogs: gameState.mogCount,
};
return JSON.stringify(payload);
};
// Deterministic time-stepping
window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step() {
if (performance.now() - start >= ms) return resolve();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};
@@ -0,0 +1,44 @@
import { GAME } from '../core/Constants.js';
export class BootScene extends Phaser.Scene {
constructor() {
super({ key: 'BootScene' });
}
preload() {
// Load expression spritesheets (4 frames: normal, happy, angry, surprised)
this.load.spritesheet('trump-expressions', 'assets/trump-expressions.png', {
frameWidth: 200,
frameHeight: 300,
});
this.load.spritesheet('biden-expressions', 'assets/biden-expressions.png', {
frameWidth: 200,
frameHeight: 300,
});
// Optional background wallpaper
this.load.image('bg-wallpaper', 'assets/bg-wallpaper.png');
// Suppress missing file warning — wallpaper is optional
this.load.on('loaderror', (file) => {
if (file.key === 'bg-wallpaper') file.state = Phaser.Loader.FILE_COMPLETE;
});
// Loading bar
const barW = GAME.WIDTH * 0.4;
const barH = GAME.HEIGHT * 0.02;
const barX = (GAME.WIDTH - barW) / 2;
const barY = GAME.HEIGHT / 2;
const bg = this.add.rectangle(GAME.WIDTH / 2, barY, barW, barH, 0x333333);
const fill = this.add.rectangle(barX, barY, 0, barH, 0xff4444).setOrigin(0, 0.5);
this.load.on('progress', (value) => {
fill.width = barW * value;
});
}
create() {
this.scene.start('GameScene');
this.scene.launch('UIScene');
}
}
+510
View File
@@ -0,0 +1,510 @@
import Phaser from 'phaser';
import { GAME, CHARACTER, MOG, COLORS, PX, UI } from '../core/Constants.js';
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
import { Character } from '../entities/Character.js';
import { initExpressionSystem } from '../systems/ExpressionSystem.js';
export class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
create() {
gameState.reset();
this.cursorDirection = 1;
this.cursorPos = 0; // -1 to 1 range (center = 0)
this.cursorSpeed = MOG.CURSOR_SPEED_START;
this.canMog = true;
this.mogActive = false;
this.roundMogs = 0;
this.screenShaking = false;
this.createBackground();
this.createCharacters();
this.createTimingBar();
this.createMogMeter();
this.createInstructions();
this.setupInput();
// Wire expression system (maps mog events → face changes)
initExpressionSystem();
// Start playing
gameState.started = true;
eventBus.emit(Events.GAME_START);
}
createBackground() {
// Gradient sky
const gfx = this.add.graphics();
gfx.fillGradientStyle(COLORS.SKY_TOP, COLORS.SKY_TOP, COLORS.SKY_BOTTOM, COLORS.SKY_BOTTOM, 1);
gfx.fillRect(0, 0, GAME.WIDTH, GAME.HEIGHT * 0.75);
// Optional wallpaper overlay for thematic depth
if (this.textures.exists('bg-wallpaper')) {
const wp = this.add.image(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.5, 'bg-wallpaper');
wp.setDisplaySize(GAME.WIDTH, GAME.HEIGHT);
wp.setAlpha(0.12);
}
// Ground
gfx.fillStyle(COLORS.GROUND);
gfx.fillRect(0, GAME.HEIGHT * 0.75, GAME.WIDTH, GAME.HEIGHT * 0.25);
gfx.fillStyle(COLORS.GROUND_DARK);
gfx.fillRect(0, GAME.HEIGHT * 0.75, GAME.WIDTH, GAME.HEIGHT * 0.01);
// Stage / platform
gfx.fillStyle(0x444444);
gfx.fillRect(GAME.WIDTH * 0.1, GAME.HEIGHT * 0.78, GAME.WIDTH * 0.8, GAME.HEIGHT * 0.04);
gfx.fillStyle(0x555555);
gfx.fillRect(GAME.WIDTH * 0.1, GAME.HEIGHT * 0.78, GAME.WIDTH * 0.8, GAME.HEIGHT * 0.01);
// "VS" text in background
this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.45, 'VS', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * 0.15)}px`,
color: '#ffffff',
stroke: '#000000',
strokeThickness: 4 * PX,
}).setOrigin(0.5).setAlpha(0.08);
// Spotlights
const spotlight = this.add.graphics();
spotlight.fillStyle(0xffffff, 0.03);
spotlight.fillTriangle(
GAME.WIDTH * 0.3, 0,
GAME.WIDTH * 0.15, GAME.HEIGHT * 0.78,
GAME.WIDTH * 0.45, GAME.HEIGHT * 0.78
);
spotlight.fillTriangle(
GAME.WIDTH * 0.7, 0,
GAME.WIDTH * 0.55, GAME.HEIGHT * 0.78,
GAME.WIDTH * 0.85, GAME.HEIGHT * 0.78
);
}
createCharacters() {
this.trump = new Character(this, CHARACTER.TRUMP_X, CHARACTER.TRUMP_Y, 'trump');
this.biden = new Character(this, CHARACTER.BIDEN_X, CHARACTER.BIDEN_Y, 'biden');
// Name labels
this.add.text(CHARACTER.TRUMP_X, CHARACTER.TRUMP_Y + CHARACTER.BODY_HEIGHT * 0.7, 'TRUMP', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO)}px`,
color: '#ff4444',
stroke: '#000000',
strokeThickness: 2 * PX,
}).setOrigin(0.5);
this.add.text(CHARACTER.BIDEN_X, CHARACTER.BIDEN_Y + CHARACTER.BODY_HEIGHT * 0.7, 'BIDEN', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO)}px`,
color: '#4488ff',
stroke: '#000000',
strokeThickness: 2 * PX,
}).setOrigin(0.5);
}
createTimingBar() {
const barX = MOG.BAR_X - MOG.BAR_WIDTH / 2;
const barY = MOG.BAR_Y - MOG.BAR_HEIGHT / 2;
// Bar background
this.timingBarBg = this.add.rectangle(MOG.BAR_X, MOG.BAR_Y, MOG.BAR_WIDTH, MOG.BAR_HEIGHT, MOG.BAR_BG)
.setStrokeStyle(2 * PX, 0x666666);
// Sweet spot zone (center)
const sweetW = MOG.BAR_WIDTH * MOG.SWEET_SPOT_RATIO;
this.sweetSpot = this.add.rectangle(MOG.BAR_X, MOG.BAR_Y, sweetW, MOG.BAR_HEIGHT, MOG.BAR_PERFECT, 0.3);
// Good zone (wider)
const goodW = MOG.BAR_WIDTH * 0.35;
this.goodZone = this.add.rectangle(MOG.BAR_X, MOG.BAR_Y, goodW, MOG.BAR_HEIGHT, MOG.BAR_FILL, 0.15);
this.goodZone.setDepth(-1);
// Cursor
this.timingCursor = this.add.rectangle(
MOG.BAR_X, MOG.BAR_Y,
MOG.CURSOR_WIDTH, MOG.BAR_HEIGHT * 1.4,
MOG.CURSOR_COLOR
);
// Label
this.add.text(MOG.BAR_X, MOG.BAR_Y - MOG.BAR_HEIGHT * 1.5, 'TIMING', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO)}px`,
color: COLORS.UI_TEXT,
stroke: COLORS.UI_SHADOW,
strokeThickness: 2 * PX,
}).setOrigin(0.5);
}
createMogMeter() {
// Meter background
this.mogMeterBg = this.add.rectangle(MOG.METER_X, MOG.METER_Y, MOG.METER_WIDTH, MOG.METER_HEIGHT, MOG.METER_BG)
.setStrokeStyle(2 * PX, MOG.METER_BORDER);
// Meter fill
this.mogMeterFill = this.add.rectangle(
MOG.METER_X - MOG.METER_WIDTH / 2, MOG.METER_Y,
0, MOG.METER_HEIGHT, MOG.METER_FILL
).setOrigin(0, 0.5);
// Label
this.mogMeterLabel = this.add.text(MOG.METER_X, MOG.METER_Y - MOG.METER_HEIGHT * 1.8, 'MOG POWER', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO)}px`,
color: COLORS.COMBO_TEXT,
stroke: COLORS.UI_SHADOW,
strokeThickness: 2 * PX,
}).setOrigin(0.5);
// Percentage text
this.mogPercentText = this.add.text(MOG.METER_X + MOG.METER_WIDTH / 2 + 10 * PX, MOG.METER_Y, '0%', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO)}px`,
color: COLORS.UI_TEXT,
}).setOrigin(0, 0.5);
}
createInstructions() {
this.instructionText = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.92, 'PRESS SPACE TO MOG', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.BODY_RATIO)}px`,
color: COLORS.TITLE_GOLD,
stroke: COLORS.UI_SHADOW,
strokeThickness: 3 * PX,
}).setOrigin(0.5);
// Pulse the instruction
this.tweens.add({
targets: this.instructionText,
alpha: 0.4,
duration: 800,
yoyo: true,
repeat: -1,
});
}
setupInput() {
this.input.keyboard.on('keydown-SPACE', () => this.attemptMog());
this.input.on('pointerdown', () => this.attemptMog());
}
attemptMog() {
if (!this.canMog || gameState.gameOver) return;
this.canMog = false;
eventBus.emit(Events.MOG_ATTEMPT);
gameState.mogCount++;
// Determine accuracy based on cursor position
const absPos = Math.abs(this.cursorPos);
let result;
let points;
let mogPower;
if (absPos <= MOG.SWEET_SPOT_RATIO / 2) {
// Perfect!
result = 'PERFECT MOG!';
points = MOG.PERFECT_MOG_POINTS;
mogPower = 150;
gameState.perfectCount++;
gameState.combo++;
eventBus.emit(Events.MOG_PERFECT);
this.flashScreen(0xffd700, 0.3);
this.showResultText(result, COLORS.TITLE_GOLD);
} else if (absPos <= 0.35 / 2) {
// Good
result = 'MOGGED!';
points = MOG.GOOD_MOG_POINTS;
mogPower = 80;
gameState.goodCount++;
gameState.combo++;
eventBus.emit(Events.MOG_GOOD);
this.showResultText(result, COLORS.SCORE_TEXT);
} else {
// Miss
result = 'WEAK...';
points = MOG.MISS_POINTS;
mogPower = 10;
gameState.missCount++;
gameState.combo = 0;
eventBus.emit(Events.MOG_MISS);
this.showResultText(result, '#888888');
}
// Combo bonus
if (gameState.combo > gameState.maxCombo) {
gameState.maxCombo = gameState.combo;
}
const comboMultiplier = Math.min(1 + gameState.combo * 0.2, 3.0);
points = Math.round(points * comboMultiplier);
mogPower = Math.round(mogPower * comboMultiplier);
// Apply score and meter
gameState.addScore(points);
gameState.addMogPower(mogPower);
eventBus.emit(Events.SCORE_CHANGED, { score: gameState.score });
// Animate characters
this.trump.mogPose(600);
this.biden.cowerPose(600);
// Scale characters
gameState.trumpScale = Math.min(gameState.trumpScale + MOG.TRUMP_GROW * (mogPower / 80), MOG.MAX_TRUMP_SCALE);
gameState.bidenScale = Math.max(gameState.bidenScale - MOG.BIDEN_SHRINK * (mogPower / 80), MOG.MIN_BIDEN_SCALE);
this.tweens.add({
targets: this.trump.container,
scaleX: gameState.trumpScale,
scaleY: gameState.trumpScale,
duration: 300,
ease: 'Back.easeOut',
});
this.tweens.add({
targets: this.biden.container,
scaleX: gameState.bidenScale,
scaleY: gameState.bidenScale,
duration: 300,
ease: 'Back.easeOut',
});
// Update mog meter visual
this.updateMogMeter();
// Screen shake on perfect
if (absPos <= MOG.SWEET_SPOT_RATIO / 2) {
this.shakeScreen(4 * PX, 200);
}
// Speed up cursor each mog
this.cursorSpeed += MOG.CURSOR_SPEED_INCREMENT;
// Check win condition
if (gameState.isMogMeterFull()) {
this.time.delayedCall(500, () => this.triggerWin());
return;
}
// Cooldown
this.time.delayedCall(400, () => {
this.canMog = true;
});
}
showResultText(text, color) {
const resultText = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.55, text, {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.HEADING_RATIO)}px`,
color: color,
stroke: COLORS.UI_SHADOW,
strokeThickness: 4 * PX,
}).setOrigin(0.5);
// Combo text
if (gameState.combo > 1) {
const comboText = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.6, `${gameState.combo}x COMBO`, {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO * 1.2)}px`,
color: COLORS.COMBO_TEXT,
stroke: COLORS.UI_SHADOW,
strokeThickness: 2 * PX,
}).setOrigin(0.5);
this.tweens.add({
targets: comboText,
y: GAME.HEIGHT * 0.55,
alpha: 0,
scaleX: 1.5,
scaleY: 1.5,
duration: 800,
onComplete: () => comboText.destroy(),
});
}
this.tweens.add({
targets: resultText,
y: GAME.HEIGHT * 0.48,
alpha: 0,
scaleX: 1.3,
scaleY: 1.3,
duration: 600,
onComplete: () => resultText.destroy(),
});
}
updateMogMeter() {
const ratio = gameState.mogMeter / MOG.METER_MAX;
this.tweens.add({
targets: this.mogMeterFill,
width: MOG.METER_WIDTH * ratio,
duration: 200,
ease: 'Cubic.easeOut',
});
this.mogPercentText.setText(`${Math.round(ratio * 100)}%`);
// Change color as it fills
if (ratio > 0.8) {
this.mogMeterFill.setFillStyle(0xff0000);
} else if (ratio > 0.5) {
this.mogMeterFill.setFillStyle(0xff6600);
}
}
flashScreen(color, alpha) {
const flash = this.add.rectangle(GAME.WIDTH / 2, GAME.HEIGHT / 2, GAME.WIDTH, GAME.HEIGHT, color, alpha);
this.tweens.add({
targets: flash,
alpha: 0,
duration: 300,
onComplete: () => flash.destroy(),
});
}
shakeScreen(intensity, duration) {
if (this.screenShaking) return;
this.screenShaking = true;
this.cameras.main.shake(duration, intensity / GAME.WIDTH);
this.time.delayedCall(duration, () => { this.screenShaking = false; });
}
triggerWin() {
gameState.gameOver = true;
this.canMog = false;
eventBus.emit(Events.MOG_METER_FULL);
// Epic flash
this.flashScreen(0xffd700, 0.6);
this.shakeScreen(8 * PX, 500);
// Trump victory scale
this.tweens.add({
targets: this.trump.container,
scaleX: MOG.MAX_TRUMP_SCALE,
scaleY: MOG.MAX_TRUMP_SCALE,
duration: 800,
ease: 'Back.easeOut',
});
// Biden shrink away
this.tweens.add({
targets: this.biden.container,
scaleX: 0.2,
scaleY: 0.2,
alpha: 0.3,
duration: 800,
ease: 'Cubic.easeIn',
});
// Victory text
this.time.delayedCall(600, () => {
const winText = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.4, 'TOTAL MOG', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.TITLE_RATIO)}px`,
color: COLORS.TITLE_GOLD,
stroke: COLORS.UI_SHADOW,
strokeThickness: 5 * PX,
}).setOrigin(0.5).setScale(0);
this.tweens.add({
targets: winText,
scaleX: 1,
scaleY: 1,
duration: 500,
ease: 'Back.easeOut',
});
// Show final stats
this.time.delayedCall(1000, () => {
this.showFinalStats();
});
});
// Hide timing bar
this.tweens.add({
targets: [this.timingBarBg, this.sweetSpot, this.goodZone, this.timingCursor],
alpha: 0,
duration: 400,
});
eventBus.emit(Events.GAME_OVER);
}
showFinalStats() {
const stats = [
`SCORE: ${gameState.score}`,
`PERFECTS: ${gameState.perfectCount}`,
`MAX COMBO: ${gameState.maxCombo}x`,
];
stats.forEach((text, i) => {
const t = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.52 + i * GAME.HEIGHT * 0.06, text, {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.BODY_RATIO)}px`,
color: COLORS.UI_TEXT,
stroke: COLORS.UI_SHADOW,
strokeThickness: 2 * PX,
}).setOrigin(0.5).setAlpha(0);
this.tweens.add({
targets: t,
alpha: 1,
y: t.y - 10 * PX,
duration: 400,
delay: i * 200,
});
});
// Restart prompt
this.time.delayedCall(1200, () => {
const restartText = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.82, 'PRESS SPACE TO MOG AGAIN', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.SMALL_RATIO)}px`,
color: COLORS.TITLE_GOLD,
stroke: COLORS.UI_SHADOW,
strokeThickness: 2 * PX,
}).setOrigin(0.5);
this.tweens.add({
targets: restartText,
alpha: 0.3,
duration: 600,
yoyo: true,
repeat: -1,
});
// Allow restart
this.input.keyboard.once('keydown-SPACE', () => this.restart());
this.input.once('pointerdown', () => this.restart());
});
}
restart() {
eventBus.emit(Events.GAME_RESTART);
this.trump.destroy();
this.biden.destroy();
this.scene.restart();
}
update(time, delta) {
if (gameState.gameOver) return;
// Move timing cursor
const dt = delta / 1000;
this.cursorPos += this.cursorDirection * (this.cursorSpeed / MOG.BAR_WIDTH) * dt * 2;
if (this.cursorPos >= 1) {
this.cursorPos = 1;
this.cursorDirection = -1;
} else if (this.cursorPos <= -1) {
this.cursorPos = -1;
this.cursorDirection = 1;
}
// Update cursor visual
this.timingCursor.x = MOG.BAR_X + this.cursorPos * (MOG.BAR_WIDTH / 2);
}
}
+77
View File
@@ -0,0 +1,77 @@
import Phaser from 'phaser';
import { GAME, UI, COLORS, PX } from '../core/Constants.js';
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
export class UIScene extends Phaser.Scene {
constructor() {
super({ key: 'UIScene' });
}
create() {
// Score display
this.scoreText = this.add.text(GAME.WIDTH * 0.5, GAME.HEIGHT * 0.04, '0', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.HEADING_RATIO)}px`,
color: COLORS.TITLE_GOLD,
stroke: COLORS.UI_SHADOW,
strokeThickness: 3 * PX,
}).setOrigin(0.5, 0);
// Combo display
this.comboText = this.add.text(GAME.WIDTH * 0.9, GAME.HEIGHT * 0.04, '', {
fontFamily: UI.FONT,
fontSize: `${Math.round(GAME.HEIGHT * UI.BODY_RATIO)}px`,
color: COLORS.COMBO_TEXT,
stroke: COLORS.UI_SHADOW,
strokeThickness: 2 * PX,
}).setOrigin(0.5, 0).setAlpha(0);
// Listen for events
eventBus.on(Events.SCORE_CHANGED, () => this.updateScore());
eventBus.on(Events.MOG_PERFECT, () => this.showCombo());
eventBus.on(Events.MOG_GOOD, () => this.showCombo());
eventBus.on(Events.MOG_MISS, () => this.hideCombo());
eventBus.on(Events.GAME_RESTART, () => this.resetUI());
}
updateScore() {
this.scoreText.setText(gameState.score.toString());
// Juice: scale pop
this.tweens.add({
targets: this.scoreText,
scaleX: 1.3,
scaleY: 1.3,
duration: 100,
yoyo: true,
});
}
showCombo() {
if (gameState.combo > 1) {
this.comboText.setText(`${gameState.combo}x`);
this.comboText.setAlpha(1);
this.tweens.add({
targets: this.comboText,
scaleX: 1.4,
scaleY: 1.4,
duration: 100,
yoyo: true,
});
}
}
hideCombo() {
this.tweens.add({
targets: this.comboText,
alpha: 0,
duration: 200,
});
}
resetUI() {
this.scoreText.setText('0');
this.comboText.setText('').setAlpha(0);
}
}
@@ -0,0 +1,42 @@
import { eventBus, Events } from '../core/EventBus.js';
import { CHARACTER } from '../core/Constants.js';
import { gameState } from '../core/GameState.js';
const EXP = CHARACTER.EXPRESSION;
/**
* Maps game events to character expression changes.
* Call initExpressionSystem() in GameScene.create().
*/
export function initExpressionSystem() {
// Perfect mog — Trump celebrates, opponent shocked
eventBus.on(Events.MOG_PERFECT, () => {
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'trump', expression: EXP.HAPPY });
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'biden', expression: EXP.SURPRISED });
});
// Good mog — Trump happy, opponent angry
eventBus.on(Events.MOG_GOOD, () => {
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'trump', expression: EXP.HAPPY });
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'biden', expression: EXP.ANGRY });
});
// Miss — Trump surprised, opponent smug
eventBus.on(Events.MOG_MISS, () => {
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'trump', expression: EXP.SURPRISED });
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'biden', expression: EXP.HAPPY });
});
// Meter full — Trump ecstatic, opponent devastated
eventBus.on(Events.MOG_METER_FULL, () => {
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'trump', expression: EXP.HAPPY });
eventBus.emit(Events.EXPRESSION_CHANGE, { target: 'biden', expression: EXP.ANGRY });
});
}
/**
* Remove all expression listeners. Call on scene shutdown.
*/
export function destroyExpressionSystem() {
// EventBus.removeAll() is called on restart, so this is a no-op safety net
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});
+2731 -47
View File
File diff suppressed because it is too large Load Diff
+75 -1
View File
@@ -1,5 +1,79 @@
{
"devDependencies": {
"gh-pages": "^6.3.0"
}
},
"name": "game-creator",
"version": "1.0.0",
"description": "The game studio for the agent internet. Build, monetize, and ship 2D (Phaser 3) and 3D (Three.js) browser games with one command. QA runs after every step. Monetize with [Play.fun](https://play.fun) (OpenGameProtocol). Works with **40+ AI coding agents** (via `npx skills add`). Share your play.fun URL on [Moltbook](https://www.moltbook.com/).",
"main": "index.js",
"directories": {
"example": "examples"
},
"dependencies": {
"@imgly/background-removal-node": "^1.4.5",
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-node": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"array-union": "^2.1.0",
"async": "^3.2.6",
"braces": "^3.0.3",
"canvas": "^3.2.1",
"commander": "^13.1.0",
"commondir": "^1.0.1",
"dir-glob": "^3.0.1",
"email-addresses": "^5.0.0",
"escape-string-regexp": "^1.0.5",
"fast-glob": "^3.3.3",
"fastq": "^1.20.1",
"filename-reserved-regex": "^2.0.0",
"filenamify": "^4.3.0",
"fill-range": "^7.1.1",
"find-cache-dir": "^3.3.2",
"find-up": "^4.1.0",
"fs-extra": "^11.3.3",
"glob-parent": "^5.1.2",
"globby": "^11.1.0",
"graceful-fs": "^4.2.11",
"ignore": "^5.3.2",
"is-extglob": "^2.1.1",
"is-glob": "^4.0.3",
"is-number": "^7.0.0",
"jsonfile": "^6.2.0",
"locate-path": "^5.0.0",
"make-dir": "^3.1.0",
"merge2": "^1.4.1",
"micromatch": "^4.0.8",
"p-limit": "^2.3.0",
"p-locate": "^4.1.0",
"p-try": "^2.2.0",
"path-exists": "^4.0.0",
"path-type": "^4.0.0",
"picomatch": "^2.3.1",
"pkg-dir": "^4.2.0",
"queue-microtask": "^1.2.3",
"reusify": "^1.1.0",
"run-parallel": "^1.2.0",
"semver": "^6.3.1",
"sharp": "^0.34.5",
"slash": "^3.0.0",
"strip-outer": "^1.0.1",
"to-regex-range": "^5.0.1",
"trim-repeated": "^1.0.0",
"universalify": "^2.0.1"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/OpusGameLabs/game-creator.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"bugs": {
"url": "https://github.com/OpusGameLabs/game-creator/issues"
},
"homepage": "https://github.com/OpusGameLabs/game-creator#readme"
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* build-character.mjs Orchestrate the full character pipeline.
*
* For each of 4 emotion images:
* 1. process-head.mjs ML background removal
* 2. crop-head.mjs isolate head (no shoulders)
* 3. build-spritesheet.mjs combine into 800x300 spritesheet
*
* Usage:
* node scripts/build-character.mjs <name> <outputDir> [--skip-find]
*
* Example:
* node scripts/build-character.mjs "Donald Trump" public/assets/trump/ --skip-find
*
* --skip-find mode (default): expects user to manually place images in <outputDir>/raw/:
* normal.jpg happy.jpg angry.jpg surprised.jpg
*
* Output structure:
* <outputDir>/
* raw/ normal.jpg happy.jpg ...
* clean/ normal.png happy.png ...
* cropped/ normal.png happy.png ...
* <name>-expressions.png # final 800x300 spritesheet
*/
import { execFileSync } from 'node:child_process';
import { resolve, dirname, join } from 'node:path';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const EXPRESSIONS = ['normal', 'happy', 'angry', 'surprised'];
const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.webp'];
// Parse CLI args
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node scripts/build-character.mjs <name> <outputDir> [--skip-find]');
process.exit(1);
}
const name = args[0];
const outputDir = resolve(args[1]);
// --skip-find is default, accepted but ignored
// --ratio is legacy, accepted but ignored (face detection handles it)
// Derive slug from name for output file
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
// Ensure directories exist
const rawDir = join(outputDir, 'raw');
const cleanDir = join(outputDir, 'clean');
const croppedDir = join(outputDir, 'cropped');
for (const dir of [rawDir, cleanDir, croppedDir]) {
mkdirSync(dir, { recursive: true });
}
console.log(`\n=== Building character: ${name} ===`);
console.log(` Output: ${outputDir}\n`);
// Find raw images for each expression
function findRawImage(expression) {
if (!existsSync(rawDir)) return null;
const files = readdirSync(rawDir);
for (const ext of IMAGE_EXTS) {
const match = files.find(f => f.toLowerCase() === `${expression}${ext}`);
if (match) return join(rawDir, match);
}
return null;
}
const processHeadScript = join(__dirname, 'process-head.mjs');
const cropHeadScript = join(__dirname, 'crop-head.mjs');
const buildSpritesheetScript = join(__dirname, 'build-spritesheet.mjs');
// Step 1 & 2: Process each expression
const croppedPaths = {};
for (const expr of EXPRESSIONS) {
const rawPath = findRawImage(expr);
if (!rawPath) {
console.log(`[${expr}] No raw image found — skipping`);
continue;
}
console.log(`\n--- ${expr.toUpperCase()} ---`);
const cleanPath = join(cleanDir, `${expr}.png`);
const croppedPath = join(croppedDir, `${expr}.png`);
// Step 1: ML background removal
if (existsSync(cleanPath)) {
console.log(` [skip] ${expr} clean already exists`);
} else {
console.log(` [process-head] ${rawPath}${cleanPath}`);
execFileSync('node', [processHeadScript, rawPath, cleanPath], { stdio: 'inherit' });
}
// Step 2: Crop to just the head
if (existsSync(croppedPath)) {
console.log(` [skip] ${expr} cropped already exists`);
} else {
console.log(` [crop-head] ${cleanPath}${croppedPath}`);
execFileSync('node', [cropHeadScript, cleanPath, croppedPath], { stdio: 'inherit' });
}
croppedPaths[expr] = croppedPath;
}
// Step 3: Build spritesheet
const spritesheetPath = join(outputDir, `${slug}-expressions.png`);
console.log(`\n--- SPRITESHEET ---`);
const spritesheetArgs = [buildSpritesheetScript, spritesheetPath];
for (const expr of EXPRESSIONS) {
if (croppedPaths[expr]) {
spritesheetArgs.push(`--${expr}`, croppedPaths[expr]);
}
}
execFileSync('node', spritesheetArgs, { stdio: 'inherit' });
console.log(`\n=== Done: ${spritesheetPath} ===\n`);
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* build-spritesheet.mjs Combine 1-4 expression PNGs into a horizontal spritesheet.
*
* Output: 800x300 PNG with 4 frames (200x300 each).
* Frame layout: [NORMAL | HAPPY | ANGRY | SURPRISED]
* Missing frames get transparent fill (runtime fallback handles it).
*
* Usage:
* node scripts/build-spritesheet.mjs <outputPath> [--normal img] [--happy img] [--angry img] [--surprised img]
*
* Example:
* node scripts/build-spritesheet.mjs public/assets/trump-expressions.png \
* --normal cropped/normal.png --happy cropped/happy.png \
* --angry cropped/angry.png --surprised cropped/surprised.png
*/
import sharp from 'sharp';
import { resolve, basename } from 'node:path';
import { existsSync } from 'node:fs';
const FRAME_W = 200;
const FRAME_H = 300;
const FRAME_COUNT = 4;
const SHEET_W = FRAME_W * FRAME_COUNT; // 800
const SHEET_H = FRAME_H; // 300
const EXPRESSIONS = ['normal', 'happy', 'angry', 'surprised'];
// Parse CLI args
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: node scripts/build-spritesheet.mjs <output> [--normal img] [--happy img] [--angry img] [--surprised img]');
process.exit(1);
}
const outputPath = resolve(args[0]);
const inputs = {};
for (let i = 1; i < args.length; i++) {
const flag = args[i].replace(/^--/, '');
if (EXPRESSIONS.includes(flag) && args[i + 1]) {
inputs[flag] = resolve(args[++i]);
}
}
console.log(`Building spritesheet: ${basename(outputPath)} (${SHEET_W}x${SHEET_H})`);
// Build composite operations
const composites = [];
for (let i = 0; i < EXPRESSIONS.length; i++) {
const expr = EXPRESSIONS[i];
const filePath = inputs[expr];
if (!filePath || !existsSync(filePath)) {
console.log(` [${i}] ${expr}: (empty — transparent fill)`);
continue;
}
console.log(` [${i}] ${expr}: ${basename(filePath)}`);
// Resize to fit within frame, centered, preserving aspect ratio
const resized = await sharp(filePath)
.resize(FRAME_W, FRAME_H, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
composites.push({
input: resized,
left: i * FRAME_W,
top: 0,
});
}
// Create transparent base and composite all frames
await sharp({
create: {
width: SHEET_W,
height: SHEET_H,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(composites)
.png()
.toFile(outputPath);
console.log(` -> ${basename(outputPath)} (${SHEET_W}x${SHEET_H}, ${composites.length}/${FRAME_COUNT} frames)`);
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* crop-head.mjs Smart face crop using face-api.js detection.
*
* Uses @vladmandic/face-api (SSD MobileNet v1) to detect the face
* bounding box, then crops with configurable padding. Falls back to
* the bounding-box-of-non-transparent-pixels heuristic if no face
* is detected.
*
* Usage:
* node scripts/crop-head.mjs <input> [output] [--padding 0.25]
*
* Example:
* node scripts/crop-head.mjs input.png output.png
* node scripts/crop-head.mjs input.png output.png --padding 0.40
*/
import sharp from 'sharp';
import { resolve, basename, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Polyfill deprecated util functions — removed/deprecated in Node 22+ but needed by @tensorflow/tfjs-node
import util from 'node:util';
if (!util.isNullOrUndefined) {
util.isNullOrUndefined = (v) => v === null || v === undefined;
}
// Override to suppress DEP0044 deprecation warning
util.isArray = Array.isArray;
const faceapi = require('@vladmandic/face-api');
const canvasPkg = require('canvas');
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Arg parsing ---
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: node scripts/crop-head.mjs <input> [output] [--padding 0.25]');
process.exit(1);
}
let facePadding = 0.25;
const positional = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--padding' && args[i + 1]) {
facePadding = parseFloat(args[++i]);
} else if (args[i] === '--ratio') {
// Legacy flag — ignore silently (face detection replaces it)
if (args[i + 1] && !args[i + 1].startsWith('--')) i++;
} else {
positional.push(args[i]);
}
}
const inputFile = positional[0];
const outputFile = positional[1];
const inputPath = resolve(inputFile);
const outputPath = resolve(outputFile || inputPath.replace(/\.png$/, '-head.png'));
console.log(`Cropping head from: ${basename(inputPath)} (padding: ${facePadding})`);
// --- Load face-api model ---
const MODEL_DIR = join(dirname(require.resolve('@vladmandic/face-api')), '..', 'model');
async function initFaceApi() {
// Monkey-patch the environment for face-api (needs canvas globals)
faceapi.env.monkeyPatch({
Canvas: canvasPkg.Canvas,
Image: canvasPkg.Image,
ImageData: canvasPkg.ImageData,
createCanvasElement: () => canvasPkg.createCanvas(1, 1),
createImageElement: () => new canvasPkg.Image(),
});
await faceapi.nets.ssdMobilenetv1.loadFromDisk(MODEL_DIR);
console.log(' Face detection model loaded');
}
// --- Face detection ---
async function detectFace(imagePath) {
const img = await canvasPkg.loadImage(imagePath);
const canvas = canvasPkg.createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const detection = await faceapi.detectSingleFace(
canvas,
new faceapi.SsdMobilenetv1Options({ minConfidence: 0.3 })
);
if (!detection) return null;
const box = detection.box;
return {
x: Math.round(box.x),
y: Math.round(box.y),
width: Math.round(box.width),
height: Math.round(box.height),
score: detection.score,
};
}
// --- Bounding box fallback (from original crop-head.mjs) ---
async function fallbackBboxCrop(inputPath) {
console.log(' No face detected — using bounding-box fallback');
const headRatio = 0.45; // generous default
const image = sharp(inputPath);
const { width, height } = await image.metadata();
const { data } = await image.raw().ensureAlpha().toBuffer({ resolveWithObject: true });
let minX = width, maxX = 0, minY = height, maxY = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const alpha = data[(y * width + x) * 4 + 3];
if (alpha > 10) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const figureW = maxX - minX;
const figureH = maxY - minY;
const centerX = minX + figureW / 2;
console.log(` Figure bounds: ${figureW}x${figureH} at (${minX},${minY})`);
const headH = Math.round(figureH * headRatio);
const headW = Math.round(headH * 0.9);
const pad = Math.round(headW * 0.15);
const cropX = Math.max(0, Math.round(centerX - headW / 2) - pad);
const cropY = Math.max(0, minY - pad);
const cropW = Math.min(width - cropX, headW + pad * 2);
const cropH = Math.min(height - cropY, headH + pad * 2);
return { cropX, cropY, cropW, cropH, width, height };
}
// --- Trim pass: remove excess transparent border, re-add padding ---
async function trimAndRepad(buffer) {
const trimImage = sharp(buffer);
const trimMeta = await trimImage.metadata();
const { data: trimData } = await trimImage.raw().ensureAlpha().toBuffer({ resolveWithObject: true });
let tMinX = trimMeta.width, tMaxX = 0, tMinY = trimMeta.height, tMaxY = 0;
for (let y = 0; y < trimMeta.height; y++) {
for (let x = 0; x < trimMeta.width; x++) {
const alpha = trimData[(y * trimMeta.width + x) * 4 + 3];
if (alpha > 10) {
if (x < tMinX) tMinX = x;
if (x > tMaxX) tMaxX = x;
if (y < tMinY) tMinY = y;
if (y > tMaxY) tMaxY = y;
}
}
}
const contentW = tMaxX - tMinX + 1;
const contentH = tMaxY - tMinY + 1;
const padX = Math.round(contentW * 0.05);
const padY = Math.round(contentH * 0.05);
const finalX = Math.max(0, tMinX - padX);
const finalY = Math.max(0, tMinY - padY);
const finalW = Math.min(trimMeta.width - finalX, contentW + padX * 2);
const finalH = Math.min(trimMeta.height - finalY, contentH + padY * 2);
console.log(` Trimmed: ${finalW}x${finalH} (content: ${contentW}x${contentH})`);
return sharp(buffer)
.extract({ left: finalX, top: finalY, width: finalW, height: finalH })
.toBuffer();
}
// --- Main ---
await initFaceApi();
const { width: imgWidth, height: imgHeight } = await sharp(inputPath).metadata();
const face = await detectFace(inputPath);
let croppedBuffer;
if (face) {
console.log(` Face detected: ${face.width}x${face.height} at (${face.x},${face.y}) [confidence: ${(face.score * 100).toFixed(1)}%]`);
// Add padding around the face box
const padX = Math.round(face.width * facePadding);
const padY = Math.round(face.height * facePadding);
const cropX = Math.max(0, face.x - padX);
const cropY = Math.max(0, face.y - padY);
const cropRight = Math.min(imgWidth, face.x + face.width + padX);
const cropBottom = Math.min(imgHeight, face.y + face.height + padY);
const cropW = cropRight - cropX;
const cropH = cropBottom - cropY;
console.log(` Crop region: ${cropW}x${cropH} at (${cropX},${cropY})`);
croppedBuffer = await sharp(inputPath)
.extract({ left: cropX, top: cropY, width: cropW, height: cropH })
.toBuffer();
} else {
// Fallback: bounding box heuristic
const { cropX, cropY, cropW, cropH } = await fallbackBboxCrop(inputPath);
console.log(` Fallback crop: ${cropW}x${cropH} at (${cropX},${cropY})`);
croppedBuffer = await sharp(inputPath)
.extract({ left: cropX, top: cropY, width: cropW, height: cropH })
.toBuffer();
}
// Trim pass — remove excess transparent border
const finalBuffer = await trimAndRepad(croppedBuffer);
await sharp(finalBuffer).toFile(outputPath);
console.log(`${basename(outputPath)}`);
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* process-head.mjs Strip background from a head/face image using ML.
*
* Uses @imgly/background-removal-node (ISNet ONNX model) to produce a
* clean transparent PNG. No halos, no color threshold hacks.
*
* Usage:
* node scripts/process-head.mjs <input> [output]
*
* Examples:
* node scripts/process-head.mjs public/assets/trump-head.png
* node scripts/process-head.mjs photo.jpg public/assets/cleaned.png
*
* If output is omitted, overwrites the input file.
* First run downloads ~40 MB of model files (cached for future runs).
*/
import { removeBackground } from '@imgly/background-removal-node';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve, basename } from 'node:path';
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: node scripts/process-head.mjs <input> [output]');
process.exit(1);
}
const inputPath = resolve(args[0]);
const outputPath = resolve(args[1] || inputPath);
console.log(`Processing: ${basename(inputPath)}`);
console.log('(First run downloads ~40 MB model — cached after that)\n');
const startTime = Date.now();
try {
// Read source image — detect MIME from magic bytes
const inputBuffer = await readFile(inputPath);
let mime = 'image/png';
if (inputBuffer[0] === 0xFF && inputBuffer[1] === 0xD8) mime = 'image/jpeg';
else if (inputBuffer[0] === 0x89 && inputBuffer[1] === 0x50) mime = 'image/png';
else if (inputBuffer[0] === 0x52 && inputBuffer[1] === 0x49) mime = 'image/webp';
const inputBlob = new Blob([inputBuffer], { type: mime });
console.log(` Detected: ${mime} (${(inputBuffer.length / 1024).toFixed(0)} KB)`);
// Run ML background removal
const resultBlob = await removeBackground(inputBlob, {
model: 'medium', // Good balance of quality/speed (~80 MB)
output: {
format: 'image/png',
quality: 1.0,
type: 'foreground', // Keep the person, remove everything else
},
progress: (key, current, total) => {
if (total > 0) {
const pct = Math.round((current / total) * 100);
process.stdout.write(`\r ${key}: ${pct}%`);
if (current >= total) process.stdout.write('\n');
}
},
});
// Write transparent PNG
const resultBuffer = Buffer.from(await resultBlob.arrayBuffer());
await writeFile(outputPath, resultBuffer);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\nDone in ${elapsed}s → ${basename(outputPath)} (${(resultBuffer.length / 1024).toFixed(0)} KB)`);
} catch (err) {
console.error('Background removal failed:', err.message);
process.exit(1);
}