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,