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:
2026-04-11 05:01:40 +09:00
parent 480db0c734
commit be0120bd0c
9 changed files with 542 additions and 855 deletions

View File

@@ -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>

View 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;
`;

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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);
}
}
}

View File

@@ -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} />;
}

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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: {}