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>
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 130 KiB |
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
@@ -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)`);
|
||||
@@ -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)}`);
|
||||
@@ -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);
|
||||
}
|
||||