fix(rpg): 자매 sprite 를 실제 일러스트와 닮게 재작성

자기야 발견: 1차 sprite 색상이 실제 자매 일러스트와 거리가 멀었음. 4 자매
실제 avatar.png (각 LXC 의 ~/.openclaw/avatar.png) 를 직접 확인 후 시그니처
정정:

  하랑 — 흰/은 머리 + 흰 cat ears + 파란 리본 + 파란 눈 (이전: 빨강 — 틀림)
  나랑 — 검은 short bob + 검은 cat ears + 청록 보타이 + 청록 눈 (그나마 비슷)
  다랑 — 흰 머리 + 흰 cat ears + 빨간 chinese 옷 + 빨간 눈 (이전: 노랑 — 틀림)
  이랑 — 흰 long hair + 흰 cat ears + 흰 옷 + 파란 눈 + 파란 나비 (이전: 보라)

핵심 변경:
- spriteFactory.ts SISTER_PALETTES 전면 재작성: hair / hairShadow / outfit /
  outfit2 / eye / earInner 등 분리. 자매별로 정확한 시그니처 색상.
- 4 자매 모두 catgirl 이라 sprite 위쪽에 cat ears 추가 (행 0-2 에 cat ear
  outline + hair fill + earInner pink).
- CHAR_H 24 → 28 로 키워서 cat ears 와 더 긴 머리/몸 표현 공간 확보.
- 자매별 추가 디테일:
    darang  → 이마에 빨간 보석 점
    erang   → 머리 옆에 작은 파란 나비
    harang  → 머리 위에 파란 리본
    narang  → 옆머리 청록 헤어핀
- narang 만 short bob (옆 bangs 다른 자매보다 짧음). 나머지 3 명은 long hair.
- OfficeScene.ts: sprite 28px 으로 커졌으니 origin 을 (0.5, 1) 로 바꾸고
  발끝을 타일 바닥 위에 정렬해 일관된 standing height.

이전: sprite 가 식별 불가능한 색깔 박스. 이번: 4 자매 각각 일러스트 시그
니처 색 + cat ears + 머리스타일이 보임.
This commit is contained in:
2026-04-11 04:37:37 +09:00
parent 9a17f65419
commit 480db0c734
2 changed files with 194 additions and 142 deletions

View File

