import type { Vec2 } from '@rpg/engine';
import type { ItemId } from './items';
import type { AreaId } from './locations';
import { FLAGS, VARS } from './ids';
/**
* Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы
* у троп, подборные сгустки, рычаги. Первая подходящая реакция (по `when`)
* срабатывает; эффекты применяет рантайм systems/Interactables.ts через sink.
*/
/** Условие реакции: все указанные поля должны выполняться. */
export interface InteractWhen {
/** Флаг поднят. */
flag?: string;
/** Флаг не поднят. */
notFlag?: string;
/** Вар не меньше значения. */
varAtLeast?: { id: string; value: number };
}
/** Реакция объекта: текст (тост) + необязательные эффекты. */
export interface InteractResponse {
/** Первая реакция с выполненным условием срабатывает (без when — всегда). */
when?: InteractWhen;
text: string;
setFlags?: string[];
clearFlags?: string[];
setVar?: { id: string; value: number };
/** Прибавить к вару (подбор мотов: +1 за каждый нарост). */
addVar?: { id: string; by: number };
/** Выдать предмет (в сумку). */
gives?: ItemId;
/** Сколько штук gives (по умолчанию 1). */
count?: number;
/** Ключ звука (sfx/...). */
sound?: string;
}
/** Вьюха объекта из ассетов: если задана — рисуется вместо плейсхолдера Graphics. */
export interface InteractableView {
/** Ключ текстуры в ассетах (tiles/…, chars/…). */
tex: string;
/** Якорь по Y в пикселях кадра от низа (по умолчанию низ спрайта). */
anchorY?: number;
}
/** Объект мира, с которым герой взаимодействует кликом. */
export interface InteractableDef {
id: string;
/** Позиция в мировых юнитах (центр тайла = координата + 0.5). */
pos: Vec2;
/** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */
tile: { x: number; y: number };
kind: 'examine' | 'pickup' | 'container' | 'lever' | 'signpost';
/** Вьюха из ассетов (без неё — плейсхолдер Graphics по kind). */
view?: InteractableView;
/** Подпись при близости (и в снапшоте). */
label?: string;
/** Одноразовый: помечается флагом used:<id> и больше не реагирует. */
once?: boolean;
/** Без предмета — отказ с lockedText. */
requiresItem?: ItemId;
lockedText?: string;
responses: InteractResponse[];
}
/** Что резолверу нужно знать о герое и мире (подставляется из GameState/сумки). */
export interface InteractCtx {
hasFlag(flag: string): boolean;
hasItem(id: ItemId): boolean;
getVar(id: string): number;
/** Одноразовый объект уже использован (флаг used:<id>). */
used(id: string): boolean;
}
/** Результат взаимодействия: реакция / отказ (заперто) / ничего (пусто). */
export type InteractPick =
| { ok: true; response: InteractResponse }
| { ok: false; lockedText: string }
| null;
/** Условие when выполнено (все указанные поля). */
function matches(when: InteractWhen | undefined, ctx: InteractCtx): boolean {
if (!when) return true;
if (when.flag !== undefined && !ctx.hasFlag(when.flag)) return false;
if (when.notFlag !== undefined && ctx.hasFlag(when.notFlag)) return false;
if (when.varAtLeast && ctx.getVar(when.varAtLeast.id) < when.varAtLeast.value) return false;
return true;
}
/**
* Чистый выбор реакции: первая подходящая по `when`; одноразовые объекты
* после использования молчат (null); requiresItem без предмета — отказ.
*/
export function resolveInteractable(def: InteractableDef, ctx: InteractCtx): InteractPick {
if (def.requiresItem && !ctx.hasItem(def.requiresItem)) {
return { ok: false, lockedText: def.lockedText ?? 'Ничего не выйдет.' };
}
if (def.once && ctx.used(def.id)) return null;
const response = def.responses.find((r) => matches(r.when, ctx));
if (!response) return null;
return { ok: true, response };
}
/** Контент по областям (см. docs/world.md): интерьеры и улицы (pos — первичен). */
const INTERACTABLES_RAW: Partial<Record<AreaId, Omit<InteractableDef, 'tile'>[]>> = {
// --- луга: столбы у троп и подборные пепельные моты (зачаток экономики) ---
meadows: [
{
id: 'sign_meadows_east',
pos: { x: 25.5, y: 13.5 },
kind: 'signpost',
label: 'Столб у тропы',
responses: [
{
text: '«Звенец» — стёрто, но различимо. Дальше на восток: иди на звон.',
sound: 'sfx/ui_click'
}
]
},
{
id: 'sign_meadows_west',
pos: { x: 2.5, y: 3.5 },
kind: 'signpost',
label: 'Столб у тропы',
responses: [
{
text: '«Серые пруды» — стрелка на северо-запад. Ниже приписка: «туман лжёт».',
sound: 'sfx/ui_click'
}
]
},
{
id: 'mote_1',
pos: { x: 11.5, y: 14.5 },
kind: 'pickup',
label: 'Пепельный мот',
once: true,
responses: [
{
text: 'Сгусток серой земли с тёплой искрой. Мот — то, ради чего сажают колокольчики.',
gives: 'mote',
addVar: { id: VARS.motes, by: 1 },
sound: 'sfx/ui_click'
}
]
},
{
id: 'mote_2',
pos: { x: 14.5, y: 20.5 },
kind: 'pickup',
label: 'Пепельный мот',
once: true,
responses: [
{
text: 'Ещё один мот. Земля здесь отдаёт их неохотно.',
gives: 'mote',
addVar: { id: VARS.motes, by: 1 },
sound: 'sfx/ui_click'
}
]
},
{
id: 'mote_3',
pos: { x: 14.5, y: 6.5 },
kind: 'pickup',
label: 'Пепельный мот',
once: true,
responses: [
{
text: 'Третий мот за день. Сумка тихо позвякивает.',
gives: 'mote',
addVar: { id: VARS.motes, by: 1 },
sound: 'sfx/ui_click'
}
]
}
],
ponds: [
{
id: 'sign_ponds',
pos: { x: 3.5, y: 2.5 },
kind: 'signpost',
label: 'Столб у берега',
responses: [
{
text: '«Не дышать накатом» — буквы вросли в ржавчину. Кто-то дописал: «голос дороже соли».',
sound: 'sfx/ui_click'
}
]
}
],
zvenets: [
{
id: 'sign_zvenets',
pos: { x: 3.5, y: 9.5 },
kind: 'signpost',
label: 'Столб у входа',
responses: [
{
text: '«Звенец. Мастерская мира». Ниже чужим почерком: «кузня чинит резонаторы, лавка меняет соль».',
sound: 'sfx/ui_click'
}
]
},
{
id: 'bell_rope',
pos: { x: 13.5, y: 7.5 },
kind: 'lever',
label: 'Верёвка колокола',
responses: [
{
when: { notFlag: FLAGS.rang_tower },
text: 'Верёвка в ладони. Колокол качнулся — низкий тон ушёл в землю. Кто-то услышит.',
setFlags: [FLAGS.rang_tower],
sound: 'sfx/bell_low'
},
{
text: 'Колокол качнулся снова. Звон тот же: пол-тона ниже, чем в звонной книге.',
sound: 'sfx/bell_low'
}
]
}
],
house_elder: [
{
id: 'chest_elder',
pos: { x: 3.5, y: 3.5 },
kind: 'container',
label: 'Сундук',
once: true,
responses: [
{
text: 'В сундуке — запас вощёного полотна.',
gives: 'cloth',
sound: 'sfx/ui_click'
}
]
},
{
id: 'hearth_elder',
pos: { x: 8.5, y: 0.5 },
kind: 'examine',
label: 'Очаг',
responses: [
{
when: { flag: FLAGS.quest_bells_done },
text: 'Очаг горит ровнее. Пепел отступает — и тепло возвращается.',
sound: 'sfx/bell_low'
},
{ text: 'В очаге тлеют щепки. Тепло — редкость теперь.', sound: 'sfx/ash_hiss' }
]
},
{
id: 'note_elder',
pos: { x: 10.5, y: 4.5 },
kind: 'examine',
label: 'Записка',
once: true,
responses: [
{
text: '«Тоны уходят вниз. Если колокол смолкнет — не буди Молчание».',
setFlags: [FLAGS.read_note],
sound: 'sfx/ui_click'
}
]
}
],
shop: [
{
id: 'counter_shop',
pos: { x: 5.5, y: 3.5 },
kind: 'examine',
label: 'Прилавок',
responses: [
{
when: { flag: FLAGS.quest_bells_done },
text: 'На прилавке — соль и цветы в плошке. Поляна гудит — Мила звонит чаще.',
sound: 'sfx/bell_hit'
},
{
text: 'Соль, вощёное полотно, вода в бутлях. Цен Мила не пишет — звонит.',
sound: 'sfx/bell_hit'
}
]
}
]
};
/** Тайл привязки вычисляется из pos при загрузке модуля. */
export const INTERACTABLES: Partial<Record<AreaId, InteractableDef[]>> = Object.fromEntries(
Object.entries(INTERACTABLES_RAW).map(([area, defs]) => [
area,
defs.map((d) => ({ ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } }))
])
);