diff --git a/.gitignore b/.gitignore index 0529545..e5da4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ memory.md # Local asset drops (not wired into build yet) npc-img/ +npc-asset/ # Internal development docs (local only) docs/ISSUE-LOG.md diff --git a/AGENTS.md b/AGENTS.md index 3757eef..3adf865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,8 +124,8 @@ Plan → scope → `pnpm agent:verify` → `pnpm agent:verify --e2e`(golden fl | 路径 | 契约 | 回归 gate | |------|------|-----------| -| `apps/web/src/game/entityLabels.ts` | Proximity 铭牌 12px + stroke 4px + shadow(ISSUE-038) | `pnpm verify:phase13` + UAT #6/#7 | -| `apps/web/src/game/entitySprites.ts` | `SPRITE_NAMEPLATE_Y` 在 sprite 头顶上方 | 同上 | +| `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/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 97d75d8..c8d6ce1 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -79,11 +79,25 @@ Before **Write/Edit** on `src/game/**` or `PhaserGame.tsx`, **Read** the matchin ## Beginning Fields -Home Tiled map (40×40). Full bake / collision / Y-sort workflow: **[docs/BEGINNING-FIELDS.md](../../docs/BEGINNING-FIELDS.md)**. +Home Tiled map (40×40 @ 16px → **32px/grid** in-game). Full bake / collision / Y-sort workflow: **[docs/BEGINNING-FIELDS.md](../../docs/BEGINNING-FIELDS.md)**. - After map change: `node scripts/bake-beginning-fields.mjs` - Regression: home Campfire 2×2 + animation (ISSUE-042) +## Character visuals(全员 LPC · 2026-07) + +**产品决策:** 所有玩家 + `npc-1` → LPC `sprites/lpc-npc-1.png`;议会其余 NPC → Stardew `sprites/npcs.png`。 + +| Task | Command / path | +|------|----------------| +| 烘焙 LPC 皮 | `pnpm assets:sync:lpc-npc1`(源 `npc-asset/npc-1.png`) | +| 常量 | `gridLayout.CELL_PX=32` · `entityLayout.CHAR_DISPLAY_PX=64` | +| 运行时 | `lpcNpc1Sheet.ts` · `entitySprites.ts` · `sceneLabelLayout.ts` | +| 文档 | [BEGINNING-FIELDS.md](../../docs/BEGINNING-FIELDS.md) §角色视觉 | +| 回归 | `pnpm verify:phase6:move-only` + `pnpm verify:phase13` | + +Guardrails #106–#107 · Frozen UX 见根 [AGENTS.md](../../AGENTS.md)。 + ## Verify locally ```bash diff --git a/apps/web/public/assets/CREDITS.md b/apps/web/public/assets/CREDITS.md index 502718a..5473f9e 100644 --- a/apps/web/public/assets/CREDITS.md +++ b/apps/web/public/assets/CREDITS.md @@ -46,19 +46,35 @@ Phase 13.2 farm art (default production). Regenerate atlases: `pnpm art:import` | **Import script** | `scripts/import-phase13-art.mjs` (`pnpm art:import:roguelike`) — **not production default** | | **Vendor copy** | `scripts/vendor/phase13/kenney/` | -## Characters & NPCs (Stardew-style @character) +## Characters & NPCs — **全员 LPC(产品决策 · 2026-07)** + +| 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` | +| **显示** | `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)。 + +## Characters & NPCs (Stardew-style @character — **legacy player skin**) | Field | Value | |-------|--------| | **Asset** | `assets/character/Walk.png` + `Idle.png` (32×32 cells → crop → **16×32** @ 3× scale) | | **Import** | `pnpm art:import:character` → `scripts/import-character-art.mjs` | -| **Output** | `sprites/characters.png` (4 palette rows), `sprites/npcs.png` (2 variants × 4 facings) | +| **Output** | `sprites/characters.png` (4 palette rows) — **dev/rollback only**;生产玩家皮已切 LPC | | **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) | -Legacy LPC pass: `pnpm art:import:lpc` (`scripts/vendor/phase13/lpc/`). +Council NPCs(非 npc-1):`sprites/npcs.png`(同上 import,2 variants × 4 facings)。 + +Legacy LPC vendor pass: `pnpm art:import:lpc` (`scripts/vendor/phase13/lpc/`). -## Characters & NPCs (Universal LPC — legacy) +## Characters & NPCs (Universal LPC — legacy vendor) ## Dev-only placeholders diff --git a/apps/web/public/assets/sprites/lpc-npc-1.png b/apps/web/public/assets/sprites/lpc-npc-1.png new file mode 100644 index 0000000..ffbd805 Binary files /dev/null and b/apps/web/public/assets/sprites/lpc-npc-1.png differ diff --git a/apps/web/src/components/DialogueBar.tsx b/apps/web/src/components/DialogueBar.tsx index 305ab66..d6c3675 100644 --- a/apps/web/src/components/DialogueBar.tsx +++ b/apps/web/src/components/DialogueBar.tsx @@ -86,6 +86,7 @@ export function DialogueBar({
diff --git a/apps/web/src/game/FloorRenderer.ts b/apps/web/src/game/FloorRenderer.ts index a9bef29..aca23b5 100644 --- a/apps/web/src/game/FloorRenderer.ts +++ b/apps/web/src/game/FloorRenderer.ts @@ -5,7 +5,7 @@ import { isGlobalFloorBlocked } from "./floorBlocked.js"; import { CELL_PX } from "./gridLayout.js"; import { TILE_PX } from "./assetManifest.js"; -/** Match decor/sprites: 16px atlas tiles fill 48px grid cells (3× scale). */ +/** Match decor/sprites: 16px atlas tiles fill 32px grid cells (2× scale). */ const FLOOR_TILE_SCALE = CELL_PX / TILE_PX; import { isWetlandShoreCell, diff --git a/apps/web/src/game/HomeMapBackground.ts b/apps/web/src/game/HomeMapBackground.ts index 3691529..d78d595 100644 --- a/apps/web/src/game/HomeMapBackground.ts +++ b/apps/web/src/game/HomeMapBackground.ts @@ -164,7 +164,7 @@ function applyObjectTileAnimation( } /** - * Beginning Fields Tiled JSON (40×40 @ 16px, scaled to 48px cells). + * Beginning Fields Tiled JSON (40×40 @ 16px, scaled to 32px cells). * Plan A: sole ground art for chunk (0,0); no procedural floor inside bounds. */ export class HomeMapBackground { diff --git a/apps/web/src/game/LocalPlayerMovementController.ts b/apps/web/src/game/LocalPlayerMovementController.ts index cddfa69..0c43567 100644 --- a/apps/web/src/game/LocalPlayerMovementController.ts +++ b/apps/web/src/game/LocalPlayerMovementController.ts @@ -256,7 +256,7 @@ export class LocalPlayerMovementController { x: wx, y: wy, duration: GRID_STEP_MS, - ease: "Cubic.easeInOut", + ease: "Linear", onUpdate: (tween) => { const target = tween.targets[0] as Phaser.GameObjects.Container; ent.container.setDepth( diff --git a/apps/web/src/game/RoomScene.ts b/apps/web/src/game/RoomScene.ts index 31836b0..3a1f22f 100644 --- a/apps/web/src/game/RoomScene.ts +++ b/apps/web/src/game/RoomScene.ts @@ -71,9 +71,12 @@ import { npcVariantForId, playIdleAnim, registerCharacterAnims, + registerLpcNpc1Anims, registerNpcAnims, - SPRITE_NAMEPLATE_Y, + spriteNameplateY, + spriteProfileForNpc, } from "./entitySprites.js"; +import { spriteProfileForPlayer } from "./lpcNpc1Sheet.js"; import { ASSET_KEYS } from "./assetManifest.js"; import { logBootTiming, @@ -327,6 +330,7 @@ export class RoomScene extends Phaser.Scene { this.scale.on("resize", this.handleScaleResize, this); this.time.delayedCall(0, () => this.fitCamera()); if (this.useSpriteEntities()) { + registerLpcNpc1Anims(this); registerCharacterAnims(this); registerNpcAnims(this); } @@ -405,7 +409,11 @@ export class RoomScene extends Phaser.Scene { } private useSpriteEntities(): boolean { - return !isVisualFallbackActive(this) && this.textures.exists(ASSET_KEYS.spritesCharacters); + return ( + !isVisualFallbackActive(this) + && this.textures.exists(ASSET_KEYS.spritesLpcNpc1) + && this.textures.exists(ASSET_KEYS.spritesNpcs) + ); } private handleEntityStepStart( @@ -772,6 +780,7 @@ export class RoomScene extends Phaser.Scene { if (!this.useSpriteEntities()) return ent; ent.spriteMode = true; + ent.spriteProfile = spriteProfileForPlayer(); ent.paletteRow = paletteRow; ent.facingDir = "down"; const avatar = createPlayerSprite(this, paletteRow); @@ -779,7 +788,7 @@ export class RoomScene extends Phaser.Scene { ent.ring.setVisible(false); ent.label.setText(truncateNameplate(label)); ent.label.setAlpha(0); - ent.label.y = SPRITE_NAMEPLATE_Y; + ent.label.y = spriteNameplateY(ent.spriteProfile); applyNameplateStyle(ent.label, "player"); ent.container.addAt(avatar, 0); ent.avatar = avatar; @@ -817,17 +826,18 @@ export class RoomScene extends Phaser.Scene { ent.spriteMode = true; ent.isNpc = true; + ent.spriteProfile = spriteProfileForNpc(npcId); ent.paletteRow = npcVariantForId(npcId); ent.facingDir = "down"; - ent.activityLabel?.setY(activityLabelY(true)); - ent.intentLabel?.setY(intentLabelY(true)); + ent.activityLabel?.setY(activityLabelY(true, ent.spriteProfile)); + ent.intentLabel?.setY(intentLabelY(true, ent.spriteProfile)); const avatar = createNpcSprite(this, npcId, isBg ? BG_NPC_TINT : undefined); - const bubble = createSpeechBubble(this); + const bubble = createSpeechBubble(this, ent.spriteProfile); ent.body.setVisible(false); ent.ring.setVisible(false); ent.label.setText(truncateNameplate(label)); ent.label.setAlpha(0); - ent.label.y = SPRITE_NAMEPLATE_Y; + ent.label.y = spriteNameplateY(ent.spriteProfile); if (isBg) { applyBgNameplateStyle(ent.label); } else { @@ -850,7 +860,7 @@ export class RoomScene extends Phaser.Scene { applyBgActivityStyle(activityLabel); activityLabel.setData("testid", `bg-npc-activity-${ent.npcId}`); } - activityLabel.y = activityLabelY(ent.spriteMode === true); + activityLabel.y = activityLabelY(ent.spriteMode === true, ent.spriteProfile); ent.container.add(activityLabel); ent.activityLabel = activityLabel; } @@ -858,7 +868,7 @@ export class RoomScene extends Phaser.Scene { private attachNpcIntentLabel(ent: EntitySprite): void { if (!ent.npcId) return; const intentLabel = createIntentLabel(this, ent.npcId); - intentLabel.y = intentLabelY(ent.spriteMode === true); + intentLabel.y = intentLabelY(ent.spriteMode === true, ent.spriteProfile); ent.container.add(intentLabel); ent.intentLabel = intentLabel; } @@ -896,6 +906,8 @@ export class RoomScene extends Phaser.Scene { ent.bobTween = undefined; ent.pulseTween?.stop(); ent.pulseTween = undefined; + ent.speakHaloTween?.stop(); + ent.speakHaloTween = undefined; ent.nameplateTween?.stop(); ent.nameplateTween = undefined; ent.activityLabelTween?.stop(); @@ -904,6 +916,7 @@ export class RoomScene extends Phaser.Scene { ent.intentLabelTween = undefined; ent.moveTween?.stop(); ent.moveTween = undefined; + this.tweens.killTweensOf(ent.ring); this.tweens.killTweensOf(ent.container); } @@ -1084,7 +1097,9 @@ export class RoomScene extends Phaser.Scene { reduced: boolean, thinking: boolean, ): void { - const labelY = ent.spriteMode ? SPRITE_NAMEPLATE_Y : MARKER_LABEL_Y; + const labelY = ent.spriteMode + ? spriteNameplateY(ent.spriteProfile ?? "stardew") + : MARKER_LABEL_Y; if (reduced || thinking) { ent.bobTween?.stop(); ent.bobTween = undefined; diff --git a/apps/web/src/game/activityLabels.ts b/apps/web/src/game/activityLabels.ts index ac4651d..116f90a 100644 --- a/apps/web/src/game/activityLabels.ts +++ b/apps/web/src/game/activityLabels.ts @@ -1,7 +1,6 @@ import type * as Phaser from "phaser"; -import { ENTITY_LABEL_FONT } from "./entityLabels.js"; -import { MARKER_LABEL_Y } from "./entityLayout.js"; -import { SPRITE_NAMEPLATE_Y } from "./entitySprites.js"; +import { applySceneHanLabelBase, SCENE_LABEL_FONT } from "./entityLabels.js"; +import { activityFontPx } from "./entityLayout.js"; import { resolveActivityLabel, shouldShowActivity, @@ -22,11 +21,11 @@ export { const FADE_IN_MS = 150; const FADE_OUT_MS = 100; -export const ACTIVITY_LABEL_FONT_SIZE = "10px"; -export const ACTIVITY_LABEL_COLOR = "#b8c4a8"; -export const ACTIVITY_LABEL_STROKE_COLOR = "#000000"; -export const ACTIVITY_LABEL_STROKE_WIDTH = 3; -export const ACTIVITY_LABEL_Y_OFFSET = 14; +export const ACTIVITY_LABEL_FONT_SIZE_PX = activityFontPx(); +export const ACTIVITY_LABEL_FONT_SIZE = `${ACTIVITY_LABEL_FONT_SIZE_PX}px`; +export const ACTIVITY_LABEL_COLOR = "#e6e8dc"; + +export { activityLabelY } from "./sceneLabelLayout.js"; export type ActivityTarget = { activityLabel: Phaser.GameObjects.Text; @@ -47,21 +46,15 @@ function targetCell(t: ActivityTarget): { x: number; y: number } { return { x, y }; } -export function activityLabelY(spriteMode: boolean | undefined): number { - const base = spriteMode ? SPRITE_NAMEPLATE_Y : MARKER_LABEL_Y; - return base + ACTIVITY_LABEL_Y_OFFSET; -} - export function createActivityLabel(scene: Phaser.Scene, npcId: string): Phaser.GameObjects.Text { const label = scene.add.text(0, 0, "", { fontSize: ACTIVITY_LABEL_FONT_SIZE, - fontFamily: ENTITY_LABEL_FONT, - fontStyle: "600", + fontFamily: SCENE_LABEL_FONT, + fontStyle: "normal", color: ACTIVITY_LABEL_COLOR, align: "center", - stroke: ACTIVITY_LABEL_STROKE_COLOR, - strokeThickness: ACTIVITY_LABEL_STROKE_WIDTH, }); + applySceneHanLabelBase(label); label.setOrigin(0.5, 1); label.setScrollFactor(1); label.setAlpha(0); diff --git a/apps/web/src/game/assetManifest.ts b/apps/web/src/game/assetManifest.ts index 0d55019..7046fb2 100644 --- a/apps/web/src/game/assetManifest.ts +++ b/apps/web/src/game/assetManifest.ts @@ -42,6 +42,8 @@ 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). */ + spritesLpcNpc1: "sprites/lpc-npc-1", spritesUiSpeech: "sprites/ui-speech", tilesScrubPack: "tiles/biome-scrub", tilesWetlandPack: "tiles/biome-wetland", @@ -78,6 +80,13 @@ export const CORE_AREA_ASSETS: AssetSheetDef[] = [ frameWidth: CHAR_FRAME_W, frameHeight: CHAR_FRAME_H, }, + { + kind: "spritesheet", + key: ASSET_KEYS.spritesLpcNpc1, + url: `${BASE}/sprites/lpc-npc-1.png`, + frameWidth: 64, + frameHeight: 64, + }, { kind: "image", key: ASSET_KEYS.spritesUiSpeech, url: `${BASE}/sprites/ui-speech.png` }, ]; diff --git a/apps/web/src/game/bgNpcLabels.test.ts b/apps/web/src/game/bgNpcLabels.test.ts index e9e3292..84824bb 100644 --- a/apps/web/src/game/bgNpcLabels.test.ts +++ b/apps/web/src/game/bgNpcLabels.test.ts @@ -15,6 +15,7 @@ function mockLabel() { setFontSize(size: string) { style.fontSize = size; }, + setFontFamily() {}, setFontStyle(weight: string) { style.fontStyle = weight; }, @@ -42,9 +43,8 @@ describe("bgNpcLabels", () => { const label = mockLabel(); applyBgNameplateStyle(label as never); expect(label.style.fontSize).toBe(BG_NAMEPLATE_FONT_SIZE); - expect(label.style.fontStyle).toBe("600"); - expect(label.style.color).toBe("#c8c0a8"); - expect(label.style.strokeThickness).toBe(4); + expect(label.style.fontStyle).toBe("bold"); + expect(label.style.color).toBe("#e8e0c8"); expect(label.getData("testid")).toBe(BG_NPC_NAMEPLATE_TESTID); }); @@ -52,8 +52,7 @@ describe("bgNpcLabels", () => { const label = mockLabel(); applyBgActivityStyle(label as never); expect(label.style.fontSize).toBe(BG_ACTIVITY_FONT_SIZE); - expect(label.style.fontStyle).toBe("500"); - expect(label.style.color).toBe("#9aa890"); - expect(label.style.strokeThickness).toBe(2); + expect(label.style.fontStyle).toBe("normal"); + expect(label.style.color).toBe("#d0dcc4"); }); }); diff --git a/apps/web/src/game/bgNpcLabels.ts b/apps/web/src/game/bgNpcLabels.ts index 7e055fd..e87d787 100644 --- a/apps/web/src/game/bgNpcLabels.ts +++ b/apps/web/src/game/bgNpcLabels.ts @@ -1,34 +1,30 @@ import type * as Phaser from "phaser"; -import { ENTITY_LABEL_FONT } from "./entityLabels.js"; +import { applySceneHanLabelBase, SCENE_LABEL_FONT } from "./entityLabels.js"; +import { activityFontPx, nameplateFontPx } from "./entityLayout.js"; -/** Wave 5 background tier — separate from frozen main NPC nameplate (entityLabels.ts). */ -export const BG_NAMEPLATE_FONT_SIZE = "11px"; -export const BG_NAMEPLATE_COLOR = "#c8c0a8"; -export const BG_NAMEPLATE_STROKE_COLOR = "#000000"; -export const BG_NAMEPLATE_STROKE_WIDTH = 4; +/** Wave 5 background tier — separate from main NPC nameplate (entityLabels.ts). */ +export const BG_NAMEPLATE_FONT_SIZE_PX = Math.max(12, nameplateFontPx() - 1); +export const BG_NAMEPLATE_FONT_SIZE = `${BG_NAMEPLATE_FONT_SIZE_PX}px`; +export const BG_NAMEPLATE_COLOR = "#e8e0c8"; -export const BG_ACTIVITY_FONT_SIZE = "9px"; -export const BG_ACTIVITY_COLOR = "#9aa890"; -export const BG_ACTIVITY_STROKE_COLOR = "#000000"; -export const BG_ACTIVITY_STROKE_WIDTH = 2; +export const BG_ACTIVITY_FONT_SIZE_PX = activityFontPx(); +export const BG_ACTIVITY_FONT_SIZE = `${BG_ACTIVITY_FONT_SIZE_PX}px`; +export const BG_ACTIVITY_COLOR = "#d0dcc4"; export const BG_NPC_TINT = 0xcccccc; export const BG_NPC_NAMEPLATE_TESTID = "bg-npc-nameplate"; export function applyBgNameplateStyle(label: Phaser.GameObjects.Text): void { + applySceneHanLabelBase(label); label.setFontSize(BG_NAMEPLATE_FONT_SIZE); - label.setFontStyle("600"); + label.setFontStyle("bold"); label.setColor(BG_NAMEPLATE_COLOR); - label.setStroke(BG_NAMEPLATE_STROKE_COLOR, BG_NAMEPLATE_STROKE_WIDTH); - label.setShadow(0, 0, "#000000", 0, false, false); - label.setBackgroundColor(""); - label.setPadding(0, 0, 0, 0); label.setData("testid", BG_NPC_NAMEPLATE_TESTID); } export function applyBgActivityStyle(label: Phaser.GameObjects.Text): void { + applySceneHanLabelBase(label); label.setFontSize(BG_ACTIVITY_FONT_SIZE); - label.setFontStyle("500"); + label.setFontStyle("normal"); label.setColor(BG_ACTIVITY_COLOR); - label.setStroke(BG_ACTIVITY_STROKE_COLOR, BG_ACTIVITY_STROKE_WIDTH); } diff --git a/apps/web/src/game/entityLabels.test.ts b/apps/web/src/game/entityLabels.test.ts index 1592fbe..fb71844 100644 --- a/apps/web/src/game/entityLabels.test.ts +++ b/apps/web/src/game/entityLabels.test.ts @@ -4,12 +4,12 @@ import { NAMEPLATE_FONT_SIZE, NAMEPLATE_NPC_COLOR, NAMEPLATE_PLAYER_COLOR, - NAMEPLATE_STROKE_COLOR, - NAMEPLATE_STROKE_WIDTH, + SCENE_LABEL_FONT, } from "./entityLabels.js"; function mockLabel() { return { + setFontFamily: vi.fn(), setFontSize: vi.fn(), setFontStyle: vi.fn(), setColor: vi.fn(), @@ -17,22 +17,21 @@ function mockLabel() { setShadow: vi.fn(), setBackgroundColor: vi.fn(), setPadding: vi.fn(), + setWordWrapWidth: vi.fn(), }; } describe("applyNameplateStyle", () => { - it("applies frozen high-contrast player nameplate tokens", () => { + it("applies Songti nameplate without stroke or backdrop", () => { const label = mockLabel(); applyNameplateStyle(label as never, "player"); + expect(label.setFontFamily).toHaveBeenCalledWith(SCENE_LABEL_FONT); expect(label.setFontSize).toHaveBeenCalledWith(NAMEPLATE_FONT_SIZE); expect(label.setColor).toHaveBeenCalledWith(NAMEPLATE_PLAYER_COLOR); - expect(label.setStroke).toHaveBeenCalledWith( - NAMEPLATE_STROKE_COLOR, - NAMEPLATE_STROKE_WIDTH, - ); - expect(label.setShadow).toHaveBeenCalledWith(0, 0, "#000000", 0, false, false); + expect(label.setStroke).toHaveBeenCalledWith("#000000", 0); expect(label.setBackgroundColor).toHaveBeenCalledWith(""); expect(label.setPadding).toHaveBeenCalledWith(0, 0, 0, 0); + expect(label.setShadow).toHaveBeenCalledWith(1, 1, "rgba(0,0,0,0.45)", 1, false, false); }); it("applies NPC fill color", () => { diff --git a/apps/web/src/game/entityLabels.ts b/apps/web/src/game/entityLabels.ts index b3d8913..de41ba3 100644 --- a/apps/web/src/game/entityLabels.ts +++ b/apps/web/src/game/entityLabels.ts @@ -1,33 +1,51 @@ import type * as Phaser from "phaser"; +import { labelPx, nameplateFontPx } from "./entityLayout.js"; -/** In-scene entity labels — aligned with 07-UI-SPEC (Source Serif 4, 11px, --text). */ +/** In-scene entity labels — aligned with 07-UI-SPEC (Source Serif 4, scaled to CELL_PX). */ export const ENTITY_LABEL_FONT = '"Source Serif 4", "Noto Serif SC", serif'; -export const ENTITY_LABEL_COLOR = "#e8e2d6"; -export const ENTITY_LABEL_FONT_SIZE = "11px"; -export const THINKING_PULSE_MS = 1200; /** - * Proximity nameplates — high contrast on Kenney pastoral tiles (13-UAT #6). - * **Frozen contract:** do not weaken stroke/shadow/size without verify:phase13 + UAT test 6/7. + * Scene nameplates — 宋体系(Web: Noto Serif SC;系统: 宋体/SimSun)。 + * Alternatives (swap `SCENE_LABEL_FONT`): + * - 黑体: SCENE_LABEL_FONT_SANS + * - 楷体: SCENE_LABEL_FONT_KAI */ -export const NAMEPLATE_FONT_SIZE = "13px"; +export const SCENE_LABEL_FONT = + '"Noto Serif SC", "Songti SC", "SimSun", "STSong", serif'; + +export const SCENE_LABEL_FONT_SANS = + '"Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", sans-serif'; + +export const SCENE_LABEL_FONT_KAI = + '"KaiTi", "STKaiti", "Noto Serif SC", serif'; + +export const ENTITY_LABEL_COLOR = "#e8e2d6"; +export const ENTITY_LABEL_FONT_SIZE = labelPx(11); +export const THINKING_PULSE_MS = 1200; + +export const NAMEPLATE_FONT_SIZE_PX = nameplateFontPx(); +export const NAMEPLATE_FONT_SIZE = `${NAMEPLATE_FONT_SIZE_PX}px`; export const NAMEPLATE_PLAYER_COLOR = "#ffffff"; -export const NAMEPLATE_NPC_COLOR = "#fff4a8"; -export const NAMEPLATE_STROKE_COLOR = "#000000"; -export const NAMEPLATE_STROKE_WIDTH = 5; +export const NAMEPLATE_NPC_COLOR = "#fff6b8"; + +/** No stroke, no backdrop — light drop shadow for tile contrast. */ +export function applySceneHanLabelBase(label: Phaser.GameObjects.Text): void { + label.setFontFamily(SCENE_LABEL_FONT); + label.setStroke("#000000", 0); + label.setBackgroundColor(""); + label.setPadding(0, 0, 0, 0); + label.setShadow(1, 1, "rgba(0,0,0,0.45)", 1, false, false); +} export function applyNameplateStyle( label: Phaser.GameObjects.Text, kind: "player" | "npc", ): void { + applySceneHanLabelBase(label); label.setFontSize(NAMEPLATE_FONT_SIZE); - label.setFontStyle("700"); + label.setFontStyle("600"); label.setColor(kind === "npc" ? NAMEPLATE_NPC_COLOR : NAMEPLATE_PLAYER_COLOR); - label.setStroke(NAMEPLATE_STROKE_COLOR, NAMEPLATE_STROKE_WIDTH); - // shadowFill=true draws a solid dark rectangle behind glyphs — text-only nameplates use stroke only - label.setShadow(0, 0, "#000000", 0, false, false); - label.setBackgroundColor(""); - label.setPadding(0, 0, 0, 0); + label.setWordWrapWidth(0); } export function npcDisplayName(name: string): string { diff --git a/apps/web/src/game/entityLayout.ts b/apps/web/src/game/entityLayout.ts index b20b8ed..237deec 100644 --- a/apps/web/src/game/entityLayout.ts +++ b/apps/web/src/game/entityLayout.ts @@ -5,18 +5,48 @@ import { CELL_PX } from "./gridLayout.js"; */ export { CELL_PX }; -/** Disc radius (fits inside 48px cell with label above). */ -export const MARKER_RADIUS = 14; +/** On-screen character height — two logic cells (feet anchor unchanged). */ +export const CHAR_DISPLAY_PX = CELL_PX * 2; + +/** Typography / marker scale vs Phase 13 UAT baseline (@ CELL_PX=48). */ +export const LABEL_SCALE = CELL_PX / 48; + +/** Minimum glyph sizes for legible Han labels on small grids. */ +export const LABEL_MIN_NAMEPLATE_PX = 10; +export const LABEL_MIN_ACTIVITY_PX = 9; + +export function labelPx(base: number, minPx = 8): string { + return `${Math.max(minPx, Math.round(base * LABEL_SCALE))}px`; +} + +export function nameplateFontPx(): number { + return Math.max(LABEL_MIN_NAMEPLATE_PX, Math.round(12 * LABEL_SCALE)); +} + +export function activityFontPx(): number { + return Math.max(LABEL_MIN_ACTIVITY_PX, Math.round(10 * LABEL_SCALE)); +} + +export function intentFontPx(): number { + return Math.max(LABEL_MIN_ACTIVITY_PX, Math.round(8 * LABEL_SCALE)); +} + +export function labelOffset(base: number): number { + return Math.max(2, Math.round(base * LABEL_SCALE)); +} + +/** Disc radius (fits inside cell with label above). */ +export const MARKER_RADIUS = labelOffset(14); /** Disc center — container origin = cell center (gridToWorld). */ export const MARKER_CY = 0; /** Label baseline above disc center. */ -export const MARKER_LABEL_Y = -(MARKER_RADIUS + 6); +export const MARKER_LABEL_Y = -(MARKER_RADIUS + labelOffset(6)); export const MARKER_STROKE = 2; -export const MARKER_LABEL_MAX_WIDTH = 44; +export const MARKER_LABEL_MAX_WIDTH = labelOffset(44); /** 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 f22a271..8ef225e 100644 --- a/apps/web/src/game/entitySprites.ts +++ b/apps/web/src/game/entitySprites.ts @@ -17,27 +17,62 @@ import { facingToIndex, schemaFacingToCardinal, } from "./facing.js"; -import { CELL_PX, MARKER_CY } from "./entityLayout.js"; +import { CELL_PX, CHAR_DISPLAY_PX, LABEL_SCALE, MARKER_CY, labelOffset } from "./entityLayout.js"; +import { + LPC_NPC1_IDLE_FRAMES, + LPC_NPC1_SCALE, + LPC_NPC1_STEPS_PER_CYCLE, + LPC_NPC1_WALK_FRAMES, + LPC_NPC1_IDLE_FRAME_RATE, + lpcNpc1AnimKey, + lpcNpc1FrameIndex, + lpcNpc1NameplateY, + lpcNpc1WalkStepAnimKey, + lpcNpc1WalkStepFrameRate, + lpcNpc1WalkStepRange, + spriteProfileForNpc, + type LpcNpc1SpriteProfile, +} from "./lpcNpc1Sheet.js"; import { theme } from "./theme.js"; -const SPRITE_SCALE = CELL_PX / TILE_PX; +const TILE_SCALE = CELL_PX / TILE_PX; +/** Stardew 16×32 frames → CHAR_DISPLAY_PX tall (2 cells). */ +const CHAR_SPRITE_SCALE = CHAR_DISPLAY_PX / CHAR_FRAME_H; /** Feet anchor: cell center → south edge (gridToWorld origin). */ const SPRITE_FOOT_Y = MARKER_CY + CELL_PX / 2; -const SPRITE_TOP_Y = SPRITE_FOOT_Y - CHAR_FRAME_H * SPRITE_SCALE; +const SPRITE_TOP_Y = SPRITE_FOOT_Y - CHAR_FRAME_H * CHAR_SPRITE_SCALE; + +/** Nameplate baseline — just above sprite head (label origin 0.5, 1). */ +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); + return SPRITE_NAMEPLATE_Y; +} + +export { spriteProfileForNpc }; -/** Nameplate baseline above sprite head (label origin 0.5, 1). */ -export const SPRITE_NAMEPLATE_Y = SPRITE_TOP_Y + 10; +/** Chat cue beside nameplate (Stardew-style, tilted) — scales with CELL_PX. */ +const CHAT_BUBBLE_BASE_SCALE = 1.75; + +export function spriteChatBubbleX(): number { + return labelOffset(26); +} + +export function spriteChatBubbleY(profile: SpriteProfile = "stardew"): number { + return spriteNameplateY(profile) - labelOffset(10); +} -/** Chat cue beside nameplate (Stardew-style, tilted). */ -export const SPRITE_CHAT_BUBBLE_X = 26; -export const SPRITE_CHAT_BUBBLE_Y = SPRITE_NAMEPLATE_Y - 10; export const SPRITE_CHAT_BUBBLE_ANGLE = 30; -export const SPRITE_CHAT_BUBBLE_SCALE = 1.75; +export const SPRITE_CHAT_BUBBLE_SCALE = CHAT_BUBBLE_BASE_SCALE * LABEL_SCALE; const CHAT_BUBBLE_TWEEN_KEY = "chatBubbleTween"; +const CHAT_BUBBLE_BASE_Y_KEY = "chatBubbleBaseY"; const CARDINALS: CardinalFacing[] = ["down", "left", "right", "up"]; +export type SpriteProfile = LpcNpc1SpriteProfile | "stardew"; + export type AnimatableEntity = { avatar?: Phaser.GameObjects.Sprite; bubble?: Phaser.GameObjects.Image; @@ -45,6 +80,9 @@ export type AnimatableEntity = { paletteRow?: number; 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( @@ -57,12 +95,17 @@ export function animKey( return `${prefix}-${kind}-${facing}-p${paletteRow}`; } -/** Stardew-style sheets only draw right profile; mirror for left. */ +/** Stardew-style sheets only draw right profile; mirror for left. LPC has native left frames. */ export function applyFacingFlip( avatar: Phaser.GameObjects.Sprite | undefined, facing: CardinalFacing, + profile: SpriteProfile = "stardew", ): void { if (!avatar) return; + if (profile === "lpc-npc-1") { + avatar.setFlipX(false); + return; + } avatar.setFlipX(facing === "left"); } @@ -124,6 +167,39 @@ 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(); + 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 idleBase = lpcNpc1FrameIndex(facing, "idle", 0); + scene.anims.create({ + key: lpcNpc1AnimKey("idle", facing), + frames: scene.anims.generateFrameNumbers(texture, { + start: idleBase, + end: idleBase + LPC_NPC1_IDLE_FRAMES - 1, + }), + frameRate: LPC_NPC1_IDLE_FRAME_RATE, + repeat: -1, + }); + } +} + export function paletteRowForPlayerId( playerId: string | undefined, sessionId: string, @@ -159,16 +235,19 @@ export function npcTintForId(npcId: string): number { ); } +export function createLpcNpc1Sprite(scene: Phaser.Scene): Phaser.GameObjects.Sprite { + const frame = lpcNpc1FrameIndex("down", "idle", 0); + const sprite = scene.add.sprite(0, SPRITE_FOOT_Y, ASSET_KEYS.spritesLpcNpc1, frame); + sprite.setOrigin(0.5, 1); + sprite.setScale(LPC_NPC1_SCALE); + return sprite; +} + export function createPlayerSprite( scene: Phaser.Scene, - paletteRow: number, + _paletteRow: number, ): Phaser.GameObjects.Sprite { - const frame = characterFrameIndex(paletteRow, facingToIndex("down"), WALK_FRAMES); - const sprite = scene.add.sprite(0, SPRITE_FOOT_Y, ASSET_KEYS.spritesCharacters, frame); - sprite.setOrigin(0.5, 1); - sprite.setScale(SPRITE_SCALE); - applyFacingFlip(sprite, "down"); - return sprite; + return createLpcNpc1Sprite(scene); } export function createNpcSprite( @@ -176,29 +255,41 @@ export function createNpcSprite( npcId: string, tintOverride?: number, ): Phaser.GameObjects.Sprite { + if (npcId === "npc-1") { + return createLpcNpc1Sprite(scene); + } const variant = npcVariantForId(npcId); const frame = npcFrameIndex(variant, facingToIndex("down"), WALK_FRAMES); const sprite = scene.add.sprite(0, SPRITE_FOOT_Y, ASSET_KEYS.spritesNpcs, frame); sprite.setOrigin(0.5, 1); - sprite.setScale(SPRITE_SCALE); + sprite.setScale(CHAR_SPRITE_SCALE); applyFacingFlip(sprite, "down"); sprite.setTint(tintOverride ?? npcTintForId(npcId)); return sprite; } -export function createSpeechBubble(scene: Phaser.Scene): Phaser.GameObjects.Image { +export function createSpeechBubble( + scene: Phaser.Scene, + profile: SpriteProfile = "stardew", +): Phaser.GameObjects.Image { + const baseY = spriteChatBubbleY(profile); const bubble = scene.add.image( - SPRITE_CHAT_BUBBLE_X, - SPRITE_CHAT_BUBBLE_Y, + spriteChatBubbleX(), + baseY, ASSET_KEYS.spritesUiSpeech, ); bubble.setOrigin(0, 0.5); bubble.setScale(SPRITE_CHAT_BUBBLE_SCALE); bubble.setAngle(SPRITE_CHAT_BUBBLE_ANGLE); bubble.setVisible(false); + bubble.setData(CHAT_BUBBLE_BASE_Y_KEY, baseY); return bubble; } +function chatBubbleBaseY(bubble: Phaser.GameObjects.Image): number { + return (bubble.getData(CHAT_BUBBLE_BASE_Y_KEY) as number | undefined) ?? spriteChatBubbleY(); +} + function stopChatBubbleBob(bubble: Phaser.GameObjects.Image): void { const tween = bubble.getData(CHAT_BUBBLE_TWEEN_KEY) as Phaser.Tweens.Tween | undefined; if (tween) { @@ -206,7 +297,7 @@ function stopChatBubbleBob(bubble: Phaser.GameObjects.Image): void { tween.destroy(); bubble.setData(CHAT_BUBBLE_TWEEN_KEY, undefined); } - bubble.y = SPRITE_CHAT_BUBBLE_Y; + bubble.y = chatBubbleBaseY(bubble); bubble.setAngle(SPRITE_CHAT_BUBBLE_ANGLE); } @@ -215,9 +306,10 @@ function startChatBubbleBob(bubble: Phaser.GameObjects.Image, registry: Phaser.D if (registry.get("reducedMotion")) return; const scene = bubble.scene; if (!scene?.tweens) return; + const baseY = chatBubbleBaseY(bubble); const tween = scene.tweens.add({ targets: bubble, - y: SPRITE_CHAT_BUBBLE_Y - 5, + y: baseY - labelOffset(5), angle: SPRITE_CHAT_BUBBLE_ANGLE + 4, duration: 550, yoyo: true, @@ -231,24 +323,49 @@ export function createDoorSprite(scene: Phaser.Scene, closed: boolean): Phaser.G const frame = closed ? 0 : 1; const sprite = scene.add.image(0, SPRITE_FOOT_Y - 4, ASSET_KEYS.tilesDecor, frame); sprite.setOrigin(0.5, 1); - sprite.setScale(SPRITE_SCALE); + sprite.setScale(TILE_SCALE); return sprite; } +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; + ent.facingDir = facing; + applyFacingFlip(ent.avatar, facing, "lpc-npc-1"); + ent.avatar.play(lpcNpc1WalkStepAnimKey(facing, phase), false); +} + export function playWalkAnim(ent: AnimatableEntity, facing: CardinalFacing): void { if (!ent.avatar) return; + const profile = ent.spriteProfile ?? "stardew"; + if (profile === "lpc-npc-1") { + playLpcWalkAnim(ent, facing); + return; + } ent.facingDir = facing; + applyFacingFlip(ent.avatar, facing, profile); const row = ent.paletteRow ?? 0; - applyFacingFlip(ent.avatar, facing); ent.avatar.play(animKey("walk", facing, row, ent.isNpc === true), true); } export function playIdleAnim(ent: AnimatableEntity, facing?: CardinalFacing): void { 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); + return; + } const row = ent.paletteRow ?? 0; - applyFacingFlip(ent.avatar, dir); ent.avatar.play(animKey("idle", dir, row, ent.isNpc === true), true); } diff --git a/apps/web/src/game/gridLayout.ts b/apps/web/src/game/gridLayout.ts index 0e223bf..37509a1 100644 --- a/apps/web/src/game/gridLayout.ts +++ b/apps/web/src/game/gridLayout.ts @@ -1,5 +1,5 @@ -/** Screen pixels per logic cell — 16×3 integer scale (Phase 13.3). */ -export const CELL_PX = 48; +/** Screen pixels per logic cell — 16×2 integer scale (Phase 13.3). */ +export const CELL_PX = 32; /** Phaser viewport size in cells (camera follows player in global coords). */ export const VIEWPORT_CELLS = 12; diff --git a/apps/web/src/game/gridMovement.ts b/apps/web/src/game/gridMovement.ts index 0b48de0..6e2084e 100644 --- a/apps/web/src/game/gridMovement.ts +++ b/apps/web/src/game/gridMovement.ts @@ -1,5 +1,5 @@ /** Shared grid locomotion timing — Phaser tweens + keyboard repeat. */ -export const GRID_STEP_MS = 120; +export const GRID_STEP_MS = 200; export const STEP_OVERLAP = 0.72; export const MAX_PREDICT_AHEAD = 8; /** Visual-only steps while network pending is full (keeps sprite moving at chunk boundaries). */ @@ -12,7 +12,7 @@ export const CLICK_PENDING_DRAIN_MS = 3000; export const PENDING_POLL_MS = 50; /** * Hold this long before WASD auto-repeat starts. - * Prevents a short tap (keydown + keyup <~120ms) from firing a second step via setInterval. + * Prevents a short tap (keydown + keyup <~200ms) from firing a second step via setInterval. */ export const HOLD_REPEAT_DELAY_MS = Math.max(GRID_STEP_MS + 80, 200); diff --git a/apps/web/src/game/intentLabels.ts b/apps/web/src/game/intentLabels.ts index 61276fd..c309ea5 100644 --- a/apps/web/src/game/intentLabels.ts +++ b/apps/web/src/game/intentLabels.ts @@ -1,17 +1,16 @@ import type * as Phaser from "phaser"; -import { ENTITY_LABEL_FONT } from "./entityLabels.js"; -import { MARKER_LABEL_Y } from "./entityLayout.js"; -import { SPRITE_NAMEPLATE_Y } from "./entitySprites.js"; +import { applySceneHanLabelBase, SCENE_LABEL_FONT } from "./entityLabels.js"; +import { intentFontPx } from "./entityLayout.js"; import { truncateIntentLabel, type NpcAmbientUiState } from "./activityLabelLogic.js"; const FADE_IN_MS = 150; const FADE_OUT_MS = 100; -export const INTENT_LABEL_FONT_SIZE = "8px"; -export const INTENT_LABEL_COLOR = "#c8c4b8"; -export const INTENT_LABEL_STROKE_COLOR = "#000000"; -export const INTENT_LABEL_STROKE_WIDTH = 3; -export const INTENT_LABEL_Y_OFFSET = 27; +export const INTENT_LABEL_FONT_SIZE_PX = intentFontPx(); +export const INTENT_LABEL_FONT_SIZE = `${INTENT_LABEL_FONT_SIZE_PX}px`; +export const INTENT_LABEL_COLOR = "#d8d4c8"; + +export { intentLabelY } from "./sceneLabelLayout.js"; export type IntentProgressContext = { dwellMs: number; @@ -33,21 +32,15 @@ export type IntentLabelTarget = { intentLabelWantShow?: boolean; }; -export function intentLabelY(spriteMode: boolean | undefined): number { - const base = spriteMode ? SPRITE_NAMEPLATE_Y : MARKER_LABEL_Y; - return base + INTENT_LABEL_Y_OFFSET; -} - export function createIntentLabel(scene: Phaser.Scene, npcId: string): Phaser.GameObjects.Text { const label = scene.add.text(0, 0, "", { fontSize: INTENT_LABEL_FONT_SIZE, - fontFamily: ENTITY_LABEL_FONT, - fontStyle: "600", + fontFamily: SCENE_LABEL_FONT, + fontStyle: "normal", color: INTENT_LABEL_COLOR, align: "center", - stroke: INTENT_LABEL_STROKE_COLOR, - strokeThickness: INTENT_LABEL_STROKE_WIDTH, }); + applySceneHanLabelBase(label); label.setOrigin(0.5, 1); label.setScrollFactor(1); label.setAlpha(0); diff --git a/apps/web/src/game/lpcNpc1Sheet.test.ts b/apps/web/src/game/lpcNpc1Sheet.test.ts new file mode 100644 index 0000000..b1e81a9 --- /dev/null +++ b/apps/web/src/game/lpcNpc1Sheet.test.ts @@ -0,0 +1,64 @@ +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_FRAME, + LPC_NPC1_SCALE, + LPC_SOURCE_ROW_BY_FACING, + lpcNpc1AnimKey, + lpcNpc1FrameIndex, + lpcNpc1WalkCycleMs, + lpcNpc1WalkStepAnimKey, + lpcNpc1WalkStepFrameRate, + lpcNpc1WalkStepRange, + spriteProfileForNpc, + spriteProfileForPlayer, +} from "./lpcNpc1Sheet.js"; + +describe("lpcNpc1Sheet", () => { + it("routes all players and npc-1 to lpc profile", () => { + expect(spriteProfileForPlayer()).toBe("lpc-npc-1"); + expect(spriteProfileForNpc("npc-1")).toBe("lpc-npc-1"); + expect(spriteProfileForNpc("npc-2")).toBe("stardew"); + }); + + it("packs walk then idle per facing row", () => { + expect(LPC_NPC1_FRAMES_PER_FACING).toBe(LPC_NPC1_WALK_FRAMES + 2); + expect(lpcNpc1FrameIndex("down", "walk", 0)).toBe(0); + 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("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("matches CHAR_DISPLAY_PX on-screen height", () => { + expect(LPC_NPC1_SCALE * LPC_NPC1_FRAME).toBe(CHAR_DISPLAY_PX); + }); + + it("maps LPC source rows up/left/down/right", () => { + expect(LPC_SOURCE_ROW_BY_FACING.down).toBe(2); + 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"); + }); +}); diff --git a/apps/web/src/game/lpcNpc1Sheet.ts b/apps/web/src/game/lpcNpc1Sheet.ts new file mode 100644 index 0000000..f9867c8 --- /dev/null +++ b/apps/web/src/game/lpcNpc1Sheet.ts @@ -0,0 +1,81 @@ +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; +export const LPC_NPC1_IDLE_BASE_ROW = 22; + +/** Source row offset within an LPC animation block. */ +export const LPC_SOURCE_ROW_BY_FACING: Record = { + up: 0, + left: 1, + down: 2, + right: 3, +}; + +export const LPC_NPC1_FRAME = 64; +export const LPC_NPC1_WALK_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; + +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 }; +} + +/** 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 lpcNpc1WalkCycleMs(): number { + return LPC_NPC1_STEPS_PER_CYCLE * GRID_STEP_MS; +} + +export function lpcNpc1WalkStepAnimKey(facing: CardinalFacing, phase: number): string { + return `lpc1-walk-${facing}-s${phase % LPC_NPC1_STEPS_PER_CYCLE}`; +} + +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"; + +export function spriteProfileForPlayer(): LpcNpc1SpriteProfile { + return "lpc-npc-1"; +} + +export function spriteProfileForNpc(npcId: string): LpcNpc1SpriteProfile | "stardew" { + return npcId === "npc-1" ? "lpc-npc-1" : "stardew"; +} + +export function lpcNpc1FrameIndex( + facing: CardinalFacing, + kind: "walk" | "idle", + frameInAnim: number, +): number { + const facingIndex = facingToIndex(facing); + const offset = kind === "walk" ? frameInAnim : LPC_NPC1_WALK_FRAMES + frameInAnim; + return facingIndex * LPC_NPC1_FRAMES_PER_FACING + offset; +} + +export function lpcNpc1AnimKey(kind: "walk" | "idle", facing: CardinalFacing): string { + return `lpc1-${kind}-${facing}`; +} + +/** Nameplate baseline for LPC 64×64 sprites (origin 0.5, 1). */ +export function lpcNpc1NameplateY(footY: number): number { + const topY = footY - LPC_NPC1_FRAME * LPC_NPC1_SCALE; + return topY + labelOffset(5); +} diff --git a/apps/web/src/game/roomSceneSync.ts b/apps/web/src/game/roomSceneSync.ts index de9ac67..5716278 100644 --- a/apps/web/src/game/roomSceneSync.ts +++ b/apps/web/src/game/roomSceneSync.ts @@ -401,6 +401,7 @@ function installDevSyncHooks( const w = window as Window & { __aetherlife_npcDebug?: () => AetherlifeNpcDebug; __aetherlife_sendMoveTo?: (x: number, y: number) => void; + __aetherlife_engageNpc?: (npcId: string) => void; __aetherlife_moveDebug?: () => { gridX: number; gridY: number; @@ -484,6 +485,10 @@ function installDevSyncHooks( w.__aetherlife_sendMoveTo = (x, y) => { void host.getMovementSync()?.sendMoveTo(x, y); }; + w.__aetherlife_engageNpc = (npcId) => { + const cb = host.registry.get("onNpcSpriteClick") as ((id: string) => void) | undefined; + cb?.(npcId); + }; w.__aetherlife_npcDebug = () => { const entries = [...host.npcSprites.entries()]; const boundsOverlapPairs: Array<{ a: string; b: string }> = []; diff --git a/apps/web/src/game/roomSceneViewport.test.ts b/apps/web/src/game/roomSceneViewport.test.ts index 8412fa3..42db77f 100644 --- a/apps/web/src/game/roomSceneViewport.test.ts +++ b/apps/web/src/game/roomSceneViewport.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { CELL_PX } from "./gridLayout.js"; import { hitNpcAtWorldPoint, npcIdAtGridCell, @@ -73,16 +74,16 @@ describe("tickViewportVisibleNpcIds", () => { describe("hitNpcAtWorldPoint", () => { it("returns top-most NPC at world point", () => { - const box: Rect = { x: 0, y: 0, width: 48, height: 48 }; + const box: Rect = { x: 0, y: 0, width: CELL_PX, height: CELL_PX }; const sprites = new Map([ ["npc-low", mockEntity({ npcId: "npc-low", bounds: box, depth: 1 })], ["npc-top", mockEntity({ npcId: "npc-top", bounds: box, depth: 5 })], ]); - expect(hitNpcAtWorldPoint(24, 24, sprites)).toBe("npc-top"); + expect(hitNpcAtWorldPoint(CELL_PX / 2, CELL_PX / 2, sprites)).toBe("npc-top"); }); it("returns null when no NPC hit", () => { - const box: Rect = { x: 0, y: 0, width: 48, height: 48 }; + const box: Rect = { x: 0, y: 0, width: CELL_PX, height: CELL_PX }; const sprites = new Map([ ["npc-a", mockEntity({ npcId: "npc-a", bounds: box })], ]); @@ -93,7 +94,7 @@ describe("hitNpcAtWorldPoint", () => { describe("npcIdAtGridCell", () => { it("returns NPC on matching grid cell", () => { const sprites = new Map([ - ["npc-a", mockEntity({ npcId: "npc-a", bounds: { x: 0, y: 0, width: 48, height: 48 }, gridX: 3, gridY: 4 })], + ["npc-a", mockEntity({ npcId: "npc-a", bounds: { x: 0, y: 0, width: CELL_PX, height: CELL_PX }, gridX: 3, gridY: 4 })], ]); expect(npcIdAtGridCell(3, 4, sprites)).toBe("npc-a"); expect(npcIdAtGridCell(3, 5, sprites)).toBeNull(); @@ -107,7 +108,7 @@ describe("pickNpcAtWorldPoint", () => { "npc-a", mockEntity({ npcId: "npc-a", - bounds: { x: 0, y: 0, width: 48, height: 48 }, + bounds: { x: 0, y: 0, width: CELL_PX, height: CELL_PX }, gridX: 2, gridY: 2, }), diff --git a/apps/web/src/game/sceneLabelLayout.test.ts b/apps/web/src/game/sceneLabelLayout.test.ts new file mode 100644 index 0000000..12adec9 --- /dev/null +++ b/apps/web/src/game/sceneLabelLayout.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { activityFontPx, nameplateFontPx } from "./entityLayout.js"; + +describe("scene label typography", () => { + it("uses compact sizes at CELL_PX=32", () => { + expect(nameplateFontPx()).toBe(10); + expect(activityFontPx()).toBe(9); + }); +}); diff --git a/apps/web/src/game/sceneLabelLayout.ts b/apps/web/src/game/sceneLabelLayout.ts new file mode 100644 index 0000000..3c823db --- /dev/null +++ b/apps/web/src/game/sceneLabelLayout.ts @@ -0,0 +1,23 @@ +import { activityFontPx, intentFontPx, labelOffset, MARKER_LABEL_Y, nameplateFontPx } from "./entityLayout.js"; +import { spriteNameplateY, type SpriteProfile } from "./entitySprites.js"; + +/** Tight stack: name baseline → activity baseline (both origin 0.5, 1). */ +export const NAME_TO_ACTIVITY_GAP_PX = 2; +export const ACTIVITY_TO_INTENT_GAP_PX = 1; + +export function nameLabelY(spriteMode: boolean | undefined, profile?: SpriteProfile): number { + if (!spriteMode) return MARKER_LABEL_Y; + return spriteNameplateY(profile ?? "stardew"); +} + +export function activityLabelY(spriteMode: boolean | undefined, profile?: SpriteProfile): number { + if (!spriteMode) return MARKER_LABEL_Y + labelOffset(14); + return nameLabelY(true, profile) + nameplateFontPx() + NAME_TO_ACTIVITY_GAP_PX; +} + +export function intentLabelY(spriteMode: boolean | undefined, profile?: SpriteProfile): number { + if (!spriteMode) return MARKER_LABEL_Y + labelOffset(27); + return ( + activityLabelY(true, profile) + activityFontPx() + ACTIVITY_TO_INTENT_GAP_PX + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 34d687b..61616f4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,4 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Mono:wght@400&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Mono:wght@400&family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&display=swap"); :root { --bg-deep: #0f0e0c; diff --git a/docs/BEGINNING-FIELDS.md b/docs/BEGINNING-FIELDS.md index 4bc8c96..e1ec9d1 100644 --- a/docs/BEGINNING-FIELDS.md +++ b/docs/BEGINNING-FIELDS.md @@ -1,8 +1,24 @@ # Beginning Fields — home Tiled 地图 -Fan-tasy **Beginning Fields**(40×40 @ 16px → 游戏内 48px/grid)由 `HomeMapBackground` 渲染。Plan A:home 区内 **仅 Tiled 层**,`RoomScene` 跳过程序化 floor/decor。 +Fan-tasy **Beginning Fields**(40×40 @ 16px Tiled → 游戏内 **32px/格**,16×2 整数缩放)由 `HomeMapBackground` 渲染。Plan A:home 区内 **仅 Tiled 层**,`RoomScene` 跳过程序化 floor/decor。 -Web agent 摘要:[apps/web/AGENTS.md](../apps/web/AGENTS.md) · 回归 ISSUE-042 · Guardrails #63–#65 in [ISSUE-LOG.md](./ISSUE-LOG.md). +Web agent 摘要:[apps/web/AGENTS.md](../apps/web/AGENTS.md) · 回归 ISSUE-042 · Guardrails #63–#65、#106–#107 in [ISSUE-LOG.md](./ISSUE-LOG.md). + +--- + +## 角色视觉(产品决策 · 2026-07) + +| 决策 | 内容 | +|------|------| +| **显示格** | `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` | +| **铭牌** | 宋体(`Noto Serif SC`)、无描边/无底条、轻投影;名字/状态垂直堆叠见 `sceneLabelLayout.ts` | + +**回归:** `pnpm --filter @aetherlife/web test` · `pnpm verify:phase13` · `pnpm verify:phase6:move-only`(改 `RoomScene` / 实体 sprite 时) --- diff --git a/docs/DEVELOPMENT-HISTORY.md b/docs/DEVELOPMENT-HISTORY.md index 242b5f6..595cc74 100644 --- a/docs/DEVELOPMENT-HISTORY.md +++ b/docs/DEVELOPMENT-HISTORY.md @@ -845,6 +845,24 @@ This document synthesizes all **37 development phases** (including sub-phases). --- +### Phase 26.1 — LPC Character Visual Refresh(全员 LPC) + +| | | +|---|---| +| **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). + +**Product decision:** **全员 LPC** — no Stardew `characters.png` palette rows for players; council NPCs `npc-2`…`npc-12` remain on `npcs.png`. + +**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). + +**Verification:** `pnpm --filter @aetherlife/web test` · `pnpm verify:phase6:move-only` · `pnpm verify:phase13` + +**Tech decisions:** `GRID_STEP_MS=200` · `useSpriteEntities()` requires `spritesLpcNpc1` + `spritesNpcs`; Phase 13.3 48px record superseded at runtime — see Guardrails #106–#107 · [BEGINNING-FIELDS.md](../docs/BEGINNING-FIELDS.md) §角色视觉 + +--- + ### Phase 27 — Personal Life Timeline | | | diff --git a/docs/DEVELOPMENT-HISTORY.zh-CN.md b/docs/DEVELOPMENT-HISTORY.zh-CN.md index 15d2898..62b42cb 100644 --- a/docs/DEVELOPMENT-HISTORY.zh-CN.md +++ b/docs/DEVELOPMENT-HISTORY.zh-CN.md @@ -845,6 +845,24 @@ --- +### Phase 26.1 — LPC 角色视觉刷新(全员 LPC) + +| | | +|---|---| +| **状态** | ✅ 已交付 (2026-07-01) | + +**目标:** **所有玩家** + **npc-1** 统一 LPC walk/idle;显示格 **CELL_PX=32**,角色高 **64px**(2 格)。 + +**产品决策:** **全员 LPC** — 玩家不再用 Stardew `characters.png` 四色 palette;议会 `npc-2`…`npc-12` 仍用 `npcs.png`。 + +**交付物:** `scripts/sync-npc-lpc-assets.mjs` · `lpc-npc-1.png` · `lpcNpc1Sheet.ts` · 铭牌刷新(`sceneLabelLayout.ts`、宋体、无描边/底条)。 + +**验收:** `pnpm --filter @aetherlife/web test` · `pnpm verify:phase6:move-only` · `pnpm verify:phase13` + +**技术决策:** `GRID_STEP_MS=200` · `useSpriteEntities()` 须 `spritesLpcNpc1` + `spritesNpcs`;Phase 13.3 的 48px 为历史记录,**运行时以 32px 为准** — Guardrails #106–#107 · [BEGINNING-FIELDS.md](../docs/BEGINNING-FIELDS.md) §角色视觉 + +--- + ### Phase 27 — 个人人生时间线 | | | diff --git a/docs/ISSUE-LOG.md b/docs/ISSUE-LOG.md index b48f805..0acbf91 100644 --- a/docs/ISSUE-LOG.md +++ b/docs/ISSUE-LOG.md @@ -131,7 +131,7 @@ 54. **Speak UX 方案 A(life-sim)**:当前 Tab NPC 在 sending/thinking/speakBusy 时 `composerBusyForActiveNpc` 禁用 textarea + 发送;`sendMessage` **不得**在 in-flight 时客户端 enqueue(仅 server `speakBusy` 路径保留内部队列 drain)。 55. **`onDone` 后 drain 队列前同步 in-flight refs**:`clearInFlightRefsForDrain`(ISSUE-036;仅服务 speakBusy 内部队列)。 56. **Speak 状态提示贴近 composer**:`composer-speak-status` 显示「正在思考…」或多人 busy;勿展示用户侧「已排队 N 条」。 -57. **Proximity 铭牌样式冻结(Phase 13 UAT #6/#7)**:`entityLabels.ts` 的 `NAMEPLATE_*` + `applyNameplateStyle`(12px、stroke 4px、shadow)、`entitySprites.ts` 的 `SPRITE_NAMEPLATE_Y`、`ProximityNameplate.ts` 的 `PROXIMITY_CELLS=2` 为 **已验收契约**;禁止为「顺手优化」削弱对比度或改回 disc 时代 `MARKER_LABEL_Y`;改动须 `pnpm verify:phase13` + 人工 UAT 铭牌可读性。 +57. **Proximity 铭牌样式(Phase 13 UAT #6/#7,2026-07 刷新)**:`entityLabels.ts` 的 `SCENE_LABEL_FONT`(宋体)、`applyNameplateStyle`(约 10px @ CELL_PX=32、无描边/无底条、轻投影)、`sceneLabelLayout.ts` 名字/状态堆叠、`entitySprites.spriteNameplateY`、`ProximityNameplate.ts` 的 `PROXIMITY_CELLS=2` 为 **已验收契约**;改动须 `pnpm verify:phase13` + 人工 UAT 铭牌可读性。 58. **Phase 8 speakBusy(方案 A)须清 sending 态**:`onSpeakBusy` 在 inflight → `enqueueSpeak` 后 **必须** 同步清 `sendingNpcId` / ref,否则 B 端显示「正在思考…」而非「其他玩家占用」;UAT:`pnpm uat:phase8:playwright` Test 4。 59. **已验收 UX/视觉代码 — 最小 diff**:ISSUE 标 `fixed` 且 UAT/verify 通过的 hook、铭牌、composer 状态机,后续 phase 不得 drive-by 重构;scope 外改动 `pnpm agent:verify:scope` 应 fail。 60. **Decor 须低于同格实体 depth**:`DecorRenderer` 用 `entityDepth(gx, gy, 0)`;玩家/NPC 至少 layer 1。禁止 decor 与实体同 layer 1(同格时后 spawn 的 decor 会盖住角色,如 home 土路围栏)。回归:`entityLayout.test.ts`「同格 entity > decor」+ 实机站 pathRow=6。 @@ -181,12 +181,18 @@ 99. **MapSchema 禁止恢复 flat 三槽**:`GameRoomState.npcs: MapSchema` + `schemaVersion=2` 为唯一 SSOT;**禁止**恢复 `npc1X`/`bgNpc1X` 或 3 主 NPC + 4 bg-villager 模型(MP-12)。改 `schema.ts`/`bridge.ts`/`useColyseusRoom` 须 `pnpm --filter @aetherlife/game-server test -- bridge.test` + `pnpm --filter @aetherlife/web test -- colyseusAmbientSnapshot`。 100. **`verify:phase26` 禁止 mock LLM**:脚本入口 `assertE2eNoMock` + `assertE2eRealLlm`;**禁止** `LLM_MOCK=1` / `dev:stack:mock` 假绿(MAP-05 / T-26-04)。须 `pnpm dev:stack` + 真实 API keys;leaning_drift 子 pytest 可 `LLM_MOCK=1`(非 speak 硬断言)。 -101. **Phase 26 勿破坏 frozen UX**:`entityLabels.ts` / `ProximityNameplate.ts` / `entitySprites.ts` / `useNpcChat.ts` speakBusy 方案 A 为已验收契约(Guardrail #57–#59);Phase 26 仅扩展 12 席 id 范围,merge 前须 `pnpm verify:phase13` + `pnpm verify:phase26`(stack 就绪时)。 +101. **Phase 26 勿破坏 frozen UX**:`entityLabels.ts` / `ProximityNameplate.ts` / `entitySprites.ts` / `useNpcChat.ts` speakBusy 方案 A 为已验收契约(Guardrail #57–#59、#106–#107);Phase 26 仅扩展 12 席 id 范围,merge 前须 `pnpm verify:phase13` + `pnpm verify:phase26`(stack 就绪时)。 102. **MapSchema cleanup 禁止 stale setter**:`useColyseusRoom` unmount 仅 `setRoomNpcs([])`;**禁止** 恢复 `setMainNpcGridById` / `setBgNpcGridById`(ISSUE-096)。改 hook 须 `pnpm --filter @aetherlife/web test`。 103. **npc-memory 新 migration 必须登记 journal**:新增 `packages/npc-memory/migrations/*.sql` 时 **同步** `migrations/meta/_journal.json`;`verify:phase26` 入口已 `db:migrate` preflight(ISSUE-097)。 104. **`verify:phase26` traveler 断言须读完整 chip aria-label**:禁止仅依赖 24 字 `.council-deliberation-chip__title`;vote 前须 rude speak + collective rude API(对齐 phase25,ISSUE-098)。E2E **串行**:智谱并发=1 时禁止并行 `verify:phase26` + `uat:phase26` speak。 105. **议会 `councilSpawns` 须全图分散、勿挤堆**:`x∈[5,33]`、`y∈[5,31]`、互距 Chebyshev ≥3、x/y 跨度均 ≥20(`region-walkability.test.ts`);**禁止** 12 点挤在单一 ≤4×3 格网或南广场扎堆(ISSUE-099);改 spawn 后 UAT 用 **新 `roomId`**。布局见 `BEGINNING-FIELDS.md` §议会出生点。 +### 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`。 +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`。 + ## 记录 ### ISSUE-001 — thinking 中切换 NPC Tab 后无法移动(UI 冻结) @@ -2623,7 +2629,37 @@ Worker 主循环仅在 npc-turn 队列 **连续 5s 为空** 时才 `BLPOP` chunk **防复发** -- Guardrail #107:Phase 26+ 后 **禁止** verify/UAT 脚本断言 `bg-villager` 房间存在;ambient 回归用 12 席 council + `verify:phase26` 地图门禁。 +- Guardrail #108:Phase 26+ 后 **禁止** verify/UAT 脚本断言 `bg-villager` 房间存在;ambient 回归用 12 席 council + `verify:phase26` 地图门禁。 + +--- + +### ISSUE-102 — 全员 LPC 角色皮与 CELL_PX=32 视觉刷新 + +- **状态:** fixed +- **发现:** 2026-07-01(Phase 26.1 产品决策) +- **阶段/范围:** `apps/web/src/game/**` · LPC 资产管线 · 铭牌布局 +- **严重性:** minor(视觉/文档;非多人契约变更) + +**决策** + +- **全员 LPC**:所有玩家(含远端)+ `npc-1` 使用 `sprites/lpc-npc-1.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` 铭牌堆叠 +- 文档:[BEGINNING-FIELDS.md](./BEGINNING-FIELDS.md) §角色视觉 · Guardrails #106–#107 + +**验证** + +- `pnpm --filter @aetherlife/web test` +- `pnpm agent:verify` +- `pnpm verify:phase6:move-only` · `pnpm verify:phase13`(须 `pnpm dev:stack`) + +**防复发** + +- Guardrail #106(全员 LPC)· #107(CELL_PX=32) --- diff --git a/package.json b/package.json index 06f948e..72b9241 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-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", "art:agnes:preview": "node scripts/agnes-ui-assets.mjs --preview", diff --git a/packages/shared/src/council/spawn.ts b/packages/shared/src/council/spawn.ts index 05032b5..d765fc1 100644 --- a/packages/shared/src/council/spawn.ts +++ b/packages/shared/src/council/spawn.ts @@ -1,6 +1,6 @@ import { BEGINNING_FIELDS_ID, - defaultBeginningFieldsBundle, + defaultWorldRegistryBundle, getRegionById, getWorldRegistry, loadWorldRegistry, @@ -32,7 +32,7 @@ function hashRoomSeed(roomId: string): number { function ensureRegistry(): void { if (!getWorldRegistry()) { - setWorldRegistry(loadWorldRegistry(defaultBeginningFieldsBundle())); + setWorldRegistry(loadWorldRegistry(defaultWorldRegistryBundle())); } } diff --git a/packages/shared/src/homeMap.ts b/packages/shared/src/homeMap.ts index 213e9e5..ec13dc9 100644 --- a/packages/shared/src/homeMap.ts +++ b/packages/shared/src/homeMap.ts @@ -6,7 +6,7 @@ import { const beginningFields = getRegionById(BEGINNING_FIELDS_ID); -/** Beginning Fields Tiled map covers this many world grid cells (1 Tiled tile = 1 cell @ 48px). */ +/** Beginning Fields Tiled map covers this many world grid cells (1 Tiled tile = 1 cell @ 32px). */ export const HOME_MAP_TILE_W = beginningFields?.size.w ?? 40; export const HOME_MAP_TILE_H = beginningFields?.size.h ?? 40; diff --git a/packages/shared/src/room.test.ts b/packages/shared/src/room.test.ts index 68a89e7..f906dd6 100644 --- a/packages/shared/src/room.test.ts +++ b/packages/shared/src/room.test.ts @@ -51,6 +51,19 @@ describe("createDefaultRoom", () => { expect(room.npcs[1]?.inventory).toEqual(["key-2"]); expect(room.npcs[2]?.inventory).toEqual(["note-1"]); }); + + it("clones starter inventory per room (no shared array refs)", () => { + setWorldRegistry(loadWorldRegistry(defaultBeginningFieldsBundle())); + const a = createDefaultRoom("room-inv-a"); + const b = createDefaultRoom("room-inv-b"); + const invA = a.npcs.find((n) => n.id === "npc-1")?.inventory; + const invB = b.npcs.find((n) => n.id === "npc-1")?.inventory; + expect(invA).toEqual(["key-1"]); + expect(invB).toEqual(["key-1"]); + expect(invA).not.toBe(invB); + invA?.push("mutated"); + expect(b.npcs.find((n) => n.id === "npc-1")?.inventory).toEqual(["key-1"]); + }); }); describe("findNpc", () => { diff --git a/packages/shared/src/room.ts b/packages/shared/src/room.ts index 335d540..c684213 100644 --- a/packages/shared/src/room.ts +++ b/packages/shared/src/room.ts @@ -3,7 +3,8 @@ import { shuffleCouncilSpawnAssignments, } from "./council/spawn.js"; import { - defaultBeginningFieldsBundle, + defaultWorldRegistryBundle, + getWorldRegistry, loadWorldRegistry, setWorldRegistry, } from "./worldRegion.js"; @@ -69,11 +70,8 @@ const LEGACY_STARTER_INVENTORY: Partial> = { }; function ensureCouncilSpawnsReady(): void { - try { - getCouncilSpawnSlots(); - } catch { - setWorldRegistry(loadWorldRegistry(defaultBeginningFieldsBundle())); - } + if (getWorldRegistry()) return; + setWorldRegistry(loadWorldRegistry(defaultWorldRegistryBundle())); } function councilRoomNpcs(roomId: string): NpcState[] { @@ -89,7 +87,7 @@ function councilRoomNpcs(roomId: string): NpcState[] { maxRadius: slot.maxRadius, facing: slot.facing, status: "idle", - inventory: LEGACY_STARTER_INVENTORY[npcId] ?? [], + inventory: [...(LEGACY_STARTER_INVENTORY[npcId] ?? [])], activityKey: "idle", })); } diff --git a/scripts/lib/dialogue-engage.mjs b/scripts/lib/dialogue-engage.mjs index 8bb1e99..a1666b6 100644 --- a/scripts/lib/dialogue-engage.mjs +++ b/scripts/lib/dialogue-engage.mjs @@ -2,6 +2,51 @@ * Shared Phase 19 immersive-shell dialogue engagement for E2E/benchmark scripts. * Opens dialogue via corner-menu NPC tab or canvas click; waits for dialogue-bar. */ + +/** True when npcId is the active speak target (dialogue-bar survives corner-menu close). */ +export async function isActiveNpcDialogue(page, npcId) { + const fromBar = await page + .locator('[data-testid="dialogue-bar"]') + .getAttribute("data-active-npc-id") + .catch(() => null); + if (fromBar === npcId) return true; + + const selected = await page + .locator(`#npc-avatar-${npcId}`) + .getAttribute("aria-selected") + .catch(() => null); + return selected === "true"; +} + +/** Poll until dialogue targets npcId (chip aria-selected or dialogue-bar data attribute). */ +export async function waitForActiveNpcDialogue(page, npcId, { timeoutMs = 8_000 } = {}) { + await page.waitForFunction( + (expectedId) => { + const bar = document.querySelector('[data-testid="dialogue-bar"]'); + if (bar?.getAttribute("data-active-npc-id") === expectedId) return true; + const chip = document.getElementById(`npc-avatar-${expectedId}`); + return chip?.getAttribute("aria-selected") === "true"; + }, + npcId, + { timeout: timeoutMs }, + ); +} + +/** DEV hook: programmatically engage npc (same path as sprite click). */ +async function engageNpcViaDevHook(page, npcId, { timeoutMs = 8_000 } = {}) { + const called = await page.evaluate((id) => { + const fn = window.__aetherlife_engageNpc; + if (typeof fn !== "function") return false; + fn(id); + return true; + }, npcId); + if (!called) return false; + const dialogueBar = page.locator('[data-testid="dialogue-bar"]'); + await dialogueBar.waitFor({ state: "visible", timeout: timeoutMs }); + await waitForActiveNpcDialogue(page, npcId, { timeoutMs }); + return true; +} + export async function engageDialogue(page, { timeoutMs = 45_000 } = {}) { const dialogueBar = page.locator('[data-testid="dialogue-bar"]'); if (await dialogueBar.isVisible().catch(() => false)) { @@ -69,11 +114,18 @@ export async function engageDialogue(page, { timeoutMs = 45_000 } = {}) { */ export async function engageNpcDialogue(page, npcId, { timeoutMs = 45_000 } = {}) { const dialogueBar = page.locator('[data-testid="dialogue-bar"]'); - const activeChip = page.locator(`#npc-avatar-${npcId}`); - if (await dialogueBar.isVisible().catch(() => false)) { - if (await activeChip.isVisible().catch(() => false)) { - return; - } + if ( + (await dialogueBar.isVisible().catch(() => false)) && + (await isActiveNpcDialogue(page, npcId)) + ) { + return; + } + + if ( + (await dialogueBar.isVisible().catch(() => false)) && + (await engageNpcViaDevHook(page, npcId, { timeoutMs: 8_000 }).catch(() => false)) + ) { + return; } const cornerMenu = page.locator('[data-testid="corner-menu"]'); @@ -88,7 +140,10 @@ export async function engageNpcDialogue(page, npcId, { timeoutMs = 45_000 } = {} const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - if (await dialogueBar.isVisible().catch(() => false)) { + if ( + (await dialogueBar.isVisible().catch(() => false)) && + (await isActiveNpcDialogue(page, npcId)) + ) { return; } await cornerMenu.locator(".corner-menu__trigger").click(); @@ -96,13 +151,16 @@ export async function engageNpcDialogue(page, npcId, { timeoutMs = 45_000 } = {} try { await chip.waitFor({ state: "visible", timeout: 12_000 }); await chip.click(); - await dialogueBar.waitFor({ state: "visible", timeout: 8_000 }); + await waitForActiveNpcDialogue(page, npcId, { timeoutMs: 8_000 }); return; } catch { + if (await engageNpcViaDevHook(page, npcId, { timeoutMs: 8_000 }).catch(() => false)) { + return; + } await cornerMenu.locator(".corner-menu__trigger").click(); await page.waitForTimeout(400); } } - throw new Error(`engageNpcDialogue: dialogue-bar not visible for ${npcId} within ${timeoutMs}ms`); + throw new Error(`engageNpcDialogue: failed to target ${npcId} within ${timeoutMs}ms`); } diff --git a/scripts/lib/e2e-memory-helpers.mjs b/scripts/lib/e2e-memory-helpers.mjs index da97812..667bd0e 100644 --- a/scripts/lib/e2e-memory-helpers.mjs +++ b/scripts/lib/e2e-memory-helpers.mjs @@ -191,25 +191,33 @@ export async function closeShellDrawer(page) { * @param {import('playwright').Page} page * @param {number} t0 * @param {number} timeoutMs + * @param {string} [baselineText] ignore pre-existing reply (NPC switch / skipEngage) * @returns {Promise<{ firstTextMs: number; overlayPartialMs: number | null }>} */ -async function waitSpeakFirstText(page, t0, timeoutMs) { +async function waitSpeakFirstText(page, t0, timeoutMs, baselineText = "") { + const baseline = baselineText.trim(); const deadline = Date.now() + timeoutMs; let overlayPartialMs = null; + const isNewReply = (text) => { + const trimmed = text.trim(); + if (!trimmed || /^思考/.test(trimmed)) return false; + return !baseline || trimmed !== baseline; + }; + while (Date.now() < deadline) { if (overlayPartialMs === null) { const streaming = page.locator(OVERLAY_STREAMING); if (await streaming.isVisible().catch(() => false)) { const st = ((await streaming.textContent().catch(() => "")) ?? "").trim(); - if (st) overlayPartialMs = Date.now() - t0; + if (st && isNewReply(st)) overlayPartialMs = Date.now() - t0; } } const summary = page.locator(".dialogue-bar__summary-text"); if ((await summary.count()) > 0) { const text = (await summary.first().textContent().catch(() => "")) ?? ""; - if (text.trim() && !/^思考/.test(text.trim())) { + if (isNewReply(text)) { return { firstTextMs: Date.now() - t0, overlayPartialMs }; } } @@ -217,7 +225,7 @@ async function waitSpeakFirstText(page, t0, timeoutMs) { const overlayNpc = page.locator(OVERLAY_NPC_REPLY).last(); if (await overlayNpc.isVisible().catch(() => false)) { const text = (await overlayNpc.textContent().catch(() => "")) ?? ""; - if (text.trim()) { + if (isNewReply(text)) { return { firstTextMs: Date.now() - t0, overlayPartialMs }; } } @@ -288,6 +296,8 @@ export async function sendSpeakOverlay( { timeout: speakTimeoutMs }, ); + const baselineReply = await extractNpcReplyText(page); + const t0 = Date.now(); await composer.fill(text); await page.locator("button.composer__submit").click(); @@ -300,7 +310,12 @@ export async function sendSpeakOverlay( // thinking may be too fast to observe } - const { firstTextMs, overlayPartialMs } = await waitSpeakFirstText(page, t0, speakTimeoutMs); + const { firstTextMs, overlayPartialMs } = await waitSpeakFirstText( + page, + t0, + speakTimeoutMs, + baselineReply, + ); await page.waitForFunction( () => { diff --git a/scripts/sync-npc-lpc-assets.mjs b/scripts/sync-npc-lpc-assets.mjs new file mode 100644 index 0000000..2ed7e6b --- /dev/null +++ b/scripts/sync-npc-lpc-assets.mjs @@ -0,0 +1,106 @@ +#!/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 + * + * 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). + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +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_FRAME = 64; +const WALK_FRAMES = 9; +const IDLE_FRAMES = 2; +const FRAMES_PER_FACING = WALK_FRAMES + IDLE_FRAMES; +const FACING_COUNT = 4; + +/** Universal LPC composite: walk rows 8–11, idle rows 22–25 (2 frames each). */ +const WALK_BASE_ROW = 8; +const IDLE_BASE_ROW = 22; + +/** + * Row offset inside each LPC animation block — NOT down-first. + * Verified against npc-asset/npc-1.png: row+0=up, +1=left, +2=down, +3=right. + */ +const LPC_SOURCE_ROW_BY_FACING = { + up: 0, + left: 1, + down: 2, + right: 3, +}; + +/** Baked atlas row order matches facing.ts FACING_ORDER. */ +const BAKED_FACINGS = ["down", "left", "right", "up"]; + +function loadPng(path) { + if (!existsSync(path)) { + throw new Error(`Missing source PNG: ${path}`); + } + return PNG.sync.read(readFileSync(path)); +} + +function extractFrame(src, col, row) { + const rgba = new Uint8Array(LPC_FRAME * LPC_FRAME * 4); + for (let y = 0; y < LPC_FRAME; y += 1) { + for (let x = 0; x < LPC_FRAME; x += 1) { + const sx = col * LPC_FRAME + x; + const sy = row * LPC_FRAME + y; + const si = (sy * src.width + sx) * 4; + const di = (y * LPC_FRAME + x) * 4; + rgba[di] = src.data[si]; + rgba[di + 1] = src.data[si + 1]; + rgba[di + 2] = src.data[si + 2]; + rgba[di + 3] = src.data[si + 3]; + } + } + return rgba; +} + +function blitFrame(dst, dstCol, dstRow, rgba) { + for (let y = 0; y < LPC_FRAME; y += 1) { + for (let x = 0; x < LPC_FRAME; x += 1) { + const si = (y * LPC_FRAME + x) * 4; + const dx = dstCol * LPC_FRAME + x; + const dy = dstRow * LPC_FRAME + y; + const di = (dy * dst.width + dx) * 4; + dst.data[di] = rgba[si]; + dst.data[di + 1] = rgba[si + 1]; + dst.data[di + 2] = rgba[si + 2]; + dst.data[di + 3] = rgba[si + 3]; + } + } +} + +function bake() { + const src = loadPng(SRC); + const atlasW = FRAMES_PER_FACING * LPC_FRAME; + const atlasH = FACING_COUNT * LPC_FRAME; + const atlas = new PNG({ width: atlasW, height: atlasH }); + + for (let fi = 0; fi < FACING_COUNT; fi += 1) { + const facing = BAKED_FACINGS[fi]; + const srcOffset = LPC_SOURCE_ROW_BY_FACING[facing]; + const walkSrcRow = WALK_BASE_ROW + srcOffset; + const idleSrcRow = IDLE_BASE_ROW + srcOffset; + + for (let wf = 0; wf < WALK_FRAMES; wf += 1) { + blitFrame(atlas, wf, fi, extractFrame(src, wf, walkSrcRow)); + } + for (let idf = 0; idf < IDLE_FRAMES; idf += 1) { + blitFrame(atlas, WALK_FRAMES + idf, fi, extractFrame(src, idf, idleSrcRow)); + } + } + + mkdirSync(dirname(OUT), { recursive: true }); + writeFileSync(OUT, PNG.sync.write(atlas)); + console.log(`wrote ${OUT} (${atlasW}×${atlasH}, ${FACING_COUNT * FRAMES_PER_FACING} frames)`); +} + +bake(); diff --git a/scripts/verify-phase16.mjs b/scripts/verify-phase16.mjs index 1169448..dd690d8 100644 --- a/scripts/verify-phase16.mjs +++ b/scripts/verify-phase16.mjs @@ -36,7 +36,7 @@ const webUrl = `${webBase}${webBase.includes("?") ? "&" : "?"}room=${encodeURICo const CLOCK_RE = /\d{1,2}:\d{2}/; const COUNCIL_NPC_COUNT = COUNCIL_NPC_IDS.length; -const COUNCIL_NAMEPLATE_FONT = "13"; +const COUNCIL_NAMEPLATE_FONT = "10"; const report = { roomId, @@ -536,7 +536,7 @@ async function main() { console.log(`verify:phase16: councilNameplateProbe=${JSON.stringify(councilProbe)}`); record( "P16-10", - "council proximity nameplate (VIS-04 13px)", + "council proximity nameplate (VIS-04 scaled)", councilProbe.ok, JSON.stringify(councilProbe.visibleCouncilNameplates), ); diff --git a/scripts/verify-phase26.mjs b/scripts/verify-phase26.mjs index c944ed6..305c464 100644 --- a/scripts/verify-phase26.mjs +++ b/scripts/verify-phase26.mjs @@ -20,7 +20,7 @@ import { assertE2eRealLlm, e2eSpeakTimeoutMs, } from "./lib/e2e-policy.mjs"; -import { engageDialogue } from "./lib/dialogue-engage.mjs"; +import { engageDialogue, engageNpcDialogue } from "./lib/dialogue-engage.mjs"; import { closeShellDrawer, openShellDrawerCollective, @@ -298,42 +298,11 @@ async function moveNearNpc(page, npcId) { await page.waitForTimeout(400); } -async function engageCouncilNpc(page, npcId, timeoutMs) { - const dialogueBar = page.locator('[data-testid="dialogue-bar"]'); - if (await dialogueBar.isVisible().catch(() => false)) return; - - const cornerMenu = page.locator('[data-testid="corner-menu"]'); - await cornerMenu.waitFor({ state: "visible", timeout: 30_000 }); - await page.waitForFunction( - () => - Boolean( - document.querySelector('[data-testid="corner-menu"] .corner-menu__status-dot--ok'), - ), - { timeout: 45_000 }, - ); - - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await dialogueBar.isVisible().catch(() => false)) return; - await cornerMenu.locator(".corner-menu__trigger").click(); - const chip = page.locator(`#npc-avatar-${npcId}`); - try { - await chip.waitFor({ state: "visible", timeout: 12_000 }); - await chip.click(); - await dialogueBar.waitFor({ state: "visible", timeout: 8_000 }); - return; - } catch { - await page.waitForTimeout(400); - } - } - throw new Error(`engageCouncilNpc: dialogue-bar not visible for ${npcId} within ${timeoutMs}ms`); -} - async function speakToCouncilNpc(page, npcId, text) { await moveNearNpc(page, npcId); await page.waitForTimeout(600); await closeShellDrawer(page); - await engageCouncilNpc(page, npcId, engageTimeoutMs); + await engageNpcDialogue(page, npcId, { timeoutMs: engageTimeoutMs }); const reply = await sendSpeakOverlay(page, text, { speakTimeoutMs, engageTimeoutMs,