Готовые решения типовых задач. Примеры взяты из живой игры (apps/game).
Смотри apps/game/src/scenes/LocationScene.ts. Скелет:
export class LocationScene implements Scene {
constructor(engine: Engine) {
const data = loadMap(); // buildMap() или parseMap(json)
const map = new IsometricTileMap(data, tileTextures(), DEFAULT_ISO);
engine.renderer.worldRoot.addChild(map.view);
engine.camera.bounds = map.worldBounds; // границы камеры в юнитах
engine.camera.deadZonePx = { width: 180, height: 120 };
// центр стартового тайла в юнитах — источник истины для движения
const u = tileToWorld(tx, ty);
engine.camera.snap(u.x, u.y);
}
update(dt: number) {
// ... движение героя ...
engine.camera.follow(hero.pos.x, hero.pos.y); // каждый тик за героем
}
}
// в update сцены:
const pointer = engine.input.getPointer();
if (pointer.justPressed) {
// виртуальные px указателя -> юниты (учёт камеры)
const w = screenToWorld(
pointer.x - engine.renderer.worldRoot.position.x,
pointer.y - engine.renderer.worldRoot.position.y
);
const target = worldToTile(w.x, w.y, map.width, map.height);
if (target) {
this.path = findPath(map, hero.tile, target);
}
}
// следование пути с фиксированной скоростью:
if (this.path && this.path.length > 0) {
const next = this.path[0];
const w = tileToWorld(next.x, next.y);
// двигаем позицию (юниты) к w, по достижении — hero.tile = next, path.shift()
}
Полная версия с анимацией ходьбы и поворотами — apps/game/src/systems/PlayerController.ts.
// данные NPC
const NPCS = [
{
id: 'elder', name: 'Ирвин', tile: { x: 12, y: 10 },
sprite: 'elder_irwin',
dialogueFirst: 'elder_first', // ключ графа
dialogueRepeat: 'elder_repeat',
flagKey: 'met_elder'
}
];
// клик по тайлу NPC:
const npc = this.npcs.find((n) => n.def.tile.x === clicked.x && clicked.y === n.def.tile.y);
if (npc) {
const met = gameState.hasFlag(npc.def.flagKey);
if (!met) gameState.setFlag(npc.def.flagKey);
runner.start(graphs[met ? npc.def.dialogueRepeat : npc.def.dialogueFirst]);
}
Квест = флаги GameState + проверки в диалогах (when/whenNot) + действия при завершении диалога:
// В графе: NPC выдаёт квест выбором с setFlags: ['quest_bells_taken'].
// Сдача квеста — узел с when: ['quest_bells_taken'], выбор с
// setFlags: ['quest_bells_done'], clearFlags: ['quest_bells_taken'].
runner.onFinish = (graph) => {
if (graph === graphs['elder_first'] && gameState.hasFlag('quest_bells_done')) {
gameState.setVar('gold', gameState.getNumber('gold') + 30);
engine.events.emit('quest:done', { quest: 'bells' });
}
};
Состояние всех квестов живёт в GameState и автоматически попадает в сейв.
import { SpriteAnimator } from '@rpg/engine';
const sprite = new Sprite(walkFrames.down[0]);
sprite.anchor.set(0.5, 1); // ноги в центре тайла
// именованные клипы; кадры можно взять и из атласа с animations:
// assets.animation('chars/hero_sheet.json', 'hero_walk_down')
const animator = new SpriteAnimator(sprite, {
walk_down: { frames: walkFrames.down, fps: 6 },
walk_up: { frames: walkFrames.up, fps: 6 },
walk_side: { frames: walkFrames.side, fps: 6 },
idle: { frames: [walkFrames.down[0]], loop: 'once' }
}, 'idle');
// при движении — каждый тик (тот же клип без restart просто продолжается):
animator.play(moving ? `walk_${dir}` : 'idle');
// при остановке — явный 'idle': уходит «застыл на произвольном кадре».
// направление right — зеркалим (флип — зона владельца спрайта):
sprite.scale.x = -1;
Аниматор добавьте в engine.fx — тикать вручную не нужно.
const ash = new ParticleEmitter({
color: 0x666677, rate: 6, lifetime: [3, 7],
velocity: { x: [-8, 8], y: [-4, 4] },
size: 1, spawnArea: { width: 480, height: 270 }, seed: 7
});
ash.position.set(240, 135);
engine.renderer.worldRoot.addChild(ash);
engine.fx.add(ash); // тик — движок, ручной update не нужен
exit() { ash.destroy({ children: true }); } // сам выпадает из engine.fx
Мотыльки — тёплые цвета (colors: [0xf0d878, 0xd8b050]), blend: 'add', wobble: 8 (дрейф по синусу), fadeIn: 0.5, acceleration: { y: 1.5 }.
Колышущиеся цветы, парящие сгустки, пульсирующие искры — процедурные оживители SpriteMotion (см. anim.md):
// Колокольчик качается; seed по координатам — кусты не качаются синхронно
const sway = new SpriteMotion(flowerView,
{ sway: { amplitude: 0.06, period: 2.2 } }, { seed: tx * 31 + ty });
engine.fx.add(sway);
// Сгусток парит (bob — только для «невесомых», не укоренённых!)
const bob = new SpriteMotion(wispView, { bob: { amplitude: 2, period: 1.6 } });
engine.fx.add(bob);
// Колокол качается только при звоне — params живые:
bellMotion.params = { sway: { amplitude: 0.1, period: 0.8 } }; // во время звона
bellMotion.params = { sway: { amplitude: 0.02, period: 3 } }; // после
Правило: bob — парящее (сгустки, духи), sway — укоренённое (цветы, трава), pulse/blink — свечение и акценты. Позиционируйте view до создания SpriteMotion (база снимается в конструкторе).
map.setTileAnimation(TILES.WATER, [
assets.texture('tiles/water_1'),
assets.texture('tiles/water_2')
], 2); // 2 кадра/сек — спокойная вода
update(dt) { map.update(dt); } // один таймлайн на все клетки воды
const boom = ParticleEmitter.oneShot(14, {
color: 0x999988, lifetime: [0.2, 0.5],
radialSpeed: [40, 90], size: 2, drag: 3, scaleOverLife: 'shrink', seed: n
});
boom.position.set(...worldToScreen(tx, ty));
depthLayer.addFx(boom, tx, ty); // +0.5: поверх актёра на этом тайле
engine.fx.add(boom); // тик и самоуничтожение — сами
Смотри apps/game/src/scenes/MenuScene.ts: MenuList + advance/up/down действия. Кнопки обрабатывают мышь сами; клавиатуру двигает сцена. Наведение мыши и фокус клавиатуры совмещены: Button.focused подсвечивает как фокус, так и hover.
void engine.scenes.replace(new LocationScene(engine), { duration: 0.4 });
// push/pop/replace принимают { duration, color }
Во время перехода ввод в сцену лучше игнорировать:
update(dt: number) {
if (engine.scenes.transitioning) return;
// ...
}
// выход в меню:
saves.save('autosave', {
pos: hero.tile,
state: gameState.serialize(),
savedAt: Date.now()
} satisfies SaveData);
void engine.scenes.replace(new MenuScene(engine), { duration: 0.3 });
// продолжить:
const data = saves.load<SaveData>('autosave');
if (data) {
gameState.load(data.state);
const scene = new LocationScene(engine, data.pos);
await engine.scenes.replace(scene, { duration: 0.3 });
}
Смотри assets-audio-save.md: Settings + onChange → audio.setBusVolume. Экран настроек — просто settings.update({ music: slider.value }).