Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
16 changes: 15 additions & 1 deletion apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions apps/web/public/assets/CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added apps/web/public/assets/sprites/lpc-npc-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/web/src/components/DialogueBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function DialogueBar({
<div
className={`dialogue-bar dialogue-bar--overlay${reducedMotion ? " dialogue-bar--reduced-motion" : ""}`}
data-testid="dialogue-bar"
data-active-npc-id={activeNpcId}
>
<div className="dialogue-bar__header">
<div className="dialogue-bar__drawer-actions">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/game/FloorRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/game/HomeMapBackground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/game/LocalPlayerMovementController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 25 additions & 10 deletions apps/web/src/game/RoomScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -772,14 +780,15 @@ 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);
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);
applyNameplateStyle(ent.label, "player");
ent.container.addAt(avatar, 0);
ent.avatar = avatar;
Expand Down Expand Up @@ -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 {
Expand All @@ -850,15 +860,15 @@ 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;
}

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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down
27 changes: 10 additions & 17 deletions apps/web/src/game/activityLabels.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/game/assetManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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` },
];

Expand Down
11 changes: 5 additions & 6 deletions apps/web/src/game/bgNpcLabels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function mockLabel() {
setFontSize(size: string) {
style.fontSize = size;
},
setFontFamily() {},
setFontStyle(weight: string) {
style.fontStyle = weight;
},
Expand Down Expand Up @@ -42,18 +43,16 @@ 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);
});

it("applies muted activity typography", () => {
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");
});
});
30 changes: 13 additions & 17 deletions apps/web/src/game/bgNpcLabels.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading