import { Container, FxLayer, Graphics, PixelText, Sprite, SpriteAnimator, SpriteMotion, type AssetLoader, type Updatable } from '@rpg/engine';
import type { InteractableDef } from '../data/interactables';
import type { NpcDef } from '../data/npcs';
/**
* Вьюхи NPC и интерактивных объектов: фабрики спрайтов/плейсхолдеров с
* подписями + растворение использованных объектов. Сцена только вызывает
* фабрики и держит слои глубины.
*/
/** Вьюха интерактивного объекта + её части (тело — для кликов, подпись — близость). */
export interface InteractableView {
def: InteractableDef;
view: Container;
body: Graphics | Sprite;
label: PixelText;
}
export interface InteractableViewDeps {
assets: AssetLoader;
/** Реестр тикающихся (engine.fx): пульс мота и idle-анимация NPC. */
addFx(u: Updatable): void;
/** Слой мировых эффектов: растворение использованной вьюхи. */
fx: FxLayer;
}
/** Экранный центр ромба тайла (worldToScreen ∘ tileToWorld). */
export type TileCenter = (tx: number, ty: number) => { x: number; y: number };
export class InteractableViews {
/** Созданные вьюхи интерактивов (подписи, клики по телу, растворение). */
readonly items: InteractableView[] = [];
constructor(private deps: InteractableViewDeps) {}
/** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */
createInteractableView(def: InteractableDef, center: TileCenter): InteractableView {
const view = new Container();
const p = center(def.tile.x, def.tile.y);
view.position.set(p.x, p.y);
let g: Graphics | Sprite;
if (def.view?.tex) {
// Настоящий арт из def.view: якорь низом в центр ромба (или задан).
const sprite = new Sprite(this.deps.assets.texture(def.view.tex));
sprite.anchor.set(0.5, def.view.anchorY ?? 1);
g = sprite;
} else {
// Плейсхолдер-ящик по роду (пока нет арта конкретного объекта).
g = new Graphics();
switch (def.kind) {
case 'signpost': // столбик с доской
g.rect(-1, -10, 2, 10).fill(0x4a3b2a);
g.rect(-6, -14, 12, 5).fill(0x5c4a33);
g.rect(-6, -14, 12, 1).fill(0x8a6d3a);
break;
case 'lever': // рычаг: основание и наклонная рукоять
g.rect(-3, -2, 6, 2).fill(0x4a3b2a);
g.rect(0, -8, 2, 7).fill(0x8a6d3a);
g.rect(-2, -10, 4, 2).fill(0x8a6d3a);
break;
case 'pickup': // пепельная насыпь, из которой теплится мот
g.rect(-6, -2, 12, 3).fill(0x3a3a3a);
g.rect(-4, -4, 8, 2).fill(0x4a4a4a);
g.rect(-2, -5, 4, 1).fill(0x5a5a5a);
break;
default: // контейнер/предмет осмотра: ящик с бронзовой кромкой
g.rect(-5, -12, 10, 12).fill(0x4a3b2a);
g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо
}
}
view.addChild(g);
// У мота — тёплая искра поверх насыпи: заметный пульс видно издалека.
if (def.kind === 'pickup') {
const spark = new Graphics();
spark.rect(-1, -1, 3, 3).fill(0xf0d878);
spark.position.set(0, -5);
view.addChild(spark);
this.deps.addFx(
new SpriteMotion(spark, { pulse: { min: 0.3, max: 1, period: 1.1 } }, {
seed: def.tile.x * 31 + def.tile.y
})
);
}
const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a });
label.anchor.set(0.5, 1);
label.position.set(0, -16);
label.visible = false;
view.addChild(label);
const made = { def, view, body: g, label };
this.items.push(made);
return made;
}
/**
* Одноразовый объект поднят (sink.onConsumed): вьюха растворяется, из
* тайла вылетают пеплинки — состояние мира меняется на глазах.
*/
consume(def: InteractableDef, center: TileCenter, burst: (s: { x: number; y: number }) => void): void {
const iv = this.items.find((v) => v.def.id === def.id);
if (!iv) return;
this.items.splice(this.items.indexOf(iv), 1);
const w = center(def.tile.x, def.tile.y);
burst(w);
// Вьюха тает и удаляется сама (пресет; removeChild+destroy внутри).
this.deps.fx.fade(iv.view, { duration: 0.5 });
}
/** NPC: спрайт с idle-дыханием кадрами атласа + маркер «с ним можно говорить». */
createNpcView(def: NpcDef, center: TileCenter): { view: Container; body: Sprite } {
const view = new Container();
const p = center(def.tile.x, def.tile.y);
view.position.set(p.x, p.y);
const sprite = new Sprite();
sprite.anchor.set(0.5, 1); // ноги в центре ромба
// idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC).
const idle = this.deps.assets.animation(
def.atlas ?? 'chars/npcs_sheet.json',
`${def.sprite}_idle`
);
sprite.texture = idle[0]!;
const animator = new SpriteAnimator(
sprite,
{ idle: { frames: idle, fps: 1.2 } },
'idle',
{ randomPhase: def.tile.y * 64 + def.tile.x }
);
this.deps.addFx(animator);
view.addChild(sprite);
// Маркер «с ним можно говорить»: восклицательный штрих над головой
// (высота — от реального кадра атласа, не от захардкоженной).
const frameH = idle[0]!.height;
const marker = new Graphics();
marker.rect(-1, -(frameH + 3), 2, 4).fill(0xf0d878);
marker.rect(-1, -(frameH - 3), 2, 2).fill(0xf0d878);
view.addChild(marker);
return { view, body: sprite };
}
}