@@ -115,9 +115,12 @@ export class OfficeScene extends Phaser.Scene {
if (ch >= '1' && ch <= '4') {
const idx = parseInt(ch, 10) - 1;
const name = SISTER_KEYS[idx]!;
// Sprite is 28px tall — anchor at the feet (origin 0.5, 1) and
// place feet at the tile floor (y + half tile) for consistent
// standing height.
const sprite = this.add
.sprite(x, y - 4, `sister-${name}`, 0)
.setOrigin(0.5, 0.5);
.sprite(x, y + TILE_SIZE / 2 - 1, `sister-${name}`, 0)
.setOrigin(0.5, 1);
sprite.setInteractive({ useHandCursor: true });
sprite.on('pointerdown', () => {
this.events_.onSisterClick?.(name);

View File

@@ -3,68 +3,92 @@ import type * as Phaser from 'phaser';
/**
* Procedural pixel art sprite factory.
*
* Why procedural: pre-baked sprite sheets would mean shipping art assets,
* downloading from external sources, and committing copyrighted-by-others
* material. Drawing every pixel in code keeps the dashboard self-contained
* and lets us match the 4-sister color identity exactly.
* Goal: each sister sprite should look like a tiny pixel-art version of
* her real anime illustration. All four sisters are catgirls in the source
* art, so every sprite has cat ears. Sister-specific signatures:
*
* Style: 1-bit-ish pixel art with 2-3 colors per sprite, 16px tiles, 16x24
* characters. Inspired by GBC Pokemon, deskrpg, openclaw-office.
* harang — white hair, blue ribbon, white cat ears, blue eyes
* narang — black short bob, teal bowtie, black cat ears, teal eyes
* darang — white hair, red chinese-style outfit, white cat ears, red eyes
* erang — white hair, white outfit + soft blue trim, white cat ears, blue eyes
*
* The 16x28 grid maps roughly to:
* row 0-1 : cat ear tips
* row 2-7 : hair top + face
* row 8-9 : eyes / mouth
* row 10-11 : neck + collar
* row 12-19 : torso + arms
* row 20-25 : legs
* row 26-27 : feet / shadow
*/
export const TILE_SIZE = 16;
export const CHAR_W = 16;
export const CHAR_H = 24;
export const CHAR_H = 28;
export interface SisterPalette {
hair: number;
outfit: number;
outfit2: number;
skin: number;
outline: number;
hair: number; // primary hair color
hairShadow: number; // hair shadow band
outfit: number; // primary garment color
outfit2: number; // accent (ribbon, bowtie, trim)
eye: number; // eye color
skin: number; // face/hand
outline: number; // dark outline (black-tinted with hair)
earInner?: number; // cat ear inner (optional pink)
accent?: number; // bonus accent (e.g. erang's butterfly)
}
export const SISTER_PALETTES: Record<string, SisterPalette> = {
// 하랑이 — 흰/은 머리, 파란 리본, 흰 cat ears, 파란 눈
harang: {
// 따뜻한 빨강 (planner / 부장 vibe)
hair: 0x6b2424,
outfit: 0xd84444,
outfit2: 0x9c2828,
skin: 0xf4cfa1,
outline: 0x1a0808,
hair: 0xe6ebf0,
hairShadow: 0xa9b1ba,
outfit: 0xf0f3f7,
outfit2: 0x3a7bc8,
eye: 0x2a5fb0,
skin: 0xf4d4ad,
outline: 0x1a1d24,
earInner: 0xd99fb0,
},
// 나랑이 — 검은 short bob, 청록 보타이, 검은 cat ears, 청록 눈
narang: {
// 차가운 청록 (developer / 손에 코드)
hair: 0x1f3a4a,
outfit: 0x37b8c7,
outfit2: 0x1d6f7c,
skin: 0xf4cfa1,
outline: 0x081a1f,
hair: 0x1c1c28,
hairShadow: 0x0a0a14,
outfit: 0x232330,
outfit2: 0x37b8c7,
eye: 0x37b8c7,
skin: 0xf4d4ad,
outline: 0x050510,
earInner: 0x6a3848,
},
// 다랑이 — 흰 머리, 빨간 chinese 옷, 흰 cat ears, 빨간 눈
darang: {
// 노랑 (qa / 검사관)
hair: 0x4a3a18,
outfit: 0xeac34a,
outfit2: 0xa07020,
skin: 0xf4cfa1,
outline: 0x1f1808,
hair: 0xe6ebf0,
hairShadow: 0xa9b1ba,
outfit: 0xc62a2a,
outfit2: 0x6a1818,
eye: 0xd83434,
skin: 0xf4d4ad,
outline: 0x180404,
earInner: 0xd99fb0,
},
// 이랑이 — 흰 long hair, 흰 옷 + 파란 trim, 흰 cat ears, 파란 눈, 파란 나비
erang: {
// 보라 (infra / 시스템)
hair: 0x2c1a3e,
outfit: 0x9858d8,
outfit2: 0x5c2c8a,
skin: 0xf4cfa1,
outline: 0x10081f,
hair: 0xeef2f7,
hairShadow: 0xc4cdd6,
outfit: 0xf2f5fa,
outfit2: 0x6e94c4,
eye: 0x4a7bbf,
skin: 0xf4d4ad,
outline: 0x1a1d24,
earInner: 0xd99fb0,
accent: 0x5fa8e6, // 파란 나비
},
};
/**
* Helper: write a 2D pixel grid into a Phaser Graphics object so it can be
* generated as a texture. Each cell of `grid` is a hex color or -1 for
* transparent.
*
* The grid is row-major: grid[y][x].
* Helper: write a 2D pixel grid into a Phaser Graphics object.
* grid[y][x] is the hex color or -1 for transparent.
*/
function paintGrid(
g: Phaser.GameObjects.Graphics,
@@ -82,93 +106,142 @@ function paintGrid(
}
}
interface FrameOpts {
sister: keyof typeof SISTER_PALETTES;
pal: SisterPalette;
/** 0 = neutral, 1 = breath (1px down) */
bob: 0 | 1;
}
/**
* Generate a simple character sprite (16x24) for one sister, looking down,
* 2 frames of idle (slight bob).
*
* Layout (16x24, frame 0):
* row 0-1 : empty
* row 2-7 : hair / head
* row 8-9 : face row
* row 10-15 : body / outfit
* row 16-19 : legs
* row 20-23 : feet / shadow base
* Generate a single 16x28 sister frame.
*/
function makeSisterFrame(
scene: Phaser.Scene,
pal: SisterPalette,
frameIdx: 0 | 1,
): number[][] {
const _ = -1; // transparent
function makeSisterFrame({ sister, pal, bob }: FrameOpts): number[][] {
const _ = -1;
const O = pal.outline;
const H = pal.hair;
const HS = pal.hairShadow;
const S = pal.skin;
const C = pal.outfit;
const C2 = pal.outfit2;
const F = 0x000000;
const E = pal.eye;
const EI = pal.earInner ?? 0xd99fb0;
const A = pal.accent ?? -1;
// Subtle vertical bob: frame 1 shifts the entire body 1px down by adding
// an extra blank row at the top and dropping the last row. This is the
// simplest "breathing" idle.
const offset = frameIdx === 1 ? 1 : 0;
const totalH = CHAR_H;
// Build a base grid (16x22 for the actual character — leaves 2 rows for
// shadow / breathing slack)
const grid: number[][] = Array.from({ length: totalH }, () =>
// Initial empty grid
const grid: number[][] = Array.from({ length: CHAR_H }, () =>
Array.from({ length: CHAR_W }, () => _),
);
function row(y: number, cells: number[]): void {
if (y >= 0 && y < totalH) {
for (let x = 0; x < CHAR_W && x < cells.length; x++) {
grid[y]![x] = cells[x]!;
}
const yOffset = bob === 1 ? 1 : 0;
const set = (x: number, y: number, c: number) => {
const yy = y + yOffset;
if (yy < 0 || yy >= CHAR_H || x < 0 || x >= CHAR_W) return;
grid[yy]![x] = c;
};
const setRow = (y: number, cells: Array<number | -1>) => {
for (let x = 0; x < cells.length && x < CHAR_W; x++) {
const c = cells[x]!;
if (c !== -1) set(x, y, c);
}
};
// ── row 0-1: cat ear tips (left ear cols 4-5, right ear cols 10-11) ──
// Ear outline + hair fill
set(4, 0, O); set(5, 0, O);
set(10, 0, O); set(11, 0, O);
set(3, 1, O); set(4, 1, H); set(5, 1, EI); set(6, 1, O);
set(9, 1, O); set(10, 1, EI); set(11, 1, H); set(12, 1, O);
set(4, 2, O); set(5, 2, H); set(6, 2, H);
set(9, 2, H); set(10, 2, H); set(11, 2, O);
// ── row 2-6: hair top + face dome ──
setRow(2, [_, _, _, O, H, H, H, O, O, H, H, H, O, _, _, _]);
setRow(3, [_, _, O, H, H, H, H, H, H, H, H, H, H, O, _, _]);
setRow(4, [_, _, O, H, HS, HS, H, H, H, H, HS, HS, H, O, _, _]);
setRow(5, [_, _, O, H, HS, S, S, S, S, S, S, HS, H, O, _, _]);
// ── row 6-9: face (eyes) ──
setRow(6, [_, _, O, H, S, S, S, S, S, S, S, S, H, O, _, _]);
setRow(7, [_, _, O, H, S, E, S, S, S, S, E, S, H, O, _, _]);
setRow(8, [_, _, O, H, S, S, S, S, S, S, S, S, H, O, _, _]);
setRow(9, [_, _, _, O, S, S, S, O, O, S, S, S, O, _, _, _]);
// hair side bangs (long hair) — overrides for narang's short bob handled below
setRow(10, [_, _, O, H, _, _, _, _, _, _, _, _, H, O, _, _]);
// narang has short hair → no side bangs reaching the shoulders
if (sister !== 'narang') {
setRow(11, [_, O, H, H, _, _, _, _, _, _, _, _, H, H, O, _]);
} else {
setRow(11, [_, _, O, _, _, _, _, _, _, _, _, _, _, O, _, _]);
}
void scene; // not used directly here — Phaser texture is created separately
void F;
// ── row 11-12: neck + collar ──
setRow(12, [_, _, _, _, O, S, S, S, S, S, S, O, _, _, _, _]);
// Apply offset
const r = (n: number) => n + offset;
// ── row 13-18: torso (outfit) ──
setRow(13, [_, _, _, O, C, C, C, C, C, C, C, C, O, _, _, _]);
setRow(14, [_, _, O, C, C, C2, C2, C2, C2, C2, C2, C, C, O, _, _]);
setRow(15, [_, _, O, C, C, C2, C2, C2, C2, C2, C2, C, C, O, _, _]);
setRow(16, [_, _, O, C, C, C, C, C, C, C, C, C, C, O, _, _]);
setRow(17, [_, O, C, C, C, C, C, C, C, C, C, C, C, C, O, _]);
setRow(18, [_, O, C, S, C, C, C, C, C, C, C, C, S, C, O, _]);
// hair top (5 rows of hair, row 1-5)
row(r(1), [_, _, _, _, O, O, O, O, O, O, O, O, _, _, _, _]);
row(r(2), [_, _, _, O, H, H, H, H, H, H, H, H, O, _, _, _]);
row(r(3), [_, _, O, H, H, H, H, H, H, H, H, H, H, O, _, _]);
row(r(4), [_, _, O, H, H, H, H, H, H, H, H, H, H, O, _, _]);
row(r(5), [_, _, O, H, H, S, S, S, S, S, S, H, H, O, _, _]);
// face (eyes/mouth)
row(r(6), [_, _, O, H, S, S, O, S, S, O, S, S, H, O, _, _]);
row(r(7), [_, _, O, H, S, S, S, S, S, S, S, S, H, O, _, _]);
row(r(8), [_, _, O, H, S, S, S, O, O, S, S, S, H, O, _, _]);
row(r(9), [_, _, _, O, S, S, S, S, S, S, S, S, O, _, _, _]);
// neck
row(r(10), [_, _, _, _, O, S, S, S, S, S, S, O, _, _, _, _]);
// outfit upper
row(r(11), [_, _, O, C, C, C, C, C, C, C, C, C, C, O, _, _]);
row(r(12), [_, O, C, C, C, C2, C2, C2, C2, C2, C2, C, C, C, O, _]);
row(r(13), [_, O, C, C, C, C2, C2, C2, C2, C2, C2, C, C, C, O, _]);
row(r(14), [_, O, C, C, C, C, C, C, C, C, C, C, C, C, O, _]);
row(r(15), [_, O, C, C, C, C, C, C, C, C, C, C, C, C, O, _]);
// arms
row(r(16), [_, O, C, S, S, C2, C2, C2, C2, C2, C2, S, S, C, O, _]);
row(r(17), [_, O, S, S, S, C2, C2, C2, C2, C2, C2, S, S, S, O, _]);
// belt
row(r(18), [_, _, O, C2, C2, C2, C2, C2, C2, C2, C2, C2, C2, O, _, _]);
// legs
row(r(19), [_, _, _, O, C2, C2, _, _, _, _, C2, C2, O, _, _, _]);
row(r(20), [_, _, _, O, C2, C2, _, _, _, _, C2, C2, O, _, _, _]);
row(r(21), [_, _, _, O, F, F, _, _, _, _, F, F, O, _, _, _]);
row(r(22), [_, _, _, _, O, O, _, _, _, _, O, O, _, _, _, _]);
// ── row 19-20: arms / hands ──
setRow(19, [_, O, S, S, C, C, C, C, C, C, C, C, S, S, O, _]);
setRow(20, [_, _, O, _, O, C2, C2, C2, C2, C2, C2, O, _, O, _, _]);
// ── row 21-24: legs (dark) ──
const L = pal.outfit2;
const LD = pal.outline;
setRow(21, [_, _, _, O, L, L, _, _, _, _, L, L, O, _, _, _]);
setRow(22, [_, _, _, O, L, L, _, _, _, _, L, L, O, _, _, _]);
setRow(23, [_, _, _, O, L, L, _, _, _, _, L, L, O, _, _, _]);
setRow(24, [_, _, _, O, L, L, _, _, _, _, L, L, O, _, _, _]);
// ── row 25-26: shoes ──
setRow(25, [_, _, _, O, LD, LD, _, _, _, _, LD, LD, O, _, _, _]);
setRow(26, [_, _, _, _, O, O, _, _, _, _, O, O, _, _, _, _]);
// ── row 27: ground shadow ──
setRow(27, [_, _, _, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, 0x000000, _, _, _]);
// ── per-sister extras ──
// darang: 이마 빨간 점 (forehead gem)
if (sister === 'darang') {
set(7, 5, 0xff5050);
set(8, 5, 0xff5050);
}
// erang: 옆에 작은 파란 나비
if (sister === 'erang' && A !== -1) {
// small butterfly to the right of head
set(13, 3, A);
set(14, 3, A);
set(13, 4, A);
set(14, 2, A);
}
// harang: 작은 파란 리본 머리에 (옅은)
if (sister === 'harang') {
set(2, 3, 0x4a8fd4);
set(3, 3, 0x4a8fd4);
set(2, 4, 0x6ba0d8);
}
// narang: 옆머리에 청록 헤어핀
if (sister === 'narang') {
set(13, 4, 0x37b8c7);
set(13, 5, 0x37b8c7);
}
return grid;
}
/**
* Generate a tile texture (16x16) representing a wood floor plank.
* Tile generators (16x16 each)
*/
function makeFloorTile(): number[][] {
const _ = 0xc4955a;
const D = 0x8a6438;
@@ -176,12 +249,10 @@ function makeFloorTile(): number[][] {
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
Array.from({ length: TILE_SIZE }, () => _),
);
// horizontal plank lines
for (let x = 0; x < TILE_SIZE; x++) {
grid[3]![x] = D;
grid[10]![x] = D;
}
// light highlights
for (let x = 0; x < TILE_SIZE; x++) {
if ((x + 1) % 4 === 0) {
grid[1]![x] = L;
@@ -199,7 +270,6 @@ function makeWallTile(): number[][] {
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
Array.from({ length: TILE_SIZE }, () => _),
);
// brick pattern
for (let y = 0; y < TILE_SIZE; y++) {
grid[y]![TILE_SIZE - 1] = D;
}
@@ -207,12 +277,10 @@ function makeWallTile(): number[][] {
grid[TILE_SIZE - 1]![x] = D;
grid[0]![x] = L;
}
// brick seam every 4 rows
for (let x = 0; x < TILE_SIZE; x++) {
grid[7]![x] = D;
grid[15]![x] = D;
}
// staggered vertical seams
for (let y = 0; y < 7; y++) grid[y]![7] = D;
for (let y = 8; y < 16; y++) grid[y]![3] = D;
for (let y = 8; y < 16; y++) grid[y]![11] = D;
@@ -236,7 +304,6 @@ function makeCarpetTile(): number[][] {
}
function makeDeskTile(): number[][] {
// 32x16 desk drawn into a 16x16 cell — top half table, bottom half legs
const T = 0x6b4628;
const D = 0x3a2614;
const L = 0x8a5e36;
@@ -244,7 +311,6 @@ function makeDeskTile(): number[][] {
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
Array.from({ length: TILE_SIZE }, () => _),
);
// tabletop
for (let x = 0; x < TILE_SIZE; x++) {
grid[2]![x] = D;
grid[3]![x] = T;
@@ -252,12 +318,10 @@ function makeDeskTile(): number[][] {
grid[5]![x] = L;
grid[6]![x] = D;
}
// monitor (small screen on top, lighter)
for (let y = 7; y < 11; y++) {
for (let x = 5; x < 11; x++) grid[y]![x] = 0x1a3a5a;
}
for (let x = 5; x < 11; x++) grid[11]![x] = 0x6e6e7c;
// legs (bottom)
for (let y = 12; y < TILE_SIZE; y++) {
grid[y]![1] = D;
grid[y]![14] = D;
@@ -272,7 +336,6 @@ function makeChairTile(): number[][] {
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
Array.from({ length: TILE_SIZE }, () => _),
);
// backrest
for (let y = 2; y < 8; y++) {
for (let x = 5; x < 11; x++) grid[y]![x] = C;
}
@@ -280,12 +343,10 @@ function makeChairTile(): number[][] {
grid[y]![5] = D;
grid[y]![10] = D;
}
// seat
for (let x = 4; x < 12; x++) {
grid[8]![x] = D;
grid[9]![x] = C;
}
// legs
grid[10]![5] = D;
grid[10]![10] = D;
grid[11]![5] = D;
@@ -293,9 +354,6 @@ function makeChairTile(): number[][] {
return grid;
}
/**
* Render a grid into a Phaser texture and register it under `key`.
*/
export function registerGridTexture(
scene: Phaser.Scene,
key: string,
@@ -311,8 +369,7 @@ export function registerGridTexture(
}
/**
* Build a 2-frame spritesheet texture for one sister.
* The two frames are stacked vertically into one image.
* Build a 2-frame spritesheet for one sister.
*/
export function registerSisterTexture(
scene: Phaser.Scene,
@@ -321,30 +378,22 @@ export function registerSisterTexture(
const key = `sister-${sister}`;
if (scene.textures.exists(key)) return;
const pal = SISTER_PALETTES[sister];
const f0 = makeSisterFrame(scene, pal, 0);
const f1 = makeSisterFrame(scene, pal, 1);
// stack vertically
const f0 = makeSisterFrame({ sister, pal, bob: 0 });
const f1 = makeSisterFrame({ sister, pal, bob: 1 });
// Stack vertically
const combined: number[][] = [...f0, ...f1];
const g = scene.add.graphics({ x: 0, y: 0 });
paintGrid(g, combined, 1);
g.generateTexture(key, CHAR_W, CHAR_H * 2);
g.destroy();
// register as a spritesheet so we can play it as an animation
const tex = scene.textures.get(key);
tex.add(0, 0, 0, 0, CHAR_W, CHAR_H);
tex.add(1, 0, 0, CHAR_H, CHAR_W, CHAR_H);
if (!scene.anims.exists(`${key}-idle`)) {
// We didn't actually create proper frames via spritesheet — instead,
// create a manual animation by swapping textures using `addKey`. Since
// Phaser anims need spritesheet/atlas frames, we re-register the
// texture as a spritesheet:
scene.textures.remove(key);
const g2 = scene.add.graphics({ x: 0, y: 0 });
paintGrid(g2, combined, 1);
g2.generateTexture(key, CHAR_W, CHAR_H * 2);
g2.destroy();
// Configure the texture frames manually
const tex = scene.textures.get(key);
tex.add(0, 0, 0, 0, CHAR_W, CHAR_H);
tex.add(1, 0, 0, CHAR_H, CHAR_W, CHAR_H);
scene.anims.create({
key: `${key}-idle`,
frames: [