diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 140b7c7..4cb6fcc 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -43,6 +43,12 @@ /** Настройки игрока (громкости и т.п.) — вне сейвов. */ readonly settings = new Settings(window.localStorage); + /** + * Активная локация (для автосейва при закрытии/скрытии вкладки). + * null — вне локации; сумка/оверлеи поверх не мешают: локация зарегистрирована. + */ + activeLocation: { autosave(): void } | null = null; + /** Текстуры тайлов и одиночные спрайты NPC. */ static readonly ASSET_KEYS = [ 'tiles/grass', diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index b8b96af..9d0070f 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -57,6 +57,16 @@ window.addEventListener('pointerdown', unlockAudio); window.addEventListener('keydown', unlockAudio); + // Автосейв при закрытии/скрытии вкладки: localStorage.write синхронен — + // успеваем до выгрузки (активная локация знает своё состояние). + const saveOnLeave = (): void => { + game.activeLocation?.autosave(); + }; + window.addEventListener('beforeunload', saveOnLeave); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') saveOnLeave(); + }); + await engine.scenes.push(new BootScene(game)); await engine.start(); } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 3e2e10a..9ac2de6 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -234,6 +234,9 @@ onConsumed: (def) => this.interactViews.consume(def, this.tileCenter, (s) => this.combatViews.hitBurst(s)) }); for (const def of area.interactables ?? []) { + // Одноразовые использованные объекты (моты, сундуки) не рисуем: + // флаг used живёт в GameState, вьюха при повторном входе возвращаться не должна. + if (def.once && this.interactables.isUsed(def.id)) continue; const made = this.interactViews.createInteractableView(def, (tx, ty) => this.tileCenter(tx, ty)); this.actors.add(made.view, def.tile.x, def.tile.y); } @@ -468,6 +471,10 @@ this.charDebug = new SpriteDebugView({ zoom: 8 }); this.charDebug.view.position.set(390, 120); this.game.renderer.uiRoot.addChild(this.charDebug.view); + + // Сцена готова: регистрируемся как активная локация (автосейв при + // закрытии вкладки — даже когда поверх открыта сумка/другая сцена). + this.game.activeLocation = this; } enter(): void { @@ -486,6 +493,9 @@ } exit(): void { + // Сняться с регистрации (replace: новый LocationScene уже занял слот — + // чистим только свой). + if (this.game.activeLocation === this) this.game.activeLocation = null; for (const off of this.eventOffs) off(); this.eventOffs = []; this.audioOff(); @@ -807,6 +817,8 @@ /** Выполнить переход: новая сцена с fade (длительность — из определения). */ private useTransition(def: TransitionDef): void { const next = this.targetEntry(def); + // Автосейв до fade: закрыл игру на переходе — вернёшься на эту же тропу. + this.autosave(); playSfx(this.game.audio, 'sfx/whoosh', 0.5); void this.game.scenes.replace( new LocationScene(this.game, null, next.area, next.entry, { @@ -817,23 +829,36 @@ ); } - private saveAndExit(): void { - const pos = this.player.currentTile(); + /** + * Сериализовать прохождение без записи (общий источник autosave: + * Esc, переходы, закрытие вкладки). + */ + private buildSaveData(): SaveData { this.game.state.setVar(VARS.hp, this.flow.playerCombat.hp); + return { + version: SAVE_VERSION, + area: this.area.id, + pos: this.player.currentTile(), + state: this.game.state.serialize(), + items: this.game.inventory.serialize().items, + clock: { minutes: this.game.clock.minutes }, + returnTo: this.returnTo ?? null, + savedAt: Date.now() + }; + } + + /** Записать autosave (localStorage синхронен — безопасно и из beforeunload). */ + autosave(): void { + const data = this.buildSaveData(); this.game.saves.save( 'autosave', - { - version: SAVE_VERSION, - area: this.area.id, - pos, - state: this.game.state.serialize(), - items: this.game.inventory.serialize().items, - clock: { minutes: this.game.clock.minutes }, - returnTo: this.returnTo ?? null, - savedAt: Date.now() - } satisfies SaveData, - { title: 'Автосохранение', savedAt: Date.now(), version: SAVE_VERSION, extras: { area: this.area.id } } + data, + { title: 'Автосохранение', savedAt: data.savedAt, version: SAVE_VERSION, extras: { area: this.area.id } } ); + } + + private saveAndExit(): void { + this.autosave(); void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); } } \ No newline at end of file diff --git a/apps/game/src/systems/InteractableViews.ts b/apps/game/src/systems/InteractableViews.ts index 890cd98..d09fb23 100644 --- a/apps/game/src/systems/InteractableViews.ts +++ b/apps/game/src/systems/InteractableViews.ts @@ -128,12 +128,12 @@ 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); + marker.rect(5, -(frameH + 3), 2, 4).fill(0xf0d878); + marker.rect(5, -(frameH - 3), 2, 2).fill(0xf0d878); view.addChild(marker); return { view, body: sprite }; }