diff --git a/AGENTS.md b/AGENTS.md index 3adf865..ab04cf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,7 @@ Plan → scope → `pnpm agent:verify` → `pnpm agent:verify --e2e`(golden fl | 路径 | 契约 | 回归 gate | |------|------|-----------| | `apps/web/src/game/entityLabels.ts` | Proximity 铭牌:宋体、`LABEL_SCALE` 字号、无描边/无底条、轻投影(Guardrail #57) | `pnpm verify:phase13` + UAT #6/#7 | -| `apps/web/src/game/entitySprites.ts` | `spriteNameplateY` + **全员 LPC**(Guardrail #106) | 同上 + `pnpm verify:phase6:move-only` | +| `apps/web/src/game/entitySprites.ts` | `spriteNameplateY` + **全员 LPC**(玩家 + `npc-1`…`npc-12`;Guardrail #106) | 同上 + `pnpm verify:phase6:move-only` | | `apps/web/src/game/ProximityNameplate.ts` | ≤2 格 fade;speak/thinking 强制显示 | 同上 | | `apps/web/src/hooks/useNpcChat.ts` | 方案 A composer busy;`onSpeakBusy` 清 `sendingNpcId`(ISSUE-037) | `uat:phase8` Test 4 + web test | diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index c8d6ce1..2412029 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -86,13 +86,13 @@ Home Tiled map (40×40 @ 16px → **32px/grid** in-game). Full bake / collision ## Character visuals(全员 LPC · 2026-07) -**产品决策:** 所有玩家 + `npc-1` → LPC `sprites/lpc-npc-1.png`;议会其余 NPC → Stardew `sprites/npcs.png`。 +**产品决策:** 所有玩家 + `npc-1`…`npc-12` → LPC `sprites/lpc-player-1.png` + `sprites/lpc-npc-{1…12}.png`。 | Task | Command / path | |------|----------------| -| 烘焙 LPC 皮 | `pnpm assets:sync:lpc-npc1`(源 `npc-asset/npc-1.png`) | +| 烘焙 LPC 皮 | `pnpm assets:sync:lpc-npcs`(别名 `assets:sync:lpc-npc1`;源 `npc-asset/player-1.png` + `npc-asset/npc-{1…12}.png`) | | 常量 | `gridLayout.CELL_PX=32` · `entityLayout.CHAR_DISPLAY_PX=64` | -| 运行时 | `lpcNpc1Sheet.ts` · `entitySprites.ts` · `sceneLabelLayout.ts` | +| 运行时 | `lpcNpc1Sheet.ts` · `entitySprites.ts`(`registerLpcNpcAnims`)· `sceneLabelLayout.ts` | | 文档 | [BEGINNING-FIELDS.md](../../docs/BEGINNING-FIELDS.md) §角色视觉 | | 回归 | `pnpm verify:phase6:move-only` + `pnpm verify:phase13` | diff --git a/apps/web/public/assets/CREDITS.md b/apps/web/public/assets/CREDITS.md index 5473f9e..04a8403 100644 --- a/apps/web/public/assets/CREDITS.md +++ b/apps/web/public/assets/CREDITS.md @@ -50,13 +50,12 @@ Phase 13.2 farm art (default production). Regenerate atlases: `pnpm art:import` | Field | Value | |-------|--------| -| **决策** | **所有玩家**(本地 + 远端)与 **`npc-1`** 统一使用 LPC 烘焙皮;**不再**用 `sprites/characters.png` 四色 palette 区分玩家 | -| **源图** | `npc-asset/npc-1.png`(Universal LPC Spritesheet Character Generator) | -| **烘焙** | `pnpm assets:sync:lpc-npc1` → `scripts/sync-npc-lpc-assets.mjs` | -| **输出** | `sprites/lpc-npc-1.png` — 64×64 帧,walk(row 8–11)+ idle(row 22–25),方向 up/left/down/right | -| **运行时** | `lpcNpc1Sheet.ts` · `entitySprites.registerLpcNpc1Anims` · `useSpriteEntities()` 须 `spritesLpcNpc1` + `spritesNpcs` | +| **决策** | **所有玩家**(本地 + 远端)与 **`npc-1`…`npc-12`** 统一使用 LPC 烘焙皮;**不再**用 `sprites/characters.png` 四色 palette 区分玩家 | +| **源图** | `npc-asset/player-1.png` + `npc-asset/npc-{1…12}.png`(Universal LPC Spritesheet Character Generator) | +| **烘焙** | `pnpm assets:sync:lpc-npcs`(别名 `assets:sync:lpc-npc1`)→ `scripts/sync-npc-lpc-assets.mjs` | +| **输出** | `sprites/lpc-player-1.png` + `sprites/lpc-npc-{1…12}.png` — 各 64×64 帧,walk(row 8–11)+ idle(row 22–25),方向 up/left/down/right | +| **运行时** | `lpcNpc1Sheet.ts` · `entitySprites.registerLpcNpcAnims` · `useSpriteEntities()` 须 `spritesLpcNpc1` + `spritesNpcs` | | **显示** | `CELL_PX=32`;角色高 `CHAR_DISPLAY_PX=64`(2 格);`LPC_NPC1_SCALE=1.0` | -| **其他 NPC** | `npc-2`…`npc-12` 仍用 Stardew `sprites/npcs.png`(16×32 @ 2×) | 文档:[BEGINNING-FIELDS.md](../../docs/BEGINNING-FIELDS.md) §角色视觉 · Guardrails #106–#107 in [ISSUE-LOG.md](../../docs/ISSUE-LOG.md)。 @@ -70,7 +69,7 @@ Phase 13.2 farm art (default production). Regenerate atlases: `pnpm art:import` | **Facing** | Sheet rows: down / up / right; **left** = horizontal flip in `entitySprites.applyFacingFlip` | | **Walk / idle** | 4 walk + 2 idle frames per facing (from 6 walk / 4 idle source columns) | -Council NPCs(非 npc-1):`sprites/npcs.png`(同上 import,2 variants × 4 facings)。 +Council NPCs(`npc-1`…`npc-12`)生产路径为 LPC 烘焙皮;`sprites/npcs.png` 仅作 legacy / Stardew fallback。 Legacy LPC vendor pass: `pnpm art:import:lpc` (`scripts/vendor/phase13/lpc/`). diff --git a/apps/web/public/assets/sprites/lpc-npc-10.png b/apps/web/public/assets/sprites/lpc-npc-10.png new file mode 100644 index 0000000..46101e6 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-10.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-11.png b/apps/web/public/assets/sprites/lpc-npc-11.png new file mode 100644 index 0000000..5598499 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-11.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-12.png b/apps/web/public/assets/sprites/lpc-npc-12.png new file mode 100644 index 0000000..701e680 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-12.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-2.png b/apps/web/public/assets/sprites/lpc-npc-2.png new file mode 100644 index 0000000..de783c7 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-2.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-3.png b/apps/web/public/assets/sprites/lpc-npc-3.png new file mode 100644 index 0000000..723f9da Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-3.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-4.png b/apps/web/public/assets/sprites/lpc-npc-4.png new file mode 100644 index 0000000..ee67def Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-4.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-5.png b/apps/web/public/assets/sprites/lpc-npc-5.png new file mode 100644 index 0000000..2a17ffe Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-5.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-6.png b/apps/web/public/assets/sprites/lpc-npc-6.png new file mode 100644 index 0000000..357703d Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-6.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-7.png b/apps/web/public/assets/sprites/lpc-npc-7.png new file mode 100644 index 0000000..df89f60 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-7.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-8.png b/apps/web/public/assets/sprites/lpc-npc-8.png new file mode 100644 index 0000000..759886b Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-8.png differ diff --git a/apps/web/public/assets/sprites/lpc-npc-9.png b/apps/web/public/assets/sprites/lpc-npc-9.png new file mode 100644 index 0000000..40120bc Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-9.png differ diff --git a/apps/web/public/assets/sprites/lpc-player-1.png b/apps/web/public/assets/sprites/lpc-player-1.png new file mode 100644 index 0000000..b8adfc6 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-player-1.png differ diff --git a/apps/web/src/game/HomeMapBackground.ts b/apps/web/src/game/HomeMapBackground.ts index d78d595..703fb6c 100644 --- a/apps/web/src/game/HomeMapBackground.ts +++ b/apps/web/src/game/HomeMapBackground.ts @@ -1,7 +1,12 @@ import { HOME_MAP_TILE_H, HOME_MAP_TILE_W, isHomeMapRegionCell } from "@aetherlife/shared"; -import type * as Phaser from "phaser"; +import * as Phaser from "phaser"; import { ASSET_KEYS, TILE_PX } from "./assetManifest.js"; -import { tiledObjectYSortDepth, YSORT_LAYER } from "./entityLayout.js"; +import { + MAP_TILE_DEPTH_BASE, + MAP_TILE_DEPTH_STEP, + tiledObjectYSortDepth, + YSORT_LAYER, +} from "./entityLayout.js"; import { CELL_PX } from "./gridLayout.js"; import { isVisualFallbackActive } from "./visualFallback.js"; @@ -169,6 +174,7 @@ function applyObjectTileAnimation( */ export class HomeMapBackground { private layers: Phaser.GameObjects.GameObject[] = []; + private objectShadowLayer: Phaser.GameObjects.Layer | null = null; private map: Phaser.Tilemaps.Tilemap | null = null; private tilesets: Phaser.Tilemaps.Tileset[] = []; @@ -192,7 +198,7 @@ export class HomeMapBackground { const layerMetas = (cacheEntry.data as { layers: TiledLayerMeta[] }).layers; const bakedTilesets = (cacheEntry.data as { tilesets: TiledTilesetDef[] }).tilesets ?? []; - let depth = -1; + let tileBandDepth = MAP_TILE_DEPTH_BASE; for (const meta of layerMetas) { if (meta.visible === false) continue; if (SKIP_LAYER_NAMES.has(meta.name)) continue; @@ -204,8 +210,11 @@ export class HomeMapBackground { if (meta.opacity !== undefined && meta.opacity < 1) { layer.setAlpha(meta.opacity); } - layer.setDepth(depth); - depth += 0.01; + if (meta.name === "Shadows") { + layer.setBlendMode(Phaser.BlendModes.MULTIPLY); + } + layer.setDepth(tileBandDepth); + tileBandDepth += MAP_TILE_DEPTH_STEP; this.layers.push(layer); continue; } @@ -215,6 +224,15 @@ export class HomeMapBackground { if (!rawLayer) continue; const isShadowLayer = meta.name === "Object Shadows"; + if (isShadowLayer && !this.objectShadowLayer) { + this.objectShadowLayer = scene.add.layer(); + this.objectShadowLayer.setDepth(tileBandDepth); + this.objectShadowLayer.setAlpha(meta.opacity ?? 0.25); + this.objectShadowLayer.setBlendMode(Phaser.BlendModes.MULTIPLY); + this.layers.push(this.objectShadowLayer); + tileBandDepth += MAP_TILE_DEPTH_STEP; + } + const sortedObjects = meta.draworder === "topdown" ? [...rawLayer.objects].sort((a, b) => { @@ -225,6 +243,7 @@ export class HomeMapBackground { }) : rawLayer.objects; + let shadowIndex = 0; for (const rawObj of sortedObjects) { if (!rawObj.gid) continue; @@ -235,24 +254,25 @@ export class HomeMapBackground { : scene.add.sprite(0, 0, "__MISSING"); placeTiledTileObject(sprite, rawObj); - if (meta.opacity !== undefined && meta.opacity < 1) { - sprite.setAlpha(meta.opacity); + + if (isShadowLayer) { + sprite.setDepth(shadowIndex++); + this.objectShadowLayer!.add(sprite); + } else { + if (meta.opacity !== undefined && meta.opacity < 1) { + sprite.setAlpha(meta.opacity); + } + const bottomWorldX = (rawObj.x ?? 0) * TILE_SCALE; + const bottomWorldY = (rawObj.y ?? 0) * TILE_SCALE; + sprite.setDepth( + tiledObjectYSortDepth(bottomWorldX, bottomWorldY, YSORT_LAYER.OBJECT), + ); + this.layers.push(sprite); } - const bottomWorldX = (rawObj.x ?? 0) * TILE_SCALE; - const bottomWorldY = (rawObj.y ?? 0) * TILE_SCALE; - sprite.setDepth( - tiledObjectYSortDepth( - bottomWorldX, - bottomWorldY, - isShadowLayer ? YSORT_LAYER.SHADOW : YSORT_LAYER.OBJECT, - ), - ); if (resolved) { applyObjectTileAnimation(scene, sprite, rawObj.gid); } - - this.layers.push(sprite); } } } @@ -291,6 +311,7 @@ export class HomeMapBackground { } this.map?.destroy(); this.layers = []; + this.objectShadowLayer = null; this.map = null; this.tilesets = []; } diff --git a/apps/web/src/game/RoomScene.ts b/apps/web/src/game/RoomScene.ts index 3a1f22f..65b2b43 100644 --- a/apps/web/src/game/RoomScene.ts +++ b/apps/web/src/game/RoomScene.ts @@ -411,6 +411,7 @@ export class RoomScene extends Phaser.Scene { private useSpriteEntities(): boolean { return ( !isVisualFallbackActive(this) + && this.textures.exists(ASSET_KEYS.spritesLpcPlayer1) && this.textures.exists(ASSET_KEYS.spritesLpcNpc1) && this.textures.exists(ASSET_KEYS.spritesNpcs) ); diff --git a/apps/web/src/game/assetManifest.ts b/apps/web/src/game/assetManifest.ts index 7046fb2..a1b84ae 100644 --- a/apps/web/src/game/assetManifest.ts +++ b/apps/web/src/game/assetManifest.ts @@ -42,8 +42,20 @@ export const ASSET_KEYS = { tilesDecor: "tiles/decor", spritesCharacters: "sprites/characters", spritesNpcs: "sprites/npcs", - /** Baked walk+idle from npc-asset/npc-1.png (scripts/sync-npc-lpc-assets.mjs). */ + /** Baked walk+idle from npc-asset LPC composites (scripts/sync-npc-lpc-assets.mjs). */ + spritesLpcPlayer1: "sprites/lpc-player-1", spritesLpcNpc1: "sprites/lpc-npc-1", + spritesLpcNpc2: "sprites/lpc-npc-2", + spritesLpcNpc3: "sprites/lpc-npc-3", + spritesLpcNpc4: "sprites/lpc-npc-4", + spritesLpcNpc5: "sprites/lpc-npc-5", + spritesLpcNpc6: "sprites/lpc-npc-6", + spritesLpcNpc7: "sprites/lpc-npc-7", + spritesLpcNpc8: "sprites/lpc-npc-8", + spritesLpcNpc9: "sprites/lpc-npc-9", + spritesLpcNpc10: "sprites/lpc-npc-10", + spritesLpcNpc11: "sprites/lpc-npc-11", + spritesLpcNpc12: "sprites/lpc-npc-12", spritesUiSpeech: "sprites/ui-speech", tilesScrubPack: "tiles/biome-scrub", tilesWetlandPack: "tiles/biome-wetland", @@ -82,11 +94,20 @@ export const CORE_AREA_ASSETS: AssetSheetDef[] = [ }, { kind: "spritesheet", - key: ASSET_KEYS.spritesLpcNpc1, - url: `${BASE}/sprites/lpc-npc-1.png`, + key: ASSET_KEYS.spritesLpcPlayer1, + url: `${BASE}/sprites/lpc-player-1.png`, frameWidth: 64, frameHeight: 64, }, + ...([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const).map( + (n): AssetSheetDef => ({ + kind: "spritesheet", + key: ASSET_KEYS[`spritesLpcNpc${n}` as keyof typeof ASSET_KEYS], + url: `${BASE}/sprites/lpc-npc-${n}.png`, + frameWidth: 64, + frameHeight: 64, + }), + ), { kind: "image", key: ASSET_KEYS.spritesUiSpeech, url: `${BASE}/sprites/ui-speech.png` }, ]; diff --git a/apps/web/src/game/entityLayout.test.ts b/apps/web/src/game/entityLayout.test.ts index 13a60df..7f038ac 100644 --- a/apps/web/src/game/entityLayout.test.ts +++ b/apps/web/src/game/entityLayout.test.ts @@ -4,6 +4,8 @@ import { entityFootSortPoint, entityYSortDepth, entityYSortDepthFromCenter, + MAP_TILE_DEPTH_BASE, + MAP_TILE_DEPTH_STEP, tiledObjectYSortDepth, ySortDepth, YSORT_LAYER, @@ -82,3 +84,10 @@ describe("tiledObjectYSortDepth", () => { expect(ySortDepth(0, 0, 0)).toBe(ENTITY_DEPTH_BASE); }); }); + +describe("MAP tile depth bands", () => { + it("keeps tile + object-shadow bands below entity Y-sort", () => { + const objectShadowBand = MAP_TILE_DEPTH_BASE + 4 * MAP_TILE_DEPTH_STEP; + expect(objectShadowBand).toBeLessThan(ENTITY_DEPTH_BASE); + }); +}); diff --git a/apps/web/src/game/entityLayout.ts b/apps/web/src/game/entityLayout.ts index 237deec..e1e0782 100644 --- a/apps/web/src/game/entityLayout.ts +++ b/apps/web/src/game/entityLayout.ts @@ -48,6 +48,10 @@ export const MARKER_STROKE = 2; export const MARKER_LABEL_MAX_WIDTH = labelOffset(44); +/** Tiled tile bands (Ground … Water) — sequential depth in map layer order. */ +export const MAP_TILE_DEPTH_BASE = 0; +export const MAP_TILE_DEPTH_STEP = 0.01; + /** Floor/tile layers use depth below this; entities & Tiled objects sit above. */ export const ENTITY_DEPTH_BASE = 10_000; diff --git a/apps/web/src/game/entitySprites.ts b/apps/web/src/game/entitySprites.ts index 8ef225e..6fc32b1 100644 --- a/apps/web/src/game/entitySprites.ts +++ b/apps/web/src/game/entitySprites.ts @@ -21,17 +21,17 @@ import { CELL_PX, CHAR_DISPLAY_PX, LABEL_SCALE, MARKER_CY, labelOffset } from ". import { LPC_NPC1_IDLE_FRAMES, LPC_NPC1_SCALE, - LPC_NPC1_STEPS_PER_CYCLE, - LPC_NPC1_WALK_FRAMES, LPC_NPC1_IDLE_FRAME_RATE, - lpcNpc1AnimKey, + isLpcProfile, + lpcNpcAnimKey, lpcNpc1FrameIndex, lpcNpc1NameplateY, - lpcNpc1WalkStepAnimKey, - lpcNpc1WalkStepFrameRate, - lpcNpc1WalkStepRange, + lpcNpc1WalkFrameRate, + lpcNpc1WalkLoopFrameRange, + LPC_NPC_PROFILES, spriteProfileForNpc, - type LpcNpc1SpriteProfile, + spriteProfileForPlayer, + type LpcNpcSpriteProfile, } from "./lpcNpc1Sheet.js"; import { theme } from "./theme.js"; @@ -46,7 +46,7 @@ const SPRITE_TOP_Y = SPRITE_FOOT_Y - CHAR_FRAME_H * CHAR_SPRITE_SCALE; export const SPRITE_NAMEPLATE_Y = SPRITE_TOP_Y + labelOffset(5); export function spriteNameplateY(profile: SpriteProfile = "stardew"): number { - if (profile === "lpc-npc-1") return lpcNpc1NameplateY(SPRITE_FOOT_Y); + if (isLpcProfile(profile)) return lpcNpc1NameplateY(SPRITE_FOOT_Y); return SPRITE_NAMEPLATE_Y; } @@ -71,7 +71,23 @@ const CHAT_BUBBLE_BASE_Y_KEY = "chatBubbleBaseY"; const CARDINALS: CardinalFacing[] = ["down", "left", "right", "up"]; -export type SpriteProfile = LpcNpc1SpriteProfile | "stardew"; +const LPC_NPC_TEXTURE: Record = { + "lpc-player-1": ASSET_KEYS.spritesLpcPlayer1, + "lpc-npc-1": ASSET_KEYS.spritesLpcNpc1, + "lpc-npc-2": ASSET_KEYS.spritesLpcNpc2, + "lpc-npc-3": ASSET_KEYS.spritesLpcNpc3, + "lpc-npc-4": ASSET_KEYS.spritesLpcNpc4, + "lpc-npc-5": ASSET_KEYS.spritesLpcNpc5, + "lpc-npc-6": ASSET_KEYS.spritesLpcNpc6, + "lpc-npc-7": ASSET_KEYS.spritesLpcNpc7, + "lpc-npc-8": ASSET_KEYS.spritesLpcNpc8, + "lpc-npc-9": ASSET_KEYS.spritesLpcNpc9, + "lpc-npc-10": ASSET_KEYS.spritesLpcNpc10, + "lpc-npc-11": ASSET_KEYS.spritesLpcNpc11, + "lpc-npc-12": ASSET_KEYS.spritesLpcNpc12, +}; + +export type SpriteProfile = LpcNpcSpriteProfile | "stardew"; export type AnimatableEntity = { avatar?: Phaser.GameObjects.Sprite; @@ -81,8 +97,6 @@ export type AnimatableEntity = { facingDir?: CardinalFacing; isNpc?: boolean; spriteProfile?: SpriteProfile; - /** LPC plan A: which 3-frame walk segment (0–2) plays on the next step. */ - lpcWalkPhase?: number; }; export function animKey( @@ -102,7 +116,7 @@ export function applyFacingFlip( profile: SpriteProfile = "stardew", ): void { if (!avatar) return; - if (profile === "lpc-npc-1") { + if (isLpcProfile(profile)) { avatar.setFlipX(false); return; } @@ -167,29 +181,26 @@ export function registerNpcAnims(scene: Phaser.Scene): void { } } -export function registerLpcNpc1Anims(scene: Phaser.Scene): void { - if (scene.anims.exists("lpc1-walk-down-s0")) return; - const texture = ASSET_KEYS.spritesLpcNpc1; - const stepRate = lpcNpc1WalkStepFrameRate(); +function registerLpcNpcAnimsForProfile(scene: Phaser.Scene, profile: LpcNpcSpriteProfile): void { + const probeKey = lpcNpcAnimKey(profile, "walk", "down"); + if (scene.anims.exists(probeKey)) return; + const texture = LPC_NPC_TEXTURE[profile]; + const walkRate = lpcNpc1WalkFrameRate(); for (let fi = 0; fi < FACING_COUNT; fi += 1) { const facing = CARDINALS[fi]!; - for (let phase = 0; phase < LPC_NPC1_STEPS_PER_CYCLE; phase += 1) { - const range = lpcNpc1WalkStepRange(phase); - const frameStart = lpcNpc1FrameIndex(facing, "walk", range.start); - const frameEnd = lpcNpc1FrameIndex(facing, "walk", range.end); - scene.anims.create({ - key: lpcNpc1WalkStepAnimKey(facing, phase), - frames: scene.anims.generateFrameNumbers(texture, { - start: frameStart, - end: frameEnd, - }), - frameRate: stepRate, - repeat: 0, - }); - } + const walkRange = lpcNpc1WalkLoopFrameRange(facing, profile); + scene.anims.create({ + key: lpcNpcAnimKey(profile, "walk", facing), + frames: scene.anims.generateFrameNumbers(texture, { + start: walkRange.start, + end: walkRange.end, + }), + frameRate: walkRate, + repeat: -1, + }); const idleBase = lpcNpc1FrameIndex(facing, "idle", 0); scene.anims.create({ - key: lpcNpc1AnimKey("idle", facing), + key: lpcNpcAnimKey(profile, "idle", facing), frames: scene.anims.generateFrameNumbers(texture, { start: idleBase, end: idleBase + LPC_NPC1_IDLE_FRAMES - 1, @@ -200,6 +211,17 @@ export function registerLpcNpc1Anims(scene: Phaser.Scene): void { } } +export function registerLpcNpcAnims(scene: Phaser.Scene): void { + for (const profile of LPC_NPC_PROFILES) { + registerLpcNpcAnimsForProfile(scene, profile); + } +} + +/** @deprecated Use registerLpcNpcAnims */ +export function registerLpcNpc1Anims(scene: Phaser.Scene): void { + registerLpcNpcAnims(scene); +} + export function paletteRowForPlayerId( playerId: string | undefined, sessionId: string, @@ -235,19 +257,28 @@ export function npcTintForId(npcId: string): number { ); } -export function createLpcNpc1Sprite(scene: Phaser.Scene): Phaser.GameObjects.Sprite { +export function createLpcNpcSprite( + scene: Phaser.Scene, + profile: LpcNpcSpriteProfile = "lpc-npc-1", +): Phaser.GameObjects.Sprite { const frame = lpcNpc1FrameIndex("down", "idle", 0); - const sprite = scene.add.sprite(0, SPRITE_FOOT_Y, ASSET_KEYS.spritesLpcNpc1, frame); + const texture = LPC_NPC_TEXTURE[profile]; + const sprite = scene.add.sprite(0, SPRITE_FOOT_Y, texture, frame); sprite.setOrigin(0.5, 1); sprite.setScale(LPC_NPC1_SCALE); return sprite; } +/** @deprecated Use createLpcNpcSprite */ +export function createLpcNpc1Sprite(scene: Phaser.Scene): Phaser.GameObjects.Sprite { + return createLpcNpcSprite(scene, "lpc-npc-1"); +} + export function createPlayerSprite( scene: Phaser.Scene, _paletteRow: number, ): Phaser.GameObjects.Sprite { - return createLpcNpc1Sprite(scene); + return createLpcNpcSprite(scene, spriteProfileForPlayer()); } export function createNpcSprite( @@ -255,8 +286,9 @@ export function createNpcSprite( npcId: string, tintOverride?: number, ): Phaser.GameObjects.Sprite { - if (npcId === "npc-1") { - return createLpcNpc1Sprite(scene); + const profile = spriteProfileForNpc(npcId); + if (isLpcProfile(profile)) { + return createLpcNpcSprite(scene, profile); } const variant = npcVariantForId(npcId); const frame = npcFrameIndex(variant, facingToIndex("down"), WALK_FRAMES); @@ -329,20 +361,22 @@ export function createDoorSprite(scene: Phaser.Scene, closed: boolean): Phaser.G function playLpcWalkAnim(ent: AnimatableEntity, facing: CardinalFacing): void { if (!ent.avatar) return; - if (ent.facingDir !== facing) { - ent.lpcWalkPhase = 0; - } - const phase = ent.lpcWalkPhase ?? 0; - ent.lpcWalkPhase = (phase + 1) % LPC_NPC1_STEPS_PER_CYCLE; + const profile = ent.spriteProfile; + if (!profile || !isLpcProfile(profile)) return; + const key = lpcNpcAnimKey(profile, "walk", facing); ent.facingDir = facing; - applyFacingFlip(ent.avatar, facing, "lpc-npc-1"); - ent.avatar.play(lpcNpc1WalkStepAnimKey(facing, phase), false); + applyFacingFlip(ent.avatar, facing, profile); + const current = ent.avatar.anims.currentAnim; + if (current?.key === key && ent.avatar.anims.isPlaying) { + return; + } + ent.avatar.play(key, true); } export function playWalkAnim(ent: AnimatableEntity, facing: CardinalFacing): void { if (!ent.avatar) return; const profile = ent.spriteProfile ?? "stardew"; - if (profile === "lpc-npc-1") { + if (isLpcProfile(profile)) { playLpcWalkAnim(ent, facing); return; } @@ -356,13 +390,10 @@ export function playIdleAnim(ent: AnimatableEntity, facing?: CardinalFacing): vo if (!ent.avatar) return; const dir = facing ?? ent.facingDir ?? "down"; const profile = ent.spriteProfile ?? "stardew"; - if (profile === "lpc-npc-1") { - ent.lpcWalkPhase = 0; - } ent.facingDir = dir; applyFacingFlip(ent.avatar, dir, profile); - if (profile === "lpc-npc-1") { - ent.avatar.play(lpcNpc1AnimKey("idle", dir), true); + if (isLpcProfile(profile)) { + ent.avatar.play(lpcNpcAnimKey(profile, "idle", dir), true); return; } const row = ent.paletteRow ?? 0; diff --git a/apps/web/src/game/lpcNpc1Sheet.test.ts b/apps/web/src/game/lpcNpc1Sheet.test.ts index b1e81a9..9581730 100644 --- a/apps/web/src/game/lpcNpc1Sheet.test.ts +++ b/apps/web/src/game/lpcNpc1Sheet.test.ts @@ -1,30 +1,35 @@ import { describe, expect, it } from "vitest"; import { CHAR_DISPLAY_PX } from "./entityLayout.js"; -import { GRID_STEP_MS } from "./gridMovement.js"; import { LPC_NPC1_FRAMES_PER_FACING, LPC_NPC1_IDLE_BASE_ROW, - LPC_NPC1_STEPS_PER_CYCLE, LPC_NPC1_WALK_FRAMES, - LPC_NPC1_WALK_FRAMES_PER_STEP, + LPC_NPC1_WALK_LOOP_FRAMES, + LPC_NPC1_WALK_LOOP_START, + LPC_PLAYER1_WALK_LOOP_FRAMES, + LPC_PLAYER1_WALK_LOOP_START, + LPC_NPC1_WALK_MS_PER_FRAME, LPC_NPC1_FRAME, LPC_NPC1_SCALE, LPC_SOURCE_ROW_BY_FACING, - lpcNpc1AnimKey, + lpcNpcAnimKey, lpcNpc1FrameIndex, lpcNpc1WalkCycleMs, - lpcNpc1WalkStepAnimKey, - lpcNpc1WalkStepFrameRate, - lpcNpc1WalkStepRange, + lpcNpc1WalkFrameRate, + lpcNpc1WalkLoopFrameRange, spriteProfileForNpc, spriteProfileForPlayer, } from "./lpcNpc1Sheet.js"; describe("lpcNpc1Sheet", () => { - it("routes all players and npc-1 to lpc profile", () => { - expect(spriteProfileForPlayer()).toBe("lpc-npc-1"); + it("routes player to lpc-player-1 and npc-1..12 to lpc npc profiles", () => { + expect(spriteProfileForPlayer()).toBe("lpc-player-1"); expect(spriteProfileForNpc("npc-1")).toBe("lpc-npc-1"); - expect(spriteProfileForNpc("npc-2")).toBe("stardew"); + expect(spriteProfileForNpc("npc-2")).toBe("lpc-npc-2"); + expect(spriteProfileForNpc("npc-3")).toBe("lpc-npc-3"); + expect(spriteProfileForNpc("npc-4")).toBe("lpc-npc-4"); + expect(spriteProfileForNpc("npc-12")).toBe("lpc-npc-12"); + expect(spriteProfileForNpc("npc-99")).toBe("stardew"); }); it("packs walk then idle per facing row", () => { @@ -33,19 +38,25 @@ describe("lpcNpc1Sheet", () => { expect(lpcNpc1FrameIndex("left", "walk", 0)).toBe(11); }); - it("uses three animated frames per grid step", () => { - expect(LPC_NPC1_STEPS_PER_CYCLE).toBe(3); - expect(LPC_NPC1_WALK_FRAMES_PER_STEP).toBe(3); - expect(lpcNpc1WalkStepRange(0)).toEqual({ start: 0, end: 2 }); - expect(lpcNpc1WalkStepRange(1)).toEqual({ start: 3, end: 5 }); - expect(lpcNpc1WalkStepRange(2)).toEqual({ start: 6, end: 8 }); + it("loops NPC walk indices 1–8 (skip idle-like frame 0)", () => { + expect(LPC_NPC1_WALK_LOOP_START).toBe(1); + expect(LPC_NPC1_WALK_LOOP_FRAMES).toBe(8); + expect(lpcNpc1WalkLoopFrameRange("down", "lpc-npc-1")).toEqual({ start: 1, end: 8 }); + expect(lpcNpc1WalkLoopFrameRange("left", "lpc-npc-2")).toEqual({ start: 12, end: 19 }); }); - it("syncs segment playback to grid step duration", () => { - const rate = lpcNpc1WalkStepFrameRate(); - const segmentMs = (LPC_NPC1_WALK_FRAMES_PER_STEP / rate) * 1000; - expect(segmentMs).toBeCloseTo(GRID_STEP_MS, 1); - expect(lpcNpc1WalkCycleMs()).toBe(3 * GRID_STEP_MS); + it("loops player walk indices 0–8 (full 9-column gait)", () => { + expect(LPC_PLAYER1_WALK_LOOP_START).toBe(0); + expect(LPC_PLAYER1_WALK_LOOP_FRAMES).toBe(9); + expect(lpcNpc1WalkLoopFrameRange("down", "lpc-player-1")).toEqual({ start: 0, end: 8 }); + expect(lpcNpc1WalkLoopFrameRange("up", "lpc-player-1")).toEqual({ start: 33, end: 41 }); + }); + + it("uses ULPC walk timing (75 ms/frame)", () => { + expect(LPC_NPC1_WALK_MS_PER_FRAME).toBe(75); + expect(lpcNpc1WalkFrameRate()).toBeCloseTo(1000 / 75, 5); + expect(lpcNpc1WalkCycleMs("lpc-npc-1")).toBe(8 * 75); + expect(lpcNpc1WalkCycleMs("lpc-player-1")).toBe(9 * 75); }); it("matches CHAR_DISPLAY_PX on-screen height", () => { @@ -57,8 +68,11 @@ describe("lpcNpc1Sheet", () => { expect(LPC_NPC1_IDLE_BASE_ROW).toBe(22); }); - it("builds step walk and idle anim keys", () => { - expect(lpcNpc1WalkStepAnimKey("down", 0)).toBe("lpc1-walk-down-s0"); - expect(lpcNpc1AnimKey("idle", "up")).toBe("lpc1-idle-up"); + it("builds walk and idle anim keys per profile", () => { + expect(lpcNpcAnimKey("lpc-player-1", "walk", "down")).toBe("lpcp1-walk-down"); + expect(lpcNpcAnimKey("lpc-npc-1", "walk", "down")).toBe("lpc1-walk-down"); + expect(lpcNpcAnimKey("lpc-npc-2", "walk", "up")).toBe("lpc2-walk-up"); + expect(lpcNpcAnimKey("lpc-npc-3", "idle", "up")).toBe("lpc3-idle-up"); + expect(lpcNpcAnimKey("lpc-npc-12", "walk", "left")).toBe("lpc12-walk-left"); }); }); diff --git a/apps/web/src/game/lpcNpc1Sheet.ts b/apps/web/src/game/lpcNpc1Sheet.ts index f9867c8..e977189 100644 --- a/apps/web/src/game/lpcNpc1Sheet.ts +++ b/apps/web/src/game/lpcNpc1Sheet.ts @@ -1,7 +1,6 @@ import type { CardinalFacing } from "./facing.js"; import { facingToIndex } from "./facing.js"; import { CHAR_DISPLAY_PX, labelOffset } from "./entityLayout.js"; -import { GRID_STEP_MS } from "./gridMovement.js"; /** Universal LPC composite: walk @8–11, idle @22–25; rows ordered up/left/down/right. */ export const LPC_NPC1_WALK_BASE_ROW = 8; @@ -16,33 +15,44 @@ export const LPC_SOURCE_ROW_BY_FACING: Record = { }; export const LPC_NPC1_FRAME = 64; +/** Walk columns on baked atlas (index 0 ≈ idle stand — not looped). */ export const LPC_NPC1_WALK_FRAMES = 9; +/** Active 8-frame LPC walk loop for NPCs: sheet indices 1–8 (index 0 ≈ idle stand). */ +export const LPC_NPC1_WALK_LOOP_START = 1; +export const LPC_NPC1_WALK_LOOP_FRAMES = 8; +/** Player sheet: all 9 walk columns are gait frames (col 0 ≠ idle). */ +export const LPC_PLAYER1_WALK_LOOP_START = 0; +export const LPC_PLAYER1_WALK_LOOP_FRAMES = 9; export const LPC_NPC1_IDLE_FRAMES = 2; export const LPC_NPC1_FRAMES_PER_FACING = LPC_NPC1_WALK_FRAMES + LPC_NPC1_IDLE_FRAMES; -/** Three overlapped grid steps complete one 9-frame LPC walk cycle. */ -export const LPC_NPC1_STEPS_PER_CYCLE = 3; +/** ULPC / pflat generator walk timing (~13.3 fps). */ +export const LPC_NPC1_WALK_MS_PER_FRAME = 75; -export const LPC_NPC1_WALK_FRAMES_PER_STEP = LPC_NPC1_WALK_FRAMES / LPC_NPC1_STEPS_PER_CYCLE; - -/** Frame ranges per step segment: [0–2], [3–5], [6–8]. */ -export function lpcNpc1WalkStepRange(phase: number): { start: number; end: number } { - const step = phase % LPC_NPC1_STEPS_PER_CYCLE; - const start = step * LPC_NPC1_WALK_FRAMES_PER_STEP; - return { start, end: start + LPC_NPC1_WALK_FRAMES_PER_STEP - 1 }; +export function lpcNpc1WalkFrameRate(): number { + return 1000 / LPC_NPC1_WALK_MS_PER_FRAME; } -/** Animate each 3-frame segment across one full grid tween (not a frozen pose). */ -export function lpcNpc1WalkStepFrameRate(): number { - return (LPC_NPC1_WALK_FRAMES_PER_STEP * 1000) / GRID_STEP_MS; +export function lpcWalkLoopSpec(profile: LpcNpcSpriteProfile): { start: number; frames: number } { + if (profile === "lpc-player-1") { + return { start: LPC_PLAYER1_WALK_LOOP_START, frames: LPC_PLAYER1_WALK_LOOP_FRAMES }; + } + return { start: LPC_NPC1_WALK_LOOP_START, frames: LPC_NPC1_WALK_LOOP_FRAMES }; } -export function lpcNpc1WalkCycleMs(): number { - return LPC_NPC1_STEPS_PER_CYCLE * GRID_STEP_MS; +export function lpcNpc1WalkCycleMs(profile: LpcNpcSpriteProfile = "lpc-npc-1"): number { + const { frames } = lpcWalkLoopSpec(profile); + return frames * LPC_NPC1_WALK_MS_PER_FRAME; } -export function lpcNpc1WalkStepAnimKey(facing: CardinalFacing, phase: number): string { - return `lpc1-walk-${facing}-s${phase % LPC_NPC1_STEPS_PER_CYCLE}`; +export function lpcNpc1WalkLoopFrameRange( + facing: CardinalFacing, + profile: LpcNpcSpriteProfile = "lpc-npc-1", +): { start: number; end: number } { + const { start, frames } = lpcWalkLoopSpec(profile); + const sheetStart = lpcNpc1FrameIndex(facing, "walk", start); + const sheetEnd = lpcNpc1FrameIndex(facing, "walk", start + frames - 1); + return { start: sheetStart, end: sheetEnd }; } export const LPC_NPC1_IDLE_FRAME_RATE = 4; @@ -50,14 +60,40 @@ export const LPC_NPC1_IDLE_FRAME_RATE = 4; /** On-screen height = 2× CELL_PX (64×64 source @ scale 1 → 64px). */ export const LPC_NPC1_SCALE = CHAR_DISPLAY_PX / LPC_NPC1_FRAME; -export type LpcNpc1SpriteProfile = "lpc-npc-1"; +/** Council + speakable NPCs with dedicated baked LPC sheets (npc-1…12). */ +export const LPC_COUNCIL_NPC_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const; +export type LpcCouncilNpcNum = (typeof LPC_COUNCIL_NPC_IDS)[number]; +export type LpcNpcSpriteProfile = + | "lpc-player-1" + | `lpc-npc-${LpcCouncilNpcNum}`; + +/** @deprecated Use LpcNpcSpriteProfile */ +export type LpcNpc1SpriteProfile = LpcNpcSpriteProfile; + +export const LPC_NPC_PROFILES: LpcNpcSpriteProfile[] = [ + "lpc-player-1", + ...LPC_COUNCIL_NPC_IDS.map((n) => `lpc-npc-${n}` as const), +]; + +const NPC_ID_TO_LPC_PROFILE: Record = Object.fromEntries( + LPC_COUNCIL_NPC_IDS.map((n) => [`npc-${n}`, `lpc-npc-${n}` as const]), +); + +export function isLpcProfile(profile: string): profile is LpcNpcSpriteProfile { + return (LPC_NPC_PROFILES as string[]).includes(profile); +} -export function spriteProfileForPlayer(): LpcNpc1SpriteProfile { - return "lpc-npc-1"; +export function lpcAnimTag(profile: LpcNpcSpriteProfile): string { + if (profile === "lpc-player-1") return "lpcp1"; + return profile.replace("lpc-npc-", "lpc"); } -export function spriteProfileForNpc(npcId: string): LpcNpc1SpriteProfile | "stardew" { - return npcId === "npc-1" ? "lpc-npc-1" : "stardew"; +export function spriteProfileForPlayer(): LpcNpcSpriteProfile { + return "lpc-player-1"; +} + +export function spriteProfileForNpc(npcId: string): LpcNpcSpriteProfile | "stardew" { + return NPC_ID_TO_LPC_PROFILE[npcId] ?? "stardew"; } export function lpcNpc1FrameIndex( @@ -70,8 +106,17 @@ export function lpcNpc1FrameIndex( return facingIndex * LPC_NPC1_FRAMES_PER_FACING + offset; } +export function lpcNpcAnimKey( + profile: LpcNpcSpriteProfile, + kind: "walk" | "idle", + facing: CardinalFacing, +): string { + return `${lpcAnimTag(profile)}-${kind}-${facing}`; +} + +/** @deprecated Use lpcNpcAnimKey("lpc-npc-1", …) */ export function lpcNpc1AnimKey(kind: "walk" | "idle", facing: CardinalFacing): string { - return `lpc1-${kind}-${facing}`; + return lpcNpcAnimKey("lpc-npc-1", kind, facing); } /** Nameplate baseline for LPC 64×64 sprites (origin 0.5, 1). */ diff --git a/docs/BEGINNING-FIELDS.md b/docs/BEGINNING-FIELDS.md index e1ec9d1..ce0cda3 100644 --- a/docs/BEGINNING-FIELDS.md +++ b/docs/BEGINNING-FIELDS.md @@ -12,10 +12,9 @@ Web agent 摘要:[apps/web/AGENTS.md](../apps/web/AGENTS.md) · 回归 ISSUE-0 |------|------| | **显示格** | `CELL_PX = 32`(`gridLayout.ts`);瓦片 16px 源图 ×2 | | **角色身高** | `CHAR_DISPLAY_PX = 64`(占 **2 逻辑格**;脚底仍锚在格心南缘) | -| **全员 LPC** | **所有玩家 + `npc-1`** 使用烘焙 LPC 皮 `sprites/lpc-npc-1.png`(walk + idle);**不再**用 `sprites/characters.png` 的 Stardew 四色 palette 区分玩家 | -| **其他 NPC** | `npc-2`…`npc-12` 等仍用 `sprites/npcs.png`(Stardew 16×32 @ 2×) | -| **资源管线** | 源图 `npc-asset/npc-1.png` → `pnpm assets:sync:lpc-npc1` → `public/assets/sprites/lpc-npc-1.png` | -| **运行时** | `lpcNpc1Sheet.ts` + `entitySprites.ts`;`useSpriteEntities()` 须同时存在 `spritesLpcNpc1` 与 `spritesNpcs` | +| **全员 LPC** | **所有玩家** + **`npc-1`…`npc-12`** 使用烘焙 LPC 皮 `sprites/lpc-player-1.png` + `sprites/lpc-npc-{1…12}.png`(walk + idle);**不再**用 `sprites/characters.png` 的 Stardew 四色 palette 区分玩家 | +| **资源管线** | 源图 `npc-asset/player-1.png` + `npc-asset/npc-{1…12}.png` → `pnpm assets:sync:lpc-npcs` → `public/assets/sprites/lpc-*.png` | +| **运行时** | `lpcNpc1Sheet.ts` + `entitySprites.ts`(`registerLpcNpcAnims` · `createLpcNpcSprite`);`useSpriteEntities()` 门槛:`spritesLpcNpc1` + `spritesNpcs`(全部 `lpc-npc-*` 随 `CORE_AREA_ASSETS` 同批加载) | | **铭牌** | 宋体(`Noto Serif SC`)、无描边/无底条、轻投影;名字/状态垂直堆叠见 `sceneLabelLayout.ts` | **回归:** `pnpm --filter @aetherlife/web test` · `pnpm verify:phase13` · `pnpm verify:phase6:move-only`(改 `RoomScene` / 实体 sprite 时) @@ -45,7 +44,7 @@ Web agent 摘要:[apps/web/AGENTS.md](../apps/web/AGENTS.md) · 回归 ISSUE-0 | `scripts/bake-beginning-fields.mjs` | 烘焙 TSX → `public/assets/one-city/` + `oneCityTilesetManifest.ts` | | `apps/web/src/game/oneCityTilesetManifest.ts` | **自动生成**;atlas = `spritesheet`,collection PNG = `image` | | `apps/web/src/game/areaLoader.ts` | `queueHomeMapAssets` → `queueAsset`(禁止 atlas 用 `loader.image`) | -| `apps/web/src/game/HomeMapBackground.ts` | Object layer 手动 sprite;**禁止** `createFromObjects` 替代 collection 路径 | +| `apps/web/src/game/HomeMapBackground.ts` | Object layer 手动 sprite;**Object Shadows** 归入 Phaser `Layer`(`MULTIPLY`);**禁止** `createFromObjects` 替代 collection 路径 | --- @@ -53,7 +52,14 @@ Web agent 摘要:[apps/web/AGENTS.md](../apps/web/AGENTS.md) · 回归 ISSUE-0 统一用 `entityLayout.ts` 的 `ySortDepth` / `entityYSortDepth` / `tiledObjectYSortDepth`。 -- Tiled tile object:**左下角** 锚点 +| 带 | depth | 说明 | +|----|-------|------| +| 瓦片层 | `MAP_TILE_DEPTH_BASE` 起按 Tiled 顺序递增 | Ground … Shadows … Water | +| **Object Shadows** | 插入 Tiled 图层序(Shadows 瓦片后、Water 前) | Phaser 4 `Layer`;`MULTIPLY` + α=0.25;**不参与** Y-sort | +| Object Layer 1 | `ENTITY_DEPTH_BASE` + Y-sort | `tiledObjectYSortDepth` | +| 玩家 / NPC | 同上 + `YSORT_LAYER` | `entityYSortDepth` | + +- Tiled tile object(Object Layer 1):**左下角** 锚点 - 玩家 / NPC:**格心 X + 格南缘 Y**(与 sprite 脚底一致) - 高大物件(树冠):将来走 Tiled **Overhead** 层 + `YSORT_OVERHEAD_DEPTH`,不单点 Y-sort diff --git a/docs/DEVELOPMENT-HISTORY.md b/docs/DEVELOPMENT-HISTORY.md index 595cc74..0a94076 100644 --- a/docs/DEVELOPMENT-HISTORY.md +++ b/docs/DEVELOPMENT-HISTORY.md @@ -851,11 +851,13 @@ This document synthesizes all **37 development phases** (including sub-phases). |---|---| | **Status** | ✅ Shipped (2026-07-01) | -**Goal:** Unify **all players** + **npc-1** on LPC walk/idle sprite; rescale grid to **CELL_PX=32** with **64px** character height (2 cells). +**Goal:** Unify **all players** + **`npc-1`…`npc-12`** on LPC walk/idle sprites; rescale grid to **CELL_PX=32** with **64px** character height (2 cells). -**Product decision:** **全员 LPC** — no Stardew `characters.png` palette rows for players; council NPCs `npc-2`…`npc-12` remain on `npcs.png`. +**Product decision:** **全员 LPC** — no Stardew `characters.png` palette rows for players; council seats **`npc-1`…`npc-12`** use baked `lpc-npc-*.png` (Stardew `npcs.png` is legacy fallback only). -**Key deliverables:** `scripts/sync-npc-lpc-assets.mjs` · `lpc-npc-1.png` · `lpcNpc1Sheet.ts` · `entitySprites` LPC path · nameplate refresh (`sceneLabelLayout.ts`, Songti, no stroke/backdrop). +**Key deliverables:** `scripts/sync-npc-lpc-assets.mjs` · `lpc-player-1.png` + `lpc-npc-{1…12}.png` · `lpcNpc1Sheet.ts` · `entitySprites` LPC path · nameplate refresh (`sceneLabelLayout.ts`, Songti, no stroke/backdrop). + +**2026-07-14 extension:** bake + runtime wire for all 12 council NPC LPC skins; `pnpm assets:sync:lpc-npcs`; Object Shadows depth band in `HomeMapBackground` + `MAP_TILE_DEPTH_*`. **Verification:** `pnpm --filter @aetherlife/web test` · `pnpm verify:phase6:move-only` · `pnpm verify:phase13` diff --git a/docs/DEVELOPMENT-HISTORY.zh-CN.md b/docs/DEVELOPMENT-HISTORY.zh-CN.md index 62b42cb..f8da76e 100644 --- a/docs/DEVELOPMENT-HISTORY.zh-CN.md +++ b/docs/DEVELOPMENT-HISTORY.zh-CN.md @@ -851,11 +851,13 @@ |---|---| | **状态** | ✅ 已交付 (2026-07-01) | -**目标:** **所有玩家** + **npc-1** 统一 LPC walk/idle;显示格 **CELL_PX=32**,角色高 **64px**(2 格)。 +**目标:** **所有玩家** + **`npc-1`…`npc-12`** 统一 LPC walk/idle;显示格 **CELL_PX=32**,角色高 **64px**(2 格)。 -**产品决策:** **全员 LPC** — 玩家不再用 Stardew `characters.png` 四色 palette;议会 `npc-2`…`npc-12` 仍用 `npcs.png`。 +**产品决策:** **全员 LPC** — 玩家不再用 Stardew `characters.png` 四色 palette;议会 **`npc-1`…`npc-12`** 均用烘焙 `lpc-npc-*.png`(`npcs.png` 仅 legacy fallback)。 -**交付物:** `scripts/sync-npc-lpc-assets.mjs` · `lpc-npc-1.png` · `lpcNpc1Sheet.ts` · 铭牌刷新(`sceneLabelLayout.ts`、宋体、无描边/底条)。 +**交付物:** `scripts/sync-npc-lpc-assets.mjs` · `lpc-player-1.png` + `lpc-npc-{1…12}.png` · `lpcNpc1Sheet.ts` · `entitySprites` LPC 路径 · 铭牌刷新(`sceneLabelLayout.ts`、宋体、无描边/底条)。 + +**2026-07-14 扩展:** 12 席议会 LPC 全量烘焙并接入运行时;`pnpm assets:sync:lpc-npcs`;`HomeMapBackground` Object Shadows depth 带 + `MAP_TILE_DEPTH_*`。 **验收:** `pnpm --filter @aetherlife/web test` · `pnpm verify:phase6:move-only` · `pnpm verify:phase13` diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index 0acbf91..8cddce9 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -189,7 +189,7 @@ ### Character visuals — LPC & CELL_PX(产品决策 · 2026-07) -106. **全员 LPC 角色皮**:本地/远端**所有玩家**与 **`npc-1`** 使用烘焙 `sprites/lpc-npc-1.png`(`createPlayerSprite` → `createLpcNpc1Sprite`);**禁止**恢复 `sprites/characters.png` 四色 palette 作玩家皮除非新开 phase 决策。`useSpriteEntities()` 门槛:`spritesLpcNpc1` + `spritesNpcs` 均须存在。烘焙:`pnpm assets:sync:lpc-npc1`(源 `npc-asset/npc-1.png`)。文档:[BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md) §角色视觉。回归:`pnpm --filter @aetherlife/web test` + `pnpm verify:phase6:move-only`。 +106. **全员 LPC 角色皮**:本地/远端**所有玩家**与 **`npc-1`…`npc-12`** 使用烘焙 `sprites/lpc-player-1.png` + `sprites/lpc-npc-{1…12}.png`(`createPlayerSprite` → `createLpcNpcSprite`;`spriteProfileForNpc` 映射 npc-1…12);**禁止**恢复 `sprites/characters.png` 四色 palette 作玩家皮除非新开 phase 决策。`useSpriteEntities()` 门槛:`spritesLpcNpc1` + `spritesNpcs` 均须存在(全部 `lpc-npc-*` 随 `CORE_AREA_ASSETS` 加载)。烘焙:`pnpm assets:sync:lpc-npcs`(源 `npc-asset/player-1.png` + `npc-asset/npc-{1…12}.png`)。文档:[BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md) §角色视觉。回归:`pnpm --filter @aetherlife/web test` + `pnpm verify:phase6:move-only`。 107. **显示格 CELL_PX=32**:`gridLayout.CELL_PX=32`(16px 源 ×2);角色显示高 `CHAR_DISPLAY_PX=64`(占 2 逻辑格)。改 `CELL_PX` 须同步 `entityLayout.LABEL_SCALE`、`GRID_STEP_MS`、地图注释与 [BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md)。Phase 13.3 历史仍为 48px 校准记录,**当前运行时以 32px 为准**。 108. **Phase 26+ verify 禁止断言 bg-villager**:`verify:phase16` / UAT 脚本 **禁止** 要求房间存在 `bg-villager-*`(ISSUE-101);ambient/铭牌回归用 12 席 council + `verify:phase26`。 @@ -2642,19 +2642,21 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk **决策** -- **全员 LPC**:所有玩家(含远端)+ `npc-1` 使用 `sprites/lpc-npc-1.png`;废弃 `sprites/characters.png` 四色 palette 作玩家皮。 +- **全员 LPC**:所有玩家(含远端)+ **`npc-1`…`npc-12`** 使用 `sprites/lpc-player-1.png` + `sprites/lpc-npc-{1…12}.png`;废弃 `sprites/characters.png` 四色 palette 作玩家皮。 - **显示格**:`CELL_PX=32`(16×2);角色高 64px(2 格);`GRID_STEP_MS=200`。 **交付** -- `scripts/sync-npc-lpc-assets.mjs` · `pnpm assets:sync:lpc-npc1` -- `lpcNpc1Sheet.ts` · `entitySprites` LPC 路径 · `sceneLabelLayout.ts` 铭牌堆叠 +- `scripts/sync-npc-lpc-assets.mjs` · `pnpm assets:sync:lpc-npcs`(源 `npc-asset/player-1.png` + `npc-{1…12}.png` → 13 张烘焙 atlas) +- `lpcNpc1Sheet.ts`(`LPC_NPC_PROFILES` · `spriteProfileForNpc`)· `entitySprites` LPC 路径 · `sceneLabelLayout.ts` 铭牌堆叠 +- `HomeMapBackground.ts` Object Shadows → Phaser `Layer`(`MULTIPLY`);`entityLayout.MAP_TILE_DEPTH_*` 瓦片 depth 带 - 文档:[BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md) §角色视觉 · Guardrails #106–#107 **验证** +- `pnpm assets:sync:lpc-npcs`(13× 704×256) +- `pnpm --filter @aetherlife/web exec vitest run src/game/lpcNpc1Sheet.test.ts` - `pnpm --filter @aetherlife/web test` -- `pnpm agent:verify` - `pnpm verify:phase6:move-only` · `pnpm verify:phase13`(须 `pnpm dev:stack`) **防复发** diff --git a/package.json b/package.json index 72b9241..724c480 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "art:import:roguelike": "node scripts/import-phase13-art.mjs --all", "art:import:tiles": "node scripts/import-phase13.2-farm-art.mjs --tiles-only", "art:import:lpc": "node scripts/import-phase13-art.mjs --characters --npcs", + "assets:sync:lpc-npcs": "node scripts/sync-npc-lpc-assets.mjs", "assets:sync:lpc-npc1": "node scripts/sync-npc-lpc-assets.mjs", "art:import:character": "node scripts/import-character-art.mjs", "art:placeholder": "node scripts/generate-phase13-atlases.mjs", diff --git a/scripts/sync-npc-lpc-assets.mjs b/scripts/sync-npc-lpc-assets.mjs index 2ed7e6b..db5c652 100644 --- a/scripts/sync-npc-lpc-assets.mjs +++ b/scripts/sync-npc-lpc-assets.mjs @@ -1,10 +1,12 @@ #!/usr/bin/env node /** - * Bake walk + idle from npc-asset/npc-1.png (Universal LPC composite) into a compact - * runtime spritesheet: apps/web/public/assets/sprites/lpc-npc-1.png + * Bake walk + idle from npc-asset LPC composites into compact runtime spritesheets. * * Layout: 4 facings × (9 walk + 2 idle) frames, 64×64 each → 704×256 PNG. * Facing row order: down, left, right, up (matches facing.ts CARDINALS). + * + * Sources: npc-asset/player-1.png + npc-asset/npc-{1..12}.png + * Output: apps/web/public/assets/sprites/lpc-player-1.png + lpc-npc-{1..12}.png */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -12,8 +14,22 @@ import { fileURLToPath } from "node:url"; import { PNG } from "pngjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const SRC = resolve(root, "npc-asset/npc-1.png"); -const OUT = resolve(root, "apps/web/public/assets/sprites/lpc-npc-1.png"); +const LPC_NPC_COUNT = 12; + +/** @type {{ src: string; out: string }[]} */ +const TARGETS = [ + { + src: resolve(root, "npc-asset/player-1.png"), + out: resolve(root, "apps/web/public/assets/sprites/lpc-player-1.png"), + }, + ...Array.from({ length: LPC_NPC_COUNT }, (_, i) => { + const n = i + 1; + return { + src: resolve(root, `npc-asset/npc-${n}.png`), + out: resolve(root, `apps/web/public/assets/sprites/lpc-npc-${n}.png`), + }; + }), +]; const LPC_FRAME = 64; const WALK_FRAMES = 9; @@ -78,8 +94,16 @@ function blitFrame(dst, dstCol, dstRow, rgba) { } } -function bake() { - const src = loadPng(SRC); +function bakeOne({ src: srcPath, out: outPath }) { + const src = loadPng(srcPath); + const minW = WALK_FRAMES * LPC_FRAME; + const minH = (IDLE_BASE_ROW + FACING_COUNT) * LPC_FRAME; + if (src.width < minW || src.height < minH) { + throw new Error( + `LPC sheet too small: ${srcPath} is ${src.width}×${src.height}, need ≥${minW}×${minH}`, + ); + } + const atlasW = FRAMES_PER_FACING * LPC_FRAME; const atlasH = FACING_COUNT * LPC_FRAME; const atlas = new PNG({ width: atlasW, height: atlasH }); @@ -98,9 +122,11 @@ function bake() { } } - mkdirSync(dirname(OUT), { recursive: true }); - writeFileSync(OUT, PNG.sync.write(atlas)); - console.log(`wrote ${OUT} (${atlasW}×${atlasH}, ${FACING_COUNT * FRAMES_PER_FACING} frames)`); + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, PNG.sync.write(atlas)); + console.log(`wrote ${outPath} (${atlasW}×${atlasH}, ${FACING_COUNT * FRAMES_PER_FACING} frames)`); } -bake(); +for (const target of TARGETS) { + bakeOne(target); +}