diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index c829d6c..3e2e10a 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -782,13 +782,15 @@ // Новый тост вытесняет старый (раньше наслаивались). this.uiFx.clear(); const hold = readSeconds(text); // базовые 1.4 с + ~45 мс на символ, не дольше 6 - this.uiFx.floatText(text, { x: 240, y: 40 }, { + // y ниже строки «Цель:» (LocationHud) — тост и трекер не накладываются. + this.uiFx.floatText(text, { x: 240, y: 56 }, { duration: hold + 0.75, hold, fadeIn: 0.25, fadeOut: 0.5, rise: 8, - style: { size: 11, color: 0xd8c79a } + // Перенос по ширине экрана: длинный тост в 2 строки, не за края. + style: { size: 11, color: 0xd8c79a, wordWrapWidth: 440 } }); } diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index cdaf045..abb62fe 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -24,7 +24,7 @@ /** Порог прибытия в waypoint (юниты): меньше шага любого кадра — на глаз незаметно. */ const ARRIVE_EPS = 1e-4; -/** Кадры героя по направлениям. side смотрит влево; вправо — флип. */ +/** Кадры героя по направлениям. side смотрит вправо; влево — флип. */ export interface HeroTextures { down: [Texture, Texture]; up: [Texture, Texture]; @@ -249,7 +249,7 @@ private setFacing(facing: Facing): void { if (facing === this.facing) return; this.facing = facing; - this.sprite.scale.x = facing === 'right' ? -1 : 1; + this.sprite.scale.x = facing === 'left' ? -1 : 1; // Имя клипа ходьбы зависит только от facing: кэш вместо строки каждый тик. this.facingClip = `walk_${facing === 'left' || facing === 'right' ? 'side' : facing}`; } diff --git a/docs/art-style.md b/docs/art-style.md index 6430517..5929e9a 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -146,7 +146,7 @@ |---|---|---| | `chars/hero_down_1.png`, `hero_down_2.png` | ходьба вниз | звонарь: плащ C0/C1, шляпа T0, колокольчик B0 | | `chars/hero_up_1.png`, `hero_up_2.png` | ходьба вверх | спина: узел плаща, лампа | -| `chars/hero_side_1.png`, `hero_side_2.png` | ходьба влево | вправо — горизонтальный флип | +| `chars/hero_side_1.png`, `hero_side_2.png` | ходьба вправо | влево — горизонтальный флип | | `chars/elder_irwin.png` | idle | длинный плащ T0/T1, борода S0, стилос | | `chars/trader_mila.png` | idle | платок W3, тележка-ручка, ручной колокольчик B1 | | `chars/fauna_deer_1.png`, `fauna_deer_2.png` | стоит/шаг | безгласный олень: серо-оливковый G1/G2, рога M2, без тёплых пикселей | diff --git a/docs/engine/practices.md b/docs/engine/practices.md index c885c4d..aefe4cf 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -94,6 +94,13 @@ `s.dialogue.waitingForChoice` перед каждым press и останавливаться. Выбор в сценарии — `pickChoiceByText('текст')`, не по индексу. +Панель/текст, зависящий от контента: вьюху (панель диалога, кнопку, тост) +размечай по **полному** тексту, а не по напечатанному остатку — при +typewriter пустая строка схлопывает панель, и текст печатается поверх мира. +Измерение — временно поставить полный текст, снять `height`, вернуть +состояние (см. `DialogueBox.show`); перенос длинных строк — `wordWrapWidth` +в `PixelText`, а не ручная нарезка. + Условия ветки: `when`/`whenNot` — флаги, `whenVars` — несколько переменных (AND), `hasItem` — предметы. Предметы раннер знает только через `DialogueWorld` (игра передаёт `setHooks({ world })`) — без него diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index ed604e5..431c1c2 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -292,7 +292,10 @@ ## DialogueBox Готовая нижняя панель для реплик (рисует имя, текст, варианты, подсказку -«далее»). Высота панели растёт от контента вверх. Опции v2: +«далее»). Высота панели растёт от контента вверх. При typewriter панель +сразу строится под **полный** текст (место под «далее ▸» резервируется +заранее) — печать идёт внутри готовой панели, ничего не прыгает и не +печатается поверх мира. Имя и текст переносятся по ширине панели. Опции v2: ```ts import { DialogueBox } from '@rpg/engine'; diff --git a/packages/engine/src/ui/Button.ts b/packages/engine/src/ui/Button.ts index e0cf26f..3d4f57b 100644 --- a/packages/engine/src/ui/Button.ts +++ b/packages/engine/src/ui/Button.ts @@ -52,11 +52,14 @@ this.onSelect = options.onSelect ?? null; this.bg = new Graphics(); + // Защита от длинной метки: перенос по ширине кнопки (иначе текст + // вылезает за рамку; двухстрочная метка в высокой кнопке читаема). this.labelText = new PixelText({ text: options.label, size: this.size, color: this.disabled ? DISABLED_COLORS.text : STATE_COLORS.normal.text, - align: options.value !== undefined ? 'left' : 'center' + align: options.value !== undefined ? 'left' : 'center', + wordWrapWidth: this.w - 16 }); if (options.value !== undefined) { // С value метка прижата влево, значение — к правому краю. diff --git a/packages/engine/src/ui/DialogueBox.ts b/packages/engine/src/ui/DialogueBox.ts index 1ac7969..11fa1e1 100644 --- a/packages/engine/src/ui/DialogueBox.ts +++ b/packages/engine/src/ui/DialogueBox.ts @@ -81,7 +81,13 @@ this.view.eventMode = 'static'; this.panel = new Graphics(); - this.nameText = new PixelText({ text: '', size: this.size, color: 0xf0d878 }); + // Имя — с переносом: длинное имя говорящего не вылезет за панель. + this.nameText = new PixelText({ + text: '', + size: this.size, + color: 0xf0d878, + wordWrapWidth: options.width - options.margin * 4 - 10 + }); this.bodyText = new PixelText({ text: '', size: this.size, @@ -120,10 +126,12 @@ // печать с начала; без typewriter — весь текст сразу this.revealElapsed = 0; this.revealingNow = this.cps > 0 && this.fullText.length > 0; - if (this.revealingNow) this.bodyText.text = ''; - else this.bodyText.text = this.fullText; - + // Панель сразу полной высоты: измеряем полный текст и строим панель, + // только потом прячем текст в печать — иначе relayout видит пустую + // строку, панель схлопывается и текст печатается поверх мира. + this.bodyText.text = this.fullText; this.relayout(); + if (this.revealingNow) this.bodyText.text = ''; } /** Печать и анимации; звать из update сцены при typewriter (dt в секундах). */ @@ -218,8 +226,10 @@ const w = this.width; const h = this.height; const m = this.margin; - const hintVisible = this.choiceViews.length === 0 && !this.revealingNow; - this.hintText.visible = hintVisible; + // Место под «далее ▸» резервируется всегда (без выбора) — панель не + // прыгает в конце печати; сам текст виден только после печати. + const withHint = this.choiceViews.length === 0; + this.hintText.visible = withHint && !this.revealingNow; this.panel.clear(); const pad = 6; @@ -227,7 +237,7 @@ const bodyH = this.fullText || this.bodyText.text ? this.bodyText.height : 0; let choicesH = 0; for (const c of this.choiceViews) choicesH += c.label.height + 3; - const hintH = hintVisible ? this.hintText.height + 2 : 0; + const hintH = withHint ? this.hintText.height + 2 : 0; const panelH = Math.round(pad * 2 + nameH + bodyH + choicesH + hintH); const top = h - m - panelH; @@ -245,7 +255,7 @@ c.label.position.set(m + 5, y); y += c.label.height + 3; } - if (hintVisible) { + if (withHint) { this.hintText.position.set(w - m - 5 - this.hintText.width, y + 2); } }