feat(rpg): Phaser/픽셀아트 빼고 profile photo 기반 가상 사무실로 전환
자기야 피드백: "픽셀 아트 안 예쁘다, profile photo 로 바꿀 수 없나?
픽셀 아트 고집 안 해도 됨." → 16x28 픽셀로는 anime 일러스트 정체성 못
살리는 게 사실. Phaser 자체도 큰 의존성이라 깔끔하게 빼고 React+CSS+SVG
로 재작성.
## 변경
### 제거
- phaser 4.0.0 의존성 (~3MB)
- frontend/components/office-rpg/ 전체 (Phaser 통합 layer + procedural
pixel sprite factory + Office scene)
### 신설
- frontend/components/office-room/OfficeRoom.tsx
- 2x2 grid layout: 하랑/나랑 (위), 다랑/이랑 (아래)
- 가운데 회의실 (시각 표시만, 향후 인터랙션용)
- 각 자매 = 책상 SVG 위에 원형 portrait card
- 72px portrait (실제 /api/sisters/{name}/avatar)
- 자매 시그니처 색 border (harang=blue, narang=teal, darang=red, erang=violet)
- hover 시 위로 살짝 떠오름
- 활성 stage 일 때 PulseRing + StatusDot 펄스
- 자매 사이 SVG flow arrow (plan → impl → review → deploy 방향)
활성 stage 시 화살표 hi-lighted
- 클릭 → 우측 SisterDetailPanel slide-in
- frontend/components/office-room/SisterDetailPanel.tsx
- 기존 office-rpg 의 detail panel 을 재사용 (PortraitLg + 자매 시그니처
accent + 역할 설명 + 백엔드 sister info)
### app/page.tsx
- OfficeRpg → OfficeRoom 으로 교체
- 4초 polling 으로 활성 파이프라인의 currentState 추적 →
STATE_TO_STAGE 매핑 → 해당 자매 카드 자동 펄스
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import OfficeRpg from '@/components/office-rpg/OfficeRpg';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import OfficeRoom from '@/components/office-room/OfficeRoom';
|
||||
import ActivePipelineStrip from '@/components/dashboard/ActivePipelineStrip';
|
||||
|
||||
const PageRoot = styled.div`
|
||||
@@ -31,7 +32,60 @@ const PageSub = styled.p`
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
type Stage = 'plan' | 'implement' | 'review' | 'deploy' | null;
|
||||
|
||||
const STATE_TO_STAGE: Record<string, Stage> = {
|
||||
planning: 'plan',
|
||||
implementing: 'implement',
|
||||
reviewing: 'review',
|
||||
deploying: 'deploy',
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeStage, setActiveStage] = useState<Stage>(null);
|
||||
|
||||
// Poll the rails pipelines list every 4s and pick the most recently
|
||||
// updated non-terminal pipeline. Map its currentState to a stage so the
|
||||
// matching sister card pulses.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/rails/pipelines?limit=10`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
pipelines?: Array<{ currentState: string; updatedAt: string }>;
|
||||
};
|
||||
if (cancelled) return;
|
||||
const live = (data.pipelines ?? []).filter((p) =>
|
||||
['planning', 'implementing', 'reviewing', 'deploying'].includes(
|
||||
p.currentState,
|
||||
),
|
||||
);
|
||||
if (live.length === 0) {
|
||||
setActiveStage(null);
|
||||
return;
|
||||
}
|
||||
// Most recently updated wins
|
||||
const sorted = live.slice().sort((a, b) =>
|
||||
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''),
|
||||
);
|
||||
const top = sorted[0];
|
||||
if (top) setActiveStage(STATE_TO_STAGE[top.currentState] ?? null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const t = setInterval(load, 4000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageRoot>
|
||||
<PageHeader>
|
||||
@@ -41,7 +95,7 @@ export default function HomePage() {
|
||||
</PageSub>
|
||||
</PageHeader>
|
||||
|
||||
<OfficeRpg />
|
||||
<OfficeRoom activeStage={activeStage} />
|
||||
|
||||
<ActivePipelineStrip />
|
||||
</PageRoot>
|
||||
|
||||
452
frontend/components/office-room/OfficeRoom.tsx
Normal file
452
frontend/components/office-room/OfficeRoom.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterDetailPanel from './SisterDetailPanel';
|
||||
|
||||
/**
|
||||
* Photo-first virtual office.
|
||||
*
|
||||
* Pixel art proved too low-resolution to capture the actual sister
|
||||
* illustrations. Instead the office is a stylized CSS/SVG room and each
|
||||
* sister is a circular portrait of her real avatar (served by the
|
||||
* backend at /api/sisters/{name}/avatar) sitting at her desk.
|
||||
*
|
||||
* The layout is a simple 2x2 grid:
|
||||
*
|
||||
* 하랑 (plan) 나랑 (implement)
|
||||
* ┌────────┐
|
||||
* │ 회의실 │
|
||||
* └────────┘
|
||||
* 다랑 (review) 이랑 (deploy)
|
||||
*
|
||||
* Each sister card has:
|
||||
* - circular portrait
|
||||
* - korean name + role
|
||||
* - status dot
|
||||
* - active stage = glowing pulse ring
|
||||
* - clickable → opens detail panel
|
||||
*/
|
||||
|
||||
interface Sister {
|
||||
key: string;
|
||||
nameKR: string;
|
||||
role: string;
|
||||
stage: 'plan' | 'implement' | 'review' | 'deploy';
|
||||
accent: string; // sister-specific accent color (matches the actual illustration)
|
||||
}
|
||||
|
||||
const SISTERS: Sister[] = [
|
||||
{ key: 'harang', nameKR: '하랑이', role: '기획', stage: 'plan', accent: '#5fafff' },
|
||||
{ key: 'narang', nameKR: '나랑이', role: '구현', stage: 'implement', accent: '#37c4d4' },
|
||||
{ key: 'darang', nameKR: '다랑이', role: '검토', stage: 'review', accent: '#e84848' },
|
||||
{ key: 'erang', nameKR: '이랑이', role: '배포', stage: 'deploy', accent: '#9b8df0' },
|
||||
];
|
||||
|
||||
interface OfficeRoomProps {
|
||||
/** Currently active stage from any running pipeline (highlights one sister). */
|
||||
activeStage?: 'plan' | 'implement' | 'review' | 'deploy' | null;
|
||||
}
|
||||
|
||||
export default function OfficeRoom({ activeStage }: OfficeRoomProps) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const handleClick = (key: string) => {
|
||||
setSelected((prev) => (prev === key ? null : key));
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<RoomCard>
|
||||
<RoomHeader>
|
||||
<RoomTitle>🏢 하나랑 사무실</RoomTitle>
|
||||
<RoomSub>4 SISTERS · LIVE</RoomSub>
|
||||
</RoomHeader>
|
||||
|
||||
<Floor>
|
||||
<RoomDecor />
|
||||
|
||||
{SISTERS.map((s, idx) => (
|
||||
<DeskWrap key={s.key} $position={idx}>
|
||||
<DeskBack />
|
||||
<SisterCard
|
||||
$accent={s.accent}
|
||||
$active={activeStage === s.stage}
|
||||
$selected={selected === s.key}
|
||||
onClick={() => handleClick(s.key)}
|
||||
>
|
||||
{activeStage === s.stage && <PulseRing $accent={s.accent} />}
|
||||
<Portrait
|
||||
src={`${API_URL}/api/sisters/${s.key}/avatar`}
|
||||
alt={s.nameKR}
|
||||
$accent={s.accent}
|
||||
/>
|
||||
<NameRow>
|
||||
<SisterName>{s.nameKR}</SisterName>
|
||||
<RoleTag $accent={s.accent}>{s.role}</RoleTag>
|
||||
</NameRow>
|
||||
<StatusDot $active={activeStage === s.stage} $accent={s.accent} />
|
||||
</SisterCard>
|
||||
</DeskWrap>
|
||||
))}
|
||||
|
||||
<MeetingRoom>
|
||||
<MeetingLabel>회의실</MeetingLabel>
|
||||
</MeetingRoom>
|
||||
|
||||
{/* Stage flow arrows (top→right→bottom→left, plan→impl→review→deploy) */}
|
||||
<FlowSvg viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<marker
|
||||
id="arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="6"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#5fafff" opacity="0.55" />
|
||||
</marker>
|
||||
</defs>
|
||||
{/* harang → narang (top horizontal) */}
|
||||
<line
|
||||
x1="35" y1="22" x2="65" y2="22"
|
||||
stroke="#5fafff" strokeWidth="0.4" strokeDasharray="1.5 1.5"
|
||||
markerEnd="url(#arrow)" opacity={activeStage === 'plan' || activeStage === 'implement' ? 0.85 : 0.25}
|
||||
/>
|
||||
{/* narang → darang (right vertical → cross to bottom-right? we draw a curve) */}
|
||||
<line
|
||||
x1="78" y1="35" x2="78" y2="65"
|
||||
stroke="#5fafff" strokeWidth="0.4" strokeDasharray="1.5 1.5"
|
||||
markerEnd="url(#arrow)" opacity={activeStage === 'implement' || activeStage === 'review' ? 0.85 : 0.25}
|
||||
/>
|
||||
{/* darang → erang? Actually deploy is below right; review→deploy is bottom horizontal reversed. Use bottom row left direction */}
|
||||
<line
|
||||
x1="65" y1="78" x2="35" y2="78"
|
||||
stroke="#5fafff" strokeWidth="0.4" strokeDasharray="1.5 1.5"
|
||||
markerEnd="url(#arrow)" opacity={activeStage === 'review' || activeStage === 'deploy' ? 0.85 : 0.25}
|
||||
/>
|
||||
{/* erang → harang? Pipeline ends at deploy. Skip return arrow. */}
|
||||
</FlowSvg>
|
||||
</Floor>
|
||||
|
||||
<Hint>책상 클릭 → 자매 상세 정보 · {activeStage ? `현재 ${activeStage} 단계 진행 중` : 'idle'}</Hint>
|
||||
</RoomCard>
|
||||
|
||||
<PanelCol>
|
||||
{selected ? (
|
||||
<SisterDetailPanel
|
||||
name={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
) : (
|
||||
<PanelEmpty>
|
||||
👀 자매를 클릭해서<br />
|
||||
상세 정보를 봐
|
||||
</PanelEmpty>
|
||||
)}
|
||||
</PanelCol>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────── styled ─────────────────────────
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { transform: scale(1); opacity: 0.6; }
|
||||
50% { transform: scale(1.15); opacity: 0.0; }
|
||||
`;
|
||||
|
||||
const dotPulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
`;
|
||||
|
||||
const Layout = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const RoomCard = styled.section`
|
||||
position: relative;
|
||||
background: linear-gradient(160deg, #1a1f2e 0%, #0e1118 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 620px;
|
||||
`;
|
||||
|
||||
const RoomHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
`;
|
||||
|
||||
const RoomTitle = styled.h2`
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const RoomSub = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const Floor = styled.div`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 0;
|
||||
padding: 36px;
|
||||
min-height: 520px;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Background decoration: subtle isometric grid lines + carpet circle.
|
||||
* Pure CSS — no images.
|
||||
*/
|
||||
const RoomDecor = styled.div`
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(circle, rgba(95, 175, 255, 0.05) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
const FlowSvg = styled.svg`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const DeskWrap = styled.div<{ $position: number }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
gap: 8px;
|
||||
${({ $position }) => {
|
||||
switch ($position) {
|
||||
case 0: return 'justify-self: start; align-self: start; padding-top: 12px;';
|
||||
case 1: return 'justify-self: end; align-self: start; padding-top: 12px;';
|
||||
case 2: return 'justify-self: start; align-self: end; padding-bottom: 12px;';
|
||||
case 3: return 'justify-self: end; align-self: end; padding-bottom: 12px;';
|
||||
default: return '';
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
/** Simple "desk" silhouette under the sister card. */
|
||||
const DeskBack = styled.div`
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 130px;
|
||||
height: 24px;
|
||||
background: linear-gradient(180deg, #4a3520 0%, #2a1d10 100%);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: -1;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 8px;
|
||||
background: #1a3654;
|
||||
border: 1px solid #0a1a2c;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface SisterCardProps {
|
||||
$accent: string;
|
||||
$active: boolean;
|
||||
$selected: boolean;
|
||||
}
|
||||
|
||||
const SisterCard = styled.button<SisterCardProps>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px 12px;
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.04)'};
|
||||
border: 1px solid
|
||||
${({ $accent, $selected }) => ($selected ? $accent : 'rgba(255, 255, 255, 0.1)')};
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: ${({ $accent }) => $accent};
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
`;
|
||||
|
||||
const PulseRing = styled.span<{ $accent: string }>`
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${({ $accent }) => $accent};
|
||||
animation: ${pulse} 1.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const Portrait = styled.img<{ $accent: string }>`
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid ${({ $accent }) => $accent};
|
||||
background: #111;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.4),
|
||||
0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const NameRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SisterName = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const RoleTag = styled.span<{ $accent: string }>`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 8px;
|
||||
color: ${({ $accent }) => $accent};
|
||||
border: 1px solid ${({ $accent }) => $accent};
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
`;
|
||||
|
||||
const StatusDot = styled.span<{ $active: boolean; $accent: string }>`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $active, $accent }) => ($active ? $accent : '#3a3a44')};
|
||||
box-shadow: ${({ $active, $accent }) =>
|
||||
$active ? `0 0 8px ${$accent}` : 'none'};
|
||||
animation: ${({ $active }) => ($active ? dotPulse : 'none')} 1.6s ease-in-out
|
||||
infinite;
|
||||
`;
|
||||
|
||||
const MeetingRoom = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 110px;
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, rgba(95, 175, 255, 0.08) 0%, rgba(95, 175, 255, 0.02) 100%);
|
||||
border: 1px solid rgba(95, 175, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const MeetingLabel = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(95, 175, 255, 0.7);
|
||||
`;
|
||||
|
||||
const Hint = styled.div`
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
`;
|
||||
|
||||
const PanelCol = styled.aside`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 620px;
|
||||
`;
|
||||
|
||||
const PanelEmpty = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.7;
|
||||
`;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
import { API_URL } from '@/lib/config';
|
||||
|
||||
interface SisterDetailPanelProps {
|
||||
@@ -35,15 +34,22 @@ const SISTER_ROLE: Record<string, string> = {
|
||||
erang: '배포 (Infra)',
|
||||
};
|
||||
|
||||
const SISTER_DESCRIPTION: Record<string, string> = {
|
||||
const SISTER_DESC: Record<string, string> = {
|
||||
harang:
|
||||
'프로젝트 요구사항을 받아서 MVP 범위와 통과 기준을 결정한다. rails 파이프라인의 첫 단계.',
|
||||
'프로젝트 요구사항에서 MVP 범위와 통과 기준을 결정한다. rails 파이프라인의 첫 단계.',
|
||||
narang:
|
||||
'하랑이의 기획을 받아서 실제 코드/파일을 생성한다. junior 가 코드 블록을 쓰고 git push 까지.',
|
||||
'하랑이의 기획을 받아 코드/파일을 직접 생성하고 git push 까지 진행한다.',
|
||||
darang:
|
||||
'narang 의 구현물을 review. APPROVE / REQUEST_CHANGES / ABORT 중 하나를 결정.',
|
||||
erang:
|
||||
'darang 이 통과시킨 산출물을 배포 검증한다. 마지막 단계, DEPLOY_DONE / DEPLOY_FAILED.',
|
||||
'darang 통과 후 산출물을 배포 검증한다. 마지막 단계, DEPLOY_DONE / DEPLOY_FAILED.',
|
||||
};
|
||||
|
||||
const SISTER_ACCENT: Record<string, string> = {
|
||||
harang: '#5fafff',
|
||||
narang: '#37c4d4',
|
||||
darang: '#e84848',
|
||||
erang: '#9b8df0',
|
||||
};
|
||||
|
||||
const Header = styled.div`
|
||||
@@ -52,11 +58,22 @@ const Header = styled.div`
|
||||
gap: 14px;
|
||||
`;
|
||||
|
||||
const PortraitLg = styled.img<{ $accent: string }>`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid ${({ $accent }) => $accent};
|
||||
background: #111;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const NameRow = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const NameKR = styled.h2`
|
||||
@@ -82,6 +99,7 @@ const CloseBtn = styled.button`
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
@@ -122,7 +140,6 @@ const StatusDot = styled.span<{ $status: string }>`
|
||||
return '#22c55e';
|
||||
case 'offline':
|
||||
return '#6b7280';
|
||||
case 'unknown':
|
||||
default:
|
||||
return '#f59e0b';
|
||||
}
|
||||
@@ -139,6 +156,7 @@ export default function SisterDetailPanel({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setInfo(null);
|
||||
fetch(`${API_URL}/api/sisters/${name}`, { credentials: 'include' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: SisterInfo | null) => {
|
||||
@@ -154,10 +172,16 @@ export default function SisterDetailPanel({
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const accent = SISTER_ACCENT[name] ?? '#5fafff';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<SisterAvatar name={name} size={56} />
|
||||
<PortraitLg
|
||||
src={`${API_URL}/api/sisters/${name}/avatar`}
|
||||
alt={name}
|
||||
$accent={accent}
|
||||
/>
|
||||
<NameRow>
|
||||
<NameKR>{SISTER_KOREAN[name] ?? name}</NameKR>
|
||||
<RoleLabel>{SISTER_ROLE[name] ?? 'unknown'}</RoleLabel>
|
||||
@@ -167,18 +191,14 @@ export default function SisterDetailPanel({
|
||||
|
||||
<Section>
|
||||
<SectionLabel>역할</SectionLabel>
|
||||
<SectionBody>{SISTER_DESCRIPTION[name] ?? '—'}</SectionBody>
|
||||
<SectionBody>{SISTER_DESC[name] ?? '—'}</SectionBody>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionLabel>상태</SectionLabel>
|
||||
<SectionBody>
|
||||
<StatusDot $status={info?.status ?? 'unknown'} />
|
||||
{loading
|
||||
? '확인 중...'
|
||||
: info?.status
|
||||
? info.status
|
||||
: '상태 정보 없음'}
|
||||
{loading ? '확인 중...' : info?.status ?? '상태 정보 없음'}
|
||||
</SectionBody>
|
||||
</Section>
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import styled from 'styled-components';
|
||||
import SisterDetailPanel from './SisterDetailPanel';
|
||||
|
||||
/**
|
||||
* Phaser is a heavy WebGL library that touches `window` on import. We must
|
||||
* dynamic-import it client-side only to keep Next.js SSR happy.
|
||||
*/
|
||||
const PhaserHostInner = dynamic(() => import('./PhaserHost'), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingHost>오피스 로딩 중...</LoadingHost>,
|
||||
});
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-height: 600px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const SceneCard = styled.div`
|
||||
position: relative;
|
||||
background: #0b0b14;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 600px;
|
||||
image-rendering: pixelated;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const HeaderLabel = styled.span`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
`;
|
||||
|
||||
const Hint = styled.div`
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
const LoadingHost = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const PanelHost = styled.aside`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 600px;
|
||||
`;
|
||||
|
||||
const PanelEmpty = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
export default function OfficeRpg() {
|
||||
const [selectedSister, setSelectedSister] = useState<string | null>(null);
|
||||
|
||||
const handleSisterClick = useCallback((name: string) => {
|
||||
setSelectedSister((prev) => (prev === name ? null : name));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<SceneCard>
|
||||
<Header>
|
||||
<HeaderLabel>🏢 하나랑 사무실</HeaderLabel>
|
||||
<HeaderLabel>4 SISTERS · LIVE</HeaderLabel>
|
||||
</Header>
|
||||
<PhaserHostInner onSisterClick={handleSisterClick} />
|
||||
<Hint>자매를 클릭하면 상세 정보가 옆 패널에 나와</Hint>
|
||||
</SceneCard>
|
||||
<PanelHost>
|
||||
{selectedSister ? (
|
||||
<SisterDetailPanel
|
||||
name={selectedSister}
|
||||
onClose={() => setSelectedSister(null)}
|
||||
/>
|
||||
) : (
|
||||
<PanelEmpty>
|
||||
👀 자매 캐릭터를 클릭해서<br />
|
||||
상세 정보를 봐
|
||||
</PanelEmpty>
|
||||
)}
|
||||
</PanelHost>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import * as Phaser from 'phaser';
|
||||
import {
|
||||
TILE_SIZE,
|
||||
CHAR_W,
|
||||
CHAR_H,
|
||||
SISTER_PALETTES,
|
||||
registerAllTextures,
|
||||
} from './spriteFactory';
|
||||
|
||||
/**
|
||||
* 16x12 tile office layout, encoded as a string. Each character is one
|
||||
* tile. Total scene size = 256 x 192 px (then upscaled by camera zoom).
|
||||
*
|
||||
* Legend:
|
||||
* # : wall
|
||||
* . : floor (wood)
|
||||
* , : carpet
|
||||
* D : desk
|
||||
* c : chair (decorative — sister sprite stands in front)
|
||||
* _ : empty / outside (rendered black)
|
||||
* 1 : harang spawn
|
||||
* 2 : narang spawn
|
||||
* 3 : darang spawn
|
||||
* 4 : erang spawn
|
||||
*/
|
||||
const LAYOUT = [
|
||||
'################',
|
||||
'#..............#',
|
||||
'#..D..D..D..D..#',
|
||||
'#..1..2..3..4..#',
|
||||
'#..............#',
|
||||
'#,,,,,,,,,,,,,,#',
|
||||
'#,,,,,,,,,,,,,,#',
|
||||
'#..............#',
|
||||
'#####......#####',
|
||||
'#............,,#',
|
||||
'#............,,#',
|
||||
'################',
|
||||
];
|
||||
|
||||
export const OFFICE_W_TILES = LAYOUT[0]!.length;
|
||||
export const OFFICE_H_TILES = LAYOUT.length;
|
||||
|
||||
export const SISTER_KEYS: Array<keyof typeof SISTER_PALETTES> = [
|
||||
'harang',
|
||||
'narang',
|
||||
'darang',
|
||||
'erang',
|
||||
];
|
||||
|
||||
export interface OfficeSceneEvents {
|
||||
onSisterClick?: (sister: string) => void;
|
||||
}
|
||||
|
||||
interface SisterStateMap {
|
||||
[name: string]: 'idle' | 'working' | 'speaking' | 'error';
|
||||
}
|
||||
|
||||
export class OfficeScene extends Phaser.Scene {
|
||||
private sisterSprites = new Map<string, Phaser.GameObjects.Sprite>();
|
||||
private statusBubbles = new Map<string, Phaser.GameObjects.Text>();
|
||||
private events_: OfficeSceneEvents = {};
|
||||
private currentStates: SisterStateMap = {};
|
||||
|
||||
constructor() {
|
||||
super({ key: 'OfficeScene' });
|
||||
}
|
||||
|
||||
init(data: OfficeSceneEvents): void {
|
||||
this.events_ = data ?? {};
|
||||
}
|
||||
|
||||
/** Public setter so React can update the click handler post-mount. */
|
||||
setEvents(events: OfficeSceneEvents): void {
|
||||
this.events_ = events;
|
||||
}
|
||||
|
||||
preload(): void {
|
||||
registerAllTextures(this);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// ── Tilemap render ──────────────────────────────────────────
|
||||
const tileWorld = (col: number, row: number): { x: number; y: number } => ({
|
||||
x: col * TILE_SIZE + TILE_SIZE / 2,
|
||||
y: row * TILE_SIZE + TILE_SIZE / 2,
|
||||
});
|
||||
|
||||
for (let row = 0; row < OFFICE_H_TILES; row++) {
|
||||
const line = LAYOUT[row]!;
|
||||
for (let col = 0; col < OFFICE_W_TILES; col++) {
|
||||
const ch = line[col]!;
|
||||
const { x, y } = tileWorld(col, row);
|
||||
|
||||
// Background — almost everything has floor or carpet under it
|
||||
let baseTexture = '';
|
||||
if (ch === ',') baseTexture = 'tile-carpet';
|
||||
else if (ch === '#') baseTexture = 'tile-wall';
|
||||
else if (ch === '_') baseTexture = '';
|
||||
else baseTexture = 'tile-floor';
|
||||
|
||||
if (baseTexture) {
|
||||
this.add.image(x, y, baseTexture).setOrigin(0.5, 0.5);
|
||||
}
|
||||
|
||||
// Furniture overlays
|
||||
if (ch === 'D') {
|
||||
this.add.image(x, y, 'tile-desk').setOrigin(0.5, 0.5);
|
||||
}
|
||||
if (ch === 'c') {
|
||||
this.add.image(x, y, 'tile-chair').setOrigin(0.5, 0.5);
|
||||
}
|
||||
|
||||
// Sister spawn
|
||||
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 + TILE_SIZE / 2 - 1, `sister-${name}`, 0)
|
||||
.setOrigin(0.5, 1);
|
||||
sprite.setInteractive({ useHandCursor: true });
|
||||
sprite.on('pointerdown', () => {
|
||||
this.events_.onSisterClick?.(name);
|
||||
});
|
||||
sprite.play(`sister-${name}-idle`);
|
||||
this.sisterSprites.set(name, sprite);
|
||||
|
||||
// Status bubble (small floating tag above the sprite)
|
||||
const bubble = this.add
|
||||
.text(x, y - CHAR_H, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '6px',
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#000000aa',
|
||||
padding: { x: 2, y: 1 },
|
||||
})
|
||||
.setOrigin(0.5, 1)
|
||||
.setVisible(false);
|
||||
this.statusBubbles.set(name, bubble);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Camera zoom — scale up so 256x192 fills the host element nicely
|
||||
this.cameras.main.setZoom(3);
|
||||
this.cameras.main.centerOn(
|
||||
(OFFICE_W_TILES * TILE_SIZE) / 2,
|
||||
(OFFICE_H_TILES * TILE_SIZE) / 2,
|
||||
);
|
||||
// Hard-pixel rendering at zoom
|
||||
this.cameras.main.setRoundPixels(true);
|
||||
}
|
||||
|
||||
/** Called from React when sister state changes externally. */
|
||||
setSisterState(name: string, state: 'idle' | 'working' | 'speaking' | 'error'): void {
|
||||
if (this.currentStates[name] === state) return;
|
||||
this.currentStates[name] = state;
|
||||
const bubble = this.statusBubbles.get(name);
|
||||
if (!bubble) return;
|
||||
const map: Record<typeof state, string> = {
|
||||
idle: '',
|
||||
working: '⚙️',
|
||||
speaking: '💬',
|
||||
error: '⚠️',
|
||||
};
|
||||
const label = map[state];
|
||||
if (label) {
|
||||
bubble.setText(label).setVisible(true);
|
||||
} else {
|
||||
bubble.setVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as Phaser from 'phaser';
|
||||
import styled from 'styled-components';
|
||||
import { OfficeScene, OFFICE_W_TILES, OFFICE_H_TILES } from './OfficeScene';
|
||||
import { TILE_SIZE } from './spriteFactory';
|
||||
|
||||
const Mount = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* Crucial: pixel art must NOT be smoothed by the browser. */
|
||||
& canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
onSisterClick?: (sister: string) => void;
|
||||
}
|
||||
|
||||
export default function PhaserHost({ onSisterClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const gameRef = useRef<Phaser.Game | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (gameRef.current) return;
|
||||
|
||||
const baseW = OFFICE_W_TILES * TILE_SIZE;
|
||||
const baseH = OFFICE_H_TILES * TILE_SIZE;
|
||||
|
||||
// Camera zoom inside the scene = 3x. So our actual canvas needs
|
||||
// baseW*3 x baseH*3 pixels.
|
||||
const zoom = 3;
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
width: baseW * zoom,
|
||||
height: baseH * zoom,
|
||||
parent: containerRef.current,
|
||||
backgroundColor: '#0b0b14',
|
||||
pixelArt: true,
|
||||
roundPixels: true,
|
||||
antialias: false,
|
||||
scene: [OfficeScene],
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
},
|
||||
});
|
||||
|
||||
// Pass the click handler to the scene once it's started
|
||||
game.scene.start('OfficeScene', { onSisterClick });
|
||||
gameRef.current = game;
|
||||
|
||||
return () => {
|
||||
game.destroy(true);
|
||||
gameRef.current = null;
|
||||
};
|
||||
// intentionally only mount once — re-mounting Phaser is expensive and
|
||||
// we route click events through a ref instead
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Forward updated click handler to the running scene without restart
|
||||
useEffect(() => {
|
||||
const game = gameRef.current;
|
||||
if (!game) return;
|
||||
const scene = game.scene.getScene('OfficeScene') as unknown as
|
||||
| OfficeScene
|
||||
| undefined;
|
||||
if (scene && typeof scene.setEvents === 'function') {
|
||||
scene.setEvents({ onSisterClick });
|
||||
}
|
||||
}, [onSisterClick]);
|
||||
|
||||
return <Mount ref={containerRef} />;
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import type * as Phaser from 'phaser';
|
||||
|
||||
/**
|
||||
* Procedural pixel art sprite factory.
|
||||
*
|
||||
* 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:
|
||||
*
|
||||
* 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 = 28;
|
||||
|
||||
export interface SisterPalette {
|
||||
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: {
|
||||
hair: 0xe6ebf0,
|
||||
hairShadow: 0xa9b1ba,
|
||||
outfit: 0xf0f3f7,
|
||||
outfit2: 0x3a7bc8,
|
||||
eye: 0x2a5fb0,
|
||||
skin: 0xf4d4ad,
|
||||
outline: 0x1a1d24,
|
||||
earInner: 0xd99fb0,
|
||||
},
|
||||
// 나랑이 — 검은 short bob, 청록 보타이, 검은 cat ears, 청록 눈
|
||||
narang: {
|
||||
hair: 0x1c1c28,
|
||||
hairShadow: 0x0a0a14,
|
||||
outfit: 0x232330,
|
||||
outfit2: 0x37b8c7,
|
||||
eye: 0x37b8c7,
|
||||
skin: 0xf4d4ad,
|
||||
outline: 0x050510,
|
||||
earInner: 0x6a3848,
|
||||
},
|
||||
// 다랑이 — 흰 머리, 빨간 chinese 옷, 흰 cat ears, 빨간 눈
|
||||
darang: {
|
||||
hair: 0xe6ebf0,
|
||||
hairShadow: 0xa9b1ba,
|
||||
outfit: 0xc62a2a,
|
||||
outfit2: 0x6a1818,
|
||||
eye: 0xd83434,
|
||||
skin: 0xf4d4ad,
|
||||
outline: 0x180404,
|
||||
earInner: 0xd99fb0,
|
||||
},
|
||||
// 이랑이 — 흰 long hair, 흰 옷 + 파란 trim, 흰 cat ears, 파란 눈, 파란 나비
|
||||
erang: {
|
||||
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.
|
||||
* grid[y][x] is the hex color or -1 for transparent.
|
||||
*/
|
||||
function paintGrid(
|
||||
g: Phaser.GameObjects.Graphics,
|
||||
grid: number[][],
|
||||
pixelSize = 1,
|
||||
): void {
|
||||
for (let y = 0; y < grid.length; y++) {
|
||||
const row = grid[y]!;
|
||||
for (let x = 0; x < row.length; x++) {
|
||||
const c = row[x]!;
|
||||
if (c < 0) continue;
|
||||
g.fillStyle(c, 1);
|
||||
g.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface FrameOpts {
|
||||
sister: keyof typeof SISTER_PALETTES;
|
||||
pal: SisterPalette;
|
||||
/** 0 = neutral, 1 = breath (1px down) */
|
||||
bob: 0 | 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single 16x28 sister frame.
|
||||
*/
|
||||
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 E = pal.eye;
|
||||
const EI = pal.earInner ?? 0xd99fb0;
|
||||
const A = pal.accent ?? -1;
|
||||
|
||||
// Initial empty grid
|
||||
const grid: number[][] = Array.from({ length: CHAR_H }, () =>
|
||||
Array.from({ length: CHAR_W }, () => _),
|
||||
);
|
||||
|
||||
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, _, _]);
|
||||
}
|
||||
|
||||
// ── row 11-12: neck + collar ──
|
||||
setRow(12, [_, _, _, _, O, S, S, S, S, S, S, O, _, _, _, _]);
|
||||
|
||||
// ── 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, _]);
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile generators (16x16 each)
|
||||
*/
|
||||
|
||||
function makeFloorTile(): number[][] {
|
||||
const _ = 0xc4955a;
|
||||
const D = 0x8a6438;
|
||||
const L = 0xd6ab78;
|
||||
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
|
||||
Array.from({ length: TILE_SIZE }, () => _),
|
||||
);
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
grid[3]![x] = D;
|
||||
grid[10]![x] = D;
|
||||
}
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
if ((x + 1) % 4 === 0) {
|
||||
grid[1]![x] = L;
|
||||
grid[7]![x] = L;
|
||||
grid[14]![x] = L;
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function makeWallTile(): number[][] {
|
||||
const _ = 0x5a5266;
|
||||
const D = 0x3a3445;
|
||||
const L = 0x726a82;
|
||||
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
|
||||
Array.from({ length: TILE_SIZE }, () => _),
|
||||
);
|
||||
for (let y = 0; y < TILE_SIZE; y++) {
|
||||
grid[y]![TILE_SIZE - 1] = D;
|
||||
}
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
grid[TILE_SIZE - 1]![x] = D;
|
||||
grid[0]![x] = L;
|
||||
}
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
grid[7]![x] = D;
|
||||
grid[15]![x] = D;
|
||||
}
|
||||
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;
|
||||
return grid;
|
||||
}
|
||||
|
||||
function makeCarpetTile(): number[][] {
|
||||
const _ = 0x4a2828;
|
||||
const A = 0x6c3838;
|
||||
const B = 0x2c1818;
|
||||
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
|
||||
Array.from({ length: TILE_SIZE }, () => _),
|
||||
);
|
||||
for (let y = 0; y < TILE_SIZE; y++) {
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
if ((x + y) % 4 === 0) grid[y]![x] = A;
|
||||
if ((x + y * 3) % 7 === 0) grid[y]![x] = B;
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function makeDeskTile(): number[][] {
|
||||
const T = 0x6b4628;
|
||||
const D = 0x3a2614;
|
||||
const L = 0x8a5e36;
|
||||
const _ = -1;
|
||||
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
|
||||
Array.from({ length: TILE_SIZE }, () => _),
|
||||
);
|
||||
for (let x = 0; x < TILE_SIZE; x++) {
|
||||
grid[2]![x] = D;
|
||||
grid[3]![x] = T;
|
||||
grid[4]![x] = T;
|
||||
grid[5]![x] = L;
|
||||
grid[6]![x] = D;
|
||||
}
|
||||
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;
|
||||
for (let y = 12; y < TILE_SIZE; y++) {
|
||||
grid[y]![1] = D;
|
||||
grid[y]![14] = D;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
function makeChairTile(): number[][] {
|
||||
const _ = -1;
|
||||
const C = 0x2c1f3f;
|
||||
const D = 0x16101f;
|
||||
const grid: number[][] = Array.from({ length: TILE_SIZE }, () =>
|
||||
Array.from({ length: TILE_SIZE }, () => _),
|
||||
);
|
||||
for (let y = 2; y < 8; y++) {
|
||||
for (let x = 5; x < 11; x++) grid[y]![x] = C;
|
||||
}
|
||||
for (let y = 2; y < 8; y++) {
|
||||
grid[y]![5] = D;
|
||||
grid[y]![10] = D;
|
||||
}
|
||||
for (let x = 4; x < 12; x++) {
|
||||
grid[8]![x] = D;
|
||||
grid[9]![x] = C;
|
||||
}
|
||||
grid[10]![5] = D;
|
||||
grid[10]![10] = D;
|
||||
grid[11]![5] = D;
|
||||
grid[11]![10] = D;
|
||||
return grid;
|
||||
}
|
||||
|
||||
export function registerGridTexture(
|
||||
scene: Phaser.Scene,
|
||||
key: string,
|
||||
grid: number[][],
|
||||
): void {
|
||||
if (scene.textures.exists(key)) return;
|
||||
const w = grid[0]?.length ?? TILE_SIZE;
|
||||
const h = grid.length;
|
||||
const g = scene.add.graphics({ x: 0, y: 0 });
|
||||
paintGrid(g, grid, 1);
|
||||
g.generateTexture(key, w, h);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 2-frame spritesheet for one sister.
|
||||
*/
|
||||
export function registerSisterTexture(
|
||||
scene: Phaser.Scene,
|
||||
sister: keyof typeof SISTER_PALETTES,
|
||||
): void {
|
||||
const key = `sister-${sister}`;
|
||||
if (scene.textures.exists(key)) return;
|
||||
const pal = SISTER_PALETTES[sister];
|
||||
|
||||
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();
|
||||
|
||||
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`)) {
|
||||
scene.anims.create({
|
||||
key: `${key}-idle`,
|
||||
frames: [
|
||||
{ key, frame: 0 },
|
||||
{ key, frame: 1 },
|
||||
],
|
||||
frameRate: 2,
|
||||
repeat: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAllTextures(scene: Phaser.Scene): void {
|
||||
registerGridTexture(scene, 'tile-floor', makeFloorTile());
|
||||
registerGridTexture(scene, 'tile-wall', makeWallTile());
|
||||
registerGridTexture(scene, 'tile-carpet', makeCarpetTile());
|
||||
registerGridTexture(scene, 'tile-desk', makeDeskTile());
|
||||
registerGridTexture(scene, 'tile-chair', makeChairTile());
|
||||
for (const name of Object.keys(SISTER_PALETTES) as Array<
|
||||
keyof typeof SISTER_PALETTES
|
||||
>) {
|
||||
registerSisterTexture(scene, name);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.2",
|
||||
"phaser": "^4.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
15
frontend/pnpm-lock.yaml
generated
15
frontend/pnpm-lock.yaml
generated
@@ -11,9 +11,6 @@ importers:
|
||||
next:
|
||||
specifier: 16.2.2
|
||||
version: 16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
phaser:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
react:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
@@ -1095,9 +1092,6 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
@@ -1690,9 +1684,6 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
phaser@4.0.0:
|
||||
resolution: {integrity: sha512-f9oYpu3/UymB5JJDZRqOsNQm5FkMMC7u8eL8yQuqGAa54wTbgE2QbTOn70vgvlOVuYeQcw2mOQ52PpJHBSBkfQ==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -3274,8 +3265,6 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -4030,10 +4019,6 @@ snapshots:
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
phaser@4.0.0:
|
||||
dependencies:
|
||||
eventemitter3: 5.0.4
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
Reference in New Issue
Block a user