diff --git a/apps/game/assets/chars/hero_sheet.png b/apps/game/assets/chars/hero_sheet.png index c01294a..780f7ea 100644 --- a/apps/game/assets/chars/hero_sheet.png +++ b/apps/game/assets/chars/hero_sheet.png Binary files differ diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 5beff80..fc44d7d 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -22,6 +22,7 @@ menu: ['Escape'], inventory: ['KeyI'], debug: ['F3'], + debugChar: ['KeyP'], up: ['KeyW', 'ArrowUp'], down: ['KeyS', 'ArrowDown'], left: ['KeyA', 'ArrowLeft'], diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index aeaa1e9..b639220 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -12,6 +12,7 @@ inCircle, findPathToNeighbor, DebugOverlay, + SpriteDebugView, VirtualJoystick, DEFAULT_ISO, type Camera, @@ -71,6 +72,7 @@ /** Тач-джойстик (активен только для касаний). */ private joystick: VirtualJoystick; private debug: DebugOverlay; + private charDebug: SpriteDebugView; constructor( private game: Game, @@ -185,7 +187,10 @@ width: size.width, height: size.height }; - this.updateCameraFollow(); + // Камера-«окно»: герой ходит в центральной зоне свободно, у виртуальной + // границы экрана толкает камеру (движение остаётся плавным). + this.camera.deadZone = { width: 180, height: 120 }; + this.updateCameraFollow(true); this.hint = new Container(); const text = new PixelText({ @@ -206,6 +211,11 @@ // Дебаг-оверлей (F3). this.debug = new DebugOverlay(false); this.game.renderer.uiRoot.addChild(this.debug.view); + + // Дебаг спрайта героя (P): текущий кадр, увеличенный с пиксельной сеткой. + this.charDebug = new SpriteDebugView({ zoom: 8 }); + this.charDebug.view.position.set(390, 120); + this.game.renderer.uiRoot.addChild(this.charDebug.view); } enter(): void { @@ -224,6 +234,7 @@ this.locationLabel.destroy({ children: true }); this.joystick.destroy({ children: true }); this.debug.view.destroy({ children: true }); + this.charDebug.view.destroy({ children: true }); } update(dt: number): void { @@ -255,6 +266,9 @@ if (input.isActionJustPressed('debug')) { this.debug.view.visible = !this.debug.view.visible; } + if (input.isActionJustPressed('debugChar')) { + this.charDebug.view.visible = !this.charDebug.view.visible; + } // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- if (input.isActionJustPressed('attack')) { @@ -304,6 +318,7 @@ `hp ${this.playerCombat.hp} kills ${this.game.state.getNumber('kills')}` ]); this.debug.update(dt); + if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); } /** Переход между локациями: герой наступил на тайл-триггер. */ @@ -391,17 +406,18 @@ this.playerCombat.revive(); // Респаун на стартовом тайле локации this.player.teleportTo(this.location.spawn); + this.updateCameraFollow(true); this.healthBar.setHp(this.playerCombat.hp); } } // ---------- остальное ---------- - private updateCameraFollow(): void { - // Непрерывное следование за ногами героя: в покое совпадает с центром тайла, - // в движении карта едет плавно, без рывка на границе тайлов. + private updateCameraFollow(snap = false): void { + // Непрерывное следование за ногами героя; snap — телепорты/спавн. const p = this.player.position; - this.camera.follow(p.x, p.y); + if (snap) this.camera.snap(p.x, p.y); + else this.camera.follow(p.x, p.y); } /** Текстуры тайлов из загруженных ассетов (id -> Texture). */ diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 9fd6424..8a607f2 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -168,6 +168,11 @@ return { x: this.pos.x, y: this.pos.y }; } + /** Текущий кадр спрайта (для дебага анимации). */ + get currentTexture(): Texture { + return this.sprite.texture; + } + /** Прервать текущий путь (например, при уроне). */ stop(): void { this.path = []; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index d318817..6c6fa09 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -109,4 +109,5 @@ export { SaveManager, type StorageLike } from './save/SaveManager'; // debug -export { DebugOverlay } from './debug/DebugOverlay'; \ No newline at end of file +export { DebugOverlay } from './debug/DebugOverlay'; +export { SpriteDebugView } from './ui/SpriteDebugView'; \ No newline at end of file diff --git a/packages/engine/src/render/Camera.ts b/packages/engine/src/render/Camera.ts index 39c4fc4..5ac98c8 100644 --- a/packages/engine/src/render/Camera.ts +++ b/packages/engine/src/render/Camera.ts @@ -19,6 +19,12 @@ x = 0; y = 0; bounds: CameraBounds | null = null; + /** + * «Мёртвая зона» (виртуальные границы экрана): пока цель внутри окна, + * камера стоит; у края окна цель толкает камеру на величину выхода. + * null — камера всегда центрируется на цели. + */ + deadZone: { width: number; height: number } | null = null; private readonly shake = new Shake(); constructor( @@ -27,11 +33,34 @@ ) {} follow(targetX: number, targetY: number): void { - this.x = targetX; - this.y = targetY; + const dz = this.deadZone; + if (dz) { + // Двигаем каждую ось только на величину выхода цели за окно. + const hw = dz.width / 2; + const hh = dz.height / 2; + const dxMin = this.x - hw; + const dxMax = this.x + hw; + if (targetX < dxMin) this.x -= dxMin - targetX; + else if (targetX > dxMax) this.x += targetX - dxMax; + const dyMin = this.y - hh; + const dyMax = this.y + hh; + if (targetY < dyMin) this.y -= dyMin - targetY; + else if (targetY > dyMax) this.y += targetY - dyMax; + } else { + this.x = targetX; + this.y = targetY; + } this.clamp(); } + /** Мгновенно поставить камеру на точку (спавн, телепорт, смена локации). */ + snap(targetX: number, targetY: number): void { + const dz = this.deadZone; + this.deadZone = null; + this.follow(targetX, targetY); + this.deadZone = dz; + } + /** Запустить толчок: амплитуда в пикселях, длительность в секундах. */ addShake(strength: number, duration: number): void { this.shake.add(strength, duration); diff --git a/packages/engine/src/render/__tests__/camera.test.ts b/packages/engine/src/render/__tests__/camera.test.ts new file mode 100644 index 0000000..e200271 --- /dev/null +++ b/packages/engine/src/render/__tests__/camera.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { Camera } from '../Camera'; + +describe('Camera dead zone', () => { + it('без dead zone — камера центрируется на цели', () => { + const cam = new Camera(480, 270); + cam.follow(100, 50); + expect(cam.x).toBe(100); + expect(cam.y).toBe(50); + }); + + it('цель внутри окна — камера стоит', () => { + const cam = new Camera(480, 270); + cam.snap(240, 135); + cam.deadZone = { width: 160, height: 100 }; + cam.follow(240 + 60, 135 + 30); // внутри окна + expect(cam.x).toBe(240); + expect(cam.y).toBe(135); + }); + + it('цель толкает камеру ровно на величину выхода', () => { + const cam = new Camera(480, 270); + cam.snap(240, 135); + cam.deadZone = { width: 160, height: 100 }; + cam.follow(240 + 100, 135 - 70); // выход 20 и 20 за окно + expect(cam.x).toBe(260); + expect(cam.y).toBe(115); + }); + + it('snap ставит камеру на точку, минуя окно', () => { + const cam = new Camera(480, 270); + cam.deadZone = { width: 160, height: 100 }; + cam.snap(300, 200); + expect(cam.x).toBe(300); + expect(cam.y).toBe(200); + // Окно сохранено: дальнейшее движение снова с dead zone. + cam.follow(300 + 50, 200); + expect(cam.x).toBe(300); + }); + + it('границы мира держат камеру прижатой', () => { + const cam = new Camera(480, 270); + cam.bounds = { x: -400, y: -120, width: 800, height: 400 }; + cam.snap(-1000, 500); // за границей по обеим осям + expect(cam.x).toBe(-160); // -400 + 480/2 + expect(cam.y).toBe(145); // -120 + 400 - 270/2 + }); +}); \ No newline at end of file diff --git a/packages/engine/src/ui/SpriteDebugView.ts b/packages/engine/src/ui/SpriteDebugView.ts new file mode 100644 index 0000000..1754d7c --- /dev/null +++ b/packages/engine/src/ui/SpriteDebugView.ts @@ -0,0 +1,47 @@ +import { Container, Graphics, Sprite, Texture } from 'pixi.js'; + +/** + * Дебаг-просмотр спрайта: текстура, увеличенная целым множителем (nearest), + * с сеткой по границам пикселей. Для правки пропорций и проверки кадров + * анимации. Показ текущего кадра — просто вызывайте setTexture каждый тик. + */ +export class SpriteDebugView { + readonly view: Container; + + private holder: Sprite; + private grid: Graphics; + private readonly zoom: number; + private readonly gridColor: number; + private readonly gridAlpha: number; + + constructor(opts: { zoom?: number; gridColor?: number; gridAlpha?: number } = {}) { + this.zoom = Math.max(1, Math.round(opts.zoom ?? 8)); + this.gridColor = opts.gridColor ?? 0x000000; + this.gridAlpha = opts.gridAlpha ?? 0.2; + + this.view = new Container(); + this.view.visible = false; + + this.holder = new Sprite(Texture.EMPTY); + this.holder.anchor.set(0.5); + this.grid = new Graphics(); + this.view.addChild(this.holder, this.grid); + } + + /** Показать текстуру: центрирует, масштабирует целым множителем, рисует сетку. */ + setTexture(tex: Texture): void { + this.holder.texture = tex; + this.holder.scale.set(this.zoom); + const w = tex.width * this.zoom; + const h = tex.height * this.zoom; + const g = this.grid; + g.clear(); + for (let x = 0; x <= tex.width; x++) { + g.moveTo(x * this.zoom - w / 2, -h / 2).lineTo(x * this.zoom - w / 2, h / 2); + } + for (let y = 0; y <= tex.height; y++) { + g.moveTo(-w / 2, y * this.zoom - h / 2).lineTo(w / 2, y * this.zoom - h / 2); + } + g.stroke({ color: this.gridColor, width: 1, alpha: this.gridAlpha }); + } +} \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 0431b1d..8598997 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -580,7 +580,8 @@ const framesJson = {}; for (let i = 0; i < heroFrames.length; i++) { const src = heroFrames[i]; - sheetCanvas.data.set(src.data, i * FRAME_W * 4); + // Buffer.set: offset в байтах — кадр целиком (16*24 пикселя). + sheetCanvas.data.set(src.data, i * FRAME_W * FRAME_H * 4); framesJson[heroNames[i]] = { frame: { x: i * FRAME_W, y: 0, w: FRAME_W, h: FRAME_H }, rotated: false, diff --git a/tools/skin-info.mjs b/tools/skin-info.mjs new file mode 100644 index 0000000..0139a91 --- /dev/null +++ b/tools/skin-info.mjs @@ -0,0 +1,34 @@ +/** Проба: печатает информацию о текущей текстуре спрайта героя из браузера. */ +import puppeteer from 'puppeteer-core'; +const url = process.argv[2] ?? 'http://localhost:5201/'; +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', headless: true, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] +}); +const page = await browser.newPage(); +await page.setViewport({ width: 960, height: 540 }); +page.on('pageerror', (e) => console.log(`[ошибка] ${e.message}`)); +const booted = new Promise((r) => page.on('console', (m) => { if (m.text().includes('[location]')) r(); })); +await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); +await new Promise((r) => setTimeout(r, 2000)); +await page.mouse.click(480, 283); +await booted; +await new Promise((r) => setTimeout(r, 1500)); +const info = await page.evaluate(() => { + const g = window.__game; + const scene = g.scenes?.current ?? g.sceneManager?.current; + const player = scene?.player; + if (!player) return { error: 'нет scene.player: ' + Object.keys(scene ?? {}) }; + const tex = player.currentTexture; + const src = tex.source; + return { + width: tex.width, height: tex.height, + frame: tex.frame, trim: tex.trim, + srcWidth: src.width, srcHeight: src.height, + label: tex.label ?? null, + textureDatas: tex.textureCacheIds ?? null + }; +}); +console.log(JSON.stringify(info, null, 2)); +await browser.close(); diff --git a/tools/skin-probe.mjs b/tools/skin-probe.mjs new file mode 100644 index 0000000..24e117e --- /dev/null +++ b/tools/skin-probe.mjs @@ -0,0 +1,26 @@ +/** + * Проба дебага спрайта: включаем просмотр кадра героя (KeyP) и снимаем скриншот. + * Запуск: node tools/skin-probe.mjs [url] [скриншот] + */ +import puppeteer from 'puppeteer-core'; +const url = process.argv[2] ?? 'http://localhost:5201/'; +const shot = process.argv[3] ?? '/tmp/skin_check.png'; +const browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', headless: true, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', + '--autoplay-policy=no-user-gesture-required', '--window-size=960,540'] +}); +const page = await browser.newPage(); +await page.setViewport({ width: 960, height: 540 }); +page.on('pageerror', (e) => console.log(`[ошибка] ${e.message}`)); +const booted = new Promise((r) => page.on('console', (m) => { if (m.text().includes('[location]')) r(); })); +await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); +await new Promise((r) => setTimeout(r, 2000)); +await page.mouse.click(480, 283); +await booted; +await new Promise((r) => setTimeout(r, 1500)); +await page.keyboard.press('KeyP'); +await new Promise((r) => setTimeout(r, 500)); +await page.screenshot({ path: shot }); +console.log(`Скриншот: ${shot}`); +await browser.close();