关于 promo-video
为 Phaser 游戏项目提供宣传视频录制流程,面向需要制作营销或社交媒体素材的开发者。它指导按游戏控制生成输入、避免游戏结束,并将录制素材转换为高帧率竖屏 MP4。
主要功能
- 01分析游戏代码并屏蔽失败逻辑
- 02按实际控制生成游戏输入
- 03以慢速录制并转换为 50 FPS
- 04输出竖屏 MP4 宣传视频
适用场景
- 01为社交媒体制作游戏实况片段
- 02在无需人工操作时录制连续玩法
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ playableintelligence/ game-creator --skill promo-video安装后在 Claude Code 中输入/promo-video确认已加载。
附带文件scripts/convert-highfps.sh
详细介绍
宣传视频录制
录制流畅、自主运行的 Phaser 游戏宣传素材,用于营销 / 社交媒体。输出为 50 FPS 的 MP4,竖屏手机比例(9:16)——可直接用于 TikTok、Reels、Moltbook 或 X。
技术方案
Playwright 的 recordVideo 帧率上限为 25 FPS,且没有配置选项。我们通过以下方式绕过限制:
- 将游戏速度降至 0.5×,方法是修改全部 5 个 Phaser 时间子系统
- 以 Playwright 原生的 25 FPS 录制目标时长的 2 倍
- 用 FFmpeg 加速 2× → 输出的有效帧率为 50 FPS
| 参数 | 默认值 | 效果 |
|---|---|---|
SLOW_MO_FACTOR | 0.5 | 游戏以半速运行 → 输出为 50 FPS |
WALL_CLOCK_DURATION | DESIRED_GAME_DURATION / SLOW_MO_FACTOR | 录制时长为 2×,以获得正确的游戏时间 |
VIEWPORT | { width: 1080, height: 1920 } | 9:16 手机竖屏(除非用户另有指定,否则始终为默认值) |
DESIRED_GAME_DURATION | 13000(ms) | 约 13 秒游戏时间 → 约 6.5 秒宣传片 |
前置条件
- Playwright — 必须已安装(
npm install -D @playwright/test && npx playwright install chromium) - FFmpeg — 必须位于 PATH 中(macOS 上运行
brew install ffmpeg) - 开发服务器正在运行 — 游戏必须通过 localhost 提供服务
开始前检查这两项:
npx playwright --version
ffmpeg -version | head -1
如果未找到 FFmpeg,请提醒用户并跳过宣传视频步骤(这不是阻塞项——没有 FFmpeg,游戏仍然可以运行)。
录制脚本 — 针对游戏进行适配
每个游戏都需要自定义的 scripts/capture-promo.mjs。子 Agent 必须读取游戏源文件,以确定:
1. 死亡 / 失败机制补丁(至关重要)
视频必须展示连续的游戏过程——绝不能出现游戏结束。读取 GameScene.js(或等效文件),找到死亡 / 失败方法,并通过猴子补丁将其禁用。
查找方法:搜索碰撞 / 死亡时调用的方法。常见模式包括:
this.triggerGameOver()— 躲避类游戏this.takeDamage()→this.lives <= 0— 多生命值游戏this.gameOver()— 直接调用eventBus.emit(Events.PLAYER_HIT)/eventBus.emit(Events.GAME_OVER)— 事件驱动
补丁模板(根据游戏进行适配):
await page.evaluate(() => {
const scene = window.__GAME__.scene.getScene("GameScene");
if (scene) {
// Patch ALL paths to game over
scene.triggerGameOver = () => {};
scene.onPlayerHit = () => {};
// For multi-life games, also prevent damage:
// scene.takeDamage = () => {};
// scene.playerDied = () => {};
}
});
2. 输入序列生成
视频必须展示动态、自然的游戏过程。读取游戏的输入处理逻辑,以确定:
- 按键 — ArrowLeft/ArrowRight?Space?WASD?鼠标点击?
- 输入方式 — 持续按住(移动)、点按(跳跃 / 射击),还是两者都有?
- 移动模式 — 玩家应横扫屏幕、反应式躲避,还是有节奏地跳跃?
不同游戏类型的输入模式:
| 游戏类型 | 输入按键 | 模式 |
|---|---|---|
| 横向躲避游戏 | ArrowLeft、ArrowRight | 交替按住(150-600ms),间隔时间不固定,偶尔快速连按两次 |
| 平台跳跃 / Flappy | Space | 有节奏地点按(按住 80-150ms),间隔时间不固定(200-800ms) |
| 俯视角 | WASD / Arrows | 混合方向按住,采用“8”字形移动模式 |
| 射击游戏 | ArrowLeft/Right + Space | 移动与快速连射交替进行 |
| 点击 / 点按游戏 | 鼠标点击 / Space | 快速连点,中间穿插短暂停顿 |
随机化时间间隔,避免移动看起来像机器人操作:
const holdMs = 150 + Math.floor(Math.random() * 450);
const pauseMs = 50 + Math.floor(Math.random() * 250);
在开始时暂停(1-2 秒),让入场动画先播放——这是吸引观众的关键。
3. 游戏启动检测
所有通过 /viral-game 或 /make-game 工作流构建的 Phaser 游戏都会公开以下全局变量:
window.__GAME__— Phaser.Game 实例window.__GAME_STATE__— GameState 单例window.__EVENT_BUS__— EventBus 单例
等待游戏启动并进入活跃游戏状态:
await page.waitForFunction(() => window.__GAME__?.isBooted, { timeout: 15000 });
await page.waitForFunction(() => window.__GAME_STATE__?.started, {
timeout: 10000,
});
4. 注入时间缩放
为录制降低 Phaser 的所有 5 个时间子系统的速度:
await page.evaluate(
({ factor }) => {
const game = window.__GAME__;
const scene = game.scene.getScene("GameScene");
// 1. Update delta — slows frame-delta-dependent logic
const originalUpdate = scene.update.bind(scene);
scene.update = function (time, delta) {
originalUpdate(time, delta * factor);
};
// 2. Tweens — slows all tween animations
scene.tweens.timeScale = factor;
// 3. Scene timers — slows scene.time.addEvent() timers
scene.time.timeScale = factor;
// 4. Physics — slows Arcade/Matter physics
// NOTE: Arcade physics timeScale is INVERSE (higher = slower)
if (scene.physics?.world) {
scene.physics.world.timeScale = 1 / factor;
}
// 5. Animations — slows sprite animation playback
if (scene.anims) {
scene.anims.globalTimeScale = factor;
}
},
{ factor: SLOW_MO_FACTOR },
);
5 个子系统:
- 更新增量 —
scene.update(time, delta * factor)降低依赖帧增量的逻辑速度 - 补间动画 —
scene.tweens.timeScale降低所有补间动画的速度 - 场景计时器 —
scene.time.timeScale降低scene.time.addEvent()计时器的速度 - 物理引擎 —
scene.physics.world.timeScale降低 Arcade/Matter 物理模拟的速度(使用倒数:1/factor) - 动画 —
scene.anims.globalTimeScale降低精灵动画的播放速度
5. 视频定稿
const video = page.video();
await context.close(); // MUST close context to finalize the video file
const videoPath = await video.path();
完整录制脚本模板
import { chromium } from "playwright";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_DIR = path.resolve(__dirname, "..");
// --- Config ---
const args = process.argv.slice(2);
function getArg(name, fallback) {
const i = args.indexOf(`--${name}`);
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
}
const PORT = getArg("port", "3000");
const GAME_URL = `http://localhost:${PORT}/`;
const VIEWPORT = { width: 1080, height: 1920 }; // 9:16 mobile portrait
const SLOW_MO_FACTOR = 0.5;
const DESIRED_GAME_DURATION = parseInt(getArg("duration", "13000"), 10);
const WALL_CLOCK_DURATION = DESIRED_GAME_DURATION / SLOW_MO_FACTOR;
const OUTPUT_DIR = path.resolve(PROJECT_DIR, getArg("output-dir", "output"));
const OUTPUT_FILE = path.join(OUTPUT_DIR, "promo-raw.webm");
// <ADAPT: Generate game-specific input sequence>
function generateInputSequence(totalMs) {
const sequence = [];
let elapsed = 0;
// Pause for entrance animation
sequence.push({ key: null, holdMs: 0, pauseMs: 1500 });
elapsed += 1500;
// <ADAPT: Replace with game-specific keys and timing>
const keys = ["ArrowLeft", "ArrowRight"];
let keyIdx = 0;
while (elapsed < totalMs) {
const holdMs = 150 + Math.floor(Math.random() * 450);
const pauseMs = 50 + Math.floor(Math.random() * 250);
// Occasional double-tap for variety
if (Math.random() < 0.15) {
sequence.push({ key: keys[keyIdx], holdMs: 100, pauseMs: 60 });
elapsed += 160;
}
sequence.push({ key: keys[keyIdx], holdMs, pauseMs });
elapsed += holdMs + pauseMs;
// Alternate direction (with occasional same-direction repeats)
if (Math.random() < 0.75) keyIdx = 1 - keyIdx;
}
return sequence;
}
async function captureGameplay() {
console.log("Capturing promo video...");
console.log(
` URL: ${GAME_URL} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`,
);
console.log(
` Game duration: ${DESIRED_GAME_DURATION}ms | Wall clock: ${WALL_CLOCK_DURATION}ms`,
);
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: VIEWPORT,
recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT },
});
const page = await context.newPage();
await page.goto(GAME_URL, { waitUntil: "networkidle" });
// Wait for game boot + gameplay active
await page.waitForFunction(() => window.__GAME__?.isBooted, {
timeout: 15000,
});
await page.waitForFunction(() => window.__GAME_STATE__?.started, {
timeout: 10000,
});
await page.waitForTimeout(300);
console.log(" Game active.");
// <ADAPT: Patch out death — find the actual methods from GameScene.js>
await page.evaluate(() => {
const scene = window.__GAME__.scene.getScene("GameScene");
if (scene) {
scene.triggerGameOver = () => {};
scene.onPlayerHit = () => {};
}
});
console.log(" Death patched.");
// Slow all 5 Phaser time subsystems
await page.evaluate(
({ factor }) => {
const game = window.__GAME__;
const scene = game.scene.getScene("GameScene");
const originalUpdate = scene.update.bind(scene);
scene.update = function (time, delta) {
originalUpdate(time, delta * factor);
};
scene.tweens.timeScale = factor;
scene.time.timeScale = factor;
if (scene.physics?.world) scene.physics.world.timeScale = 1 / factor;
if (scene.anims) scene.anims.globalTimeScale = factor;
},
{ factor: SLOW_MO_FACTOR },
);
console.log(` Slowed to ${SLOW_MO_FACTOR}x.`);
// Execute input sequence
const sequence = generateInputSequence(WALL_CLOCK_DURATION);
console.log(
` Playing ${sequence.length} inputs over ${WALL_CLOCK_DURATION}ms...`,
);
for (const seg of sequence) {
if (!seg.key) {
await page.waitForTimeout(seg.pauseMs);
continue;
}
await page.keyboard.down(seg.key);
await page.waitForTimeout(seg.holdMs);
await page.keyboard.up(seg.key);
if (seg.pauseMs > 0) await page.waitForTimeout(seg.pauseMs);
}
console.log(" Input complete.");
// Finalize video
const video = page.video();
await context.close();
const videoPath = await video.path();
if (videoPath !== OUTPUT_FILE) {
fs.renameSync(videoPath, OUTPUT_FILE);
}
await browser.close();
console.log(` Raw recording: ${OUTPUT_FILE}`);
console.log("Done.");
}
captureGameplay().catch((err) => {
console.error("Capture failed:", err);
process.exit(1);
});
FFmpeg 转换
录制完成后,将原始慢动作 WebM 转换为高帧率 MP4。convert-highfps.sh 脚本与此技能一同打包,位于 skills/promo-video/scripts/convert-highfps.sh。
# Copy to project (orchestrator does this)
cp <plugin-root>/skills/promo-video/scripts/convert-highfps.sh <project-dir>/scripts/
# Run conversion
bash scripts/convert-highfps.sh output/promo-raw.webm output/promo.mp4 0.5
该脚本:
- 应用
setpts,将视频加速1/factor倍 - 将输出帧率设置为
25 / factor(0.5× 慢动作时为 50 FPS) - 使用
crf 23、yuv420p和faststart编码 H.264 - 验证输出时长、帧率和文件大小
视口默认设置
除非用户明确提出其他要求,否则始终以移动端竖屏(9:16)录制。 原因:
- 游戏是在手机上玩的,宣传视频应展示真实的移动端体验
- 9:16 是 TikTok、Instagram Reels 和 YouTube Shorts 的原生比例
- 1080×1920 是标准分辨率
| 宽高比 | 视口 | 使用场景 |
|---|---|---|
| 9:16(默认) | 1080 × 1920 | 移动端竖屏 — TikTok、Reels、Shorts、Moltbook |
| 1:1 | 1080 × 1080 | 正方形 — Instagram 动态、X 帖子 |
| 16:9 | 1920 × 1080 | 横屏 — YouTube、预告片、桌面游戏 |
时长指南
| 游戏类型 | 建议时长 | 原因 |
|---|---|---|
| 街机 / 躲避类 | 10-15s | 快节奏动作,展示多轮躲避 |
| 平台跳跃类 | 15-20s | 展示跳跃时机和关卡进度 |
| 射击类 | 12-18s | 展示瞄准和敌人波次 |
| 解谜类 | 8-12s | 展示一个解谜过程 |
检查清单
开始录制前:
- 开发服务器正在运行且响应正常
- 系统已安装 FFmpeg
- 已安装带有 Chromium 的 Playwright
- 游戏启动后直接进入游戏(没有菜单阻挡)
- 已确定并修补死亡/失败方式
- 输入按键与游戏的实际控制方式一致
- 已包含入场动画暂停(1-2s)
- 输出目录已存在
录制后:
- output/ 中存在原始 WebM 文件
- FFmpeg 转换生成了有效的 MP4
- 时长约为原始录制的一半(加速有效)
- 帧率为 50 FPS
- 视频展示的是游戏画面(不是黑屏)