Compare commits
62 Commits
52c9111ecd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27cb0a11a | ||
|
|
6f462b8cb1 | ||
|
|
9bfb30a5e7 | ||
|
|
f99784331f | ||
|
|
16d3c86486 | ||
|
|
953ae90546 | ||
|
|
c682725968 | ||
|
|
bc199de10b | ||
|
|
8fb4219749 | ||
|
|
77a7492b85 | ||
|
|
7e83d061bc | ||
|
|
5aad13f8a5 | ||
|
|
df280eb5b0 | ||
|
|
467d8ee4c2 | ||
|
|
c34497a445 | ||
|
|
fa7ceea668 | ||
|
|
11ad2be147 | ||
|
|
1fc4d8c7fa | ||
|
|
dfe4ce1ca4 | ||
|
|
3e414e62ca | ||
|
|
559fb8507c | ||
|
|
4de9ca467c | ||
|
|
c41803ac45 | ||
|
|
f812789bee | ||
|
|
489c017a96 | ||
|
|
f340f790b5 | ||
|
|
c0fb44e395 | ||
|
|
d6496dc1fa | ||
|
|
f5e4a7ce59 | ||
|
|
595d3cdf4f | ||
|
|
ad7af30daa | ||
|
|
6321ea8c0d | ||
|
|
dfddeec8af | ||
|
|
dc4e11b64c | ||
|
|
26b1b0c64c | ||
|
|
54e3c74854 | ||
|
|
4c5c3e5707 | ||
|
|
96e406b2c9 | ||
|
|
2610e37d49 | ||
|
|
bd081936e0 | ||
|
|
41ab2cf187 | ||
|
|
5dd8e6ea07 | ||
|
|
31aedbfcc9 | ||
|
|
9519b09be8 | ||
|
|
a10139b51a | ||
|
|
62a6caa6a7 | ||
|
|
c5c30d4ada | ||
|
|
aa7f6bffef | ||
|
|
f72be633f8 | ||
|
|
f81f20a4bf | ||
|
|
847a714077 | ||
|
|
242ab3ad71 | ||
|
|
b2f998fa95 | ||
|
|
17b1d0fcda | ||
|
|
b1f3d7c500 | ||
|
|
0f4d22f413 | ||
|
|
6ee8da18f1 | ||
|
|
f4d63e1544 | ||
|
|
b4a3c51201 | ||
|
|
3675a5f62e | ||
|
|
7c8c5af4bb | ||
|
|
dae9f588e6 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
apps/web/dist
|
||||
test-results
|
||||
data
|
||||
.git
|
||||
deploy/.env
|
||||
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
test-results/
|
||||
playwright-report/
|
||||
41
README.md
41
README.md
@@ -1,2 +1,41 @@
|
||||
# joke-app
|
||||
# 같이 놀자 보드게임
|
||||
|
||||
광고 없이 가볍고, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임 사이트.
|
||||
계획서는 [docs/](docs/README.md)에 있고, 구현은 계획서를 따른다.
|
||||
|
||||
## 개발
|
||||
```bash
|
||||
bun install
|
||||
bun run --filter '@bg/server' dev # 서버 :3000
|
||||
bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록시)
|
||||
```
|
||||
브라우저에서 http://localhost:5173 을 연다. 디스코드 값이 없으면 게스트 로그인만 보인다.
|
||||
|
||||
## 확인
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run test # 규칙·서버 통합·방 단위 테스트 (무작위 대국이 길어 제한 60초)
|
||||
(cd apps/web && bunx vite build) # 웹 빌드
|
||||
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
||||
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||
bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지)
|
||||
bun e2e/a11y.e2e.ts # 접근성(axe) 검사
|
||||
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
|
||||
```
|
||||
|
||||
## 배포
|
||||
`deploy/.env.example`을 `deploy/.env`로 복사해 값을 채운 뒤:
|
||||
```bash
|
||||
cd deploy && docker compose up -d --build
|
||||
```
|
||||
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
|
||||
|
||||
## 관리자
|
||||
`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
|
||||
|
||||
## 구조
|
||||
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구
|
||||
- `packages/shared` — 프로토콜, 닉네임·방 코드 규칙, 게임 목록 (`/lite`는 브라우저용)
|
||||
- `packages/games/<id>` — 게임 규칙(순수 함수)
|
||||
- `apps/server` — Bun 서버(HTTP API, WebSocket, 방 관리, SQLite)
|
||||
- `apps/web` — React 웹
|
||||
|
||||
18
apps/server/package.json
Normal file
18
apps/server/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@bg/server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bg/engine": "workspace:*",
|
||||
"@bg/games": "workspace:*",
|
||||
"@bg/shared": "workspace:*",
|
||||
"hono": "4.13.12",
|
||||
"ulid": "3.0.2",
|
||||
"zod": "4.6.5"
|
||||
}
|
||||
}
|
||||
208
apps/server/src/admin.test.ts
Normal file
208
apps/server/src/admin.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/** Admin site tests (docs/14-admin.md §9). */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
|
||||
const SUPER = '293719842274541579';
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) c.close();
|
||||
for (const s of running.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
function start(): Booted {
|
||||
const s = boot(undefined, {
|
||||
discordExchange: async (code) => ({ id: code, username: `user${code.slice(-4)}`, global_name: `디코${code.slice(-4)}`, avatar: null }),
|
||||
});
|
||||
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Logs in through the (mocked) Discord flow; returns the session cookie and user id. */
|
||||
async function discordLogin(s: Booted, discordId: string): Promise<{ cookie: string; id: string }> {
|
||||
const st = await api(s.base, '/api/auth/discord/start?next=/');
|
||||
const state = new URL(st.res.headers.get('location')!).searchParams.get('state')!;
|
||||
const oauth = st.setCookie!.split(';')[0]!;
|
||||
const cb = await api(s.base, `/api/auth/discord/callback?code=${discordId}&state=${state}`, { cookie: oauth });
|
||||
const loc = cb.res.headers.get('location');
|
||||
if (loc !== '/') throw new Error(`login redirect ${loc}`);
|
||||
const sid = cb.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
|
||||
const me = await api(s.base, '/api/me', { cookie: sid });
|
||||
return { cookie: sid, id: me.body.me.id };
|
||||
}
|
||||
|
||||
describe('roles', () => {
|
||||
test('guest and plain discord users get 401/403; superadmin by fixed Discord ID', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '손님');
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: g.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/admin/me')).res.status).toBe(401);
|
||||
const plain = await discordLogin(s, '111111111111111111');
|
||||
expect((await api(s.base, '/api/admin/overview', { cookie: plain.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/me', { cookie: plain.cookie })).body.role).toBe('user');
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie });
|
||||
expect(me.body.role).toBe('superadmin');
|
||||
expect((await api(s.base, '/api/me', { cookie: sup.cookie })).body.role).toBe('superadmin');
|
||||
});
|
||||
|
||||
test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const adminDiscord = '222222222222222222';
|
||||
const bad = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: '12ab' }) });
|
||||
expect(bad.res.status).toBe(400);
|
||||
// Pre-register before the person ever logged in.
|
||||
const add = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: adminDiscord, note: '친구1' }) });
|
||||
expect(add.res.status).toBe(200);
|
||||
expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, adminDiscord]);
|
||||
const adm = await discordLogin(s, adminDiscord);
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).body.role).toBe('admin');
|
||||
expect((await api(s.base, '/api/admin/users', { cookie: adm.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/admin/admins', { cookie: adm.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/admin/games', { cookie: adm.cookie })).res.status).toBe(200);
|
||||
// Fixed superadmin cannot be removed.
|
||||
expect((await api(s.base, `/api/admin/admins/${SUPER}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(400);
|
||||
expect((await api(s.base, `/api/admin/admins/${adminDiscord}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(200);
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).res.status).toBe(403);
|
||||
const audit = await api(s.base, '/api/admin/audit', { cookie: sup.cookie });
|
||||
expect(audit.body.entries.map((e: any) => e.action)).toEqual(['admin.remove', 'admin.add']);
|
||||
});
|
||||
|
||||
test('admin API rejects foreign-origin writes', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const res = await fetch(s.base + '/api/admin/site', {
|
||||
method: 'PUT',
|
||||
headers: { origin: 'https://evil.example', cookie: sup.cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ maintenance: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game settings', () => {
|
||||
test('schema exposed with Korean titles; defaults apply to new rooms; locked options win; invalid values rejected', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const games = await api(s.base, '/api/admin/games', { cookie: sup.cookie });
|
||||
const omok = games.body.games.find((g: any) => g.id === 'omok');
|
||||
expect(omok.schema.properties.ruleSet.title).toBe('규칙');
|
||||
expect(omok.schema.properties.autoMoveLimit.title).toContain('자동 착수');
|
||||
const bad = await api(s.base, '/api/admin/games/omok', {
|
||||
method: 'PUT',
|
||||
cookie: sup.cookie,
|
||||
body: JSON.stringify({ defaultOptions: { ruleSet: 'nope' } }),
|
||||
});
|
||||
expect(bad.res.status).toBe(400);
|
||||
const tooMany = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ maxPlayers: 3 }) });
|
||||
expect(tooMany.res.status).toBe(400);
|
||||
const put = await api(s.base, '/api/admin/games/omok', {
|
||||
method: 'PUT',
|
||||
cookie: sup.cookie,
|
||||
body: JSON.stringify({
|
||||
nameKo: '오목(친구룰)',
|
||||
notice: '렌주룰로만 해요',
|
||||
defaultOptions: { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 } },
|
||||
lockedOptions: ['ruleSet'],
|
||||
}),
|
||||
});
|
||||
expect(put.res.status).toBe(200);
|
||||
expect(put.body.effective.defaultOptions.timeControl.perMoveSec).toBe(45);
|
||||
const cfg = await api(s.base, '/api/config');
|
||||
expect(cfg.body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목(친구룰)', notice: '렌주룰로만 해요' });
|
||||
|
||||
// A host creates a room → admin defaults; trying to change the locked rule is overridden.
|
||||
const host = await guest(s.base, '방장');
|
||||
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c = await new Client(s.base, host.cookie, host.id).connect();
|
||||
clients.push(c);
|
||||
c.send({ t: 'join', code: room.body.code, as: 'player' });
|
||||
const first = await c.next('room');
|
||||
expect((first.room.options as any).ruleSet).toBe('renju');
|
||||
expect(first.room.lockedOptions).toEqual(['ruleSet']);
|
||||
c.send({ t: 'config', options: { ...(first.room.options as any), ruleSet: 'free', boardSize: 19 } });
|
||||
const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19);
|
||||
expect((after.room.options as any).ruleSet).toBe('renju');
|
||||
|
||||
// Disable the game → creation refused and hidden from catalog.
|
||||
await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ enabled: false }) });
|
||||
const refused = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
expect(refused.res.status).toBe(400);
|
||||
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok').available).toBe(false);
|
||||
// Reset restores code defaults.
|
||||
await api(s.base, '/api/admin/games/omok/reset', { method: 'POST', cookie: sup.cookie });
|
||||
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목', available: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('site settings, rooms, users', () => {
|
||||
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const site = (await api(s.base, '/api/admin/site', { cookie: sup.cookie })).body.site;
|
||||
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, maintenance: true, announcement: '점검 중이에요' }) });
|
||||
const g = await guest(s.base, '방장님');
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
expect(r.res.status).toBe(429);
|
||||
expect(r.body.error).toBe('점검 중이에요');
|
||||
expect((await api(s.base, '/api/config')).body).toMatchObject({ maintenance: true, announcement: '점검 중이에요' });
|
||||
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, bannedWords: ['바보'] }) });
|
||||
expect((await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: '왕바보' }) })).res.status).toBe(400);
|
||||
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
c.send({ t: 'join', code: room.body.code, as: 'player' });
|
||||
await c.next('room');
|
||||
const chat = c.next('chat');
|
||||
c.send({ t: 'chat', text: '너 바보야' });
|
||||
expect((await chat).text).toBe('너 **야');
|
||||
});
|
||||
|
||||
test('admin closes a room: everyone is told and disconnected from it', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const g = await guest(s.base, '방주인');
|
||||
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
c.send({ t: 'join', code: room.body.code, as: 'player' });
|
||||
await c.next('room');
|
||||
const list = await api(s.base, '/api/admin/rooms', { cookie: sup.cookie });
|
||||
expect(list.body.rooms.map((r: any) => r.code)).toContain(room.body.code);
|
||||
const bye = c.next('bye');
|
||||
expect((await api(s.base, `/api/admin/rooms/${room.body.code}/close`, { method: 'POST', cookie: sup.cookie })).res.status).toBe(200);
|
||||
expect((await bye).reason).toBe('closed');
|
||||
expect((await api(s.base, `/api/rooms/${room.body.code}`)).res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('superadmin: search, rename, ban (sessions revoked, socket closed, discord re-login refused), unban', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const victim = await discordLogin(s, '333333333333333333');
|
||||
const c = await new Client(s.base, victim.cookie, victim.id).connect();
|
||||
clients.push(c);
|
||||
const found = await api(s.base, '/api/admin/users?q=333333333333333333', { cookie: sup.cookie });
|
||||
expect(found.body.users.map((u: any) => u.id)).toEqual([victim.id]);
|
||||
const ren = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ nickname: '바뀐 이름' }) });
|
||||
expect(ren.res.status).toBe(200);
|
||||
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me.nickname).toBe('바뀐 이름');
|
||||
const ban = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: '도배' } }) });
|
||||
expect(ban.res.status).toBe(200);
|
||||
for (let i = 0; i < 20 && !c.closed; i++) await sleep(25);
|
||||
expect(c.closed?.code).toBe(4403);
|
||||
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me).toBeNull();
|
||||
await expect(discordLogin(s, '333333333333333333')).rejects.toThrow('/?login=banned');
|
||||
// Superadmin cannot ban themselves.
|
||||
const self = await api(s.base, `/api/admin/users/${sup.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: 'x' } }) });
|
||||
expect(self.res.status).toBe(400);
|
||||
await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: null }) });
|
||||
const again = await discordLogin(s, '333333333333333333');
|
||||
expect(again.id).toBe(victim.id);
|
||||
const actions = (await api(s.base, '/api/admin/audit', { cookie: sup.cookie })).body.entries.map((e: any) => e.action);
|
||||
expect(actions).toEqual(['user.unban', 'user.ban', 'user.rename']);
|
||||
});
|
||||
});
|
||||
90
apps/server/src/admin/roles.ts
Normal file
90
apps/server/src/admin/roles.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Role resolution by linked Discord ID (docs/14-admin.md §2). Computed per request. */
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
export type Role = 'user' | 'admin' | 'superadmin';
|
||||
|
||||
export class Roles {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private superadminDiscordIds: string[],
|
||||
) {}
|
||||
|
||||
discordIdOf(userId: string): string | null {
|
||||
return (
|
||||
this.db
|
||||
.query<{ provider_user_id: string }, [string]>("SELECT provider_user_id FROM oauth_accounts WHERE user_id = ? AND provider = 'discord'")
|
||||
.get(userId)?.provider_user_id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
roleOfDiscord(discordId: string | null): Role {
|
||||
if (!discordId) return 'user';
|
||||
if (this.superadminDiscordIds.includes(discordId)) return 'superadmin';
|
||||
const row = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM admins WHERE discord_id = ?').get(discordId);
|
||||
return (row?.n ?? 0) > 0 ? 'admin' : 'user';
|
||||
}
|
||||
|
||||
roleOf(userId: string | null): Role {
|
||||
return userId ? this.roleOfDiscord(this.discordIdOf(userId)) : 'user';
|
||||
}
|
||||
|
||||
isSuperadminDiscord(discordId: string): boolean {
|
||||
return this.superadminDiscordIds.includes(discordId);
|
||||
}
|
||||
|
||||
listAdmins(): { discordId: string; note: string | null; addedBy: string | null; addedAt: number; userId: string | null; nickname: string | null; fixed: boolean }[] {
|
||||
const rows = this.db
|
||||
.query<{ discord_id: string; note: string | null; added_by: string | null; added_at: number; user_id: string | null; nickname: string | null }, []>(
|
||||
`SELECT a.discord_id, a.note, a.added_by, a.added_at, o.user_id, u.nickname FROM admins a
|
||||
LEFT JOIN oauth_accounts o ON o.provider = 'discord' AND o.provider_user_id = a.discord_id
|
||||
LEFT JOIN users u ON u.id = o.user_id ORDER BY a.added_at`,
|
||||
)
|
||||
.all()
|
||||
.map((r) => ({ discordId: r.discord_id, note: r.note, addedBy: r.added_by, addedAt: r.added_at, userId: r.user_id, nickname: r.nickname, fixed: false }));
|
||||
const fixed = this.superadminDiscordIds.map((d) => {
|
||||
const u = this.db
|
||||
.query<{ user_id: string; nickname: string }, [string]>(
|
||||
"SELECT o.user_id, u.nickname FROM oauth_accounts o JOIN users u ON u.id = o.user_id WHERE o.provider = 'discord' AND o.provider_user_id = ?",
|
||||
)
|
||||
.get(d);
|
||||
return { discordId: d, note: '슈퍼어드민(설정 고정)', addedBy: null, addedAt: 0, userId: u?.user_id ?? null, nickname: u?.nickname ?? null, fixed: true };
|
||||
});
|
||||
return [...fixed, ...rows];
|
||||
}
|
||||
|
||||
addAdmin(discordId: string, note: string | null, by: string, now = Date.now()): boolean {
|
||||
return this.db.query('INSERT OR IGNORE INTO admins (discord_id, note, added_by, added_at) VALUES (?, ?, ?, ?)').run(discordId, note, by, now).changes > 0;
|
||||
}
|
||||
|
||||
removeAdmin(discordId: string): boolean {
|
||||
return this.db.query('DELETE FROM admins WHERE discord_id = ?').run(discordId).changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class Audit {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
log(actorId: string, action: string, target: string | null, before: unknown, after: unknown, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO admin_audit (actor_id, action, target, before_json, after_json, at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(actorId, action, target, before === undefined ? null : JSON.stringify(before), after === undefined ? null : JSON.stringify(after), now);
|
||||
}
|
||||
|
||||
list(limit: number, offset: number) {
|
||||
return this.db
|
||||
.query<{ id: number; actor_id: string; nickname: string | null; action: string; target: string | null; before_json: string | null; after_json: string | null; at: number }, [number, number]>(
|
||||
'SELECT a.*, u.nickname FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ?',
|
||||
)
|
||||
.all(limit, offset)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
actorId: r.actor_id,
|
||||
actor: r.nickname,
|
||||
action: r.action,
|
||||
target: r.target,
|
||||
before: r.before_json ? JSON.parse(r.before_json) : null,
|
||||
after: r.after_json ? JSON.parse(r.after_json) : null,
|
||||
at: r.at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
159
apps/server/src/admin/settings.ts
Normal file
159
apps/server/src/admin/settings.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Admin-editable site and game settings with an in-memory cache (docs/14-admin.md §4–§5).
|
||||
* Changes apply to new rooms / new games only.
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { CATALOG, type CatalogEntry } from '@bg/shared';
|
||||
|
||||
export const SiteSettingsSchema = z.object({
|
||||
siteName: z.string().trim().min(1).max(40).default('같이 놀자 보드게임'),
|
||||
announcement: z.string().trim().max(300).default(''),
|
||||
maintenance: z.boolean().default(false),
|
||||
bannedWords: z.array(z.string().trim().min(1).max(30)).max(500).default([]),
|
||||
maxRoomsPerUser: z.number().int().min(1).max(50).default(3),
|
||||
maxRooms: z.number().int().min(10).max(20000).default(2000),
|
||||
graceSec: z.number().int().min(10).max(600).default(60),
|
||||
chatEnabledDefault: z.boolean().default(true),
|
||||
chatFilterDefault: z.boolean().default(true),
|
||||
});
|
||||
export type SiteSettings = z.infer<typeof SiteSettingsSchema>;
|
||||
|
||||
export const GameSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
nameKo: z.string().trim().min(1).max(30).optional(),
|
||||
blurb: z.string().trim().max(80).optional(),
|
||||
notice: z.string().trim().max(500).optional(),
|
||||
minPlayers: z.number().int().min(1).max(32).optional(),
|
||||
maxPlayers: z.number().int().min(1).max(32).optional(),
|
||||
defaultOptions: z.unknown().optional(),
|
||||
lockedOptions: z.array(z.string().max(60)).max(100).optional(),
|
||||
});
|
||||
export type GameSettings = z.infer<typeof GameSettingsSchema>;
|
||||
|
||||
export interface EffectiveGame {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
nameKo: string;
|
||||
blurb: string;
|
||||
notice: string;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
defaultOptions: unknown;
|
||||
lockedOptions: string[];
|
||||
}
|
||||
|
||||
export class SettingsStore {
|
||||
private site: SiteSettings;
|
||||
private games = new Map<string, GameSettings>();
|
||||
|
||||
constructor(
|
||||
private db: Database,
|
||||
private defs: Record<string, AnyGameDefinition>,
|
||||
) {
|
||||
const row = db.query<{ json: string }, [string]>("SELECT json FROM site_settings WHERE key = ?").get('site');
|
||||
this.site = SiteSettingsSchema.parse(row ? JSON.parse(row.json) : {});
|
||||
for (const r of db.query<{ game_id: string; json: string }, []>('SELECT game_id, json FROM game_settings').all()) {
|
||||
const parsed = GameSettingsSchema.safeParse(JSON.parse(r.json));
|
||||
if (parsed.success) this.games.set(r.game_id, parsed.data);
|
||||
}
|
||||
}
|
||||
|
||||
getSite(): SiteSettings {
|
||||
return this.site;
|
||||
}
|
||||
|
||||
setSite(next: SiteSettings, by: string, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO site_settings (key, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
|
||||
.run('site', JSON.stringify(next), by, now);
|
||||
this.site = next;
|
||||
}
|
||||
|
||||
rawGame(id: string): GameSettings {
|
||||
return this.games.get(id) ?? {};
|
||||
}
|
||||
|
||||
/** Validates against the game's own schemas. Returns an error message or null. */
|
||||
validateGame(id: string, s: GameSettings): string | null {
|
||||
const def = this.defs[id];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
if (s.defaultOptions !== undefined) {
|
||||
const r = def.optionsSchema.safeParse(s.defaultOptions);
|
||||
if (!r.success) return `기본 규칙 값이 올바르지 않아요: ${r.error.issues.map((i) => i.path.join('.') + ' ' + i.message).join(', ')}`;
|
||||
}
|
||||
const min = s.minPlayers ?? def.minPlayers;
|
||||
const max = s.maxPlayers ?? def.maxPlayers;
|
||||
if (min < def.minPlayers || max > def.maxPlayers || min > max) {
|
||||
return `인원은 원작 범위(${def.minPlayers}~${def.maxPlayers}명) 안에서만 정할 수 있어요.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setGame(id: string, s: GameSettings, by: string, now = Date.now()): void {
|
||||
const def = this.defs[id]!;
|
||||
const clean: GameSettings = { ...s };
|
||||
if (clean.defaultOptions !== undefined) clean.defaultOptions = def.optionsSchema.parse(clean.defaultOptions);
|
||||
this.db
|
||||
.query('INSERT INTO game_settings (game_id, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(game_id) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
|
||||
.run(id, JSON.stringify(clean), by, now);
|
||||
this.games.set(id, clean);
|
||||
}
|
||||
|
||||
resetGame(id: string): void {
|
||||
this.db.query('DELETE FROM game_settings WHERE game_id = ?').run(id);
|
||||
this.games.delete(id);
|
||||
}
|
||||
|
||||
game(id: string): EffectiveGame | null {
|
||||
const def = this.defs[id];
|
||||
if (!def) return null;
|
||||
const meta = CATALOG.find((c) => c.id === id);
|
||||
const s = this.rawGame(id);
|
||||
let defaults: unknown = def.defaultOptions;
|
||||
if (s.defaultOptions !== undefined) {
|
||||
const r = def.optionsSchema.safeParse(s.defaultOptions);
|
||||
if (r.success) defaults = r.data;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
enabled: s.enabled ?? meta?.available ?? true,
|
||||
nameKo: s.nameKo ?? meta?.nameKo ?? def.nameKo,
|
||||
blurb: s.blurb ?? meta?.blurb ?? '',
|
||||
notice: s.notice ?? '',
|
||||
minPlayers: s.minPlayers ?? def.minPlayers,
|
||||
maxPlayers: s.maxPlayers ?? def.maxPlayers,
|
||||
defaultOptions: defaults,
|
||||
lockedOptions: s.lockedOptions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Catalog for /api/config: code metadata merged with admin overrides. */
|
||||
catalog(): (CatalogEntry & { notice: string })[] {
|
||||
return CATALOG.map((c) => {
|
||||
const g = this.game(c.id);
|
||||
if (!g) return { ...c, available: false, notice: '' };
|
||||
return { ...c, nameKo: g.nameKo, blurb: g.blurb, minPlayers: g.minPlayers, maxPlayers: g.maxPlayers, available: g.enabled, notice: g.notice };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces locked option paths (dot-separated) to the admin default values. */
|
||||
export function applyLocks(options: unknown, defaults: unknown, locked: string[]): unknown {
|
||||
if (!locked.length || typeof options !== 'object' || options === null) return options;
|
||||
const out = structuredClone(options) as Record<string, unknown>;
|
||||
for (const path of locked) {
|
||||
const keys = path.split('.');
|
||||
let src: unknown = defaults;
|
||||
for (const k of keys) src = (src as Record<string, unknown> | undefined)?.[k];
|
||||
if (src === undefined) continue;
|
||||
let dst = out;
|
||||
for (const k of keys.slice(0, -1)) {
|
||||
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
|
||||
dst = dst[k] as Record<string, unknown>;
|
||||
}
|
||||
dst[keys[keys.length - 1]!] = structuredClone(src);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
59
apps/server/src/auth/discord.ts
Normal file
59
apps/server/src/auth/discord.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Discord OAuth2 (authorization code + state), docs/05-accounts-auth.md §3. */
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
const API = 'https://discord.com/api/v10';
|
||||
|
||||
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
|
||||
const p = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'identify',
|
||||
state,
|
||||
prompt: 'none',
|
||||
});
|
||||
return `https://discord.com/oauth2/authorize?${p}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
cfg: { clientId: string; clientSecret: string },
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscordUser> {
|
||||
const tokenRes = await fetchImpl(`${API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error(`discord token exchange failed: ${tokenRes.status}`);
|
||||
const token = (await tokenRes.json()) as { access_token: string; token_type: string };
|
||||
const meRes = await fetchImpl(`${API}/users/@me`, {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!meRes.ok) throw new Error(`discord /users/@me failed: ${meRes.status}`);
|
||||
return (await meRes.json()) as DiscordUser;
|
||||
}
|
||||
|
||||
export function avatarUrl(u: DiscordUser): string | null {
|
||||
return u.avatar ? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.png?size=128` : null;
|
||||
}
|
||||
|
||||
/** Only same-site relative paths are allowed as post-login redirect targets. */
|
||||
export function safeNext(next: string | null | undefined): string {
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\')) return '/';
|
||||
return next;
|
||||
}
|
||||
86
apps/server/src/auth/sessions.ts
Normal file
86
apps/server/src/auth/sessions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
const DAY = 24 * 3600_000;
|
||||
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
||||
export const SESSION_COOKIE = 'sid';
|
||||
|
||||
export function randomToken(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Buffer.from(buf).toString('base64url');
|
||||
}
|
||||
|
||||
export function sha256Hex(input: string): string {
|
||||
return new Bun.CryptoHasher('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export class Sessions {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
create(userId: string, kind: 'guest' | 'member', userAgent: string | null, now = Date.now()): { token: string; expiresAt: number } {
|
||||
const token = randomToken();
|
||||
const expiresAt = now + SESSION_TTL[kind];
|
||||
this.db
|
||||
.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at, last_used_at, user_agent) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(sha256Hex(token), userId, now, expiresAt, now, userAgent?.slice(0, 200) ?? null);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
/** Resolves a token to a user id; slides expiry at most once per day. */
|
||||
resolve(token: string, now = Date.now()): string | null {
|
||||
const hash = sha256Hex(token);
|
||||
const row = this.db
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
|
||||
)
|
||||
.get(hash);
|
||||
if (!row) return null;
|
||||
if (row.expires_at <= now) {
|
||||
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash);
|
||||
return null;
|
||||
}
|
||||
if (now - row.last_used_at > DAY) {
|
||||
this.db
|
||||
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
||||
.run(now, now + SESSION_TTL[row.kind], hash);
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
revoke(token: string): void {
|
||||
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(sha256Hex(token));
|
||||
}
|
||||
|
||||
revokeAll(userId: string): void {
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
purgeExpired(now = Date.now()): number {
|
||||
return this.db.query('DELETE FROM sessions WHERE expires_at <= ?').run(now).changes;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal HMAC-signed value for short-lived cookies (OAuth state + PKCE verifier). */
|
||||
export async function signValue(secret: string, value: string): Promise<string> {
|
||||
const mac = await hmac(secret, value);
|
||||
return `${Buffer.from(value).toString('base64url')}.${mac}`;
|
||||
}
|
||||
|
||||
export async function unsignValue(secret: string, signed: string): Promise<string | null> {
|
||||
const dot = signed.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
const value = Buffer.from(signed.slice(0, dot), 'base64url').toString();
|
||||
const expected = await hmac(secret, value);
|
||||
const given = signed.slice(dot + 1);
|
||||
if (expected.length !== given.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
|
||||
return diff === 0 ? value : null;
|
||||
}
|
||||
|
||||
async function hmac(secret: string, value: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value));
|
||||
return Buffer.from(sig).toString('base64url');
|
||||
}
|
||||
179
apps/server/src/auth/users.ts
Normal file
179
apps/server/src/auth/users.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ulid } from 'ulid';
|
||||
import type { PublicUser } from '@bg/shared';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
kind: 'guest' | 'member';
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
use_avatar: number;
|
||||
settings_json: string;
|
||||
nick_changed_at: number | null;
|
||||
nick_change_count: number;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
deleted_at: number | null;
|
||||
banned_at: number | null;
|
||||
banned_reason: string | null;
|
||||
}
|
||||
|
||||
export const NICK_CHANGES_PER_DAY = 10;
|
||||
|
||||
export class Users {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
get(id: string): UserRow | null {
|
||||
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
|
||||
}
|
||||
|
||||
createGuest(nickname: string, now = Date.now()): UserRow {
|
||||
const id = ulid(now);
|
||||
this.db
|
||||
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, 'guest', nickname, now, now);
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
findByOauth(provider: string, providerUserId: string): UserRow | null {
|
||||
return this.db
|
||||
.query<UserRow, [string, string]>(
|
||||
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
|
||||
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
|
||||
)
|
||||
.get(provider, providerUserId);
|
||||
}
|
||||
|
||||
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
|
||||
const id = ulid(now);
|
||||
this.db
|
||||
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, 'member', nickname, avatarUrl, now, now);
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(provider, providerUserId, userId, username, now);
|
||||
}
|
||||
|
||||
promoteToMember(userId: string, avatarUrl: string | null): void {
|
||||
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
|
||||
}
|
||||
|
||||
setAvatar(userId: string, avatarUrl: string | null): void {
|
||||
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
|
||||
}
|
||||
|
||||
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
|
||||
mergeGuestInto(guestId: string, memberId: string): void {
|
||||
this.db.transaction(() => {
|
||||
const rows = this.db
|
||||
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
|
||||
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
|
||||
)
|
||||
.all(guestId);
|
||||
for (const r of rows) {
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
|
||||
wins = wins + excluded.wins, draws = draws + excluded.draws`,
|
||||
)
|
||||
.run(memberId, r.game_id, r.played, r.wins, r.draws);
|
||||
}
|
||||
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
|
||||
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
|
||||
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Returns an error message or null. Enforces the daily change limit. */
|
||||
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
|
||||
const u = this.get(userId);
|
||||
if (!u) return '사용자를 찾을 수 없어요.';
|
||||
if (u.nickname === nickname) return null;
|
||||
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
|
||||
const count = sameDay ? u.nick_change_count : 0;
|
||||
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
|
||||
this.db
|
||||
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
|
||||
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
|
||||
if (patch.useAvatar !== undefined) {
|
||||
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
|
||||
}
|
||||
if (patch.settings) {
|
||||
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin rename: ignores the daily limit. */
|
||||
forceNickname(userId: string, nickname: string): void {
|
||||
this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId);
|
||||
}
|
||||
|
||||
setBan(userId: string, reason: string | null, now = Date.now()): void {
|
||||
if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId);
|
||||
else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId);
|
||||
}
|
||||
|
||||
search(q: string, limit: number, offset: number) {
|
||||
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
||||
const rows = this.db
|
||||
.query<UserRow & { discord_id: string | null; discord_name: string | null }, [string, string, string, string, number, number]>(
|
||||
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u
|
||||
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
|
||||
WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?)
|
||||
ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(q, like, q, q, limit, offset);
|
||||
const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
counts(): { total: number; guests: number; members: number; banned: number } {
|
||||
return this.db
|
||||
.query<{ total: number; guests: number; members: number; banned: number }, []>(
|
||||
`SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned
|
||||
FROM users WHERE deleted_at IS NULL`,
|
||||
)
|
||||
.get()!;
|
||||
}
|
||||
|
||||
touch(userId: string, now = Date.now()): void {
|
||||
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
||||
}
|
||||
|
||||
softDelete(userId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
|
||||
.run(now, userId);
|
||||
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
|
||||
return this.db
|
||||
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
|
||||
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
|
||||
)
|
||||
.all(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export function toPublicUser(u: UserRow): PublicUser {
|
||||
return {
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
|
||||
kind: u.kind,
|
||||
};
|
||||
}
|
||||
41
apps/server/src/config.ts
Normal file
41
apps/server/src/config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface Config {
|
||||
port: number;
|
||||
publicOrigin: string;
|
||||
/** Extra origins accepted for WS/CSRF checks (e.g. Vite dev server). */
|
||||
allowedOrigins: string[];
|
||||
dbPath: string;
|
||||
discord: { clientId: string; clientSecret: string } | null;
|
||||
sessionSecret: string;
|
||||
secureCookies: boolean;
|
||||
/** Trust X-Forwarded-For (only when running behind our reverse proxy). */
|
||||
trustProxy: boolean;
|
||||
superadminDiscordIds: string[];
|
||||
/** Load-test only: lift per-IP HTTP limits. Ignored for https origins. */
|
||||
loadTestNoHttpLimits: boolean;
|
||||
}
|
||||
|
||||
/** The site owner's Discord ID (docs/14-admin.md §2). */
|
||||
export const DEFAULT_SUPERADMIN = '293719842274541579';
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const clientId = env.DISCORD_CLIENT_ID;
|
||||
const clientSecret = env.DISCORD_CLIENT_SECRET;
|
||||
const sessionSecret = env.SESSION_SECRET ?? '';
|
||||
if (publicOrigin.startsWith('https://') && sessionSecret.length < 32) {
|
||||
throw new Error('SESSION_SECRET must be at least 32 characters in production');
|
||||
}
|
||||
return {
|
||||
port: Number(env.PORT ?? 3000),
|
||||
publicOrigin,
|
||||
allowedOrigins: [publicOrigin, ...extra],
|
||||
dbPath: env.DB_PATH ?? './data/app.db',
|
||||
discord: clientId && clientSecret ? { clientId, clientSecret } : null,
|
||||
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
|
||||
secureCookies: publicOrigin.startsWith('https://'),
|
||||
trustProxy: env.TRUST_PROXY === '1',
|
||||
loadTestNoHttpLimits: env.LOADTEST_NO_HTTP_LIMITS === '1' && !publicOrigin.startsWith('https://'),
|
||||
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
32
apps/server/src/db/index.ts
Normal file
32
apps/server/src/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const MIGRATIONS_DIR = join(import.meta.dir, 'migrations');
|
||||
|
||||
export function openDb(path: string): Database {
|
||||
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||
const db = new Database(path, { create: true, strict: true });
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
db.exec('PRAGMA synchronous = NORMAL;');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec('PRAGMA busy_timeout = 5000;');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function migrate(db: Database): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)');
|
||||
const applied = new Set(
|
||||
db.query<{ name: string }, []>('SELECT name FROM schema_migrations').all().map((r) => r.name),
|
||||
);
|
||||
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
|
||||
db.transaction(() => {
|
||||
db.exec(sql);
|
||||
db.query('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now());
|
||||
})();
|
||||
}
|
||||
}
|
||||
100
apps/server/src/db/migrations/001_init.sql
Normal file
100
apps/server/src/db/migrations/001_init.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('guest','member')),
|
||||
nickname TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
use_avatar INTEGER NOT NULL DEFAULT 1,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
nick_changed_at INTEGER,
|
||||
nick_change_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_accounts (
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
username TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (provider, provider_user_id)
|
||||
);
|
||||
CREATE INDEX oauth_accounts_user ON oauth_accounts(user_id);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL,
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX sessions_user ON sessions(user_id);
|
||||
|
||||
CREATE TABLE rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('lobby','playing','finished','paused','broken','closed')),
|
||||
visibility TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
closed_at INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX rooms_open_code ON rooms(code) WHERE status <> 'closed';
|
||||
CREATE INDEX rooms_status ON rooms(status);
|
||||
|
||||
CREATE TABLE room_state (
|
||||
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE room_log (
|
||||
room_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
game_no INTEGER NOT NULL,
|
||||
actor_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
PRIMARY KEY (room_id, seq)
|
||||
);
|
||||
|
||||
CREATE TABLE games (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL,
|
||||
game_no INTEGER NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL,
|
||||
seed_hash TEXT NOT NULL,
|
||||
seed TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
end_reason TEXT,
|
||||
result_json TEXT
|
||||
);
|
||||
CREATE INDEX games_room ON games(room_id);
|
||||
|
||||
CREATE TABLE game_players (
|
||||
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
rank INTEGER,
|
||||
score REAL,
|
||||
PRIMARY KEY (game_pk, user_id)
|
||||
);
|
||||
CREATE INDEX game_players_user ON game_players(user_id);
|
||||
|
||||
CREATE TABLE user_stats (
|
||||
user_id TEXT NOT NULL,
|
||||
game_id TEXT NOT NULL,
|
||||
played INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
draws INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, game_id)
|
||||
);
|
||||
34
apps/server/src/db/migrations/002_admin.sql
Normal file
34
apps/server/src/db/migrations/002_admin.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE admins (
|
||||
discord_id TEXT PRIMARY KEY,
|
||||
note TEXT,
|
||||
added_by TEXT,
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE game_settings (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_by TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE site_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_by TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE admin_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX admin_audit_at ON admin_audit(at);
|
||||
|
||||
ALTER TABLE users ADD COLUMN banned_at INTEGER;
|
||||
ALTER TABLE users ADD COLUMN banned_reason TEXT;
|
||||
239
apps/server/src/http/admin.ts
Normal file
239
apps/server/src/http/admin.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/** Admin API (docs/14-admin.md §8). Mounted under /api/admin by createHttpApp. */
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { nicknameError, normalizeNickname } from '@bg/shared';
|
||||
import type { Audit, Role, Roles } from '../admin/roles';
|
||||
import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings';
|
||||
import type { Sessions } from '../auth/sessions';
|
||||
import { toPublicUser, type Users } from '../auth/users';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
|
||||
export interface AdminDeps {
|
||||
users: Users;
|
||||
sessions: Sessions;
|
||||
rooms: RoomManager;
|
||||
roles: Roles;
|
||||
audit: Audit;
|
||||
settings: SettingsStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
disconnectUser: (userId: string) => void;
|
||||
stats: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null; role: Role } };
|
||||
|
||||
const DISCORD_ID = /^\d{17,20}$/;
|
||||
|
||||
export function createAdminApp(d: AdminDeps) {
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const role = d.roles.roleOf(c.get('userId'));
|
||||
if (role === 'user') return c.json({ error: '권한이 없어요.' }, c.get('userId') ? 403 : 401);
|
||||
c.set('role', role);
|
||||
await next();
|
||||
});
|
||||
|
||||
const superOnly = async (c: Context<Env>, next: () => Promise<void>) => {
|
||||
if (c.get('role') !== 'superadmin') return c.json({ error: '슈퍼어드민만 할 수 있어요.' }, 403);
|
||||
await next();
|
||||
};
|
||||
const actor = (c: Context<Env>) => c.get('userId')!;
|
||||
const body = async (c: Context<Env>) => c.req.json().catch(() => null);
|
||||
|
||||
app.get('/me', (c) => c.json({ role: c.get('role'), me: toPublicUser(d.users.get(actor(c))!) }));
|
||||
|
||||
app.get('/overview', (c) => c.json({ users: d.users.counts(), ...d.stats() }));
|
||||
|
||||
// ------------------------------------------------------------ games
|
||||
app.get('/games', (c) =>
|
||||
c.json({
|
||||
games: Object.values(d.games).map((def) => ({
|
||||
id: def.id,
|
||||
codeDefaults: { nameKo: def.nameKo, minPlayers: def.minPlayers, maxPlayers: def.maxPlayers, defaultOptions: def.defaultOptions },
|
||||
raw: d.settings.rawGame(def.id),
|
||||
effective: d.settings.game(def.id),
|
||||
schema: z.toJSONSchema(def.optionsSchema, { io: 'input', unrepresentable: 'any' }),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
app.put('/games/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404);
|
||||
const parsed = GameSettingsSchema.safeParse(await body(c));
|
||||
if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400);
|
||||
const err = d.settings.validateGame(id, parsed.data);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
const before = d.settings.rawGame(id);
|
||||
d.settings.setGame(id, parsed.data, actor(c));
|
||||
d.audit.log(actor(c), 'game.update', id, before, d.settings.rawGame(id));
|
||||
return c.json({ effective: d.settings.game(id), raw: d.settings.rawGame(id) });
|
||||
});
|
||||
|
||||
app.post('/games/:id/reset', (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404);
|
||||
const before = d.settings.rawGame(id);
|
||||
d.settings.resetGame(id);
|
||||
d.audit.log(actor(c), 'game.reset', id, before, null);
|
||||
return c.json({ effective: d.settings.game(id), raw: {} });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ site
|
||||
app.get('/site', (c) => c.json({ site: d.settings.getSite() }));
|
||||
app.put('/site', async (c) => {
|
||||
const parsed = SiteSettingsSchema.safeParse(await body(c));
|
||||
if (!parsed.success) return c.json({ error: `입력 값이 올바르지 않아요: ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` }, 400);
|
||||
const before = d.settings.getSite();
|
||||
d.settings.setSite(parsed.data, actor(c));
|
||||
d.audit.log(actor(c), 'site.update', null, before, parsed.data);
|
||||
return c.json({ site: parsed.data });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ rooms
|
||||
app.get('/rooms', (c) =>
|
||||
c.json({
|
||||
rooms: d.rooms.all().map((r) => ({
|
||||
code: r.code,
|
||||
gameId: r.config.gameId,
|
||||
status: r.status,
|
||||
host: d.users.get(r.hostId)?.nickname ?? null,
|
||||
seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)),
|
||||
spectators: r.spectators.size,
|
||||
connections: r.conns.size,
|
||||
visibility: r.config.visibility,
|
||||
gameNo: r.gameNo,
|
||||
lastActiveAt: r.lastActiveAt,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
app.post('/rooms/:code/close', (c) => {
|
||||
const code = c.req.param('code');
|
||||
if (!d.rooms.closeByAdmin(code)) return c.json({ error: '방을 찾을 수 없어요.' }, 404);
|
||||
d.audit.log(actor(c), 'room.close', code, null, null);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ audit
|
||||
app.get('/audit', (c) => {
|
||||
const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0);
|
||||
return c.json({ entries: d.audit.list(50, page * 50) });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ users (superadmin)
|
||||
app.use('/users/*', superOnly);
|
||||
app.use('/users', superOnly);
|
||||
app.get('/users', (c) => {
|
||||
const q = (c.req.query('q') ?? '').trim().slice(0, 60);
|
||||
const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0);
|
||||
const { rows, total } = d.users.search(q, 30, page * 30);
|
||||
return c.json({
|
||||
total,
|
||||
users: rows.map((u) => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
kind: u.kind,
|
||||
discordId: u.discord_id,
|
||||
discordName: u.discord_name,
|
||||
role: d.roles.roleOfDiscord(u.discord_id),
|
||||
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
|
||||
createdAt: u.created_at,
|
||||
lastSeenAt: u.last_seen_at,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id', (c) => {
|
||||
const u = d.users.get(c.req.param('id'));
|
||||
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
const discordId = d.roles.discordIdOf(u.id);
|
||||
return c.json({
|
||||
user: {
|
||||
...toPublicUser(u),
|
||||
discordId,
|
||||
role: d.roles.roleOfDiscord(discordId),
|
||||
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
|
||||
createdAt: u.created_at,
|
||||
lastSeenAt: u.last_seen_at,
|
||||
activeRoom: d.rooms.activeRoomFor(u.id),
|
||||
},
|
||||
stats: d.users.stats(u.id),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/users/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const u = d.users.get(id);
|
||||
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
const parsed = z
|
||||
.object({ nickname: z.string().max(100).optional(), ban: z.object({ reason: z.string().trim().min(1).max(200) }).nullable().optional() })
|
||||
.safeParse(await body(c));
|
||||
if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400);
|
||||
const discordId = d.roles.discordIdOf(id);
|
||||
if (parsed.data.ban && discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 제한할 수 없어요.' }, 400);
|
||||
if (parsed.data.nickname !== undefined) {
|
||||
const nick = normalizeNickname(parsed.data.nickname);
|
||||
const e = nicknameError(nick, d.settings.getSite().bannedWords);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
d.users.forceNickname(id, nick);
|
||||
d.audit.log(actor(c), 'user.rename', id, u.nickname, nick);
|
||||
}
|
||||
if (parsed.data.ban !== undefined) {
|
||||
if (parsed.data.ban) {
|
||||
d.users.setBan(id, parsed.data.ban.reason);
|
||||
d.sessions.revokeAll(id);
|
||||
d.disconnectUser(id);
|
||||
d.audit.log(actor(c), 'user.ban', id, null, parsed.data.ban);
|
||||
} else {
|
||||
d.users.setBan(id, null);
|
||||
d.audit.log(actor(c), 'user.unban', id, { reason: u.banned_reason }, null);
|
||||
}
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/users/:id/logout', (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
d.sessions.revokeAll(id);
|
||||
d.disconnectUser(id);
|
||||
d.audit.log(actor(c), 'user.logout', id, null, null);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/users/:id', (c) => {
|
||||
const id = c.req.param('id');
|
||||
const u = d.users.get(id);
|
||||
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
const discordId = d.roles.discordIdOf(id);
|
||||
if (discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 삭제할 수 없어요.' }, 400);
|
||||
d.disconnectUser(id);
|
||||
d.users.softDelete(id);
|
||||
d.audit.log(actor(c), 'user.delete', id, { nickname: u.nickname, discordId }, null);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ admins (superadmin)
|
||||
app.use('/admins/*', superOnly);
|
||||
app.use('/admins', superOnly);
|
||||
app.get('/admins', (c) => c.json({ admins: d.roles.listAdmins() }));
|
||||
app.post('/admins', async (c) => {
|
||||
const parsed = z.object({ discordId: z.string().trim(), note: z.string().trim().max(100).optional() }).safeParse(await body(c));
|
||||
if (!parsed.success || !DISCORD_ID.test(parsed.data.discordId)) return c.json({ error: '디스코드 ID는 숫자 17~20자리예요.' }, 400);
|
||||
if (d.roles.isSuperadminDiscord(parsed.data.discordId)) return c.json({ error: '이미 슈퍼어드민이에요.' }, 400);
|
||||
if (!d.roles.addAdmin(parsed.data.discordId, parsed.data.note ?? null, actor(c))) return c.json({ error: '이미 등록된 어드민이에요.' }, 400);
|
||||
d.audit.log(actor(c), 'admin.add', parsed.data.discordId, null, { note: parsed.data.note ?? null });
|
||||
return c.json({ admins: d.roles.listAdmins() });
|
||||
});
|
||||
app.delete('/admins/:discordId', (c) => {
|
||||
const id = c.req.param('discordId');
|
||||
if (d.roles.isSuperadminDiscord(id)) return c.json({ error: '설정 파일의 슈퍼어드민은 지울 수 없어요.' }, 400);
|
||||
if (!d.roles.removeAdmin(id)) return c.json({ error: '등록되지 않은 디스코드 ID예요.' }, 404);
|
||||
d.audit.log(actor(c), 'admin.remove', id, null, null);
|
||||
return c.json({ admins: d.roles.listAdmins() });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
292
apps/server/src/http/app.ts
Normal file
292
apps/server/src/http/app.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/** HTTP API (docs/05 §6, docs/06). */
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
import { z } from 'zod';
|
||||
import { nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
|
||||
import type { Config } from '../config';
|
||||
import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord';
|
||||
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
|
||||
import { toPublicUser, type Users } from '../auth/users';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
import { WindowLimiter } from '../ws/rate-limit';
|
||||
import { createAdminApp, type AdminDeps } from './admin';
|
||||
import type { Roles } from '../admin/roles';
|
||||
import type { SettingsStore } from '../admin/settings';
|
||||
|
||||
export interface HttpDeps {
|
||||
config: Config;
|
||||
users: Users;
|
||||
sessions: Sessions;
|
||||
rooms: RoomManager;
|
||||
/** Injected for tests. */
|
||||
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
||||
ipOf: (req: Request) => string;
|
||||
health: () => { ok: boolean; [k: string]: unknown };
|
||||
roles: Roles;
|
||||
settings: SettingsStore;
|
||||
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null } };
|
||||
|
||||
const OAUTH_COOKIE = 'oauth';
|
||||
|
||||
export function createHttpApp(d: HttpDeps) {
|
||||
const app = new Hono<Env>();
|
||||
const lift = d.config.loadTestNoHttpLimits ? 1_000_000 : 1;
|
||||
const createLimiter = new WindowLimiter(10 * lift, 60_000);
|
||||
const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
|
||||
const guestLimiter = new WindowLimiter(20 * lift, 60_000);
|
||||
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
|
||||
|
||||
const cookieOpts = (maxAgeMs: number) => ({
|
||||
httpOnly: true,
|
||||
secure: d.config.secureCookies,
|
||||
sameSite: 'Lax' as const,
|
||||
path: '/',
|
||||
maxAge: Math.floor(maxAgeMs / 1000),
|
||||
});
|
||||
|
||||
// Session resolution.
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
c.set('userId', token ? d.sessions.resolve(token) : null);
|
||||
await next();
|
||||
});
|
||||
|
||||
// CSRF: state-changing requests must come from our origin (docs/04 §7).
|
||||
app.use('/api/*', async (c, next) => {
|
||||
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
|
||||
const origin = c.req.header('origin');
|
||||
if (!origin || !d.config.allowedOrigins.includes(origin)) return c.json({ error: '잘못된 요청이에요.' }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
const requireUser = (c: Context<Env>) => c.get('userId');
|
||||
|
||||
const startSession = (c: Context<Env>, userId: string, kind: 'guest' | 'member') => {
|
||||
const { token } = d.sessions.create(userId, kind, c.req.header('user-agent') ?? null);
|
||||
setCookie(c, SESSION_COOKIE, token, cookieOpts(SESSION_TTL[kind]));
|
||||
};
|
||||
|
||||
app.get('/healthz', (c) => {
|
||||
const h = d.health();
|
||||
return c.json(h, h.ok ? 200 : 503);
|
||||
});
|
||||
|
||||
app.get('/api/config', (c) => {
|
||||
const site = d.settings.getSite();
|
||||
return c.json({
|
||||
discord: d.config.discord !== null,
|
||||
catalog: d.settings.catalog(),
|
||||
siteName: site.siteName,
|
||||
announcement: site.announcement,
|
||||
maintenance: site.maintenance,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/guest', async (c) => {
|
||||
if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400);
|
||||
const nick = normalizeNickname(body.data.nickname);
|
||||
const e = nicknameError(nick, d.settings.getSite().bannedWords);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
const current = requireUser(c);
|
||||
if (current) {
|
||||
// Already signed in: treat as a nickname change instead of creating another account.
|
||||
const err = d.users.changeNickname(current, nick);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
return c.json({ me: toPublicUser(d.users.get(current)!) });
|
||||
}
|
||||
const u = d.users.createGuest(nick);
|
||||
startSession(c, u.id, 'guest');
|
||||
return c.json({ me: toPublicUser(u) });
|
||||
});
|
||||
|
||||
app.get('/api/auth/discord/start', async (c) => {
|
||||
if (!d.config.discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
|
||||
const state = randomToken(24);
|
||||
const next = safeNext(c.req.query('next'));
|
||||
setCookie(c, OAUTH_COOKIE, await signValue(d.config.sessionSecret, JSON.stringify({ state, next, at: Date.now() })), {
|
||||
...cookieOpts(10 * 60_000),
|
||||
path: '/api/auth/discord',
|
||||
});
|
||||
return c.redirect(authorizeUrl(d.config.discord.clientId, redirectUri, state));
|
||||
});
|
||||
|
||||
app.get('/api/auth/discord/callback', async (c) => {
|
||||
const discord = d.config.discord;
|
||||
if (!discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
|
||||
const raw = getCookie(c, OAUTH_COOKIE);
|
||||
deleteCookie(c, OAUTH_COOKIE, { path: '/api/auth/discord' });
|
||||
const payload = raw ? await unsignValue(d.config.sessionSecret, raw) : null;
|
||||
const parsed = payload ? (JSON.parse(payload) as { state: string; next: string; at: number }) : null;
|
||||
const code = c.req.query('code');
|
||||
if (!parsed || !code || parsed.state !== c.req.query('state') || Date.now() - parsed.at > 10 * 60_000) {
|
||||
return c.redirect('/?login=failed');
|
||||
}
|
||||
let du: DiscordUser;
|
||||
try {
|
||||
du = d.discordExchange ? await d.discordExchange(code, redirectUri) : await exchangeCode(discord, code, redirectUri);
|
||||
} catch {
|
||||
return c.redirect('/?login=failed');
|
||||
}
|
||||
const avatar = avatarUrl(du);
|
||||
const existing = d.users.findByOauth('discord', du.id);
|
||||
if (existing?.banned_at) return c.redirect('/?login=banned');
|
||||
const current = requireUser(c) ? d.users.get(requireUser(c)!) : null;
|
||||
let userId: string;
|
||||
let merged = false;
|
||||
if (existing) {
|
||||
userId = existing.id;
|
||||
d.users.setAvatar(userId, avatar);
|
||||
if (current && current.kind === 'guest' && current.id !== existing.id) {
|
||||
d.users.mergeGuestInto(current.id, existing.id);
|
||||
merged = true;
|
||||
}
|
||||
} else if (current && current.kind === 'guest') {
|
||||
d.users.linkOauth(current.id, 'discord', du.id, du.username);
|
||||
d.users.promoteToMember(current.id, avatar);
|
||||
userId = current.id;
|
||||
merged = true;
|
||||
} else {
|
||||
const nick = normalizeNickname(du.global_name ?? du.username).slice(0, 12);
|
||||
const u = d.users.createMember(nicknameError(nick) ? '디스코드 사용자' : nick, avatar);
|
||||
d.users.linkOauth(u.id, 'discord', du.id, du.username);
|
||||
userId = u.id;
|
||||
}
|
||||
const old = getCookie(c, SESSION_COOKIE);
|
||||
if (old) d.sessions.revoke(old);
|
||||
startSession(c, userId, 'member');
|
||||
const next = safeNext(parsed.next);
|
||||
return c.redirect(merged ? `${next}${next.includes('?') ? '&' : '?'}linked=1` : next);
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (c) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) d.sessions.revoke(token);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout-all', (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
d.sessions.revokeAll(u);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/me', (c) => {
|
||||
const u = requireUser(c);
|
||||
const row = u ? d.users.get(u) : null;
|
||||
if (!row) return c.json({ me: null });
|
||||
return c.json({
|
||||
me: toPublicUser(row),
|
||||
settings: JSON.parse(row.settings_json),
|
||||
useAvatar: !!row.use_avatar,
|
||||
role: d.roles.roleOf(row.id),
|
||||
hasDiscordAvatar: !!row.avatar_url,
|
||||
activeRoom: d.rooms.activeRoomFor(row.id),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/api/me', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const body = z
|
||||
.object({
|
||||
nickname: z.string().max(100).optional(),
|
||||
useAvatar: z.boolean().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
fontScale: z.enum(['normal', 'large', 'xlarge']).optional(),
|
||||
theme: z.enum(['auto', 'light', 'dark']).optional(),
|
||||
sound: z.boolean().optional(),
|
||||
vibrate: z.boolean().optional(),
|
||||
colorBlind: z.boolean().optional(),
|
||||
tapConfirm: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400);
|
||||
if (body.data.nickname !== undefined) {
|
||||
const nick = normalizeNickname(body.data.nickname);
|
||||
const e = nicknameError(nick, d.settings.getSite().bannedWords) ?? d.users.changeNickname(u, nick);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
}
|
||||
d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings });
|
||||
return c.json({ me: toPublicUser(d.users.get(u)!) });
|
||||
});
|
||||
|
||||
app.delete('/api/me', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const body = z.object({ confirm: z.literal('탈퇴') }).safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '확인 문구를 정확히 입력해 주세요.' }, 400);
|
||||
d.users.softDelete(u);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/me/stats', (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
return c.json({ stats: d.users.stats(u) });
|
||||
});
|
||||
|
||||
app.post('/api/rooms', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
});
|
||||
|
||||
app.post('/api/quick/:gameId', (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const gameId = c.req.param('gameId');
|
||||
if (!d.settings.game(gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const r = d.rooms.quickPlay(u, gameId);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
});
|
||||
|
||||
/** Room preview for the join screen (no auth needed). */
|
||||
app.get('/api/rooms/:code', (c) => {
|
||||
if (!lookupLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const code = normalizeRoomCode(c.req.param('code'));
|
||||
const r = code ? d.rooms.get(code) : undefined;
|
||||
if (!r || r.status === 'broken') return c.json({ error: '방을 찾을 수 없어요. 코드를 다시 확인해 주세요.' }, 404);
|
||||
const host = d.users.get(r.hostId);
|
||||
return c.json({
|
||||
code: r.code,
|
||||
codeLabel: formatRoomCode(r.code),
|
||||
gameId: r.config.gameId,
|
||||
host: host?.nickname ?? '',
|
||||
seated: r.seatedIds().length,
|
||||
maxPlayers: r.config.maxPlayers,
|
||||
status: r.status,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
|
||||
app.get('/api/public-rooms', (c) => c.json({ rooms: d.rooms.publicRooms(null).slice(0, 50) }));
|
||||
|
||||
app.route(
|
||||
'/api/admin',
|
||||
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
27
apps/server/src/index.ts
Normal file
27
apps/server/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { loadConfig } from './config';
|
||||
import { jsonLog, startServer } from './server';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = startServer({ config });
|
||||
jsonLog('info', 'server started', { port: app.server.port, origin: config.publicOrigin, discord: config.discord !== null });
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
jsonLog('info', 'shutting down', { signal });
|
||||
const force = setTimeout(() => process.exit(0), 10_000);
|
||||
await app.stop();
|
||||
clearTimeout(force);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (err) => {
|
||||
jsonLog('error', 'uncaughtException', { err: String(err), stack: err.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (err) => {
|
||||
jsonLog('error', 'unhandledRejection', { err: String(err) });
|
||||
process.exit(1);
|
||||
});
|
||||
191
apps/server/src/rooms/catchmind.room.test.ts
Normal file
191
apps/server/src/rooms/catchmind.room.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/** 그림 맞히기 through a real Room: stroke streaming, resync on reconnect, drawer disconnect, rate limit (docs/games/catchmind.md §5.5, §8.5, §10). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { CanvasOp, CatchmindState, CatchmindView } from '@bg/games/catchmind';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
type Msgs = ServerMessage[];
|
||||
type StateMsg = Extract<ServerMessage, { t: 'state' }>;
|
||||
|
||||
function harness(players: string[]) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const games = { catchmind: GAMES.catchmind! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'catchmind',
|
||||
options: games.catchmind.defaultOptions,
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'catchmind', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conn = (p: string) => {
|
||||
const msgs: Msgs = [];
|
||||
const c: Conn & { msgs: Msgs } = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
const conns: Record<string, Conn & { msgs: Msgs }> = {};
|
||||
for (const p of players) {
|
||||
conns[p] = conn(p);
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
expect(room.start(players[0]!)).toBeNull();
|
||||
room.clearTimer();
|
||||
const state = () => room.game!.state as CatchmindState;
|
||||
let cs = 100;
|
||||
const act = (p: string, a: unknown) => {
|
||||
room.act(p, ++cs, a);
|
||||
room.clearTimer();
|
||||
return cs;
|
||||
};
|
||||
return { room, db, clock, conns, conn, state, act };
|
||||
}
|
||||
|
||||
const drawOps = (stroke: number): CanvasOp[] => [
|
||||
{ k: 'b', s: stroke, c: 3, w: 1, e: 0 },
|
||||
{ k: 'p', s: stroke, q: 0, d: [100, 100, 10, 0, 10, 5] },
|
||||
{ k: 'p', s: stroke, q: 1, d: [130, 120, 4, 4] },
|
||||
{ k: 'e', s: stroke },
|
||||
];
|
||||
const lastState = (m: Msgs) => m.filter((x): x is StateMsg => x.t === 'state').at(-1)!;
|
||||
const pointCount = (ops: CanvasOp[]) => ops.reduce((n, o) => n + (o.k === 'p' ? o.d.length / 2 : 0), 0);
|
||||
|
||||
describe('그림 맞히기 room flow', () => {
|
||||
test('그림은 stream 메시지로만 중계(전체 view 재전송 없음), 재접속한 사람은 그림 기록 전체를 받음', () => {
|
||||
const { room, db, clock, conns, conn, state, act } = harness(['A', 'B', 'C']);
|
||||
const d = state().turn.drawer;
|
||||
const [g1, g2] = state().order.filter((p) => p !== d) as [string, string];
|
||||
act(d, { type: 'chooseWord', index: 0 });
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
const before = conns[g1]!.msgs.length;
|
||||
const ops = [...drawOps(1), ...drawOps(2)];
|
||||
for (const op of ops) {
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op });
|
||||
}
|
||||
const got = conns[g1]!.msgs.slice(before);
|
||||
expect(got.every((m) => m.t === 'stream')).toBe(true);
|
||||
expect(got).toHaveLength(ops.length);
|
||||
const relayed = got.flatMap((m) => (m.t === 'stream' ? m.events : [])) as { type: string; n: number; op: CanvasOp }[];
|
||||
expect(relayed.map((e) => e.n)).toEqual(ops.map((_, i) => i));
|
||||
expect(relayed.map((e) => e.op)).toEqual(ops);
|
||||
// The drawer's echo carries its cs (ack).
|
||||
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'stream', cs: expect.any(Number) });
|
||||
// Regular views carry only the count.
|
||||
act(g1, { type: 'guess', text: '모르겠어요' });
|
||||
const v = lastState(conns[g2]!.msgs).view as CatchmindView;
|
||||
expect(v.canvas).toBeNull();
|
||||
expect(v.canvasCount).toBe(ops.length);
|
||||
|
||||
// g2 drops, more strokes are drawn, g2 comes back → the join view has the whole drawing.
|
||||
room.disconnect(conns[g2]!);
|
||||
room.clearTimer();
|
||||
for (const op of drawOps(3)) {
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op });
|
||||
}
|
||||
const back = conn(g2);
|
||||
room.join(back, 'player');
|
||||
room.clearTimer();
|
||||
const sv = lastState(back.msgs).view as CatchmindView;
|
||||
expect(sv.canvas).toEqual(state().turn.canvas);
|
||||
expect(sv.canvas!.filter((o) => o.k === 'b')).toHaveLength(3);
|
||||
expect(pointCount(sv.canvas!)).toBe(pointCount(state().turn.canvas));
|
||||
expect(sv.canvasCount).toBe(12);
|
||||
// Explicit resync too (client gap detection).
|
||||
room.sendSync(back);
|
||||
expect((lastState(back.msgs).view as CatchmindView).canvas).toHaveLength(12);
|
||||
|
||||
// Streamed actions are logged in batches with the next snapshot.
|
||||
const drawLogs = () => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM room_log WHERE data_json LIKE '%\"draw\"%'").get()!.n;
|
||||
expect(drawLogs()).toBe(12); // the join wrote the pending ones
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op: { k: 'b', s: 4, c: 0, w: 0, e: 0 } });
|
||||
expect(drawLogs()).toBe(12); // not yet written
|
||||
room.flushStream();
|
||||
expect(drawLogs()).toBe(13);
|
||||
const seqs = db.query<{ seq: number }, []>('SELECT seq FROM room_log ORDER BY seq').all().map((r) => r.seq);
|
||||
expect(new Set(seqs).size).toBe(seqs.length);
|
||||
const snap = db.query<{ state_json: string }, []>('SELECT state_json FROM room_state').get()!;
|
||||
expect(JSON.parse(snap.state_json).game.state.turn.canvas).toHaveLength(13);
|
||||
});
|
||||
|
||||
test('초당 31번째 그림 메시지는 거절, 출제자가 아니면 거절', () => {
|
||||
const { clock, conns, state, act } = harness(['A', 'B']);
|
||||
const d = state().turn.drawer;
|
||||
const g = state().order.find((p) => p !== d)!;
|
||||
act(d, { type: 'chooseWord', index: 1 });
|
||||
const cs = act(g, { type: 'draw', op: { k: 'b', s: 1, c: 0, w: 0, e: 0 } });
|
||||
expect(conns[g]!.msgs.at(-1)).toEqual({ t: 'reject', cs, reason: '그림은 출제자만 그릴 수 있어요.' });
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
clock.advance(10);
|
||||
act(d, { type: 'draw', op: { k: 'b', s, c: 0, w: 0, e: 0 } });
|
||||
}
|
||||
expect(state().turn.canvas).toHaveLength(30);
|
||||
const c31 = act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
|
||||
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'reject', cs: c31, reason: expect.stringContaining('너무 빨리') });
|
||||
clock.advance(1000);
|
||||
act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
|
||||
expect(state().turn.canvas).toHaveLength(31);
|
||||
});
|
||||
|
||||
test('출제자 연결 끊김: 고르기 중이면 바로 자동 선택, 그리기 10초 뒤 턴 종료·정답 공개·다음 출제자', () => {
|
||||
const { room, clock, conns, state } = harness(['A', 'B', 'C']);
|
||||
const d = state().turn.drawer;
|
||||
const g = state().order.find((p) => p !== d)!;
|
||||
room.disconnect(conns[d]!);
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now());
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
const word = state().turn.word!.word;
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
|
||||
clock.advance(9_999);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
clock.advance(1);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('reveal');
|
||||
expect(state().turn.endReason).toBe('drawerAway');
|
||||
expect(state().scores[d]).toBe(0);
|
||||
expect((lastState(conns[g]!.msgs).view as CatchmindView).word).toBe(word);
|
||||
clock.advance(5_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('choosing');
|
||||
expect(state().turn.drawer).not.toBe(d);
|
||||
expect(state().away).toEqual([d]);
|
||||
});
|
||||
|
||||
test('연결된 출제자는 힌트·시간 만료 때만 tick(10초 지나도 끝나지 않음)', () => {
|
||||
const { room, clock, state, act } = harness(['A', 'B']);
|
||||
const d = state().turn.drawer;
|
||||
act(d, { type: 'chooseWord', index: 0 });
|
||||
const t0 = state().turn.startedAt;
|
||||
const ends = state().turn.endsAt;
|
||||
expect(room.effectiveDeadline()).toBe(t0 + (ends - t0) / 2);
|
||||
clock.advance(20_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
clock.advance(ends - t0);
|
||||
for (let i = 0; i < 3 && state().turn.phase === 'drawing'; i++) {
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
}
|
||||
expect(state().turn.endReason).toBe('time');
|
||||
});
|
||||
});
|
||||
120
apps/server/src/rooms/dobble.room.test.ts
Normal file
120
apps/server/src/rooms/dobble.room.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(players: string[], opts: Partial<DobbleOptions> = {}) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
|
||||
const games = { dobble: GAMES.dobble! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'dobble',
|
||||
options: games.dobble.optionsSchema.parse(opts),
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'dobble', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
|
||||
for (const p of players) {
|
||||
const msgs: ServerMessage[] = [];
|
||||
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
expect(room.start(players[0]!)).toBeNull();
|
||||
room.clearTimer();
|
||||
// 3s countdown → playing.
|
||||
clock.advance(3000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
const state = () => room.game!.state as DobbleState;
|
||||
/** The correct claim of `p` (first pair), as the client would send it. */
|
||||
const right = (p: string) => {
|
||||
const pair = pairsFor(state(), p)[0]!;
|
||||
return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) };
|
||||
};
|
||||
return { room, clock, conns, state, right };
|
||||
}
|
||||
|
||||
describe('도블 room flow', () => {
|
||||
test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => {
|
||||
const { room, conns, state, right } = harness(['A', 'B', 'C']);
|
||||
const a = right('A');
|
||||
const b = right('B');
|
||||
const towerB = state().piles.B!.length;
|
||||
room.act('B', 1, b);
|
||||
room.act('A', 1, a);
|
||||
room.clearTimer();
|
||||
expect(state().piles.B).toHaveLength(towerB + 1);
|
||||
expect(state().piles.A).toHaveLength(1);
|
||||
expect(state().lockedUntil.A).toBe(0);
|
||||
expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]);
|
||||
// Nobody's view carries the answer or hidden cards; the hit is public.
|
||||
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
|
||||
expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' });
|
||||
});
|
||||
|
||||
test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => {
|
||||
const { room, conns } = harness(['A', 'B']);
|
||||
room.act('A', 1, { type: 'tick' });
|
||||
room.act('A', 2, { type: 'leave' });
|
||||
expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']);
|
||||
});
|
||||
|
||||
test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => {
|
||||
const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' });
|
||||
// RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap).
|
||||
const s = state();
|
||||
s.rtt.A = 20;
|
||||
s.rtt.B = 200;
|
||||
const a = right('A');
|
||||
const b = right('B');
|
||||
room.act('A', 1, a);
|
||||
clock.advance(40);
|
||||
room.act('B', 1, b);
|
||||
expect(state().pendingWindow?.claims).toHaveLength(2);
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60);
|
||||
clock.set(room.effectiveDeadline()!);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().pendingWindow).toBeNull();
|
||||
// B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10).
|
||||
expect(state().lastHit).toMatchObject({ player: 'B' });
|
||||
});
|
||||
|
||||
test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => {
|
||||
const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' });
|
||||
const t0 = clock.now();
|
||||
room.disconnect(conns.C!);
|
||||
room.clearTimer();
|
||||
let fired = 0;
|
||||
while (state().status.C === 'playing' && fired < 50) {
|
||||
const d = room.effectiveDeadline()!;
|
||||
if (d > clock.now()) clock.set(d);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
fired++;
|
||||
// The others keep finding pictures now and then.
|
||||
if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A'));
|
||||
room.clearTimer();
|
||||
}
|
||||
expect(state().status.C).toBe('forfeit');
|
||||
const took = clock.now() - t0;
|
||||
expect(took).toBeGreaterThanOrEqual(60_000 + 20_000);
|
||||
expect(took).toBeLessThanOrEqual(60_000 + 45_000);
|
||||
expect(fired).toBeLessThan(20);
|
||||
expect(room.status).toBe('playing');
|
||||
expect(state().phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
123
apps/server/src/rooms/halli-galli.room.test.ts
Normal file
123
apps/server/src/rooms/halli-galli.room.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/** 할리갈리 through a real Room: bell arrival order, "늦었어요" rejects, away players' auto flips (docs/games/halli-galli.md §5.4, §8.5). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { CARDS, type HalliGalliState } from '@bg/games/halli-galli';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(players: string[]) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
|
||||
const games = { 'halli-galli': GAMES['halli-galli']! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'halli-galli',
|
||||
options: games['halli-galli'].defaultOptions,
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'halli-galli', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
|
||||
for (const p of players) {
|
||||
const msgs: ServerMessage[] = [];
|
||||
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
expect(room.start(players[0]!)).toBeNull();
|
||||
room.clearTimer();
|
||||
// 3s countdown → playing.
|
||||
clock.advance(3000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
const state = () => room.game!.state as HalliGalliState;
|
||||
return { room, clock, conns, state };
|
||||
}
|
||||
|
||||
/** Moves the named cards (fruit + count) from wherever they are onto the players' face-up piles. */
|
||||
function placeTops(s: HalliGalliState, tops: Record<string, [string, number]>): void {
|
||||
for (const p of s.order) {
|
||||
s.decks[p]!.push(...s.faceUp[p]!);
|
||||
s.faceUp[p] = [];
|
||||
}
|
||||
for (const [p, [fruit, count]] of Object.entries(tops)) {
|
||||
const c = CARDS.find((x) => x.fruit === fruit && x.count === count && !Object.values(s.faceUp).flat().includes(x.id))!;
|
||||
for (const q of s.order) s.decks[q] = s.decks[q]!.filter((id) => id !== c.id);
|
||||
s.pot = s.pot.filter((id) => id !== c.id);
|
||||
s.faceUp[p]!.push(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
describe('할리갈리 room flow', () => {
|
||||
test('거의 동시에 친 두 종: 서버 도착 순서로 먼저 온 사람만 수거, 나중 사람은 "늦었어요" 거절(벌칙 없음)', () => {
|
||||
const { room, conns, state } = harness(['A', 'B', 'C']);
|
||||
// §5.5: banana 3 + strawberry 2 + banana 2 → banana 5.
|
||||
const [x, y, z] = state().order as [string, string, string];
|
||||
placeTops(state(), { [x]: ['banana', 3], [y]: ['strawberry', 2], [z]: ['banana', 2] });
|
||||
const v = state().tableVersion;
|
||||
const decks = Object.fromEntries(state().order.map((p) => [p, state().decks[p]!.length]));
|
||||
const pot = state().pot.length; // 3 players: 2 leftover cards under the bell
|
||||
// Same millisecond: the room's single-threaded queue decides — B's message is handled first.
|
||||
room.act('B', 1, { type: 'ring', version: v });
|
||||
room.act('C', 1, { type: 'ring', version: v });
|
||||
room.clearTimer();
|
||||
expect(state().decks.B).toHaveLength(decks.B! + 3 + pot);
|
||||
expect(state().decks.C).toHaveLength(decks.C!);
|
||||
expect(state().wrongCount.C).toBe(0);
|
||||
expect(state().turn).toBe('B');
|
||||
expect(conns.C!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' }]);
|
||||
// B got the success echo (state with its cs) and everyone sees who rang.
|
||||
expect(conns.B!.msgs.some((m) => m.t === 'state' && m.cs === 1)).toBe(true);
|
||||
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
|
||||
expect((last.view as { lastBell: { player: string; correct: boolean } }).lastBell).toMatchObject({ player: 'B', correct: true });
|
||||
});
|
||||
|
||||
test('틀린 종은 바로 벌칙, 버전이 지난 종은 무시', () => {
|
||||
const { room, clock, conns, state } = harness(['A', 'B']);
|
||||
placeTops(state(), { A: ['lime', 1] });
|
||||
const v = state().tableVersion;
|
||||
const before = state().decks.B!.length;
|
||||
room.act('B', 1, { type: 'ring', version: v });
|
||||
room.clearTimer();
|
||||
expect(state().decks.B).toHaveLength(before - 1);
|
||||
// The turn player flips (after the guard) — a bell for the old table is ignored as late.
|
||||
clock.advance(1000);
|
||||
room.act(state().turn, 2, { type: 'flip' });
|
||||
room.clearTimer();
|
||||
expect(state().tableVersion).toBe(v + 1);
|
||||
room.act('A', 3, { type: 'ring', version: v });
|
||||
expect(conns.A!.msgs.find((m) => m.t === 'reject')).toMatchObject({ reason: expect.stringContaining('늦었어요') });
|
||||
});
|
||||
|
||||
test('연결이 끊겨 자리 비움이 된 사람도 자동 뒤집기로 계속 진행(1초 간격, 바쁜 반복 없음)', () => {
|
||||
const { room, clock, conns, state } = harness(['A', 'B']);
|
||||
const away = state().turn;
|
||||
const other = away === 'A' ? 'B' : 'A';
|
||||
room.disconnect(conns[away]!);
|
||||
room.clearTimer();
|
||||
clock.advance(61_000); // grace period (60s) over
|
||||
const gaps: [string, number][] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const s = state();
|
||||
const d = room.effectiveDeadline()!;
|
||||
gaps.push([s.turn, d - s.turnStartedAt]);
|
||||
if (d > clock.now()) clock.set(d);
|
||||
const flips = s.faceUp[s.turn]!.length;
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().faceUp[s.turn]!.length).toBe(flips + 1);
|
||||
}
|
||||
// The first (overdue) flip happens at once; afterwards the away player flips 1s into each turn,
|
||||
// the connected player gets the normal 3 seconds.
|
||||
for (const [who, gap] of gaps.slice(1)) expect(gap).toBe(who === other ? 3000 : 1000);
|
||||
expect(room.status).toBe('playing');
|
||||
});
|
||||
});
|
||||
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { MafiaState, MafiaView } from '@bg/games/mafia';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(players: string[]) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const games = { mafia: GAMES.mafia! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'mafia',
|
||||
options: { ...games.mafia.defaultOptions, firstPhase: 'night' },
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'mafia', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
|
||||
for (const p of players) {
|
||||
const msgs: ServerMessage[] = [];
|
||||
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
const state = () => room.game!.state as MafiaState;
|
||||
const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>).view as MafiaView;
|
||||
return { room, clock, conns, state, lastView };
|
||||
}
|
||||
|
||||
describe('마피아 room flow', () => {
|
||||
test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => {
|
||||
const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']);
|
||||
expect(room.chat('A', '대기실 인사')).toBeNull();
|
||||
expect(room.start('A')).toBeNull();
|
||||
expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.');
|
||||
// Role reveal → night on the server clock.
|
||||
clock.advance(8_000);
|
||||
room.onTimer();
|
||||
expect(state().phase.kind).toBe('night');
|
||||
const mafia = state().players.find((p) => p.role === 'mafia')!.id;
|
||||
const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id);
|
||||
room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' });
|
||||
expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET');
|
||||
for (const t of town) {
|
||||
const all = JSON.stringify(conns[t]!.msgs);
|
||||
expect(all).not.toContain('ROOM-MAFIA-SECRET');
|
||||
expect(all).not.toContain(`"role":"mafia"`);
|
||||
}
|
||||
// Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients.
|
||||
room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' });
|
||||
expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' });
|
||||
room.act(town[0]!, 3, { type: 'phaseTimeout' });
|
||||
expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' });
|
||||
expect(state().phase.kind).toBe('night');
|
||||
clock.advance(30_000);
|
||||
room.onTimer();
|
||||
expect(state().phase.kind).toBe('discussion');
|
||||
expect(state().day).toBe(2);
|
||||
});
|
||||
});
|
||||
206
apps/server/src/rooms/manager.ts
Normal file
206
apps/server/src/rooms/manager.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
|
||||
import { ulid } from 'ulid';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
|
||||
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
|
||||
import type { RoomStore } from './store';
|
||||
import type { SettingsStore } from '../admin/settings';
|
||||
|
||||
export const MAX_ROOMS = 2000;
|
||||
export const MAX_ROOMS_PER_USER = 3;
|
||||
const EMPTY_LOBBY_TTL = 10 * 60_000;
|
||||
const FINISHED_TTL = 30 * 60_000;
|
||||
|
||||
export interface ManagerDeps {
|
||||
store: RoomStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: RoomDeps['log'];
|
||||
random?: () => number;
|
||||
settings?: SettingsStore;
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
private byCode = new Map<string, Room>();
|
||||
/** userId → room code where the user holds a seat. */
|
||||
private seatIndex = new Map<string, Set<string>>();
|
||||
private createdBy = new Map<string, Set<string>>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private deps: ManagerDeps) {
|
||||
this.now = deps.now ?? Date.now;
|
||||
}
|
||||
|
||||
private roomDeps(): RoomDeps {
|
||||
return {
|
||||
store: this.deps.store,
|
||||
games: this.deps.games,
|
||||
users: this.deps.users,
|
||||
now: this.now,
|
||||
log: this.deps.log,
|
||||
settings: this.deps.settings,
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
||||
if (seated) set.add(room.code);
|
||||
else set.delete(room.code);
|
||||
if (set.size) this.seatIndex.set(userId, set);
|
||||
else this.seatIndex.delete(userId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get(code: string): Room | undefined {
|
||||
return this.byCode.get(code);
|
||||
}
|
||||
|
||||
all(): Room[] {
|
||||
return [...this.byCode.values()];
|
||||
}
|
||||
|
||||
private newCode(): string {
|
||||
const rand = this.deps.random ?? Math.random;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const code = String(Math.floor(rand() * 1_000_000)).padStart(6, '0');
|
||||
if (!isAllowedRoomCode(code) || this.byCode.has(code)) continue;
|
||||
if (this.deps.store.codeInUse(code, this.now())) continue;
|
||||
return code;
|
||||
}
|
||||
throw new Error('could not allocate room code');
|
||||
}
|
||||
|
||||
/** Returns the room or a Korean error message. */
|
||||
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
|
||||
const def = this.deps.games[gameId];
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
const site = this.deps.settings?.getSite();
|
||||
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
|
||||
if (site?.maintenance) return site.announcement || '지금은 점검 중이라 새 방을 만들 수 없어요.';
|
||||
const maxRooms = site?.maxRooms ?? MAX_ROOMS;
|
||||
const perUser = site?.maxRoomsPerUser ?? MAX_ROOMS_PER_USER;
|
||||
if (this.byCode.size >= maxRooms) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
|
||||
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
|
||||
if (mine.length >= perUser) return `방은 한 번에 ${perUser}개까지 만들 수 있어요.`;
|
||||
const options = eff?.defaultOptions ?? def.defaultOptions;
|
||||
const base = def.playersFor ? def.playersFor(options).max : def.maxPlayers;
|
||||
const config: RoomConfig = {
|
||||
gameId,
|
||||
options,
|
||||
maxPlayers: Math.min(base, eff?.maxPlayers ?? base),
|
||||
visibility,
|
||||
allowSpectators: true,
|
||||
chatEnabled: site?.chatEnabledDefault ?? true,
|
||||
chatFilter: site?.chatFilterDefault ?? true,
|
||||
graceSec: site?.graceSec ?? 60,
|
||||
};
|
||||
const code = this.newCode();
|
||||
const id = ulid(this.now());
|
||||
const room = new Room(id, code, config, hostId, this.roomDeps());
|
||||
this.deps.store.insertRoom({
|
||||
id,
|
||||
code,
|
||||
hostId,
|
||||
gameId,
|
||||
status: 'lobby',
|
||||
visibility,
|
||||
config,
|
||||
stateJson: JSON.stringify(room.snapshot()),
|
||||
now: this.now(),
|
||||
});
|
||||
this.byCode.set(code, room);
|
||||
this.createdBy.set(hostId, new Set([...mine, code]));
|
||||
return room;
|
||||
}
|
||||
|
||||
/** Rooms where the user holds a seat in an unfinished game. */
|
||||
activeRoomFor(userId: string): string | null {
|
||||
for (const code of this.seatIndex.get(userId) ?? []) {
|
||||
const r = this.byCode.get(code);
|
||||
if (r && (r.inGame(userId) || r.status === 'lobby' || r.status === 'finished')) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** When a user takes a seat in `code`, free lobby seats they hold elsewhere (one room at a time). */
|
||||
releaseOtherLobbySeats(userId: string, code: string): void {
|
||||
for (const other of [...(this.seatIndex.get(userId) ?? [])]) {
|
||||
if (other === code) continue;
|
||||
const r = this.byCode.get(other);
|
||||
if (r && !r.inGame(userId)) r.leave(userId);
|
||||
}
|
||||
}
|
||||
|
||||
publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||
return this.all()
|
||||
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby')
|
||||
.map((r) => ({
|
||||
code: r.code,
|
||||
gameId: r.config.gameId,
|
||||
host: this.deps.users.publicUser(r.hostId)?.nickname ?? '',
|
||||
seated: r.seatedIds().length,
|
||||
max: r.config.maxPlayers,
|
||||
options: r.config.options,
|
||||
}))
|
||||
.filter((r) => r.seated < r.max)
|
||||
.sort((a, b) => a.max - a.seated - (b.max - b.seated));
|
||||
}
|
||||
|
||||
/** Quick play: fullest open public lobby, else a new public room. */
|
||||
quickPlay(userId: string, gameId: string): Room | string {
|
||||
const best = this.publicRooms(gameId)[0];
|
||||
if (best) return this.byCode.get(best.code)!;
|
||||
return this.create(userId, gameId, 'public');
|
||||
}
|
||||
|
||||
restoreAll(): { restored: number; broken: number } {
|
||||
let restored = 0;
|
||||
let broken = 0;
|
||||
for (const { row, seq, stateJson } of this.deps.store.openRooms()) {
|
||||
try {
|
||||
const snap = JSON.parse(stateJson) as RoomSnapshot;
|
||||
const room = Room.restore(row.id, row.code, seq, snap, this.roomDeps());
|
||||
this.byCode.set(row.code, room);
|
||||
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, true);
|
||||
restored++;
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'room restore failed', { code: row.code, err: String(err) });
|
||||
this.deps.store.markBroken(row.id);
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
return { restored, broken };
|
||||
}
|
||||
|
||||
/** Periodic cleanup (call every minute). */
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
for (const room of this.all()) {
|
||||
if (room.conns.size > 0) continue;
|
||||
const idle = now - room.lastActiveAt;
|
||||
const closable =
|
||||
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
|
||||
(room.status === 'finished' && idle > FINISHED_TTL) ||
|
||||
((room.status === 'paused' || room.status === 'broken') && idle > FINISHED_TTL);
|
||||
if (closable) this.close(room);
|
||||
}
|
||||
}
|
||||
|
||||
closeByAdmin(code: string): boolean {
|
||||
const room = this.byCode.get(code);
|
||||
if (!room) return false;
|
||||
room.closeByAdmin();
|
||||
this.close(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
close(room: Room): void {
|
||||
room.shutdown();
|
||||
this.deps.store.close(room.id, this.now());
|
||||
this.byCode.delete(room.code);
|
||||
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, false);
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
for (const r of this.all()) r.shutdown();
|
||||
}
|
||||
}
|
||||
228
apps/server/src/rooms/room.test.ts
Normal file
228
apps/server/src/rooms/room.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/** Room-level tests with a controllable clock: timers, grace period, restore, error isolation. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig, type RoomSnapshot } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
function harness(games: Record<string, AnyGameDefinition> = GAMES) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const deps = {
|
||||
store,
|
||||
games,
|
||||
users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) },
|
||||
now: clock.now,
|
||||
};
|
||||
const config: RoomConfig = {
|
||||
gameId: Object.keys(games)[0]!,
|
||||
options: Object.values(games)[0]!.defaultOptions,
|
||||
maxPlayers: 2,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, 'A', deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: config.gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now: now });
|
||||
const conn = (userId: string) => {
|
||||
const msgs: ServerMessage[] = [];
|
||||
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
return { room, store, clock, deps, conn, db };
|
||||
}
|
||||
|
||||
describe('Room timers', () => {
|
||||
test('8. turn timeout applies onTimeout (omok perMove autoMove)', () => {
|
||||
const { room, clock, conn } = harness();
|
||||
const a = conn('A');
|
||||
const b = conn('B');
|
||||
room.join(a, 'player');
|
||||
room.join(b, 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
|
||||
clock.advance(9_999);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).moves).toHaveLength(0);
|
||||
clock.advance(1);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).moves).toHaveLength(1);
|
||||
expect((room.game!.state as any).moves[0].auto).toBe(true);
|
||||
});
|
||||
|
||||
test('disconnected player is auto-acted after the grace period, not before the turn deadline rules', () => {
|
||||
const { room, clock, conn } = harness();
|
||||
const a = conn('A');
|
||||
const b = conn('B');
|
||||
room.join(a, 'player');
|
||||
room.join(b, 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
// No time control: idle limit 300s. Black disconnects → grace 60s wins.
|
||||
room.disconnect(a);
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
|
||||
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
|
||||
// Reconnect before the grace period ends → deadline goes back to the idle limit.
|
||||
clock.advance(30_000);
|
||||
room.join(conn('A'), 'player');
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000);
|
||||
});
|
||||
|
||||
test('restore postpones overdue deadlines by 30s and keeps game state', () => {
|
||||
const { room, clock, conn, deps, store } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
room.clearTimer();
|
||||
const { seq, stateJson } = store.openRooms()[0]!;
|
||||
clock.advance(600_000); // server was down for 10 minutes
|
||||
const restored = Room.restore('room1', '482913', seq, JSON.parse(stateJson) as RoomSnapshot, deps);
|
||||
restored.clearTimer();
|
||||
expect((restored.game!.state as any).moves).toHaveLength(1);
|
||||
const d = restored.effectiveDeadline()!;
|
||||
expect(d).toBeGreaterThanOrEqual(clock.now() + 30_000);
|
||||
expect(d).toBeLessThan(clock.now() + 60_000);
|
||||
expect(restored.roomView().seats.every((s) => s && !s.connected)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room isolation and lobby', () => {
|
||||
const boom: AnyGameDefinition = {
|
||||
id: 'boom',
|
||||
nameKo: '폭탄',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
defaultOptions: {},
|
||||
optionsSchema: z.object({}),
|
||||
actionSchema: z.object({ type: z.literal('go') }),
|
||||
setup: ({ players }) => ({ players, n: 0 }),
|
||||
validate: () => ({ ok: true }),
|
||||
apply: () => {
|
||||
throw new Error('bug');
|
||||
},
|
||||
view: (s) => s,
|
||||
activePlayers: (s: any) => s.players,
|
||||
onTimeout: () => ({ type: 'go' }),
|
||||
result: () => null,
|
||||
};
|
||||
|
||||
test('a throwing game rejects the action, keeps state, pauses after 3 errors', () => {
|
||||
const { room, conn } = harness({ boom });
|
||||
const a = conn('A');
|
||||
room.join(a, 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.start('A');
|
||||
for (let i = 1; i <= 3; i++) room.act('A', i, { type: 'go' });
|
||||
expect(a.msgs.filter((m) => m.t === 'reject')).toHaveLength(3);
|
||||
expect(room.status).toBe('paused');
|
||||
expect((room.game!.state as any).n).toBe(0);
|
||||
expect(room.voidGame('A')).toBeNull();
|
||||
expect(room.status).toBe('finished');
|
||||
});
|
||||
|
||||
test('seat rules: start needs min players, only host starts, kicked users cannot rejoin', () => {
|
||||
const { room, conn } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
expect(room.start('A')).toBe('1명 더 필요해요.');
|
||||
room.join(conn('B'), 'player');
|
||||
expect(room.start('B')).toBe('방장만 시작할 수 있어요.');
|
||||
room.join(conn('C'), 'player');
|
||||
expect(room.spectators.has('C')).toBe(true);
|
||||
expect(room.kick('A', 'C')).toBeNull();
|
||||
expect(room.join(conn('C'), 'player')).toBe('방장이 내보낸 방에는 다시 들어갈 수 없어요.');
|
||||
});
|
||||
|
||||
test('rematch starts when all seated players vote; colours alternate with gameNo', () => {
|
||||
const { room, conn } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.configure('A', { options: { colorAssignment: 'alternate' } });
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).players.black).toBe('A');
|
||||
room.act('B', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.sessionStats).toEqual({ A: { played: 1, wins: 1 }, B: { played: 1, wins: 0 } });
|
||||
room.rematch('A');
|
||||
expect(room.status).toBe('finished');
|
||||
room.rematch('B');
|
||||
room.clearTimer();
|
||||
expect(room.status).toBe('playing');
|
||||
expect(room.gameNo).toBe(2);
|
||||
expect((room.game!.state as any).players.black).toBe('B');
|
||||
});
|
||||
|
||||
test('leaving mid-game resigns via onLeave', () => {
|
||||
const { room, conn } = harness();
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('B'), 'player');
|
||||
room.start('A');
|
||||
room.clearTimer();
|
||||
room.leave('B');
|
||||
expect(room.status).toBe('finished');
|
||||
expect(room.lastResult?.ranking).toEqual([['A'], ['B']]);
|
||||
expect(room.seatOf('B')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('윷놀이 room rules', () => {
|
||||
const yutGames = () => ({ yut: GAMES.yut! });
|
||||
|
||||
test('team mode follows the game range to 8 seats and refuses odd counts via playerCountError', () => {
|
||||
const { room, conn } = harness(yutGames());
|
||||
room.join(conn('A'), 'player');
|
||||
expect(room.configure('A', { maxPlayers: 4 })).toBeNull();
|
||||
expect(room.configure('A', { options: { ...GAMES.yut!.defaultOptions, mode: 'team' } })).toBeNull();
|
||||
expect(room.seats).toHaveLength(8);
|
||||
for (const u of ['B', 'C', 'D', 'E']) room.join(conn(u), 'player');
|
||||
expect(room.start('A')).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.');
|
||||
room.join(conn('F'), 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).sides.map((s: any) => s.members)).toEqual([
|
||||
['A', 'C', 'E'],
|
||||
['B', 'D', 'F'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a disconnected player is auto-played after the grace period; reconnecting gets the current view', () => {
|
||||
const { room, clock, conn } = harness(yutGames());
|
||||
const conns = { A: conn('A'), B: conn('B') };
|
||||
room.join(conns.A, 'player');
|
||||
room.join(conns.B, 'player');
|
||||
room.configure('A', { options: { ...GAMES.yut!.defaultOptions, turnSeconds: null } });
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.clearTimer();
|
||||
const cur = (room.game!.state as any).current as 'A' | 'B';
|
||||
room.disconnect(conns[cur]);
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
|
||||
clock.advance(60_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect((room.game!.state as any).turnSeq).toBe(1);
|
||||
const back = conn(cur);
|
||||
room.join(back, 'player');
|
||||
room.clearTimer();
|
||||
const st = back.msgs.find((m) => m.t === 'state') as any;
|
||||
expect(st.view.turnSeq).toBe((room.game!.state as any).turnSeq);
|
||||
});
|
||||
});
|
||||
906
apps/server/src/rooms/room.ts
Normal file
906
apps/server/src/rooms/room.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* One room: seats, spectators, chat, game runner, timers (docs/06, docs/03, docs/09 §4).
|
||||
* All mutations run on the single JS thread, so a room never sees concurrent updates.
|
||||
*/
|
||||
import { ulid } from 'ulid';
|
||||
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import type { LogEntry, RoomStore } from './store';
|
||||
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
|
||||
|
||||
export interface Conn {
|
||||
userId: string;
|
||||
send(msg: ServerMessage): void;
|
||||
close(code: number, reason: string): void;
|
||||
}
|
||||
|
||||
export interface RoomConfig {
|
||||
gameId: string;
|
||||
options: unknown;
|
||||
maxPlayers: number;
|
||||
visibility: 'private' | 'public';
|
||||
allowSpectators: boolean;
|
||||
chatEnabled: boolean;
|
||||
chatFilter: boolean;
|
||||
/** Seconds a disconnected player keeps their turn timer before auto actions kick in (docs/03 §6.2). */
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
export interface Seat {
|
||||
userId: string;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
interface Presence {
|
||||
connected: boolean;
|
||||
since: number;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
interface GameSlot {
|
||||
gameId: string;
|
||||
stateVersion: number;
|
||||
state: unknown;
|
||||
rng: RngState;
|
||||
gamePk: string;
|
||||
seedHex: string;
|
||||
seedHash: string;
|
||||
startedAt: number;
|
||||
players: string[];
|
||||
finished: boolean;
|
||||
/** After a restore, deadlines are not enforced before this time (docs/04 §3.3). */
|
||||
deadlineFloor: number | null;
|
||||
}
|
||||
|
||||
/** Shape persisted to room_state.state_json. */
|
||||
export interface RoomSnapshot {
|
||||
v: 1;
|
||||
config: RoomConfig;
|
||||
hostId: string;
|
||||
status: Room['status'];
|
||||
seats: (Seat | null)[];
|
||||
gameNo: number;
|
||||
sessionStats: Record<string, { played: number; wins: number }>;
|
||||
rematchVotes: string[];
|
||||
lastSeed: string | null;
|
||||
lastResult: GameResult | null;
|
||||
game: GameSlot | null;
|
||||
joinedAt: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RoomDeps {
|
||||
store: RoomStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
onEmpty?: (room: Room) => void;
|
||||
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
||||
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
||||
}
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
const ERROR_LIMIT = 3;
|
||||
/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */
|
||||
const STREAM_FLUSH_MS = 1000;
|
||||
|
||||
export class Room {
|
||||
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
|
||||
seq = 0;
|
||||
hostId: string;
|
||||
seats: (Seat | null)[];
|
||||
spectators = new Set<string>();
|
||||
presence = new Map<string, Presence>();
|
||||
conns = new Map<string, Conn>();
|
||||
kicked = new Set<string>();
|
||||
/** Players who left during a game; their seat is freed when the game ends. */
|
||||
leftDuringGame = new Set<string>();
|
||||
gameNo = 0;
|
||||
sessionStats: Record<string, { played: number; wins: number }> = {};
|
||||
rematchVotes = new Set<string>();
|
||||
lastSeed: string | null = null;
|
||||
lastResult: GameResult | null = null;
|
||||
game: GameSlot | null = null;
|
||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||
lastActiveAt: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
||||
private pendingLogs: LogEntry[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private recentCs = new Map<string, number[]>();
|
||||
private lastChatAt = new Map<string, number>();
|
||||
private consecutiveErrors = 0;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly code: string,
|
||||
public config: RoomConfig,
|
||||
hostId: string,
|
||||
private deps: RoomDeps,
|
||||
) {
|
||||
this.hostId = hostId;
|
||||
this.seats = new Array(config.maxPlayers).fill(null);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.lastActiveAt = this.now();
|
||||
}
|
||||
|
||||
get def(): AnyGameDefinition {
|
||||
const d = this.deps.games[this.config.gameId];
|
||||
if (!d) throw new Error(`unknown game ${this.config.gameId}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
playerRange(): { min: number; max: number } {
|
||||
return this.rangeFor(this.config.gameId, this.config.options);
|
||||
}
|
||||
|
||||
/** Game's own range (per options) intersected with the admin-configured range. */
|
||||
private rangeFor(gameId: string, options: unknown): { min: number; max: number } {
|
||||
const d = this.deps.games[gameId]!;
|
||||
const base = d.playersFor ? d.playersFor(options) : { min: d.minPlayers, max: d.maxPlayers };
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
if (!eff) return base;
|
||||
const min = Math.max(base.min, eff.minPlayers);
|
||||
const max = Math.min(base.max, eff.maxPlayers);
|
||||
return min <= max ? { min, max } : base;
|
||||
}
|
||||
|
||||
private locked(gameId: string): { defaults: unknown; paths: string[] } {
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] };
|
||||
}
|
||||
|
||||
seatOf(userId: string): number {
|
||||
return this.seats.findIndex((s) => s?.userId === userId);
|
||||
}
|
||||
|
||||
isParticipant(userId: string): boolean {
|
||||
return this.seatOf(userId) >= 0 || this.spectators.has(userId);
|
||||
}
|
||||
|
||||
seatedIds(): string[] {
|
||||
return this.seats.filter((s): s is Seat => s !== null).map((s) => s.userId);
|
||||
}
|
||||
|
||||
inGame(userId: string): boolean {
|
||||
return !!this.game && !this.game.finished && this.game.players.includes(userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
snapshot(): RoomSnapshot {
|
||||
const joinedAt: Record<string, number> = {};
|
||||
for (const [u, p] of this.presence) if (this.seatOf(u) >= 0) joinedAt[u] = p.joinedAt;
|
||||
return {
|
||||
v: 1,
|
||||
config: this.config,
|
||||
hostId: this.hostId,
|
||||
status: this.status,
|
||||
seats: this.seats,
|
||||
gameNo: this.gameNo,
|
||||
sessionStats: this.sessionStats,
|
||||
rematchVotes: [...this.rematchVotes],
|
||||
lastSeed: this.lastSeed,
|
||||
lastResult: this.lastResult,
|
||||
game: this.game,
|
||||
joinedAt,
|
||||
};
|
||||
}
|
||||
|
||||
static restore(id: string, code: string, seq: number, snap: RoomSnapshot, deps: RoomDeps): Room {
|
||||
const room = new Room(id, code, snap.config, snap.hostId, deps);
|
||||
room.seq = seq;
|
||||
room.status = snap.status;
|
||||
room.seats = snap.seats;
|
||||
room.gameNo = snap.gameNo;
|
||||
room.sessionStats = snap.sessionStats;
|
||||
room.rematchVotes = new Set(snap.rematchVotes);
|
||||
room.lastSeed = snap.lastSeed;
|
||||
room.lastResult = snap.lastResult;
|
||||
room.game = snap.game;
|
||||
const now = room.now();
|
||||
for (const s of room.seats) {
|
||||
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
||||
}
|
||||
if (room.game && !room.game.finished) {
|
||||
const def = room.def;
|
||||
if (def.stateVersion !== room.game.stateVersion) {
|
||||
if (!def.migrate) throw new Error(`state version mismatch for ${def.id}`);
|
||||
room.game.state = def.migrate(room.game.state, room.game.stateVersion);
|
||||
room.game.stateVersion = def.stateVersion;
|
||||
}
|
||||
// 30s to reconnect, plus up to 30s jitter so thousands of restored rooms don't all time out
|
||||
// in the same instant (thundering herd after a restart, docs/04 §3).
|
||||
room.game.deadlineFloor = now + 30_000 + Math.floor(Math.random() * 30_000);
|
||||
room.scheduleTimer();
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
||||
const nextSeq = this.seq + 1;
|
||||
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
||||
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
||||
this.deps.store.save(
|
||||
this.id,
|
||||
nextSeq,
|
||||
JSON.stringify(this.snapshot()),
|
||||
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
|
||||
logs,
|
||||
);
|
||||
this.pendingLogs = [];
|
||||
if (this.flushTimer) clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
this.seq = nextSeq;
|
||||
this.lastActiveAt = this.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
||||
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
||||
*/
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
||||
this.seq += 1;
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
||||
this.lastActiveAt = this.now();
|
||||
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
||||
}
|
||||
|
||||
/** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */
|
||||
flushStream(): void {
|
||||
this.flushTimer = null;
|
||||
if (this.pendingLogs.length) this.persist(null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
roomView(): RoomView {
|
||||
const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const seats: (SeatView | null)[] = this.seats.map((s) => {
|
||||
if (!s) return null;
|
||||
const p = this.presence.get(s.userId);
|
||||
const connected = !!p?.connected;
|
||||
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000;
|
||||
return { user: pub(s.userId), ready: s.ready, connected, away };
|
||||
});
|
||||
const range = this.playerRange();
|
||||
return {
|
||||
code: this.code,
|
||||
hostId: this.hostId,
|
||||
gameId: this.config.gameId,
|
||||
options: this.config.options,
|
||||
maxPlayers: this.config.maxPlayers,
|
||||
minPlayers: range.min,
|
||||
visibility: this.config.visibility,
|
||||
turnSeconds: null,
|
||||
allowSpectators: this.config.allowSpectators,
|
||||
chatEnabled: this.config.chatEnabled,
|
||||
chatFilter: this.config.chatFilter,
|
||||
seats,
|
||||
spectators: [...this.spectators].map(pub),
|
||||
status: this.status,
|
||||
gameNo: this.gameNo,
|
||||
rematchVotes: [...this.rematchVotes],
|
||||
sessionStats: this.sessionStats,
|
||||
seedHash: this.game?.seedHash ?? null,
|
||||
lockedOptions: this.locked(this.config.gameId).paths,
|
||||
lastSeed: this.lastSeed,
|
||||
};
|
||||
}
|
||||
|
||||
/** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */
|
||||
private stateMessage(viewer: string, cs?: number, sync = false): ServerMessage | null {
|
||||
if (!this.game) return null;
|
||||
const def = this.def;
|
||||
const asPlayer = this.game.players.includes(viewer) ? viewer : null;
|
||||
let view: unknown;
|
||||
try {
|
||||
view = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : def.view(this.game.state, asPlayer);
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
|
||||
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
|
||||
}
|
||||
return {
|
||||
t: 'state',
|
||||
seq: this.seq,
|
||||
view,
|
||||
events: [],
|
||||
active: this.game.finished ? [] : def.activePlayers(this.game.state),
|
||||
deadline: this.game.finished ? null : this.effectiveDeadline(),
|
||||
...(cs !== undefined ? { cs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
broadcastRoom(): void {
|
||||
const msg: ServerMessage = { t: 'room', seq: this.seq, room: this.roomView() };
|
||||
for (const c of this.conns.values()) c.send(msg);
|
||||
}
|
||||
|
||||
private broadcastState(events: unknown[], actor?: string, cs?: number): void {
|
||||
if (!this.game) return;
|
||||
let spectatorMsg: ServerMessage | null = null;
|
||||
for (const c of this.conns.values()) {
|
||||
let msg: ServerMessage | null;
|
||||
if (this.game.players.includes(c.userId)) {
|
||||
msg = this.stateMessage(c.userId, c.userId === actor ? cs : undefined);
|
||||
} else {
|
||||
spectatorMsg ??= this.stateMessage('');
|
||||
msg = spectatorMsg;
|
||||
}
|
||||
if (msg && msg.t === 'state') c.send({ ...msg, events });
|
||||
else if (msg) c.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
notice(code: string, message: string): void {
|
||||
for (const c of this.conns.values()) c.send({ t: 'notice', code, message });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- membership
|
||||
|
||||
/** Returns an error message, or null on success. */
|
||||
join(conn: Conn, as: 'player' | 'spectator'): string | null {
|
||||
const u = conn.userId;
|
||||
if (this.kicked.has(u)) return '방장이 내보낸 방에는 다시 들어갈 수 없어요.';
|
||||
if (this.status === 'broken') return '서버 업데이트로 이 게임을 이어갈 수 없어요. 새로 시작해 주세요.';
|
||||
const old = this.conns.get(u);
|
||||
if (old && old !== conn) {
|
||||
old.send({ t: 'bye', reason: 'replaced' });
|
||||
old.close(4000, 'replaced');
|
||||
}
|
||||
const now = this.now();
|
||||
const wasSeated = this.seatOf(u) >= 0;
|
||||
if (!wasSeated && !this.spectators.has(u)) {
|
||||
const seatable = as === 'player' && (this.status === 'lobby' || this.status === 'finished') && this.seats.some((s) => s === null);
|
||||
if (seatable) {
|
||||
const idx = this.seats.findIndex((s) => s === null);
|
||||
this.seats[idx] = { userId: u, ready: false };
|
||||
this.deps.onSeatChange?.(this, u, true);
|
||||
} else {
|
||||
if (!this.config.allowSpectators && !this.inGame(u)) return '이 방은 가득 찼어요.';
|
||||
this.spectators.add(u);
|
||||
}
|
||||
}
|
||||
const prev = this.presence.get(u);
|
||||
this.leftDuringGame.delete(u);
|
||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
||||
this.conns.set(u, conn);
|
||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||
}
|
||||
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
|
||||
this.broadcastRoom();
|
||||
const st = this.stateMessage(u, undefined, true);
|
||||
if (st) conn.send(st);
|
||||
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
|
||||
for (const m of this.chatLog) conn.send(m);
|
||||
this.scheduleTimer();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Connection dropped (not an intentional leave). */
|
||||
disconnect(conn: Conn): void {
|
||||
const u = conn.userId;
|
||||
if (this.conns.get(u) !== conn) return;
|
||||
this.conns.delete(u);
|
||||
const p = this.presence.get(u);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
p.since = this.now();
|
||||
}
|
||||
if (this.spectators.has(u)) {
|
||||
this.spectators.delete(u);
|
||||
this.presence.delete(u);
|
||||
} else if (this.inGame(u)) {
|
||||
this.notice('disconnected', `${this.nick(u)}님의 연결이 끊겼어요.`);
|
||||
}
|
||||
this.broadcastRoom();
|
||||
this.scheduleTimer();
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
}
|
||||
|
||||
leave(userId: string): void {
|
||||
const conn = this.conns.get(userId);
|
||||
this.conns.delete(userId);
|
||||
this.spectators.delete(userId);
|
||||
this.presence.delete(userId);
|
||||
const seat = this.seatOf(userId);
|
||||
if (seat >= 0) {
|
||||
if (this.inGame(userId)) {
|
||||
// Stay in the seat as "away" until the game ends so the game state stays consistent.
|
||||
this.leftDuringGame.add(userId);
|
||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||
const action = this.def.onLeave?.(this.game!.state, userId);
|
||||
if (action) this.runAction(userId, action, undefined, true);
|
||||
} else {
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(userId);
|
||||
this.deps.onSeatChange?.(this, userId, false);
|
||||
}
|
||||
}
|
||||
if (this.hostId === userId) this.pickNewHost();
|
||||
this.persist({ actorId: userId, kind: 'leave', data: null });
|
||||
this.broadcastRoom();
|
||||
conn?.send({ t: 'bye', reason: 'closed' });
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
}
|
||||
|
||||
private pickNewHost(): void {
|
||||
const candidates = [...this.presence.entries()]
|
||||
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
|
||||
.sort((a, b) => a[1].joinedAt - b[1].joinedAt);
|
||||
const next = candidates[0]?.[0] ?? this.seatedIds()[0] ?? [...this.spectators][0];
|
||||
if (next) this.hostId = next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lobby commands
|
||||
|
||||
private requireLobby(): string | null {
|
||||
return this.status === 'lobby' || this.status === 'finished' ? null : '게임 중에는 바꿀 수 없어요.';
|
||||
}
|
||||
|
||||
seat(userId: string, seat?: number): string | null {
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const target = seat ?? this.seats.findIndex((s) => s === null);
|
||||
if (target < 0 || target >= this.seats.length) return '빈자리가 없어요.';
|
||||
if (this.seats[target] && this.seats[target]!.userId !== userId) return '이미 다른 사람이 앉은 자리예요.';
|
||||
const cur = this.seatOf(userId);
|
||||
if (cur === target) return null;
|
||||
const ready = cur >= 0 ? this.seats[cur]!.ready : false;
|
||||
if (cur >= 0) this.seats[cur] = null;
|
||||
this.seats[target] = { userId, ready };
|
||||
this.spectators.delete(userId);
|
||||
if (cur < 0) this.deps.onSeatChange?.(this, userId, true);
|
||||
this.persist({ actorId: userId, kind: 'seat', data: { seat: target } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
unseat(userId: string): string | null {
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const cur = this.seatOf(userId);
|
||||
if (cur < 0) return null;
|
||||
if (!this.config.allowSpectators) return '이 방은 관전을 허용하지 않아요.';
|
||||
this.seats[cur] = null;
|
||||
this.spectators.add(userId);
|
||||
this.rematchVotes.delete(userId);
|
||||
this.deps.onSeatChange?.(this, userId, false);
|
||||
this.persist({ actorId: userId, kind: 'unseat', data: null });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
ready(userId: string, ready: boolean): string | null {
|
||||
const cur = this.seatOf(userId);
|
||||
if (cur < 0) return '자리에 앉아야 준비할 수 있어요.';
|
||||
if (this.requireLobby()) return this.requireLobby();
|
||||
this.seats[cur]!.ready = ready;
|
||||
this.persist(null);
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
configure(
|
||||
userId: string,
|
||||
patch: Partial<Omit<RoomConfig, 'graceSec'>> & { gameId?: string },
|
||||
): string | null {
|
||||
if (userId !== this.hostId) return '방장만 설정을 바꿀 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const next: RoomConfig = { ...this.config };
|
||||
if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) {
|
||||
const def = this.deps.games[patch.gameId];
|
||||
const eff = this.deps.settings?.game(patch.gameId);
|
||||
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
|
||||
next.gameId = patch.gameId;
|
||||
next.options = eff?.defaultOptions ?? def.defaultOptions;
|
||||
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers;
|
||||
}
|
||||
const def = this.deps.games[next.gameId]!;
|
||||
if (patch.options !== undefined) {
|
||||
const parsed = def.optionsSchema.safeParse(patch.options);
|
||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||
next.options = parsed.data;
|
||||
}
|
||||
// Admin-locked options always keep the admin value (docs/14-admin.md §4).
|
||||
const lk = this.locked(next.gameId);
|
||||
if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths));
|
||||
const range = this.rangeFor(next.gameId, next.options);
|
||||
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
|
||||
// A room that had every seat its game allows keeps doing so when an option changes the range (e.g. 윷놀이 팀전 8명).
|
||||
else if (next.gameId === this.config.gameId && this.config.maxPlayers === this.playerRange().max) next.maxPlayers = range.max;
|
||||
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
|
||||
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
|
||||
for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) {
|
||||
if (patch[k] !== undefined) (next as unknown as Record<string, unknown>)[k] = patch[k];
|
||||
}
|
||||
// Resize seats, compacting occupied seats to the front if needed.
|
||||
if (next.maxPlayers !== this.seats.length) {
|
||||
const occupied = this.seats.filter((s): s is Seat => s !== null);
|
||||
const seats: (Seat | null)[] = new Array(next.maxPlayers).fill(null);
|
||||
let i = 0;
|
||||
for (let k = 0; k < this.seats.length && k < next.maxPlayers; k++) if (this.seats[k]) seats[k] = this.seats[k]!;
|
||||
const placed = new Set(seats.filter(Boolean).map((s) => s!.userId));
|
||||
for (const s of occupied) {
|
||||
if (placed.has(s.userId)) continue;
|
||||
while (seats[i]) i++;
|
||||
seats[i] = s;
|
||||
}
|
||||
this.seats = seats;
|
||||
}
|
||||
this.config = next;
|
||||
for (const s of this.seats) if (s) s.ready = false;
|
||||
this.persist({ actorId: userId, kind: 'config', data: patch });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
kick(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
|
||||
if (target === userId) return '자기 자신은 내보낼 수 없어요.';
|
||||
if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
|
||||
const conn = this.conns.get(target);
|
||||
this.kicked.add(target);
|
||||
this.leave(target);
|
||||
conn?.send({ t: 'bye', reason: 'kicked' });
|
||||
conn?.close(4001, 'kicked');
|
||||
return null;
|
||||
}
|
||||
|
||||
transferHost(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
|
||||
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
|
||||
this.hostId = target;
|
||||
this.persist({ actorId: userId, kind: 'host', data: { target } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
chat(userId: string, text: string): string | null {
|
||||
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
||||
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
||||
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
|
||||
this.lastChatAt.set(userId, now);
|
||||
const clean = text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||
if (!clean) return null;
|
||||
const msg: Extract<ServerMessage, { t: 'chat' }> = {
|
||||
t: 'chat',
|
||||
from: this.deps.users.publicUser(userId),
|
||||
text: this.config.chatFilter ? maskProfanity(clean, this.deps.settings?.getSite().bannedWords ?? []) : clean,
|
||||
at: now,
|
||||
};
|
||||
this.chatLog.push(msg);
|
||||
if (this.chatLog.length > CHAT_HISTORY) this.chatLog.shift();
|
||||
for (const c of this.conns.values()) c.send(msg);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
|
||||
private gameChatOnly(): boolean {
|
||||
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
|
||||
}
|
||||
|
||||
emote(userId: string, id: string): void {
|
||||
if (!this.isParticipant(userId) || this.gameChatOnly()) return;
|
||||
const now = this.now();
|
||||
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
|
||||
this.lastChatAt.set(`e:${userId}`, now);
|
||||
for (const c of this.conns.values()) c.send({ t: 'emote', from: userId, id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- game lifecycle
|
||||
|
||||
start(userId: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 시작할 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const players = this.orderedPlayers();
|
||||
const range = this.playerRange();
|
||||
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
|
||||
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`;
|
||||
return this.startGame(players);
|
||||
}
|
||||
|
||||
rematch(userId: string): string | null {
|
||||
if (this.status !== 'finished') return '게임이 끝난 뒤에 누를 수 있어요.';
|
||||
if (this.seatOf(userId) < 0) return '자리에 앉아야 해요.';
|
||||
this.rematchVotes.add(userId);
|
||||
const seated = this.seatedIds();
|
||||
const range = this.playerRange();
|
||||
if (seated.length >= range.min && seated.every((u) => this.rematchVotes.has(u))) {
|
||||
return this.startGame(this.orderedPlayers());
|
||||
}
|
||||
this.persist(null);
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Seated players in seat order, rotated so the host (if seated) comes first. */
|
||||
private orderedPlayers(): string[] {
|
||||
const ids = this.seatedIds();
|
||||
const h = ids.indexOf(this.hostId);
|
||||
return h > 0 ? [...ids.slice(h), ...ids.slice(0, h)] : ids;
|
||||
}
|
||||
|
||||
private startGame(players: string[]): string | null {
|
||||
const def = this.def;
|
||||
const lk = this.locked(def.id);
|
||||
const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options);
|
||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||
const countErr = def.playerCountError?.(parsed.data, players.length);
|
||||
if (countErr) return countErr;
|
||||
const eff = this.deps.settings?.game(def.id);
|
||||
if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.';
|
||||
this.config = { ...this.config, options: parsed.data };
|
||||
const seed = createSeed();
|
||||
const seedHex = seedToHex(seed);
|
||||
const rng = SeededRng.fromSeed(seed);
|
||||
const now = this.now();
|
||||
const gameNo = this.gameNo + 1;
|
||||
let state: unknown;
|
||||
try {
|
||||
state = def.setup({ players, options: parsed.data, rng, now, gameNo });
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'setup failed', { room: this.code, err: String(err) });
|
||||
return '게임을 시작하지 못했어요.';
|
||||
}
|
||||
const gamePk = ulid(now);
|
||||
const seedHash = sha256Hex(seedHex);
|
||||
this.deps.store.insertGame({ id: gamePk, roomId: this.id, gameNo, gameId: def.id, options: parsed.data, seedHash, startedAt: now });
|
||||
this.gameNo = gameNo;
|
||||
this.game = {
|
||||
gameId: def.id,
|
||||
stateVersion: def.stateVersion,
|
||||
state,
|
||||
rng: rng.state(),
|
||||
gamePk,
|
||||
seedHex,
|
||||
seedHash,
|
||||
startedAt: now,
|
||||
players,
|
||||
finished: false,
|
||||
deadlineFloor: null,
|
||||
};
|
||||
this.status = 'playing';
|
||||
this.rematchVotes.clear();
|
||||
this.lastResult = null;
|
||||
this.consecutiveErrors = 0;
|
||||
for (const s of this.seats) if (s) s.ready = false;
|
||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } });
|
||||
this.broadcastRoom();
|
||||
this.broadcastState([{ type: 'gameStarted' }]);
|
||||
this.scheduleTimer();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Client action entry. `cs` dedupes retransmissions (docs/03 §5). */
|
||||
act(userId: string, cs: number, action: unknown): void {
|
||||
const conn = this.conns.get(userId);
|
||||
const reject = (reason: string) => conn?.send({ t: 'reject', cs, reason });
|
||||
const recent = this.recentCs.get(userId) ?? [];
|
||||
if (recent.includes(cs)) return;
|
||||
recent.push(cs);
|
||||
if (recent.length > 50) recent.shift();
|
||||
this.recentCs.set(userId, recent);
|
||||
if (!this.game || this.game.finished || this.status !== 'playing') return reject('진행 중인 게임이 없어요.');
|
||||
if (!this.game.players.includes(userId)) return reject('이 게임의 플레이어가 아니에요.');
|
||||
const parsed = this.def.actionSchema.safeParse(action);
|
||||
if (!parsed.success) return reject('잘못된 요청이에요.');
|
||||
const err = this.runAction(userId, parsed.data, cs, false);
|
||||
if (err) reject(err);
|
||||
}
|
||||
|
||||
/** Validates and applies one action; returns an error message on rejection. */
|
||||
private runAction(userId: string, action: unknown, cs: number | undefined, system: boolean): string | null {
|
||||
const g = this.game;
|
||||
if (!g || g.finished) return '진행 중인 게임이 없어요.';
|
||||
const def = this.def;
|
||||
let next: { state: unknown; events: unknown[] };
|
||||
const rng = new SeededRng(g.rng);
|
||||
const now = this.now();
|
||||
try {
|
||||
const v = def.validate(g.state, userId, action, { now });
|
||||
if (!v.ok) return v.reason;
|
||||
next = def.apply(g.state, userId, action, { rng, now });
|
||||
} catch (err) {
|
||||
this.consecutiveErrors++;
|
||||
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
|
||||
if (this.consecutiveErrors >= ERROR_LIMIT) {
|
||||
this.status = 'paused';
|
||||
this.clearTimer();
|
||||
this.persist({ actorId: null, kind: 'paused', data: null });
|
||||
this.broadcastRoom();
|
||||
this.notice('paused', '오류가 반복되어 게임을 멈췄어요. 방장은 게임을 끝낼 수 있어요.');
|
||||
}
|
||||
return '알 수 없는 오류가 발생했어요.';
|
||||
}
|
||||
this.consecutiveErrors = 0;
|
||||
g.state = next.state;
|
||||
g.rng = rng.state();
|
||||
g.deadlineFloor = null;
|
||||
if (!system && def.isStreamAction?.(action)) {
|
||||
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
||||
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
||||
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
||||
} else {
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
}
|
||||
const result = def.result(g.state);
|
||||
if (result) this.finishGame(result);
|
||||
else this.scheduleTimer();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Host ends a paused/broken game without counting it. */
|
||||
voidGame(userId: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 할 수 있어요.';
|
||||
if (!this.game || this.game.finished) return '진행 중인 게임이 없어요.';
|
||||
this.finishGame({ ranking: [this.game.players], summary: '게임이 무효 처리됐어요.', reason: 'abandoned' }, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
private finishGame(result: GameResult, voided = false): void {
|
||||
const g = this.game!;
|
||||
g.finished = true;
|
||||
this.clearTimer();
|
||||
const rankOf = new Map<string, number>();
|
||||
let rank = 1;
|
||||
for (const group of result.ranking) {
|
||||
for (const u of group) rankOf.set(u, rank);
|
||||
rank += group.length;
|
||||
}
|
||||
const players = g.players.map((u) => ({
|
||||
userId: u,
|
||||
seat: this.seatOf(u),
|
||||
rank: rankOf.get(u) ?? null,
|
||||
score: result.scores?.[u] ?? null,
|
||||
}));
|
||||
this.deps.store.finishGame({
|
||||
id: g.gamePk,
|
||||
gameId: g.gameId,
|
||||
seed: g.seedHex,
|
||||
endedAt: this.now(),
|
||||
endReason: voided ? 'void' : result.reason,
|
||||
result,
|
||||
players,
|
||||
});
|
||||
const allFirst = players.every((p) => p.rank === 1);
|
||||
if (!voided) {
|
||||
for (const p of players) {
|
||||
const st = (this.sessionStats[p.userId] ??= { played: 0, wins: 0 });
|
||||
st.played++;
|
||||
if (p.rank === 1 && !allFirst) st.wins++;
|
||||
}
|
||||
}
|
||||
this.lastSeed = g.seedHex;
|
||||
this.lastResult = result;
|
||||
this.status = 'finished';
|
||||
// Players who left during the game lose their seat now.
|
||||
for (let i = 0; i < this.seats.length; i++) {
|
||||
const s = this.seats[i];
|
||||
if (s && this.leftDuringGame.has(s.userId)) {
|
||||
this.seats[i] = null;
|
||||
this.presence.delete(s.userId);
|
||||
this.deps.onSeatChange?.(this, s.userId, false);
|
||||
}
|
||||
}
|
||||
this.leftDuringGame.clear();
|
||||
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
|
||||
this.broadcastState([{ type: 'gameOver' }]);
|
||||
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });
|
||||
this.broadcastRoom();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- timers
|
||||
|
||||
/** Earliest time any timed player must be auto-acted for. */
|
||||
effectiveDeadline(): number | null {
|
||||
const g = this.game;
|
||||
if (!g || g.finished || this.status !== 'playing') return null;
|
||||
const def = this.def;
|
||||
const base = def.deadline?.(g.state) ?? null;
|
||||
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
|
||||
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
|
||||
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
||||
let best: number | null = null;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + graceMs;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (d === null) continue;
|
||||
if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor);
|
||||
if (notBefore !== null) d = Math.max(d, notBefore);
|
||||
best = best === null ? d : Math.min(best, d);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
scheduleTimer(): void {
|
||||
this.clearTimer();
|
||||
const at = this.effectiveDeadline();
|
||||
if (at === null) return;
|
||||
const delay = Math.max(0, at - this.now());
|
||||
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
|
||||
}
|
||||
|
||||
clearTimer(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
/** Fires due timeouts. Public for tests. */
|
||||
onTimer(): void {
|
||||
this.timer = null;
|
||||
const g = this.game;
|
||||
if (!g || g.finished || this.status !== 'playing') return;
|
||||
const def = this.def;
|
||||
const now = this.now();
|
||||
const base = def.deadline?.(g.state) ?? null;
|
||||
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
|
||||
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
|
||||
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + graceMs;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
|
||||
if (notBefore !== null && d !== null) d = Math.max(d, notBefore);
|
||||
if (d === null || d > now) continue;
|
||||
const action = def.onTimeout(g.state, u);
|
||||
const err = this.runAction(u, action, undefined, true);
|
||||
if (err) this.deps.log?.('warn', 'timeout action rejected', { room: this.code, user: u, err });
|
||||
break; // state changed; re-evaluate via scheduleTimer
|
||||
}
|
||||
if (!this.game?.finished) this.scheduleTimer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- misc
|
||||
|
||||
private nick(u: string): string {
|
||||
return this.deps.users.publicUser(u)?.nickname ?? '알 수 없음';
|
||||
}
|
||||
|
||||
sendSync(conn: Conn): void {
|
||||
conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
|
||||
const st = this.stateMessage(conn.userId, undefined, true);
|
||||
if (st) conn.send(st);
|
||||
}
|
||||
|
||||
/** Admin closes the room: void any running game, tell everyone, disconnect. */
|
||||
closeByAdmin(): void {
|
||||
if (this.game && !this.game.finished) {
|
||||
this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true);
|
||||
}
|
||||
this.notice('closed', '관리자가 방을 닫았어요.');
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'closed' });
|
||||
c.close(4002, 'closed');
|
||||
}
|
||||
this.conns.clear();
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.flushStream();
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'restart' });
|
||||
c.close(1012, 'restart');
|
||||
}
|
||||
}
|
||||
}
|
||||
140
apps/server/src/rooms/store.ts
Normal file
140
apps/server/src/rooms/store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** Room persistence (docs/04 §2, docs/08 §3). All writes are synchronous SQLite transactions. */
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
export interface RoomRow {
|
||||
id: string;
|
||||
code: string;
|
||||
host_id: string;
|
||||
game_id: string;
|
||||
status: string;
|
||||
visibility: string;
|
||||
config_json: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
seq: number;
|
||||
gameNo: number;
|
||||
actorId: string | null;
|
||||
kind: string;
|
||||
data: unknown;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export class RoomStore {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
codeInUse(code: string, now = Date.now()): boolean {
|
||||
// Open rooms, plus rooms closed within the last 24h (docs/06 §3).
|
||||
const row = this.db
|
||||
.query<{ n: number }, [string, number]>(
|
||||
"SELECT COUNT(*) AS n FROM rooms WHERE code = ? AND (status <> 'closed' OR closed_at > ?)",
|
||||
)
|
||||
.get(code, now - 24 * 3600_000);
|
||||
return (row?.n ?? 0) > 0;
|
||||
}
|
||||
|
||||
insertRoom(r: { id: string; code: string; hostId: string; gameId: string; status: string; visibility: string; config: unknown; stateJson: string; now: number }): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query('INSERT INTO rooms (id, code, host_id, game_id, status, visibility, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(r.id, r.code, r.hostId, r.gameId, r.status, r.visibility, JSON.stringify(r.config), r.now, r.now);
|
||||
this.db.query('INSERT INTO room_state (room_id, seq, state_json, updated_at) VALUES (?, 0, ?, ?)').run(r.id, r.stateJson, r.now);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */
|
||||
save(
|
||||
roomId: string,
|
||||
seq: number,
|
||||
stateJson: string,
|
||||
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
|
||||
log: LogEntry | LogEntry[] | null,
|
||||
now = Date.now(),
|
||||
): void {
|
||||
this.db.transaction(() => {
|
||||
for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) {
|
||||
this.db
|
||||
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(roomId, e.seq, e.gameNo, e.actorId, e.kind, JSON.stringify(e.data ?? null), e.at);
|
||||
}
|
||||
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
|
||||
this.db
|
||||
.query('UPDATE rooms SET host_id = ?, game_id = ?, status = ?, visibility = ?, config_json = ?, updated_at = ? WHERE id = ?')
|
||||
.run(meta.hostId, meta.gameId, meta.status, meta.visibility, JSON.stringify(meta.config), now, roomId);
|
||||
})();
|
||||
}
|
||||
|
||||
close(roomId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db.query("UPDATE rooms SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?").run(now, now, roomId);
|
||||
this.db.query('DELETE FROM room_state WHERE room_id = ?').run(roomId);
|
||||
})();
|
||||
}
|
||||
|
||||
markBroken(roomId: string, now = Date.now()): void {
|
||||
this.db.query("UPDATE rooms SET status = 'broken', updated_at = ? WHERE id = ?").run(now, roomId);
|
||||
}
|
||||
|
||||
openRooms(): { row: RoomRow; seq: number; stateJson: string }[] {
|
||||
return this.db
|
||||
.query<RoomRow & { seq: number; state_json: string }, []>(
|
||||
"SELECT r.*, s.seq, s.state_json FROM rooms r JOIN room_state s ON s.room_id = r.id WHERE r.status IN ('lobby','playing','finished','paused')",
|
||||
)
|
||||
.all()
|
||||
.map((r) => ({ row: r, seq: r.seq, stateJson: r.state_json }));
|
||||
}
|
||||
|
||||
insertGame(g: { id: string; roomId: string; gameNo: number; gameId: string; options: unknown; seedHash: string; startedAt: number }): void {
|
||||
this.db
|
||||
.query('INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(g.id, g.roomId, g.gameNo, g.gameId, JSON.stringify(g.options), g.seedHash, g.startedAt);
|
||||
}
|
||||
|
||||
finishGame(g: {
|
||||
id: string;
|
||||
gameId: string;
|
||||
seed: string;
|
||||
endedAt: number;
|
||||
endReason: string;
|
||||
result: unknown;
|
||||
players: { userId: string; seat: number; rank: number | null; score: number | null }[];
|
||||
}): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
|
||||
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id);
|
||||
const counted = g.endReason !== 'void';
|
||||
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
|
||||
const firstCount = ranks.filter((r) => r === 1).length;
|
||||
for (const p of g.players) {
|
||||
this.db
|
||||
.query('INSERT OR REPLACE INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(g.id, p.userId, p.seat, p.rank, p.score);
|
||||
if (!counted) continue;
|
||||
const draw = p.rank === 1 && firstCount === g.players.length && g.players.length > 1;
|
||||
const win = p.rank === 1 && !draw;
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + 1, wins = wins + excluded.wins, draws = draws + excluded.draws`,
|
||||
)
|
||||
.run(p.userId, g.gameId, win ? 1 : 0, draw ? 1 : 0);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
log(roomId: string): LogEntry[] {
|
||||
return this.db
|
||||
.query<{ seq: number; game_no: number; actor_id: string | null; kind: string; data_json: string; at: number }, [string]>(
|
||||
'SELECT seq, game_no, actor_id, kind, data_json, at FROM room_log WHERE room_id = ? ORDER BY seq',
|
||||
)
|
||||
.all(roomId)
|
||||
.map((r) => ({ seq: r.seq, gameNo: r.game_no, actorId: r.actor_id, kind: r.kind, data: JSON.parse(r.data_json), at: r.at }));
|
||||
}
|
||||
|
||||
purgeOldLogs(now = Date.now()): void {
|
||||
this.db.query('DELETE FROM room_log WHERE at < ?').run(now - 90 * 24 * 3600_000);
|
||||
}
|
||||
}
|
||||
302
apps/server/src/server.test.ts
Normal file
302
apps/server/src/server.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/** Integration scenarios from docs/10-testing.md §3. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) {
|
||||
try {
|
||||
c.close();
|
||||
} catch {}
|
||||
}
|
||||
for (const s of running.splice(0)) {
|
||||
try {
|
||||
await s.stop();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
function start(dbPath?: string): Booted {
|
||||
const s = boot(dbPath);
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
|
||||
const g = cookie ?? (await guest(s.base, nick));
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function createRoom(s: Booted, c: Client, gameId = 'omok'): Promise<string> {
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId }) });
|
||||
expect(r.res.status).toBe(200);
|
||||
return r.body.code;
|
||||
}
|
||||
|
||||
async function join(c: Client, code: string, as: 'player' | 'spectator' = 'player') {
|
||||
c.send({ t: 'join', code, as });
|
||||
return c.next('room');
|
||||
}
|
||||
|
||||
/** A hosts (black), B joins; game started. */
|
||||
async function twoPlayerGame(s: Booted) {
|
||||
const a = await client(s, '호랑이');
|
||||
const b = await client(s, '고양이');
|
||||
const code = await createRoom(s, a);
|
||||
await join(a, code);
|
||||
await join(b, code);
|
||||
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
|
||||
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
|
||||
const sa = a.next('state');
|
||||
const sb = b.next('state');
|
||||
a.send({ t: 'start' });
|
||||
await Promise.all([sa, sb]);
|
||||
return { a, b, code };
|
||||
}
|
||||
|
||||
const place = (c: Client, x: number, y: number) => c.act({ type: 'place', x, y });
|
||||
|
||||
describe('M1 integration scenarios', () => {
|
||||
test('1. create room, join by code, auto-seat, play omok to the end, result stored', async () => {
|
||||
const s = start();
|
||||
const { a, b, code } = await twoPlayerGame(s);
|
||||
expect(code).toMatch(/^\d{6}$/);
|
||||
const room = a.lastOf('room')!.room;
|
||||
expect(room.seats.filter(Boolean).map((x) => x!.user.nickname)).toEqual(['호랑이', '고양이']);
|
||||
// Black (A) plays a horizontal five, White (B) answers elsewhere.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let p = b.next('state', (m) => (m.view as any).turn === 'white');
|
||||
place(a, 3 + i, 7);
|
||||
await p;
|
||||
p = a.next('state', (m) => (m.view as any).turn === 'black');
|
||||
place(b, 3 + i, 0);
|
||||
await p;
|
||||
}
|
||||
const res = a.next('result');
|
||||
place(a, 7, 7);
|
||||
const r = await res;
|
||||
expect(r.result.ranking).toEqual([[a.id], [b.id]]);
|
||||
const stats = await api(s.base, '/api/me/stats', { cookie: a.cookie });
|
||||
expect(stats.body.stats).toEqual([{ gameId: 'omok', played: 1, wins: 1, draws: 0 }]);
|
||||
const room2 = (await a.next('room', (m) => m.room.status === 'finished').catch(() => a.lastOf('room')!)).room;
|
||||
expect(room2.lastSeed).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(room2.sessionStats[a.id]).toEqual({ played: 1, wins: 1 });
|
||||
});
|
||||
|
||||
test('2. joining via link preview without login, then guest + auto seat', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '방장님');
|
||||
const code = await createRoom(s, a);
|
||||
await join(a, code);
|
||||
const preview = await api(s.base, `/api/rooms/${code}`);
|
||||
expect(preview.body).toMatchObject({ code, gameId: 'omok', host: '방장님', seated: 1, maxPlayers: 2 });
|
||||
const b = await client(s, '손님');
|
||||
const r = await join(b, code);
|
||||
expect(r.room.seats[1]?.user.id).toBe(b.id);
|
||||
// A third person becomes a spectator.
|
||||
const c = await client(s, '구경꾼');
|
||||
const r3 = await join(c, code);
|
||||
expect(r3.room.spectators.map((u) => u.id)).toContain(c.id);
|
||||
});
|
||||
|
||||
test('3. disconnect and reconnect keeps seat and state', async () => {
|
||||
const s = start();
|
||||
const { a, b, code } = await twoPlayerGame(s);
|
||||
const p = b.next('state', (m) => (m.view as any).moveCount === 1);
|
||||
place(a, 7, 7);
|
||||
await p;
|
||||
const notice = a.next('notice', (m) => m.code === 'disconnected');
|
||||
b.close();
|
||||
await notice;
|
||||
const roomMsg = await a.next('room', (m) => m.room.seats[1]?.connected === false).catch(() => a.lastOf('room')!);
|
||||
expect(roomMsg.room.seats[1]?.connected).toBe(false);
|
||||
const b2 = await client(s, '', { cookie: b.cookie, id: b.id });
|
||||
expect(b2.lastOf('welcome')!.activeRoom).toBe(code);
|
||||
b2.send({ t: 'join', code, as: 'player' });
|
||||
const st = await b2.next('state');
|
||||
expect((st.view as any).board[7 * 15 + 7]).toBe(1);
|
||||
expect((st.view as any).turn).toBe('white');
|
||||
const after = b2.lastOf('room') ?? (await b2.next('room'));
|
||||
expect(after.room.seats[1]?.user.id).toBe(b.id);
|
||||
});
|
||||
|
||||
test('4. server crash (no graceful stop) → restart restores the game', async () => {
|
||||
const s1 = boot();
|
||||
const { a, b, code } = await twoPlayerGame(s1);
|
||||
let p = b.next('state', (m) => (m.view as any).moveCount === 1);
|
||||
place(a, 7, 7);
|
||||
await p;
|
||||
p = a.next('state', (m) => (m.view as any).moveCount === 2);
|
||||
place(b, 0, 0);
|
||||
await p;
|
||||
// Simulate a crash: drop the listener and timers without the graceful bye/cleanup.
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
await Promise.race([s1.server.stop(true), sleep(300)]);
|
||||
// Closing the sockets re-arms grace timers; a crashed process has none, and they must not touch the closed DB later.
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
s1.db.close();
|
||||
const s2 = start(s1.dbPath);
|
||||
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
|
||||
const b2 = await client(s2, '', { cookie: b.cookie, id: b.id });
|
||||
expect(a2.lastOf('welcome')!.activeRoom).toBe(code);
|
||||
a2.send({ t: 'join', code, as: 'player' });
|
||||
b2.send({ t: 'join', code, as: 'player' });
|
||||
const st = await a2.next('state');
|
||||
const v = st.view as any;
|
||||
expect(v.moveCount).toBe(2);
|
||||
expect(v.turn).toBe('black');
|
||||
const p2 = b2.next('state', (m) => (m.view as any).moveCount === 3);
|
||||
place(a2, 8, 8);
|
||||
await p2;
|
||||
});
|
||||
|
||||
test('5. acting out of turn is rejected without state change', async () => {
|
||||
const s = start();
|
||||
const { a, b } = await twoPlayerGame(s);
|
||||
const before = b.lastOf('state')!.seq;
|
||||
const rej = b.next('reject');
|
||||
const cs = place(b, 1, 1);
|
||||
const r = await rej;
|
||||
expect(r.cs).toBe(cs);
|
||||
expect(r.reason).toBe('지금은 내 차례가 아니에요.');
|
||||
await sleep(50);
|
||||
expect(a.lastOf('state')!.seq).toBe(before);
|
||||
});
|
||||
|
||||
test('6. duplicate cs is processed once', async () => {
|
||||
const s = start();
|
||||
const { a, b } = await twoPlayerGame(s);
|
||||
const cs = place(a, 7, 7);
|
||||
a.send({ t: 'act', cs, a: { type: 'place', x: 8, y: 8 } });
|
||||
await b.next('state', (m) => (m.view as any).moveCount >= 1);
|
||||
await sleep(100);
|
||||
const states = b.messages.filter((m) => m.t === 'state');
|
||||
expect((states[states.length - 1]!.view as any).moveCount).toBe(1);
|
||||
const mine = a.messages.filter((m) => m.t === 'state' && m.cs === cs);
|
||||
expect(mine).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('7. second tab replaces the first', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '탭주인');
|
||||
const code = await createRoom(s, a);
|
||||
await join(a, code);
|
||||
const a2 = await client(s, '', { cookie: a.cookie, id: a.id });
|
||||
const bye = a.next('bye');
|
||||
await join(a2, code);
|
||||
expect((await bye).reason).toBe('replaced');
|
||||
});
|
||||
|
||||
test('9. host leaves → host transfers', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '첫방장');
|
||||
const b = await client(s, '다음방장');
|
||||
const code = await createRoom(s, a);
|
||||
await join(a, code);
|
||||
await join(b, code);
|
||||
const r = b.next('room', (m) => m.room.hostId === b.id);
|
||||
a.send({ t: 'leave' });
|
||||
expect((await r).room.seats.filter(Boolean)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('10. flooding is rate limited and the server stays healthy', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '도배왕');
|
||||
for (let i = 0; i < 100; i++) a.send({ t: 'ping', ts: i });
|
||||
await sleep(200);
|
||||
const pongs = a.messages.filter((m) => m.t === 'pong').length;
|
||||
expect(pongs).toBeLessThanOrEqual(45);
|
||||
expect(pongs).toBeGreaterThan(0);
|
||||
const h = await api(s.base, '/healthz');
|
||||
expect(h.res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('11. malformed / oversized / schema-invalid messages', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '이상한값');
|
||||
a.ws.send('{not json');
|
||||
await a.next('error', (m) => m.code === 'bad-json');
|
||||
a.send({ t: 'act', cs: 'x' });
|
||||
await a.next('error', (m) => m.code === 'bad-message');
|
||||
a.ws.send('x'.repeat(20 * 1024));
|
||||
for (let i = 0; i < 20 && !a.closed; i++) await sleep(25);
|
||||
expect(a.closed?.code).toBe(1009);
|
||||
const h = await api(s.base, '/healthz');
|
||||
expect(h.res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('12a. discord login is hidden when not configured', async () => {
|
||||
const s = boot(undefined, {
|
||||
discordExchange: async () => ({ id: '123', username: 'disco', global_name: '디코친구', avatar: null }),
|
||||
});
|
||||
running.push(s);
|
||||
// Discord disabled in this config → 404.
|
||||
const off = await api(s.base, '/api/auth/discord/start');
|
||||
expect(off.res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('origin check: foreign origin cannot open WS or POST', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '보안확인');
|
||||
const res = await fetch(s.base + '/ws', { headers: { origin: 'https://evil.example', cookie: g.cookie, upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' } });
|
||||
expect(res.status).toBe(403);
|
||||
const post = await fetch(s.base + '/api/rooms', { method: 'POST', headers: { origin: 'https://evil.example', cookie: g.cookie, 'content-type': 'application/json' }, body: '{"gameId":"omok"}' });
|
||||
expect(post.status).toBe(403);
|
||||
});
|
||||
|
||||
test('nickname rules and change', async () => {
|
||||
const s = start();
|
||||
const bad = await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: 'a' }) });
|
||||
expect(bad.res.status).toBe(400);
|
||||
const g = await guest(s.base, ' 처음 이름 ');
|
||||
const me = await api(s.base, '/api/me', { cookie: g.cookie });
|
||||
expect(me.body.me.nickname).toBe('처음 이름');
|
||||
const ch = await api(s.base, '/api/me', { method: 'PATCH', cookie: g.cookie, body: JSON.stringify({ nickname: '바뀐이름' }) });
|
||||
expect(ch.body.me.nickname).toBe('바뀐이름');
|
||||
});
|
||||
|
||||
test('chat is filtered and spectators receive public view only', async () => {
|
||||
const s = start();
|
||||
const { a, code } = await twoPlayerGame(s);
|
||||
const c = await client(s, '관전자');
|
||||
c.send({ t: 'join', code, as: 'spectator' });
|
||||
const st = await c.next('state');
|
||||
expect((st.view as any).myColor).toBeNull();
|
||||
const chat = c.next('chat');
|
||||
a.send({ t: 'chat', text: '안녕 씨발' });
|
||||
expect((await chat).text).toBe('안녕 **');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discord login flow', () => {
|
||||
test('state mismatch rejected, valid flow merges guest into new member', async () => {
|
||||
const s = boot(undefined, {
|
||||
discordExchange: async () => ({ id: '42', username: 'disco', global_name: '디코친구', avatar: 'abc' }),
|
||||
});
|
||||
running.push(s);
|
||||
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
|
||||
const g = await guest(s.base, '게스트였음');
|
||||
const startRes = await api(s.base, '/api/auth/discord/start?next=/r/123456', { cookie: g.cookie });
|
||||
expect(startRes.res.status).toBe(302);
|
||||
const loc = new URL(startRes.res.headers.get('location')!);
|
||||
expect(loc.host).toBe('discord.com');
|
||||
const state = loc.searchParams.get('state')!;
|
||||
const oauthCookie = startRes.setCookie!.split(';')[0]!;
|
||||
const bad = await api(s.base, `/api/auth/discord/callback?code=x&state=wrong`, { cookie: `${g.cookie}; ${oauthCookie}` });
|
||||
expect(bad.res.headers.get('location')).toBe('/?login=failed');
|
||||
const good = await api(s.base, `/api/auth/discord/callback?code=x&state=${state}`, { cookie: `${g.cookie}; ${oauthCookie}` });
|
||||
expect(good.res.headers.get('location')).toBe('/r/123456?linked=1');
|
||||
const sid = good.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
|
||||
const me = await api(s.base, '/api/me', { cookie: sid });
|
||||
expect(me.body.me).toMatchObject({ id: g.id, nickname: '게스트였음', kind: 'member' });
|
||||
expect(me.body.me.avatar).toBe('https://cdn.discordapp.com/avatars/42/abc.png?size=128');
|
||||
// Old guest session no longer valid.
|
||||
const old = await api(s.base, '/api/me', { cookie: g.cookie });
|
||||
expect(old.body.me).toBeNull();
|
||||
});
|
||||
});
|
||||
156
apps/server/src/server.ts
Normal file
156
apps/server/src/server.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/** Wires DB, auth, rooms, HTTP and WebSocket into one Bun server (docs/02 §2). */
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import type { Server } from 'bun';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { Config } from './config';
|
||||
import { openDb } from './db';
|
||||
import { SESSION_COOKIE, Sessions } from './auth/sessions';
|
||||
import { Users, toPublicUser } from './auth/users';
|
||||
import { createHttpApp, type HttpDeps } from './http/app';
|
||||
import { RoomManager } from './rooms/manager';
|
||||
import { RoomStore } from './rooms/store';
|
||||
import { Gateway, type WsData } from './ws/gateway';
|
||||
import { Audit, Roles } from './admin/roles';
|
||||
import { SettingsStore } from './admin/settings';
|
||||
|
||||
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
|
||||
export const jsonLog: LogFn = (level, msg, extra) => {
|
||||
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
|
||||
if (level === 'error') console.error(line);
|
||||
else console.log(line);
|
||||
};
|
||||
|
||||
export interface StartOptions {
|
||||
config: Config;
|
||||
log?: LogFn;
|
||||
staticDir?: string | null;
|
||||
discordExchange?: HttpDeps['discordExchange'];
|
||||
}
|
||||
|
||||
export function startServer(opts: StartOptions) {
|
||||
const { config } = opts;
|
||||
const log = opts.log ?? jsonLog;
|
||||
const db = openDb(config.dbPath);
|
||||
const users = new Users(db);
|
||||
const sessions = new Sessions(db);
|
||||
const store = new RoomStore(db);
|
||||
const publicUser = (id: string) => {
|
||||
const u = users.get(id);
|
||||
return u ? toPublicUser(u) : null;
|
||||
};
|
||||
const settings = new SettingsStore(db, GAMES);
|
||||
const roles = new Roles(db, config.superadminDiscordIds);
|
||||
const audit = new Audit(db);
|
||||
const startedAt = Date.now();
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
let ready = true;
|
||||
|
||||
const ipOf = (req: Request) =>
|
||||
(config.trustProxy ? req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() : undefined) ||
|
||||
server?.requestIP(req)?.address ||
|
||||
'unknown';
|
||||
|
||||
const app = createHttpApp({
|
||||
config,
|
||||
users,
|
||||
sessions,
|
||||
rooms,
|
||||
ipOf,
|
||||
discordExchange: opts.discordExchange,
|
||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||
roles,
|
||||
settings,
|
||||
admin: {
|
||||
audit,
|
||||
games: GAMES,
|
||||
disconnectUser: (id) => gateway.disconnectUser(id),
|
||||
stats: () => {
|
||||
const all = rooms.all();
|
||||
const day = Date.now() - 24 * 3600_000;
|
||||
return {
|
||||
connections: gateway.connectionCount,
|
||||
rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length },
|
||||
games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day),
|
||||
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
memoryMb: Math.round(process.memoryUsage().rss / 1048576),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
|
||||
const serveStatic = staticDir && existsSync(staticDir);
|
||||
|
||||
const server: Server<WsData> = Bun.serve<WsData>({
|
||||
port: config.port,
|
||||
async fetch(req, srv) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/ws') {
|
||||
const origin = req.headers.get('origin');
|
||||
if (!origin || !config.allowedOrigins.includes(origin)) return new Response('forbidden origin', { status: 403 });
|
||||
const cookie = req.headers.get('cookie') ?? '';
|
||||
const token = cookie
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith(`${SESSION_COOKIE}=`))
|
||||
?.slice(SESSION_COOKIE.length + 1);
|
||||
const userId = token ? sessions.resolve(decodeURIComponent(token)) : null;
|
||||
if (!userId) return new Response('unauthorized', { status: 401 });
|
||||
return gateway.upgrade(req, srv, userId);
|
||||
}
|
||||
if (url.pathname.startsWith('/api/') || url.pathname === '/healthz') return app.fetch(req);
|
||||
if (url.pathname.startsWith('/internal/')) return new Response('not found', { status: 404 });
|
||||
if (serveStatic) return serveFile(staticDir!, url.pathname);
|
||||
return new Response('not found', { status: 404 });
|
||||
},
|
||||
websocket: {
|
||||
...gateway.handlers,
|
||||
idleTimeout: 40,
|
||||
sendPings: true,
|
||||
perMessageDeflate: true,
|
||||
maxPayloadLength: 64 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
const sweeper = setInterval(() => {
|
||||
try {
|
||||
rooms.sweep();
|
||||
sessions.purgeExpired();
|
||||
store.purgeOldLogs();
|
||||
} catch (err) {
|
||||
log('error', 'sweep failed', { err: String(err) });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const stop = async () => {
|
||||
ready = false;
|
||||
clearInterval(sweeper);
|
||||
gateway.shutdown();
|
||||
rooms.shutdown();
|
||||
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
||||
// listener is already closed, so bound the wait.
|
||||
await Promise.race([server.stop(true), Bun.sleep(1000)]);
|
||||
db.close();
|
||||
};
|
||||
|
||||
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
|
||||
}
|
||||
|
||||
async function serveFile(root: string, pathname: string): Promise<Response> {
|
||||
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
||||
const candidate = join(root, safe);
|
||||
if (candidate.startsWith(root) && !candidate.endsWith('/')) {
|
||||
const f = Bun.file(candidate);
|
||||
if (await f.exists()) {
|
||||
const immutable = safe.startsWith('/assets/');
|
||||
return new Response(f, { headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' } });
|
||||
}
|
||||
}
|
||||
// SPA fallback.
|
||||
return new Response(Bun.file(join(root, 'index.html')), { headers: { 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8' } });
|
||||
}
|
||||
128
apps/server/src/test-utils.ts
Normal file
128
apps/server/src/test-utils.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/** Helpers for integration tests: boot a real server, act as browser clients. */
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { loadConfig } from './config';
|
||||
import { startServer, type StartOptions } from './server';
|
||||
|
||||
export const ORIGIN = 'http://localhost:5173';
|
||||
|
||||
export function tempDbPath(): string {
|
||||
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
|
||||
return join(dir, 'app.db');
|
||||
}
|
||||
|
||||
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
|
||||
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
|
||||
const app = startServer({ config, log: () => {}, staticDir: null, ...extra });
|
||||
const base = `http://localhost:${app.server.port}`;
|
||||
return { ...app, base, dbPath, config };
|
||||
}
|
||||
|
||||
export async function api(base: string, path: string, init: RequestInit & { cookie?: string } = {}) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('origin', ORIGIN);
|
||||
if (init.cookie) headers.set('cookie', init.cookie);
|
||||
if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
||||
const res = await fetch(base + path, { ...init, headers, redirect: 'manual' });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
body = await res.text();
|
||||
}
|
||||
return { res, body, setCookie };
|
||||
}
|
||||
|
||||
export async function guest(base: string, nickname: string): Promise<{ cookie: string; id: string }> {
|
||||
const r = await api(base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname }) });
|
||||
if (r.res.status !== 200) throw new Error(`guest failed: ${JSON.stringify(r.body)}`);
|
||||
const cookie = r.setCookie!.split(';')[0]!;
|
||||
return { cookie, id: r.body.me.id };
|
||||
}
|
||||
|
||||
export class Client {
|
||||
ws!: WebSocket;
|
||||
messages: ServerMessage[] = [];
|
||||
private waiters: { pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void }[] = [];
|
||||
closed: { code: number; reason: string } | null = null;
|
||||
cs = 0;
|
||||
|
||||
constructor(
|
||||
private base: string,
|
||||
public cookie: string,
|
||||
public id: string,
|
||||
) {}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
this.closed = null;
|
||||
const url = this.base.replace('http', 'ws') + '/ws';
|
||||
// Bun's WebSocket client accepts custom headers.
|
||||
this.ws = new WebSocket(url, { headers: { cookie: this.cookie, origin: ORIGIN } } as unknown as string[]);
|
||||
this.ws.onmessage = (e) => {
|
||||
const m = JSON.parse(String(e.data)) as ServerMessage;
|
||||
this.messages.push(m);
|
||||
for (const w of [...this.waiters]) {
|
||||
if (w.pred(m)) {
|
||||
this.waiters.splice(this.waiters.indexOf(w), 1);
|
||||
w.resolve(m);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.ws.onclose = (e) => {
|
||||
this.closed = { code: e.code, reason: e.reason };
|
||||
};
|
||||
await this.waitFor((m) => m.t === 'welcome');
|
||||
return this;
|
||||
}
|
||||
|
||||
send(m: unknown): void {
|
||||
this.ws.send(JSON.stringify(m));
|
||||
}
|
||||
|
||||
/** Resolves with the first message (already received after `since`, or future) matching pred. */
|
||||
waitFor<T extends ServerMessage['t']>(
|
||||
pred: (m: ServerMessage) => boolean,
|
||||
timeout = 3000,
|
||||
since = 0,
|
||||
): Promise<Extract<ServerMessage, { t: T }>> {
|
||||
const existing = this.messages.slice(since).find(pred);
|
||||
if (existing) return Promise.resolve(existing as any);
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = { pred, resolve: resolve as (m: ServerMessage) => void };
|
||||
this.waiters.push(w);
|
||||
setTimeout(() => {
|
||||
const i = this.waiters.indexOf(w);
|
||||
if (i >= 0) {
|
||||
this.waiters.splice(i, 1);
|
||||
reject(new Error(`timeout waiting; last: ${JSON.stringify(this.messages.slice(-3))}`));
|
||||
}
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** Waits for the next message of type t received after now. */
|
||||
next<T extends ServerMessage['t']>(t: T, pred: (m: Extract<ServerMessage, { t: T }>) => boolean = () => true, timeout = 3000) {
|
||||
const since = this.messages.length;
|
||||
return this.waitFor<T>((m) => m.t === t && pred(m as any), timeout, since);
|
||||
}
|
||||
|
||||
lastOf<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
|
||||
for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]!.t === t) return this.messages[i] as any;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
act(a: unknown): number {
|
||||
const cs = ++this.cs;
|
||||
this.send({ t: 'act', cs, a });
|
||||
return cs;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
216
apps/server/src/ws/gateway.ts
Normal file
216
apps/server/src/ws/gateway.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/** WebSocket gateway: auth on upgrade, validation, rate limits, dispatch (docs/03). */
|
||||
import type { Server, ServerWebSocket } from 'bun';
|
||||
import { ClientMessage, MAX_CLIENT_MESSAGE_BYTES, PROTOCOL, type PublicUser, type ServerMessage } from '@bg/shared';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
import type { Conn, Room } from '../rooms/room';
|
||||
import { TokenBucket } from './rate-limit';
|
||||
|
||||
export interface WsData {
|
||||
userId: string;
|
||||
connId: number;
|
||||
bucket: TokenBucket;
|
||||
strikes: number;
|
||||
room: string | null;
|
||||
conn: Conn | null;
|
||||
}
|
||||
|
||||
export interface GatewayDeps {
|
||||
rooms: RoomManager;
|
||||
me(userId: string): PublicUser | null;
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const MAX_CONNS_PER_USER = 5;
|
||||
const MAX_BUFFERED = 1024 * 1024;
|
||||
|
||||
export class Gateway {
|
||||
private nextId = 1;
|
||||
private byUser = new Map<string, Set<ServerWebSocket<WsData>>>();
|
||||
private closing = false;
|
||||
|
||||
constructor(private deps: GatewayDeps) {}
|
||||
|
||||
get connectionCount(): number {
|
||||
let n = 0;
|
||||
for (const s of this.byUser.values()) n += s.size;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Called from fetch() after the session was resolved. Returns a Response on refusal. */
|
||||
upgrade(req: Request, server: Server<WsData>, userId: string): Response | undefined {
|
||||
if (this.closing) return new Response('restarting', { status: 503 });
|
||||
if ((this.byUser.get(userId)?.size ?? 0) >= MAX_CONNS_PER_USER) return new Response('too many connections', { status: 429 });
|
||||
const data: WsData = { userId, connId: this.nextId++, bucket: new TokenBucket(20, 40), strikes: 0, room: null, conn: null };
|
||||
if (server.upgrade(req, { data })) return undefined;
|
||||
return new Response('upgrade failed', { status: 400 });
|
||||
}
|
||||
|
||||
private send(ws: ServerWebSocket<WsData>, msg: ServerMessage): void {
|
||||
if (ws.getBufferedAmount() > MAX_BUFFERED) {
|
||||
ws.close(1013, 'slow consumer');
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
private connFor(ws: ServerWebSocket<WsData>): Conn {
|
||||
ws.data.conn ??= {
|
||||
userId: ws.data.userId,
|
||||
send: (m) => this.send(ws, m),
|
||||
close: (code, reason) => ws.close(code, reason),
|
||||
};
|
||||
return ws.data.conn;
|
||||
}
|
||||
|
||||
readonly handlers = {
|
||||
open: (ws: ServerWebSocket<WsData>) => {
|
||||
const set = this.byUser.get(ws.data.userId) ?? new Set();
|
||||
set.add(ws);
|
||||
this.byUser.set(ws.data.userId, set);
|
||||
const me = this.deps.me(ws.data.userId);
|
||||
if (!me) {
|
||||
ws.close(4401, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
this.send(ws, {
|
||||
t: 'welcome',
|
||||
me,
|
||||
serverTime: (this.deps.now ?? Date.now)(),
|
||||
protocol: PROTOCOL,
|
||||
activeRoom: this.deps.rooms.activeRoomFor(ws.data.userId),
|
||||
});
|
||||
},
|
||||
|
||||
message: (ws: ServerWebSocket<WsData>, raw: string | Buffer) => {
|
||||
const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.byteLength;
|
||||
if (size > MAX_CLIENT_MESSAGE_BYTES) {
|
||||
ws.close(1009, 'message too big');
|
||||
return;
|
||||
}
|
||||
if (!ws.data.bucket.take()) {
|
||||
if (++ws.data.strikes > 50) ws.close(4429, 'rate limited');
|
||||
return;
|
||||
}
|
||||
let parsed: ClientMessage;
|
||||
try {
|
||||
const json = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
||||
const r = ClientMessage.safeParse(json);
|
||||
if (!r.success) {
|
||||
this.send(ws, { t: 'error', code: 'bad-message', message: '잘못된 요청이에요.' });
|
||||
return;
|
||||
}
|
||||
parsed = r.data;
|
||||
} catch {
|
||||
this.send(ws, { t: 'error', code: 'bad-json', message: '잘못된 요청이에요.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.dispatch(ws, parsed);
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'dispatch failed', { user: ws.data.userId, t: parsed.t, err: String(err), stack: (err as Error).stack });
|
||||
this.send(ws, { t: 'error', code: 'internal', message: '알 수 없는 오류가 발생했어요.' });
|
||||
}
|
||||
},
|
||||
|
||||
close: (ws: ServerWebSocket<WsData>) => {
|
||||
this.byUser.get(ws.data.userId)?.delete(ws);
|
||||
if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId);
|
||||
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (room && ws.data.conn) room.disconnect(ws.data.conn);
|
||||
},
|
||||
};
|
||||
|
||||
private dispatch(ws: ServerWebSocket<WsData>, m: ClientMessage): void {
|
||||
const u = ws.data.userId;
|
||||
const err = (message: string, code = 'rejected') => this.send(ws, { t: 'error', code, message });
|
||||
if (m.t === 'ping') {
|
||||
this.send(ws, { t: 'pong', ts: m.ts, serverTime: (this.deps.now ?? Date.now)() });
|
||||
return;
|
||||
}
|
||||
if (m.t === 'join') {
|
||||
const room = this.deps.rooms.get(m.code);
|
||||
if (!room) return err('방을 찾을 수 없어요. 코드를 다시 확인해 주세요.', 'room-not-found');
|
||||
// Leaving a previous room on this socket.
|
||||
if (ws.data.room && ws.data.room !== m.code) {
|
||||
const prev = this.deps.rooms.get(ws.data.room);
|
||||
if (prev && ws.data.conn) prev.disconnect(ws.data.conn);
|
||||
}
|
||||
if (m.as === 'player') this.deps.rooms.releaseOtherLobbySeats(u, m.code);
|
||||
const e = room.join(this.connFor(ws), m.as);
|
||||
if (e) return err(e, 'join-failed');
|
||||
ws.data.room = m.code;
|
||||
return;
|
||||
}
|
||||
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
|
||||
let e: string | null = null;
|
||||
switch (m.t) {
|
||||
case 'leave':
|
||||
room.leave(u);
|
||||
ws.data.room = null;
|
||||
return;
|
||||
case 'seat':
|
||||
e = room.seat(u, m.seat);
|
||||
break;
|
||||
case 'unseat':
|
||||
e = room.unseat(u);
|
||||
break;
|
||||
case 'ready':
|
||||
e = room.ready(u, m.ready);
|
||||
break;
|
||||
case 'config': {
|
||||
const { t: _t, ...patch } = m;
|
||||
e = room.configure(u, patch);
|
||||
break;
|
||||
}
|
||||
case 'start':
|
||||
e = room.start(u);
|
||||
break;
|
||||
case 'kick':
|
||||
e = room.kick(u, m.userId);
|
||||
break;
|
||||
case 'host':
|
||||
e = room.transferHost(u, m.userId);
|
||||
break;
|
||||
case 'act':
|
||||
room.act(u, m.cs, m.a);
|
||||
return;
|
||||
case 'chat':
|
||||
e = room.chat(u, m.text);
|
||||
break;
|
||||
case 'emote':
|
||||
room.emote(u, m.id);
|
||||
return;
|
||||
case 'rematch':
|
||||
e = room.rematch(u);
|
||||
break;
|
||||
case 'void':
|
||||
e = room.voidGame(u);
|
||||
break;
|
||||
case 'sync':
|
||||
room.sendSync(this.connFor(ws));
|
||||
return;
|
||||
}
|
||||
if (e) err(e);
|
||||
}
|
||||
|
||||
/** Drop every connection of a user (ban / forced logout). */
|
||||
disconnectUser(userId: string): void {
|
||||
for (const ws of [...(this.byUser.get(userId) ?? [])]) {
|
||||
this.send(ws, { t: 'bye', reason: 'kicked' });
|
||||
ws.close(4403, 'banned');
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
|
||||
shutdown(): void {
|
||||
this.closing = true;
|
||||
for (const set of this.byUser.values()) {
|
||||
for (const ws of set) {
|
||||
this.send(ws, { t: 'bye', reason: 'restart' });
|
||||
ws.close(1012, 'restart');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/server/src/ws/rate-limit.ts
Normal file
45
apps/server/src/ws/rate-limit.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** Token bucket (docs/03 §8). */
|
||||
export class TokenBucket {
|
||||
private tokens: number;
|
||||
private last: number;
|
||||
constructor(
|
||||
private ratePerSec: number,
|
||||
private burst: number,
|
||||
now = Date.now(),
|
||||
) {
|
||||
this.tokens = burst;
|
||||
this.last = now;
|
||||
}
|
||||
|
||||
take(now = Date.now()): boolean {
|
||||
this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec);
|
||||
this.last = now;
|
||||
if (this.tokens < 1) return false;
|
||||
this.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed-window counter per key (HTTP endpoints). */
|
||||
export class WindowLimiter {
|
||||
private hits = new Map<string, { start: number; n: number }>();
|
||||
constructor(
|
||||
private limit: number,
|
||||
private windowMs: number,
|
||||
) {}
|
||||
|
||||
allow(key: string, now = Date.now()): boolean {
|
||||
const h = this.hits.get(key);
|
||||
if (!h || now - h.start >= this.windowMs) {
|
||||
this.hits.set(key, { start: now, n: 1 });
|
||||
if (this.hits.size > 50_000) this.prune(now);
|
||||
return true;
|
||||
}
|
||||
h.n++;
|
||||
return h.n <= this.limit;
|
||||
}
|
||||
|
||||
private prune(now: number): void {
|
||||
for (const [k, v] of this.hits) if (now - v.start >= this.windowMs) this.hits.delete(k);
|
||||
}
|
||||
}
|
||||
13
apps/server/tsconfig.json
Normal file
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
19
apps/web/index.html
Normal file
19
apps/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#F7F5F0" />
|
||||
<meta name="description" content="광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임" />
|
||||
<meta property="og:title" content="같이 놀자 보드게임" />
|
||||
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>같이 놀자 보드게임</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>이 사이트를 쓰려면 자바스크립트를 켜 주세요.</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
apps/web/package.json
Normal file
27
apps/web/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@bg/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bg/shared": "workspace:*",
|
||||
"pretendard": "1.3.9",
|
||||
"qrcode-generator": "2.0.4",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-router": "8.4.0",
|
||||
"zustand": "5.0.15",
|
||||
"@bg/games": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"vite": "8.3.2"
|
||||
}
|
||||
}
|
||||
1
apps/web/public/favicon.svg
Normal file
1
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
106
apps/web/src/components/ChatPanel.tsx
Normal file
106
apps/web/src/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/** Chat list + quick reactions + input. Inline on PC, inside a sheet on phones. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CHAT_MAX, EMOTE_LABEL, EMOTES, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { create } from 'zustand';
|
||||
import { socket, useRoom } from '../net/socket';
|
||||
|
||||
export const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 }));
|
||||
|
||||
export function ChatPanel({ room, me, title = true }: { room: RoomView; me: PublicUser; title?: boolean }) {
|
||||
const chat = useRoom((s) => s.chat);
|
||||
const [text, setText] = useState('');
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
useChatSeen.setState({ seen: chat.length });
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||
}, [chat.length]);
|
||||
// Enter anywhere (outside inputs) focuses chat — PC shortcut (docs/07 §3.5).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const t = e.target as HTMLElement;
|
||||
if (e.key === 'Enter' && !['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(t.tagName) && t.getAttribute('role') !== 'grid') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
return (
|
||||
<section className="chat-panel" aria-label="채팅">
|
||||
{title && <h2 style={{ fontSize: '1.1rem' }}>채팅</h2>}
|
||||
<div ref={listRef} className="chat-list" aria-live="polite">
|
||||
{chat.length === 0 && <p className="muted small">아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.</p>}
|
||||
{chat
|
||||
.filter((m) => !m.from || !hidden.has(m.from.id))
|
||||
.map((m) => (
|
||||
<div key={m.id} className="chat-line">
|
||||
<strong>{m.from?.nickname ?? '알림'}</strong>
|
||||
{m.text}
|
||||
{m.from && m.from.id !== me.id && (
|
||||
<button
|
||||
className="muted small"
|
||||
style={{ border: 0, background: 'none', cursor: 'pointer' }}
|
||||
title="이 사람의 채팅 가리기"
|
||||
onClick={() => setHidden(new Set([...hidden, m.from!.id]))}
|
||||
>
|
||||
가리기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="emote-row">
|
||||
{EMOTES.map((id) => (
|
||||
<button key={id} type="button" onClick={() => socket.send({ t: 'emote', id })}>
|
||||
{EMOTE_LABEL[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{room.chatEnabled ? (
|
||||
<form
|
||||
className="row"
|
||||
style={{ flexWrap: 'nowrap' }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) });
|
||||
setText('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
style={{ flex: 1, minHeight: 44 }}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && (e.currentTarget as HTMLInputElement).blur()}
|
||||
maxLength={CHAT_MAX}
|
||||
placeholder="메시지 입력 (Enter)"
|
||||
aria-label="메시지"
|
||||
enterKeyHint="send"
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm">보내기</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="muted small">이 방은 채팅을 쓰지 않아요. 반응 버튼은 쓸 수 있어요.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Floating emote bubbles (both layouts). */
|
||||
export function EmoteBubbles({ room }: { room: RoomView }) {
|
||||
const emotes = useRoom((s) => s.emotes);
|
||||
const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? '';
|
||||
return (
|
||||
<div style={{ position: 'fixed', left: '50%', transform: 'translateX(-50%)', bottom: 24, zIndex: 35, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center', pointerEvents: 'none' }}>
|
||||
{emotes.map((e) => (
|
||||
<div key={e.id} className="toast">
|
||||
{nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/components/CodeInput.tsx
Normal file
83
apps/web/src/components/CodeInput.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/** 6-digit code entry: big boxes, numeric keypad, paste detection, auto-submit (docs/06 §3). */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { normalizeRoomCode } from '@bg/shared/lite';
|
||||
|
||||
export function CodeInput({ onComplete, busy, autoFocus = true }: { onComplete: (code: string) => void; busy?: boolean; autoFocus?: boolean }) {
|
||||
const [digits, setDigits] = useState('');
|
||||
const [clip, setClip] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
// Offer a big paste button if the clipboard already holds a code or invite link.
|
||||
if (!autoFocus) return; // reading the clipboard can prompt; only on the dedicated join page
|
||||
navigator.clipboard
|
||||
?.readText?.()
|
||||
.then((t) => setClip(normalizeRoomCode(t)))
|
||||
.catch(() => {});
|
||||
}, [autoFocus]);
|
||||
|
||||
const set = (v: string) => {
|
||||
const code = normalizeRoomCode(v);
|
||||
const next = code ?? v.replace(/\D/g, '').slice(0, 6);
|
||||
setDigits(next);
|
||||
if (next.length === 6) onComplete(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<label htmlFor="code-input" className="sr-only">
|
||||
방 코드 6자리
|
||||
</label>
|
||||
<div style={{ position: 'relative' }} onClick={() => inputRef.current?.focus()}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 6 }} aria-hidden>
|
||||
{[0, 1, 2, -1, 3, 4, 5].map((i, k) =>
|
||||
i < 0 ? (
|
||||
<span key={k} style={{ alignSelf: 'center', textAlign: 'center', fontSize: '1.4rem', color: 'var(--text-sub)' }}>
|
||||
·
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={k}
|
||||
style={{
|
||||
height: 64,
|
||||
borderRadius: 12,
|
||||
border: `2px solid ${i === digits.length ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: 'var(--surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{digits[i] ?? ''}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="code-input"
|
||||
ref={inputRef}
|
||||
value={digits}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
set(e.clipboardData.getData('text'));
|
||||
}}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={40}
|
||||
disabled={busy}
|
||||
style={{ position: 'absolute', inset: 0, opacity: 0, fontSize: 16 }}
|
||||
/>
|
||||
</div>
|
||||
{clip && digits.length < 6 && (
|
||||
<button className="btn btn-soft btn-block" onClick={() => set(clip)}>
|
||||
📋 복사한 코드 {clip.slice(0, 3)} {clip.slice(3)} 붙여넣기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/components/GameGrid.tsx
Normal file
24
apps/web/src/components/GameGrid.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Link } from 'react-router';
|
||||
import type { CatalogEntry } from '@bg/shared/lite';
|
||||
import { GAME_ICON } from '../lib/catalog-ui';
|
||||
|
||||
export function GameGrid({ games, columns = 'repeat(auto-fill, minmax(200px, 1fr))' }: { games: CatalogEntry[]; columns?: string }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: columns, gap: 16 }}>
|
||||
{games.map((g) => (
|
||||
<Link key={g.id} to={`/g/${g.id}`} className="card game-card" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', flexDirection: 'column', gap: 8, padding: 20 }}>
|
||||
<span style={{ fontSize: '3rem', lineHeight: 1 }} aria-hidden>
|
||||
{GAME_ICON[g.id] ?? '🎲'}
|
||||
</span>
|
||||
<strong style={{ fontSize: '1.25rem' }}>{g.nameKo}</strong>
|
||||
<span className="muted">{g.blurb}</span>
|
||||
<span className="row small" style={{ gap: 6, marginTop: 'auto' }}>
|
||||
<span className="badge">{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`}</span>
|
||||
<span className="badge">{g.minutes}분</span>
|
||||
<span className="badge">{g.level}</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/GameNotice.tsx
Normal file
18
apps/web/src/components/GameNotice.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { catalogById } from '@bg/shared/lite';
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
/** Display name with admin overrides applied. */
|
||||
export function gameName(id: string): string {
|
||||
return useSession.getState().catalog.find((c) => c.id === id)?.nameKo ?? catalogById(id)?.nameKo ?? '게임';
|
||||
}
|
||||
|
||||
/** Admin notice for a game (docs/14-admin.md §4). */
|
||||
export function GameNotice({ gameId }: { gameId: string }) {
|
||||
const notice = useSession((s) => s.catalog.find((c) => c.id === gameId)?.notice);
|
||||
if (!notice) return null;
|
||||
return (
|
||||
<p className="small" style={{ background: 'var(--primary-soft)', borderRadius: 10, padding: '8px 12px', whiteSpace: 'pre-wrap' }}>
|
||||
📢 {notice}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/NicknameForm.tsx
Normal file
60
apps/web/src/components/NicknameForm.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/** One-field start: nickname (pre-filled suggestion) + start button; Discord as secondary (docs/05 §1). */
|
||||
import { useState } from 'react';
|
||||
import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/lite';
|
||||
import { api, discordLoginUrl } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
export function NicknameForm({ next, submitLabel = '이 이름으로 시작하기', onDone }: { next: string; submitLabel?: string; onDone?: () => void }) {
|
||||
const [nick, setNick] = useState(() => suggestNickname());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const discord = useSession((s) => s.discordEnabled);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const n = normalizeNickname(nick);
|
||||
const err = nicknameError(n);
|
||||
if (err) return setError(err);
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.guest(n);
|
||||
useSession.getState().setMe(r.me);
|
||||
await useSession.getState().refresh();
|
||||
onDone?.();
|
||||
} catch (ex) {
|
||||
setError((ex as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<label className="field">
|
||||
내 이름(닉네임)
|
||||
<input
|
||||
className="input"
|
||||
value={nick}
|
||||
onChange={(e) => {
|
||||
setNick(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
maxLength={20}
|
||||
autoComplete="nickname"
|
||||
enterKeyHint="go"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
<button className="btn btn-primary btn-block" disabled={busy}>
|
||||
{busy ? '잠시만요…' : submitLabel}
|
||||
</button>
|
||||
{discord && (
|
||||
<a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}>
|
||||
디스코드로 로그인
|
||||
</a>
|
||||
)}
|
||||
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/components/QrModal.tsx
Normal file
42
apps/web/src/components/QrModal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sheet, CodeLabel } from './ui';
|
||||
|
||||
/** QR is generated lazily so the library is not in the first-page bundle. */
|
||||
export function QrModal({ url, code, onClose }: { url: string; code: string; onClose: () => void }) {
|
||||
const [cells, setCells] = useState<{ n: number; path: string } | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
import('qrcode-generator').then(({ default: qrcode }) => {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount();
|
||||
let path = '';
|
||||
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) path += `M${x} ${y}h1v1h-1z`;
|
||||
if (alive) setCells({ n, path });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url]);
|
||||
return (
|
||||
<Sheet onClose={onClose} label="QR 코드">
|
||||
<h2>휴대폰 카메라로 찍으면 들어와요</h2>
|
||||
<div style={{ background: '#fff', borderRadius: 16, padding: 12, width: 'min(80vw, 320px)', aspectRatio: '1', alignSelf: 'center' }}>
|
||||
{cells ? (
|
||||
<svg viewBox={`-2 -2 ${cells.n + 4} ${cells.n + 4}`} width="100%" height="100%" role="img" aria-label="초대 QR 코드" shapeRendering="crispEdges">
|
||||
<path d={cells.path} fill="#000" />
|
||||
</svg>
|
||||
) : (
|
||||
<p className="muted">만드는 중…</p>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ textAlign: 'center', fontSize: '1.6rem', fontWeight: 700 }}>
|
||||
코드 <CodeLabel code={code} />
|
||||
</p>
|
||||
<button className="btn btn-secondary btn-block" onClick={onClose}>
|
||||
닫기
|
||||
</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/SiteHeader.tsx
Normal file
51
apps/web/src/components/SiteHeader.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */
|
||||
import { useState } from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router';
|
||||
import { normalizeRoomCode } from '@bg/shared/lite';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
import { Avatar } from './ui';
|
||||
|
||||
export function SiteHeader() {
|
||||
const { me, siteName, role } = useSession();
|
||||
const [code, setCode] = useState('');
|
||||
const nav = useNavigate();
|
||||
const join = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const c = normalizeRoomCode(code);
|
||||
if (!c) return toast('방 코드 6자리를 넣어 주세요.', 'error');
|
||||
try {
|
||||
await api.room(c);
|
||||
setCode('');
|
||||
nav(`/r/${c}`);
|
||||
} catch (ex) {
|
||||
toast((ex as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="site-header-inner">
|
||||
<Link to="/" className="site-logo" aria-label={`${siteName} 홈`}>
|
||||
<img src="/favicon.svg" alt="" width={32} height={32} />
|
||||
<span className="full">{siteName}</span>
|
||||
</Link>
|
||||
<nav className="site-nav" aria-label="주 메뉴">
|
||||
<NavLink to="/" end>
|
||||
홈
|
||||
</NavLink>
|
||||
<NavLink to="/games">게임 목록</NavLink>
|
||||
{role !== 'user' && <NavLink to="/admin">관리자</NavLink>}
|
||||
</nav>
|
||||
<form className="header-join" onSubmit={join} aria-label="코드로 참여">
|
||||
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" />
|
||||
<button className="btn btn-primary btn-sm">참여</button>
|
||||
</form>
|
||||
<Link to="/me" className="header-user" aria-label="내 정보">
|
||||
{me ? <Avatar user={me} size={36} /> : <span style={{ fontSize: 24 }}>👤</span>}
|
||||
<span className="wide-only">{me ? me.nickname : '시작하기'}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/components/TurnTimer.tsx
Normal file
44
apps/web/src/components/TurnTimer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/** Ring countdown to a server deadline (docs/07 §4). */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { socket } from '../net/socket';
|
||||
import { haptic, sounds } from '../lib/feedback';
|
||||
|
||||
export function useServerNow(intervalMs = 250): number {
|
||||
const [now, setNow] = useState(() => socket.now());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(socket.now()), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function TurnTimer({ deadline, totalMs, mine, size = 44 }: { deadline: number | null; totalMs: number; mine: boolean; size?: number }) {
|
||||
const now = useServerNow();
|
||||
const warned = useRef<number | null>(null);
|
||||
const left = deadline ? Math.max(0, deadline - now) : null;
|
||||
const sec = left === null ? null : Math.ceil(left / 1000);
|
||||
useEffect(() => {
|
||||
if (!mine || sec === null) return;
|
||||
if ((sec === 10 || sec === 5) && warned.current !== sec) {
|
||||
warned.current = sec;
|
||||
sounds.warn();
|
||||
if (sec === 5) haptic([60, 40, 60]);
|
||||
}
|
||||
}, [sec, mine]);
|
||||
if (left === null) return null;
|
||||
const frac = Math.min(1, left / Math.max(1, totalMs));
|
||||
const color = sec! <= 5 ? 'var(--danger)' : sec! <= 10 ? 'var(--warning)' : 'var(--success)';
|
||||
const r = size / 2 - 4;
|
||||
const c = 2 * Math.PI * r;
|
||||
return (
|
||||
<span style={{ position: 'relative', width: size, height: size, display: 'inline-flex' }} role="timer" aria-label={`남은 시간 ${sec}초`}>
|
||||
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }} aria-hidden>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-2)" strokeWidth={4} />
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={4} strokeDasharray={c} strokeDashoffset={c * (1 - frac)} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: size > 40 ? '0.95rem' : '0.8rem', color }}>
|
||||
{sec! >= 600 ? `${Math.floor(sec! / 60)}분` : sec! >= 100 ? `${Math.floor(sec! / 60)}:${String(sec! % 60).padStart(2, '0')}` : sec}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/components/cards/Chips.tsx
Normal file
35
apps/web/src/components/cards/Chips.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Play-chip display (docs/04 §6): chips are room-only play points.
|
||||
* Never shown as money: no currency symbols, no "원/돈/충전/환전" wording.
|
||||
*/
|
||||
export function formatChips(n: number): string {
|
||||
return `${Math.max(0, Math.round(n)).toLocaleString('ko-KR')} 칩`;
|
||||
}
|
||||
|
||||
export function Chips({ n, strong, className }: { n: number; strong?: boolean; className?: string }) {
|
||||
return (
|
||||
<span className={className} style={{ fontVariantNumeric: 'tabular-nums', fontWeight: strong ? 800 : 600, whiteSpace: 'nowrap' }}>
|
||||
<ChipIcon /> {formatChips(n)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChipIcon({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 20 20" aria-hidden style={{ verticalAlign: '-3px' }}>
|
||||
<circle cx="10" cy="10" r="9" fill="#2563eb" />
|
||||
<circle cx="10" cy="10" r="5.5" fill="none" stroke="#fff" strokeWidth="2" strokeDasharray="3 2.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown once per room in betting games. */
|
||||
export const PLAY_CHIP_NOTICE = '칩은 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.';
|
||||
|
||||
export function PlayChipNotice() {
|
||||
return (
|
||||
<p className="small" style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '8px 12px', margin: 0 }}>
|
||||
<ChipIcon /> {PLAY_CHIP_NOTICE}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/components/cards/HwatuCard.tsx
Normal file
184
apps/web/src/components/cards/HwatuCard.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Hwatu card drawn in SVG (our own simple artwork, no commercial hwatu images — docs/games/seotda.md §12).
|
||||
* Large month number + kind badge so the card is readable without knowing the pictures.
|
||||
*/
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export interface HwatuInfo {
|
||||
id: number;
|
||||
month: number; // 0 for bonus
|
||||
kind: 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus';
|
||||
tti?: 'hong' | 'cheong' | 'cho' | 'plain';
|
||||
name: string;
|
||||
piValue?: number;
|
||||
}
|
||||
|
||||
const MONTH_COLOR = ['#6b7280', '#15803d', '#be185d', '#db2777', '#1f2937', '#7c3aed', '#dc2626', '#b91c1c', '#111827', '#ca8a04', '#c2410c', '#7c2d12', '#1e40af'];
|
||||
const MONTH_NAME = ['보너스', '송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비'];
|
||||
const KIND_BADGE: Record<HwatuInfo['kind'], string> = { gwang: '光', yeol: '열', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '+' };
|
||||
const TTI_COLOR = { hong: '#dc2626', cheong: '#2563eb', cho: '#15803d', plain: '#dc2626' };
|
||||
const TTI_TEXT = { hong: '홍단', cheong: '청단', cho: '', plain: '' };
|
||||
|
||||
/** A small motif per month (flowers/plants), drawn with basic shapes. */
|
||||
function Motif({ month, color }: { month: number; color: string }): ReactNode {
|
||||
const c = color;
|
||||
switch (month) {
|
||||
case 1: // 송학: pine
|
||||
return <path d="M50 52 L28 92 H72 Z M50 72 L22 116 H78 Z" fill={c} opacity="0.85" />;
|
||||
case 2: // 매조: plum blossoms
|
||||
case 3: // 벚꽃
|
||||
return (
|
||||
<g fill={c} opacity="0.85">
|
||||
{[[34, 70], [62, 62], [50, 92], [70, 100], [30, 108]].map(([x, y], i) => (
|
||||
<circle key={i} cx={x} cy={y} r="10" />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
case 4: // 흑싸리
|
||||
case 7: // 홍싸리
|
||||
return (
|
||||
<g stroke={c} strokeWidth="5" strokeLinecap="round" opacity="0.85">
|
||||
{[30, 45, 60, 75].map((x, i) => (
|
||||
<line key={i} x1={x} y1="52" x2={x - 6} y2={100 + (i % 2) * 14} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
case 5: // 난초: iris leaves
|
||||
return (
|
||||
<g fill="none" stroke={c} strokeWidth="5" opacity="0.85">
|
||||
<path d="M40 120 Q34 80 44 52" />
|
||||
<path d="M55 120 Q62 84 52 54" />
|
||||
<path d="M70 120 Q76 92 68 64" />
|
||||
</g>
|
||||
);
|
||||
case 6: // 모란: peony
|
||||
return (
|
||||
<g fill={c} opacity="0.85">
|
||||
<circle cx="50" cy="86" r="22" />
|
||||
<circle cx="50" cy="86" r="10" fill="#fde68a" />
|
||||
</g>
|
||||
);
|
||||
case 8: // 공산: hill
|
||||
return <path d="M8 120 Q50 52 92 120 Z" fill={c} opacity="0.85" />;
|
||||
case 9: // 국화
|
||||
return (
|
||||
<g fill={c} opacity="0.9">
|
||||
{Array.from({ length: 10 }, (_, i) => {
|
||||
const a = (i / 10) * Math.PI * 2;
|
||||
return <ellipse key={i} cx={50 + Math.cos(a) * 14} cy={88 + Math.sin(a) * 14} rx="8" ry="4" transform={`rotate(${(a * 180) / Math.PI} ${50 + Math.cos(a) * 14} ${88 + Math.sin(a) * 14})`} />;
|
||||
})}
|
||||
<circle cx="50" cy="88" r="7" fill="#78350f" />
|
||||
</g>
|
||||
);
|
||||
case 10: // 단풍: maple
|
||||
return <path d="M50 58 L58 78 L78 74 L64 90 L74 108 L50 98 L26 108 L36 90 L22 74 L42 78 Z" fill={c} opacity="0.9" />;
|
||||
case 11: // 오동
|
||||
return (
|
||||
<g fill={c} opacity="0.85">
|
||||
<ellipse cx="38" cy="82" rx="16" ry="22" />
|
||||
<ellipse cx="64" cy="94" rx="16" ry="22" />
|
||||
</g>
|
||||
);
|
||||
case 12: // 비: rain lines + umbrella
|
||||
return (
|
||||
<g stroke={c} strokeWidth="3" opacity="0.85">
|
||||
{[20, 35, 50, 65, 80].map((x) => (
|
||||
<line key={x} x1={x} y1="54" x2={x - 8} y2="118" />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
default:
|
||||
return <text x="50" y="98" fontSize="36" textAnchor="middle" fill={c}>★</text>;
|
||||
}
|
||||
}
|
||||
|
||||
export type HwatuSize = 'sm' | 'md' | 'lg';
|
||||
const WIDTH: Record<HwatuSize, number> = { sm: 40, md: 60, lg: 84 };
|
||||
|
||||
export function HwatuCard({
|
||||
card,
|
||||
size = 'md',
|
||||
selected,
|
||||
dimmed,
|
||||
highlight,
|
||||
onClick,
|
||||
style,
|
||||
}: {
|
||||
card: HwatuInfo | null;
|
||||
size?: HwatuSize;
|
||||
selected?: boolean;
|
||||
dimmed?: boolean;
|
||||
highlight?: boolean;
|
||||
onClick?: () => void;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const w = WIDTH[size];
|
||||
const h = Math.round(w * 1.6);
|
||||
const Tag = onClick ? 'button' : 'span';
|
||||
const s: CSSProperties = {
|
||||
width: w,
|
||||
height: h,
|
||||
flex: 'none',
|
||||
display: 'inline-block',
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'none',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
opacity: dimmed ? 0.45 : 1,
|
||||
transform: selected ? `translateY(${-Math.round(w * 0.2)}px)` : undefined,
|
||||
transition: 'transform 0.12s ease',
|
||||
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
|
||||
...style,
|
||||
};
|
||||
if (!card) {
|
||||
return (
|
||||
<Tag style={s} onClick={onClick} aria-label="뒤집힌 화투">
|
||||
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
|
||||
<rect x="1" y="1" width="98" height="158" rx="8" fill="#991b1b" stroke="#450a0a" strokeWidth="3" />
|
||||
<rect x="10" y="10" width="80" height="140" rx="5" fill="#b91c1c" />
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
const color = MONTH_COLOR[card.month] ?? '#374151';
|
||||
return (
|
||||
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
|
||||
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
|
||||
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
|
||||
{card.kind === 'bonus' ? (
|
||||
<text x="50" y="102" fontSize="34" fontWeight="900" fill="#b45309" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
|
||||
{card.piValue ?? 2}피
|
||||
</text>
|
||||
) : (
|
||||
<Motif month={card.month} color={color} />
|
||||
)}
|
||||
{card.kind === 'tti' && (
|
||||
<g>
|
||||
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
|
||||
{TTI_TEXT[card.tti ?? 'plain'] && (
|
||||
<text x="67" y="62" fontSize="13" fontWeight="800" fill="#fff" textAnchor="middle" style={{ writingMode: 'vertical-rl' } as CSSProperties}>
|
||||
{TTI_TEXT[card.tti ?? 'plain']}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
{(card.kind === 'gwang' || card.kind === 'yeol') && (
|
||||
<g>
|
||||
<circle cx="78" cy="120" r="15" fill={card.kind === 'gwang' ? '#dc2626' : '#a16207'} />
|
||||
<text x="78" y="127" fontSize={card.kind === 'gwang' ? 20 : 16} fontWeight="800" fill="#fff" textAnchor="middle">
|
||||
{card.kind === 'gwang' ? '光' : '열'}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
{/* Month number: large and always readable */}
|
||||
<rect x="6" y="6" width="38" height="38" rx="8" fill={color} />
|
||||
<text x="25" y="35" fontSize="28" fontWeight="900" fill="#fff" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
|
||||
{card.month || '+'}
|
||||
</text>
|
||||
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
|
||||
{MONTH_NAME[card.month]} · {card.kind === 'bonus' ? `${card.piValue ?? 2}피` : card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
|
||||
</text>
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/components/cards/PlayingCard.tsx
Normal file
102
apps/web/src/components/cards/PlayingCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/** Trump card drawn in SVG (no image assets). Big corner index for readability on PC and phones. */
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
|
||||
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
|
||||
const RANK_LABEL: Record<string, string> = { T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' };
|
||||
const FACE_KO: Record<string, string> = { J: '잭', Q: '퀸', K: '킹', A: '에이스' };
|
||||
|
||||
export type CardSize = 'sm' | 'md' | 'lg';
|
||||
const WIDTH: Record<CardSize, number> = { sm: 44, md: 64, lg: 88 };
|
||||
|
||||
export interface PlayingCardProps {
|
||||
/** e.g. "AS", "TD", "X1" (joker). null = face down. */
|
||||
card: string | null;
|
||||
size?: CardSize;
|
||||
selected?: boolean;
|
||||
dimmed?: boolean;
|
||||
highlight?: boolean;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
/** Colour-blind friendly: 4-colour suits. */
|
||||
fourColor?: boolean;
|
||||
}
|
||||
|
||||
export function cardAria(card: string | null): string {
|
||||
if (!card) return '뒤집힌 카드';
|
||||
if (card.startsWith('X')) return '조커';
|
||||
const r = card[0]!;
|
||||
return `${SUIT_KO[card[1]!]} ${FACE_KO[r] ?? RANK_LABEL[r] ?? r}`;
|
||||
}
|
||||
|
||||
export function PlayingCard({ card, size = 'md', selected, dimmed, highlight, onClick, title, style, fourColor }: PlayingCardProps) {
|
||||
const w = WIDTH[size];
|
||||
const h = Math.round(w * 1.4);
|
||||
const lift = selected ? -Math.round(w * 0.22) : 0;
|
||||
const common: CSSProperties = {
|
||||
width: w,
|
||||
height: h,
|
||||
flex: 'none',
|
||||
transform: `translateY(${lift}px)`,
|
||||
transition: 'transform 0.12s ease',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
opacity: dimmed ? 0.45 : 1,
|
||||
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))',
|
||||
...style,
|
||||
};
|
||||
const Tag = onClick ? 'button' : 'span';
|
||||
const btnReset: CSSProperties = onClick ? { padding: 0, border: 0, background: 'none' } : {};
|
||||
if (!card) {
|
||||
return (
|
||||
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? '뒤집힌 카드'} title={title}>
|
||||
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
|
||||
<rect x="1" y="1" width="98" height="138" rx="9" fill="#1e3a8a" stroke="#fff" strokeWidth="3" />
|
||||
<rect x="9" y="9" width="82" height="122" rx="6" fill="none" stroke="#93c5fd" strokeWidth="2" />
|
||||
<path d="M50 30 L70 70 L50 110 L30 70 Z" fill="#3b82f6" opacity="0.7" />
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
const joker = card.startsWith('X');
|
||||
const suit = card[1]!;
|
||||
const rank = card[0]!;
|
||||
const color = joker
|
||||
? card === 'X1' ? '#111827' : '#dc2626'
|
||||
: fourColor
|
||||
? { S: '#111827', H: '#dc2626', D: '#2563eb', C: '#15803d' }[suit]!
|
||||
: suit === 'H' || suit === 'D'
|
||||
? '#dc2626'
|
||||
: '#111827';
|
||||
const label = joker ? '★' : (RANK_LABEL[rank] ?? rank);
|
||||
const sym = joker ? '' : SUIT_SYMBOL[suit];
|
||||
return (
|
||||
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? cardAria(card)} aria-pressed={onClick ? !!selected : undefined} title={title}>
|
||||
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
|
||||
<rect x="1" y="1" width="98" height="138" rx="9" fill="#fffef9" stroke={selected ? '#2563eb' : '#9ca3af'} strokeWidth={selected ? 5 : 2} />
|
||||
<text x="9" y="33" fontSize="30" fontWeight="800" fill={color} fontFamily="Pretendard Variable, sans-serif">
|
||||
{label}
|
||||
</text>
|
||||
<text x="11" y="60" fontSize="26" fill={color}>
|
||||
{sym}
|
||||
</text>
|
||||
{joker ? (
|
||||
<text x="50" y="98" fontSize="22" fontWeight="800" textAnchor="middle" fill={color}>
|
||||
JOKER
|
||||
</text>
|
||||
) : 'JQK'.includes(rank) ? (
|
||||
<>
|
||||
<rect x="30" y="44" width="56" height="78" rx="6" fill={color} opacity="0.1" />
|
||||
<text x="58" y="100" fontSize="44" fontWeight="800" textAnchor="middle" fill={color}>
|
||||
{rank}
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<text x="60" y="108" fontSize="54" textAnchor="middle" fill={color}>
|
||||
{sym}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/ui.tsx
Normal file
163
apps/web/src/components/ui.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/** Shared UI primitives (docs/07 §4). */
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { useToasts, toast } from '../store/toast';
|
||||
import { useRoom } from '../net/socket';
|
||||
import { haptic } from '../lib/feedback';
|
||||
|
||||
const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626'];
|
||||
|
||||
export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nickname' | 'avatar'>; size?: number }) {
|
||||
let h = 0;
|
||||
for (const ch of user.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||||
return (
|
||||
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label, done = '복사했어요 ✓', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: string; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onClick = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// Fallback for old browsers / insecure contexts.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
if (!ok) {
|
||||
toast(`복사가 안 돼요. 직접 적어 주세요: ${text}`, 'error', 6000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
haptic(30);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick} aria-live="polite">
|
||||
{copied ? done : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShareButton({ title, text, url, className = 'btn btn-secondary' }: { title: string; text: string; url: string; className?: string }) {
|
||||
const canShare = typeof navigator !== 'undefined' && 'share' in navigator;
|
||||
if (!canShare) return <CopyButton text={`${text}\n${url}`} label="💬 초대글 복사" className={className} />;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => navigator.share({ title, text, url }).catch(() => {})}
|
||||
>
|
||||
📤 공유하기
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sheet({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
ref.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className="sheet-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="sheet" role="dialog" aria-modal="true" aria-label={label} tabIndex={-1} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmSheet({
|
||||
title,
|
||||
body,
|
||||
confirm,
|
||||
cancel = '취소',
|
||||
danger,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
confirm: string;
|
||||
cancel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet onClose={onClose} label={title}>
|
||||
<h2>{title}</h2>
|
||||
{body && <div className="muted">{body}</div>}
|
||||
<div className="stack">
|
||||
<button
|
||||
className={`btn btn-block ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{confirm}
|
||||
</button>
|
||||
<button className="btn btn-block btn-secondary" onClick={onClose}>
|
||||
{cancel}
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
const toasts = useToasts((s) => s.toasts);
|
||||
return (
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.kind === 'error' ? 'error' : ''}`}>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionBanner() {
|
||||
const status = useRoom((s) => s.status);
|
||||
const code = useRoom((s) => s.code);
|
||||
if (!code) return null;
|
||||
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
|
||||
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CodeLabel({ code }: { code: string }) {
|
||||
return <span style={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '0.08em' }}>{formatRoomCode(code)}</span>;
|
||||
}
|
||||
|
||||
export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) {
|
||||
return (
|
||||
<nav className="topbar" aria-label="페이지 메뉴">
|
||||
{onBack && (
|
||||
<button className="icon-btn" onClick={onBack} aria-label="뒤로">
|
||||
←<span className="lbl">뒤로</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="topbar-title">{title}</div>
|
||||
{right}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
859
apps/web/src/games/baduk/index.tsx
Normal file
859
apps/web/src/games/baduk/index.tsx
Normal file
@@ -0,0 +1,859 @@
|
||||
/** 바둑 화면 (docs/games/baduk.md §9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { BadukOptions, BadukView, Pt, ScoreBreakdown } from '@bg/games/baduk';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
const COLS = 'ABCDEFGHJKLMNOPQRST';
|
||||
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
|
||||
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const ILLEGAL_KO = {
|
||||
suicide: '둘 곳의 활로가 없어 둘 수 없어요(자충수).',
|
||||
ko: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.',
|
||||
superko: '같은 모양이 반복되어 둘 수 없어요.',
|
||||
} as const;
|
||||
|
||||
function stars(size: number): Pt[] {
|
||||
if (size === 9) return [{ x: 2, y: 2 }, { x: 6, y: 2 }, { x: 2, y: 6 }, { x: 6, y: 6 }, { x: 4, y: 4 }];
|
||||
if (size === 13) return [{ x: 3, y: 3 }, { x: 9, y: 3 }, { x: 3, y: 9 }, { x: 9, y: 9 }, { x: 6, y: 6 }];
|
||||
const s = [3, 9, 15];
|
||||
return s.flatMap((x) => s.map((y) => ({ x, y })));
|
||||
}
|
||||
|
||||
/** Points like "흑 2집 반" (territory) or "1.5점" (area). */
|
||||
function fmtScore(n: number, method: 'territory' | 'area'): string {
|
||||
if (method === 'area') return String(n);
|
||||
const whole = Math.floor(n);
|
||||
const half = n - whole >= 0.5;
|
||||
return half ? (whole === 0 ? '반' : `${whole}.5`) : String(whole);
|
||||
}
|
||||
|
||||
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1}>
|
||||
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.05} />
|
||||
{color === 'black' ? (
|
||||
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
|
||||
) : (
|
||||
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkMode = 'dead' | 'seki';
|
||||
|
||||
function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps<BadukView>) {
|
||||
const n = v.size;
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const marking = v.phase === 'scoring' && v.myColor !== null;
|
||||
const [mode, setMode] = useState<MarkMode>('dead');
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) >> 1, y: (n - 1) >> 1 });
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [bubble, setBubble] = useState<{ p: Pt; text: string } | null>(null);
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [changed, setChanged] = useState<Set<number>>(new Set());
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
const prevDead = useRef<Set<number> | null>(null);
|
||||
|
||||
const illegal = useMemo(() => new Map(v.illegalPoints.map((p) => [p.y * n + p.x, p.reason])), [v.illegalPoints, n]);
|
||||
// Dead stones: the live marks while scoring, or (after the game) stones owned by the other colour.
|
||||
const dead = useMemo(() => {
|
||||
if (v.scoring) return new Set(v.scoring.dead.map((p) => p.y * n + p.x));
|
||||
const own = v.result?.score?.ownership;
|
||||
const out = new Set<number>();
|
||||
if (own) v.board.forEach((c, i) => c !== 0 && own[i] !== c && out.add(i));
|
||||
return out;
|
||||
}, [v.scoring, v.result, v.board, n]);
|
||||
const seki = useMemo(() => new Set((v.scoring?.seki ?? []).map((p) => p.y * n + p.x)), [v.scoring?.seki, n]);
|
||||
|
||||
// Move sounds and announcements.
|
||||
useEffect(() => {
|
||||
if (v.moveCount > prevCount.current) {
|
||||
const m = v.moves[v.moves.length - 1]!;
|
||||
if (m.pt) {
|
||||
sounds.stone();
|
||||
if (m.captured > 0) haptic(30);
|
||||
setAnnounce(`${COLOR_KO[m.color]}이 ${label(n, m.pt)}에 두었어요.${m.captured ? ` 돌 ${m.captured}개를 따냈어요.` : ''}`);
|
||||
} else setAnnounce(`${COLOR_KO[m.color]}이 패스했어요.`);
|
||||
if (myTurn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}
|
||||
prevCount.current = v.moveCount;
|
||||
setPreview(null);
|
||||
setBubble(null);
|
||||
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Highlight groups whose dead/alive mark just changed (e.g. by the opponent).
|
||||
useEffect(() => {
|
||||
if (!v.scoring) {
|
||||
prevDead.current = null;
|
||||
return;
|
||||
}
|
||||
const before = prevDead.current;
|
||||
prevDead.current = dead;
|
||||
if (!before) return;
|
||||
const diff = new Set<number>();
|
||||
for (const i of dead) if (!before.has(i)) diff.add(i);
|
||||
for (const i of before) if (!dead.has(i)) diff.add(i);
|
||||
if (diff.size === 0) return;
|
||||
setChanged(diff);
|
||||
const t = setTimeout(() => setChanged(new Set()), 2500);
|
||||
return () => clearTimeout(t);
|
||||
}, [dead]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const t = setTimeout(() => setBubble(null), 2500);
|
||||
return () => clearTimeout(t);
|
||||
}, [bubble]);
|
||||
|
||||
const at = (p: Pt) => v.board[p.y * n + p.x];
|
||||
const canPlace = (p: Pt) => myTurn && !pending && at(p) === 0 && !illegal.has(p.y * n + p.x);
|
||||
const canMark = (p: Pt) => marking && !pending && (mode === 'dead' ? at(p) !== 0 : at(p) === 0);
|
||||
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
const mark = (p: Pt) => act(mode === 'dead' ? { type: 'toggleDead', x: p.x, y: p.y } : { type: 'toggleSeki', x: p.x, y: p.y });
|
||||
|
||||
const tap = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (marking) {
|
||||
if (canMark(p)) mark(p);
|
||||
else if (mode === 'dead' && at(p) === 0) setBubble({ p, text: '돌을 눌러 사석/생석을 바꿔요. 빈 곳은 [빅 표시]에서 눌러요.' });
|
||||
return;
|
||||
}
|
||||
if (!myTurn || at(p) !== 0) return;
|
||||
const why = illegal.get(p.y * n + p.x);
|
||||
if (why) {
|
||||
haptic([30, 30, 30]);
|
||||
setBubble({ p, text: ILLEGAL_KO[why] });
|
||||
setAnnounce(ILLEGAL_KO[why]);
|
||||
return;
|
||||
}
|
||||
if (mouse || !tapConfirm) {
|
||||
if (canPlace(p)) place(p);
|
||||
return;
|
||||
}
|
||||
if (preview && preview.x === p.x && preview.y === p.y) place(p);
|
||||
else setPreview(p);
|
||||
};
|
||||
|
||||
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
const x = Math.round(loc.x);
|
||||
const y = Math.round(loc.y);
|
||||
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
tap(cursor, true);
|
||||
}
|
||||
};
|
||||
|
||||
// Ownership marks: live preview while marking, final result after the game.
|
||||
const score: ScoreBreakdown | undefined = v.scoring?.preview ?? v.result?.score;
|
||||
const ghost = preview ?? (hover && canPlace(hover) ? hover : null);
|
||||
const hoverMark = marking && hover && canMark(hover) ? hover : null;
|
||||
const lastColor: Color | null = v.lastMove ? (at(v.lastMove) === 1 ? 'black' : 'white') : null;
|
||||
const pad = 0.95;
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
background: 'var(--wood)',
|
||||
borderRadius: 12,
|
||||
touchAction: 'manipulation',
|
||||
cursor: myTurn || marking ? 'pointer' : 'default',
|
||||
boxShadow: 'var(--shadow)',
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !(myTurn || marking)) return;
|
||||
const p = pointToCell(e);
|
||||
if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p);
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p, e.pointerType === 'mouse');
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`바둑판 ${n}줄. 방향키로 움직이고 Enter로 ${marking ? '표시를 바꿉니다' : '둡니다'}. 현재 위치 ${label(n, cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.36} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
<text x={i} y={-0.62}>{COLS[i]}</text>
|
||||
<text x={i} y={n - 1 + 0.62}>{COLS[i]}</text>
|
||||
<text x={-0.62} y={i}>{n - i}</text>
|
||||
<text x={n - 1 + 0.62} y={i}>{n - i}</text>
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{/* Ko / superko points for the mover (suicide points are explained on click only). */}
|
||||
{myTurn &&
|
||||
v.illegalPoints
|
||||
.filter((p) => p.reason !== 'suicide')
|
||||
.map((p) => <rect key={`k${p.x}-${p.y}`} x={p.x - 0.18} y={p.y - 0.18} width={0.36} height={0.36} fill="none" stroke="#b91c1c" strokeWidth={0.07} />)}
|
||||
{v.board.map((c, i) => {
|
||||
if (c === 0) return null;
|
||||
const color: Color = c === 1 ? 'black' : 'white';
|
||||
const x = i % n;
|
||||
const y = Math.floor(i / n);
|
||||
const isDead = dead.has(i);
|
||||
return (
|
||||
<g key={i} opacity={isDead ? 0.45 : 1}>
|
||||
<Stone color={color} cx={x} cy={y} r={0.47} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Stones just captured fade out. */}
|
||||
{v.phase === 'playing' &&
|
||||
v.lastCaptured.map((p) => (
|
||||
<g key={`cap${v.moveCount}-${p.x}-${p.y}`} opacity={0}>
|
||||
<animate attributeName="opacity" from={0.7} to={0} dur="0.6s" fill="freeze" />
|
||||
<Stone color={lastColor ? other(lastColor) : 'white'} cx={p.x} cy={p.y} r={0.47} />
|
||||
</g>
|
||||
))}
|
||||
{/* Territory / area marks (small squares) and seki marks. */}
|
||||
{score &&
|
||||
score.ownership.map((o, i) => {
|
||||
if (o === 0 || (v.board[i] !== 0 && !dead.has(i))) return null;
|
||||
return <rect key={`o${i}`} x={(i % n) - 0.17} y={Math.floor(i / n) - 0.17} width={0.34} height={0.34} fill={o === 1 ? '#111' : '#fafafa'} stroke={o === 1 ? '#000' : '#555'} strokeWidth={0.03} />;
|
||||
})}
|
||||
{[...seki].map((i) => (
|
||||
<path key={`seki${i}`} d={`M ${i % n} ${Math.floor(i / n) - 0.2} l 0.2 0.2 l -0.2 0.2 l -0.2 -0.2 z`} fill="#f59e0b" opacity={0.9} />
|
||||
))}
|
||||
{[...dead].map((i) => (
|
||||
<g key={`x${i}`} stroke={v.board[i] === 1 ? '#fafafa' : '#111'} strokeWidth={0.08}>
|
||||
<line x1={(i % n) - 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) + 0.2} y2={Math.floor(i / n) + 0.2} />
|
||||
<line x1={(i % n) + 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) - 0.2} y2={Math.floor(i / n) + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{[...changed].map((i) => (
|
||||
<circle key={`ch${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.52} fill="none" stroke="#3b82f6" strokeWidth={0.09}>
|
||||
<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="3" />
|
||||
</circle>
|
||||
))}
|
||||
{v.lastMove && lastColor && (
|
||||
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
|
||||
)}
|
||||
{myTurn &&
|
||||
v.atariWarnings.map((p) => (
|
||||
<circle key={`a${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08}>
|
||||
<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
|
||||
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
|
||||
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
{bubble && <circle cx={bubble.p.x} cy={bubble.p.y} r={0.48} fill="none" stroke="#dc2626" strokeWidth={0.09} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{bubble && (
|
||||
<div className="card" role="status" style={{ padding: '8px 12px', border: '2px solid var(--danger)', fontWeight: 600 }}>
|
||||
{label(n, bubble.p)}: {bubble.text}
|
||||
</div>
|
||||
)}
|
||||
{marking && (
|
||||
<div className="card stack" style={{ padding: 12, gap: 8 }}>
|
||||
<div className="segmented" role="group" aria-label="표시 종류">
|
||||
<button type="button" aria-pressed={mode === 'dead'} onClick={() => setMode('dead')}>
|
||||
✕ 사석 표시
|
||||
</button>
|
||||
<button type="button" aria-pressed={mode === 'seki'} onClick={() => setMode('seki')}>
|
||||
◆ 빅 표시
|
||||
</button>
|
||||
</div>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
{mode === 'dead'
|
||||
? '죽은 돌 묶음을 누르면 사석(✕)으로 바뀌고, 다시 누르면 살아 있는 돌로 돌아와요.'
|
||||
: '빅(세키) 안의 빈 곳을 누르면 ◆로 표시돼요. 한국·일본식 계산에서는 빅 안의 집을 세지 않아요.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{`${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function ScoreTable({ score, heading }: { score: ScoreBreakdown; heading?: boolean }) {
|
||||
const t = score.method === 'territory';
|
||||
const rows: [string, string | number, string | number][] = t
|
||||
? [
|
||||
['집', score.black.territory, score.white.territory],
|
||||
['따낸 돌(사석 포함)', score.black.captures, score.white.captures],
|
||||
['덤', '-', score.white.komi],
|
||||
]
|
||||
: [
|
||||
['판 위 살아 있는 돌', score.black.stones, score.white.stones],
|
||||
['영역(빈 곳)', score.black.territory, score.white.territory],
|
||||
['덤', '-', score.white.komi],
|
||||
...(score.white.handicapComp ? [['치석 보정', '-', score.white.handicapComp] as [string, string, number]] : []),
|
||||
];
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontVariantNumeric: 'tabular-nums' }}>
|
||||
<thead>
|
||||
<tr className="small muted">
|
||||
<th style={{ textAlign: 'left', fontWeight: 600 }}>{heading ? (t ? '집 계산' : '영역 계산') : ''}</th>
|
||||
<th style={{ textAlign: 'right' }}>● 흑</th>
|
||||
<th style={{ textAlign: 'right' }}>○ 백</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([k, b, w]) => (
|
||||
<tr key={k} className="small">
|
||||
<td>{k}</td>
|
||||
<td style={{ textAlign: 'right' }}>{b}</td>
|
||||
<td style={{ textAlign: 'right' }}>{w}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr style={{ fontWeight: 800, borderTop: '2px solid var(--border)' }}>
|
||||
<td>합계</td>
|
||||
<td style={{ textAlign: 'right' }}>{score.black.total}</td>
|
||||
<td style={{ textAlign: 'right' }}>{score.white.total}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoringPanel({ view: v, act, pending }: { view: BadukView; act: (a: unknown) => void; pending: boolean }) {
|
||||
const [confirmResume, setConfirmResume] = useState(false);
|
||||
const sc = v.scoring!;
|
||||
const p = sc.preview;
|
||||
const lead = p.diff === 0 ? '동점' : `${p.diff > 0 ? '흑' : '백'} ${fmtScore(Math.abs(p.diff), p.method)}${p.method === 'territory' ? '집' : '점'} 앞섬`;
|
||||
const mine = v.myColor;
|
||||
return (
|
||||
<div className="card stack" style={{ padding: 14, gap: 10 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong style={{ fontSize: '1.1rem' }}>사석 확인</strong>
|
||||
<TurnTimer deadline={sc.deadlineAt} totalMs={sc.durationMs} mine={!!mine && !sc.agreed[mine]} size={40} />
|
||||
</div>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
대국이 멈췄어요. 판 위의 <strong>죽은 돌</strong>을 눌러 ✕ 표시를 맞춘 뒤 두 사람 모두 <strong>동의</strong>하면 계가가 끝나요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요.
|
||||
</p>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
|
||||
흑 {p.black.total} : 백 {p.white.total}
|
||||
</div>
|
||||
<div className="muted small" style={{ textAlign: 'center' }}>{lead}</div>
|
||||
<ScoreTable score={p} heading />
|
||||
<div className="row small" style={{ justifyContent: 'space-around' }}>
|
||||
<span>● 흑 {sc.agreed.black ? '✅ 동의함' : '⏳ 확인 중'}</span>
|
||||
<span>○ 백 {sc.agreed.white ? '✅ 동의함' : '⏳ 확인 중'}</span>
|
||||
</div>
|
||||
{mine && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<button className="btn btn-primary btn-block" style={{ fontSize: '1.1rem' }} disabled={pending || sc.agreed[mine]} onClick={() => act({ type: 'agreeScore' })}>
|
||||
{sc.agreed[mine] ? '동의했어요 · 상대를 기다려요' : '✔ 이 표시대로 동의'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" disabled={pending} onClick={() => setConfirmResume(true)}>
|
||||
이의 – 대국 재개
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{confirmResume && (
|
||||
<ConfirmSheet
|
||||
title="대국을 다시 둘까요?"
|
||||
body="표시를 모두 지우고 대국으로 돌아가요. 다시 시작하면 상대가 먼저 둬요."
|
||||
confirm="대국 재개"
|
||||
onConfirm={() => act({ type: 'resumePlay' })}
|
||||
onClose={() => setConfirmResume(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BadukSide({ view: v, me, players, act, pending, deadline }: BoardProps<BadukView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'pass'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
|
||||
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
|
||||
const opp: Color = v.myColor === 'white' ? 'black' : 'white';
|
||||
const mine: Color = v.myColor ?? 'black';
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const last = v.moves[v.moves.length - 1];
|
||||
const oppPassed = myTurn && last && !last.pt && last.color !== v.myColor;
|
||||
return (
|
||||
<>
|
||||
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
|
||||
{oppPassed && (
|
||||
<div className="card" style={{ padding: 10, border: '2px solid var(--warning)' }}>
|
||||
<strong>상대가 패스했어요.</strong> 끝내려면 나도 패스하세요. 더 둘 곳이 있으면 계속 둬도 돼요.
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'scoring' && <ScoringPanel view={v} act={act} pending={pending} />}
|
||||
{v.phase === 'finished' && v.result?.score && (
|
||||
<div className="card stack" style={{ padding: 14, gap: 8 }}>
|
||||
<strong style={{ fontSize: '1.15rem' }}>{v.result.summaryKo}</strong>
|
||||
<ScoreTable score={v.result.score} heading />
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-block" disabled={!myTurn || pending} onClick={() => setConfirm('pass')}>
|
||||
✋ 패스
|
||||
</button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'scoring' && v.myColor && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">{incoming === 'undo' ? '수락하면 돌과 따낸 돌이 그 전으로 돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'pass' && (
|
||||
<ConfirmSheet
|
||||
title="패스할까요?"
|
||||
body={v.consecutivePasses > 0 ? '상대도 패스했으니, 패스하면 대국이 멈추고 사석 확인으로 넘어가요.' : '이번 차례에 돌을 두지 않고 넘겨요.'}
|
||||
confirm="패스하기"
|
||||
onConfirm={() => act({ type: 'pass' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대의 불계승이에요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: BadukView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 150, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {m.pt ? label(v.size, m.pt) : '패스'}
|
||||
{m.captured ? <span className="muted"> ×{m.captured}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const mmss = (ms: number) => {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: BadukView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(250);
|
||||
const c = v.clock;
|
||||
const elapsed = active ? Math.max(0, now - c.turnStartedAt) : 0;
|
||||
const mine = id === me;
|
||||
let clock: React.ReactNode = null;
|
||||
let periodSecLeft: number | null = null;
|
||||
if (c.kind === 'byoyomi') {
|
||||
const main = c.mainMs[color];
|
||||
if (elapsed < main) {
|
||||
clock = <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem' }}>{mmss(main - elapsed)}</span>;
|
||||
} else {
|
||||
const periodMs = c.periodSec * 1000;
|
||||
const over = elapsed - main;
|
||||
const used = Math.floor(over / periodMs);
|
||||
const left = Math.max(0, c.periodsLeft[color] - used);
|
||||
periodSecLeft = active ? Math.ceil((periodMs - (over % periodMs)) / 1000) : c.periodSec;
|
||||
const urgent = active && periodSecLeft <= 10;
|
||||
clock = (
|
||||
<span className="stack" style={{ alignItems: 'flex-end', gap: 2 }}>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 800, fontSize: urgent ? '1.6rem' : '1.15rem', color: urgent ? 'var(--danger)' : undefined }}>
|
||||
{periodSecLeft}초
|
||||
</span>
|
||||
<span aria-label={`초읽기 ${left}번 남음`} style={{ letterSpacing: 2 }}>
|
||||
{Array.from({ length: c.periods }, (_, i) => (
|
||||
<span key={i} style={{ color: i < left ? 'var(--primary)' : 'var(--border)' }}>●</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
} else if (c.kind === 'fischer') {
|
||||
const ms = c.mainMs[color] - elapsed;
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span>
|
||||
);
|
||||
} else if (active) {
|
||||
clock = <TurnTimer deadline={deadline} totalMs={c.idleLimitSec * 1000} mine={mine} />;
|
||||
}
|
||||
|
||||
// Byoyomi countdown beeps for my own clock (last 5 seconds of a period).
|
||||
const beeped = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!active || !mine || periodSecLeft === null || periodSecLeft > 5 || beeped.current === periodSecLeft) return;
|
||||
beeped.current = periodSecLeft;
|
||||
sounds.warn();
|
||||
}, [periodSecLeft, active, mine]);
|
||||
|
||||
const urgentBorder = active && periodSecLeft !== null && periodSecLeft <= 10;
|
||||
return (
|
||||
<div
|
||||
className="card row"
|
||||
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${urgentBorder ? 'var(--danger)' : active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
|
||||
>
|
||||
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
|
||||
<Stone color={color} cx={0} cy={0} r={0.9} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{mine && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{COLOR_KO[color]} · 따낸 돌 {v.captures[color]}
|
||||
{color === 'white' && v.rules.komi ? ` · 덤 ${v.rules.komi}` : ''}
|
||||
{active ? ' · 두는 중' : ''}
|
||||
{v.phase === 'scoring' && v.scoring?.agreed[color] ? ' · 동의함' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: BadukView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요';
|
||||
if (v.phase === 'scoring') {
|
||||
if (v.myColor && !v.scoring!.agreed[v.myColor]) return '사석을 확인하고 [동의]를 눌러 주세요';
|
||||
if (v.myColor) return '상대가 사석을 확인하고 있어요…';
|
||||
return '사석 확인 중이에요';
|
||||
}
|
||||
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
|
||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||
if (v.myColor === v.turn) {
|
||||
if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요';
|
||||
if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`;
|
||||
return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TC = BadukOptions['timeControl'];
|
||||
const TIME_PRESETS: [string, string, Partial<TC>][] = [
|
||||
['auto', '판 크기에 맞춤 + 초읽기 30초×3', { kind: 'byoyomi', mainSecAuto: true, periodSec: 30, periods: 3 }],
|
||||
['b5', '5분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 300, periodSec: 30, periods: 3 }],
|
||||
['b10', '10분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 600, periodSec: 30, periods: 3 }],
|
||||
['b30', '30분 + 60초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 1800, periodSec: 60, periods: 3 }],
|
||||
['f', '피셔 10분 + 10초', { kind: 'fischer', mainSecAuto: false, mainSec: 600, incSec: 10 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: TC): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'fischer') return tc.mainSec === 600 && tc.incSec === 10 && !tc.mainSecAuto ? 'f' : 'custom';
|
||||
if (tc.mainSecAuto && tc.periodSec === 30 && tc.periods === 3) return 'auto';
|
||||
const hit = TIME_PRESETS.find(([k, , p]) => k.startsWith('b') && !tc.mainSecAuto && p.mainSec === tc.mainSec && p.periodSec === tc.periodSec && p.periods === tc.periods);
|
||||
return hit ? hit[0] : 'custom';
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN: Record<number, number> = { 9: 180, 13: 600, 19: 1200 };
|
||||
function timeLabel(o: BadukOptions): string {
|
||||
const tc = o.timeControl;
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
const main = tc.mainSecAuto ? DEFAULT_MAIN[o.boardSize]! : tc.mainSec;
|
||||
const m = Math.floor(main / 60);
|
||||
const mainLabel = main === 0 ? '' : `${m ? `${m}분` : ''}${main % 60 ? ` ${main % 60}초` : ''}`.trim();
|
||||
if (tc.kind === 'fischer') return `${mainLabel} + 수당 ${tc.incSec}초`;
|
||||
return `${mainLabel ? `${mainLabel} + ` : ''}${tc.periodSec}초×${tc.periods}`;
|
||||
}
|
||||
|
||||
function komiOf(o: BadukOptions): number {
|
||||
if (!o.komiAuto) return o.komi;
|
||||
if (o.handicap > 0) return 0.5;
|
||||
return o.scoring === 'area' ? 7.5 : 6.5;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<BadukOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<BadukOptions>) => onChange({ ...o, ...patch });
|
||||
const setTc = (patch: Partial<TC>) => set({ timeControl: { ...o.timeControl, ...patch } });
|
||||
const maxHandicap = o.boardSize === 9 ? 5 : 9;
|
||||
const handicaps = ([0, 2, 3, 4, 5, 6, 7, 8, 9] as const).filter((h) => h <= maxHandicap);
|
||||
const tk = timeKey(o.timeControl);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>판 크기</strong>
|
||||
<Seg
|
||||
value={o.boardSize}
|
||||
options={[[9, '9줄 (처음이면 추천)'], [13, '13줄'], [19, '19줄']]}
|
||||
onChange={(boardSize) => set({ boardSize, handicap: boardSize === 9 && o.handicap > 5 ? 5 : o.handicap })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>계가 방식</strong>
|
||||
<Seg value={o.scoring} options={[['territory', '집 계산 (한국·일본식)'], ['area', '영역 계산 (중국식)']]} onChange={(scoring) => set({ scoring })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>접바둑</strong>
|
||||
<Seg value={o.handicap} options={handicaps.map((h) => [h, h === 0 ? '맞바둑' : `${h}점`] as [BadukOptions['handicap'], string])} onChange={(handicap) => set({ handicap })} />
|
||||
{o.handicap > 0 && <span className="muted small">흑(하수)이 치석 {o.handicap}개를 미리 놓고, 백이 먼저 둬요. 덤은 0.5집이에요.</span>}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={tk}
|
||||
options={[
|
||||
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
|
||||
...(tk === 'custom' ? [['custom', `${timeLabel(o)}(관리자 설정)`] as [string, string]] : []),
|
||||
]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) setTc(preset[2]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>흑백 정하기</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['nigiri', '무작위'], ['hostBlack', o.handicap ? '방장이 흑(하수)' : '방장이 흑'], ['hostWhite', o.handicap ? '방장이 백(상수)' : '방장이 백']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>덤 (지금 {komiOf(o)})</strong>
|
||||
<Seg value={o.komiAuto ? 'auto' : 'custom'} options={[['auto', '자동'], ['custom', '직접 정하기']]} onChange={(k) => set({ komiAuto: k === 'auto' })} />
|
||||
{!o.komiAuto && (
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 160 }}
|
||||
type="number"
|
||||
min={-10}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={o.komi}
|
||||
onChange={(e) => {
|
||||
const n = Math.round(Number(e.target.value) * 2) / 2;
|
||||
if (Number.isFinite(n)) set({ komi: Math.max(-10, Math.min(10, n)) });
|
||||
}}
|
||||
aria-label="덤"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>패 규칙</strong>
|
||||
<Seg value={o.koRule} options={[['positionalSuperko', '같은 모양 반복 금지'], ['korean', '한국식(삼패 등은 무승부)']]} onChange={(koRule) => set({ koRule })} />
|
||||
</div>
|
||||
{o.timeControl.kind === 'byoyomi' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>초읽기</strong>
|
||||
<Seg value={o.timeControl.periodSec} options={[[10, '10초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(periodSec) => setTc({ periodSec })} />
|
||||
<Seg value={o.timeControl.periods} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(periods) => setTc({ periods })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>도움 표시</strong>
|
||||
<Seg value={o.atariWarning ? 'on' : 'off'} options={[['on', '단수 경고 켜기'], ['off', '단수 경고 끄기']]} onChange={(k) => set({ atariWarning: k === 'on' })} />
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>사석 확인 제한 시간</strong>
|
||||
<Seg value={o.scoringDeadlineSec} options={[[120, '2분'], [180, '3분'], [300, '5분']]} onChange={(scoringDeadlineSec) => set({ scoringDeadlineSec })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: BadukOptions): string {
|
||||
return [
|
||||
o.scoring === 'territory' ? '집 계산' : '영역 계산',
|
||||
`${o.boardSize}줄`,
|
||||
o.handicap ? `${o.handicap}점 접바둑` : `덤 ${komiOf(o)}`,
|
||||
timeLabel(o),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: BadukOptions }) {
|
||||
const scoring = options?.scoring ?? 'territory';
|
||||
const koRule = options?.koRule ?? 'positionalSuperko';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>흑과 백이 번갈아 판의 <strong>교차점</strong>에 돌을 하나씩 놓아요. 흑이 먼저 둬요(접바둑이면 흑이 치석을 미리 놓고 백이 먼저 둬요). 한 번 놓은 돌은 움직이지 않아요.</p>
|
||||
<p>
|
||||
<strong>따내기</strong>: 돌에 상하좌우로 맞닿은 빈 점을 <strong>활로</strong>라고 해요. 상하좌우로 붙은 같은 색 돌은 한 덩어리로 활로를 함께 써요. 상대 돌 덩어리의 활로를 모두 막으면 그 돌을 판에서 들어내고, 따낸 돌로 세어요. 활로가 하나만 남은 상태를 <strong>단수</strong>라고 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>자충수 금지</strong>: 놓는 순간 내 돌의 활로가 없어지는 자리에는 둘 수 없어요. 단, 그 수로 상대 돌을 따내서 활로가 생기면 둘 수 있어요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>패</strong>: 돌 하나를 서로 번갈아 따낼 수 있는 모양에서는, 따인 쪽이 <strong>바로</strong> 되따낼 수 없어요. 다른 곳에 한 수 둔 뒤에 되따낼 수 있어요.
|
||||
{koRule === 'positionalSuperko'
|
||||
? ' 이 방은 판 전체 모양이 이전과 똑같아지는 수를 모두 금지해요(동형 반복 금지).'
|
||||
: ' 이 방은 한국식이라 같은 국면이 세 번 나오면(삼패 등) 무승부로 끝나요.'}
|
||||
{' '}둘 수 없는 패 자리는 빨간 네모로 보여 줘요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>끝내기</strong>: 더 둘 곳이 없으면 <strong>패스</strong>해요. 두 사람이 연달아 패스하면 대국이 멈추고 <strong>사석 확인</strong>을 해요. 판에서 죽은 돌을 눌러 ✕ 표시를 맞추고, 두 사람 모두 [동의]하면 점수가 나와요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 결론을 내요.
|
||||
</p>
|
||||
{scoring === 'territory' ? (
|
||||
<p>
|
||||
<strong>집 계산(한국·일본식)</strong>: 내 돌로만 둘러싼 빈 점(집) + 따낸 돌(사석 포함)이 내 점수예요. 백은 먼저 둔 흑의 이득을 메우려고 <strong>덤</strong>을 더 받아요. 빅(서로 잡을 수 없이 공배를 나눠 사는 모양) 안의 집은 세지 않아요.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>영역 계산(중국식)</strong>: 판 위의 살아 있는 내 돌 + 내 돌로만 둘러싼 빈 점이 내 점수예요. 따낸 돌은 세지 않으니 끝내기 전에 공배(양쪽 사이의 빈 점)를 메운 뒤 패스하세요. 백은 덤을 더 받고, 접바둑이면 치석 수만큼 더 받아요.
|
||||
</p>
|
||||
)}
|
||||
<p>점수가 높은 쪽이 이겨요. 덤이 0.5 단위면 비기는 일이 없어요. 언제든 기권(불계패)할 수 있어요. 처음이라면 <strong>9줄</strong> 판으로 시작해 보세요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<BadukView, BadukOptions> = {
|
||||
Board: BadukBoard,
|
||||
Side: BadukSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => {
|
||||
if (!v.myColor || v.players[v.myColor] !== me) return false;
|
||||
if (v.phase === 'playing') return v.turn === v.myColor;
|
||||
if (v.phase === 'scoring') return !v.scoring!.agreed[v.myColor];
|
||||
return false;
|
||||
},
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
1285
apps/web/src/games/bang/index.tsx
Normal file
1285
apps/web/src/games/bang/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1245
apps/web/src/games/catchmind/index.tsx
Normal file
1245
apps/web/src/games/catchmind/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
856
apps/web/src/games/chess/index.tsx
Normal file
856
apps/web/src/games/chess/index.tsx
Normal file
@@ -0,0 +1,856 @@
|
||||
/** 체스 화면 (docs/games/chess.md §9). Legal moves come from the server view; the client has no rule code. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ChessOptions, ChessView, LegalMove } from '@bg/games/chess';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type Color = 'white' | 'black';
|
||||
type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p';
|
||||
type Promo = 'q' | 'r' | 'b' | 'n';
|
||||
const COLOR_KO: Record<Color, string> = { white: '백', black: '흑' };
|
||||
const PIECE_KO: Record<PieceType, string> = { k: '킹', q: '퀸', r: '룩', b: '비숍', n: '나이트', p: '폰' };
|
||||
const FILES = 'abcdefgh';
|
||||
const other = (c: Color): Color => (c === 'white' ? 'black' : 'white');
|
||||
|
||||
// ---------------------------------------------------------------- squares
|
||||
|
||||
/** Board array index (a8 = 0 … h1 = 63) for a square name. */
|
||||
const idxOf = (sq: string) => (8 - Number(sq[1])) * 8 + FILES.indexOf(sq[0]!);
|
||||
const sqOf = (file: number, rank: number) => `${FILES[file]}${rank + 1}`;
|
||||
/** Display cell (0..7 from the top-left) for a square, given which colour sits at the bottom. */
|
||||
function cellOf(sq: string, bottom: Color): { x: number; y: number } {
|
||||
const f = FILES.indexOf(sq[0]!);
|
||||
const r = Number(sq[1]) - 1;
|
||||
return bottom === 'white' ? { x: f, y: 7 - r } : { x: 7 - f, y: r };
|
||||
}
|
||||
function squareAt(x: number, y: number, bottom: Color): string {
|
||||
return bottom === 'white' ? sqOf(x, 7 - y) : sqOf(7 - x, y);
|
||||
}
|
||||
const pieceAt = (v: ChessView, sq: string) => v.board[idxOf(sq)] ?? null;
|
||||
const colorOfPiece = (p: string): Color => (p === p.toUpperCase() ? 'white' : 'black');
|
||||
|
||||
// ---------------------------------------------------------------- pieces (hand-drawn SVG, 100×100 box)
|
||||
|
||||
const BASE = 'M20 91 H80 Q84 91 84 87 V84 Q84 79 79 79 H21 Q16 79 16 84 V87 Q16 91 20 91 Z';
|
||||
const ROYAL_BODY = 'M30 79 L36 57 H64 L70 79 Z';
|
||||
const SHAPES: Record<PieceType, { fill: string[]; balls?: [number, number, number][]; detail?: string[] }> = {
|
||||
p: {
|
||||
fill: [
|
||||
'M33 79 C35 68 39 62 43 58 H57 C61 62 65 68 67 79 Z',
|
||||
'M38 59 H62 Q65 59 65 56 Q65 52 61 52 H39 Q35 52 35 56 Q35 59 38 59 Z',
|
||||
],
|
||||
balls: [[50, 38, 14]],
|
||||
},
|
||||
r: {
|
||||
fill: ['M30 79 L34 47 H66 L70 79 Z', 'M28 47 L27 19 H37 V26 H45 V19 H55 V26 H63 V19 H73 L72 47 Z'],
|
||||
detail: ['M33 39 H67', 'M33 66 H67'],
|
||||
},
|
||||
b: {
|
||||
fill: [
|
||||
'M35 79 C36 72 40 68 42 64 H58 C60 68 64 72 65 79 Z',
|
||||
'M37 65 H63 Q66 65 66 62 Q66 58 62 58 H38 Q34 58 34 62 Q34 65 37 65 Z',
|
||||
'M50 17 C62 26 68 38 65 49 Q63 56 58 58 H42 Q37 56 35 49 C32 38 38 26 50 17 Z',
|
||||
],
|
||||
balls: [[50, 13, 5.5]],
|
||||
detail: ['M57 29 L47 42'],
|
||||
},
|
||||
n: {
|
||||
fill: [
|
||||
'M33 79 C32 66 37 58 46 51 C41 51 36 53 31 57 C27 60 21 58 20 52 C19 47 23 43 28 39 L43 25 C45 20 48 16 52 12 L56 19 C71 22 80 40 77 79 Z',
|
||||
],
|
||||
detail: ['M61 26 C69 36 72 52 70 70'],
|
||||
balls: [
|
||||
[44, 32, 2.8],
|
||||
[26, 51, 1.6],
|
||||
],
|
||||
},
|
||||
q: {
|
||||
fill: [ROYAL_BODY, 'M32 57 L19 30 L33 44 L35 24 L44 41 L50 19 L56 41 L65 24 L67 44 L81 30 L68 57 Z'],
|
||||
balls: [
|
||||
[19, 29, 4.5],
|
||||
[35, 23, 4.5],
|
||||
[50, 17, 4.5],
|
||||
[65, 23, 4.5],
|
||||
[81, 29, 4.5],
|
||||
],
|
||||
detail: ['M34 63 H66'],
|
||||
},
|
||||
k: {
|
||||
fill: [
|
||||
ROYAL_BODY,
|
||||
'M32 57 C23 48 25 33 36 33 C43 33 47 38 50 44 C53 38 57 33 64 33 C75 33 77 48 68 57 Z',
|
||||
'M47 8 H53 V16 H60 V22 H53 V36 H47 V22 H40 V16 H47 Z',
|
||||
],
|
||||
detail: ['M34 63 H66', 'M50 44 V57'],
|
||||
},
|
||||
};
|
||||
|
||||
function PieceShape({ type, color }: { type: PieceType; color: Color }) {
|
||||
const shape = SHAPES[type];
|
||||
const fill = color === 'white' ? '#fbfaf5' : '#2c2a28';
|
||||
const stroke = color === 'white' ? '#1d1b19' : '#0c0b0a';
|
||||
const detail = color === 'white' ? '#1d1b19' : '#ece7dc';
|
||||
return (
|
||||
<g strokeLinejoin="round" strokeLinecap="round">
|
||||
<path d={BASE} fill={fill} stroke={stroke} strokeWidth={3.2} />
|
||||
{shape.fill.map((d, i) => (
|
||||
<path key={i} d={d} fill={fill} stroke={stroke} strokeWidth={3.2} />
|
||||
))}
|
||||
{shape.balls?.map(([cx, cy, r], i) =>
|
||||
type === 'n' ? (
|
||||
<circle key={i} cx={cx} cy={cy} r={r} fill={detail} />
|
||||
) : (
|
||||
<circle key={i} cx={cx} cy={cy} r={r} fill={fill} stroke={stroke} strokeWidth={3.2} />
|
||||
),
|
||||
)}
|
||||
{shape.detail?.map((d, i) => (
|
||||
<path key={`d${i}`} d={d} fill="none" stroke={detail} strokeWidth={2.6} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** A piece as a standalone inline icon (player bars, picker, rules). */
|
||||
function PieceIcon({ type, color, size = 22 }: { type: PieceType; color: Color; size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<PieceShape type={type} color={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function letterPiece(p: string): { type: PieceType; color: Color } {
|
||||
return { type: p.toLowerCase() as PieceType, color: colorOfPiece(p) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
const LIGHT = '#efd9b4';
|
||||
const DARK = '#b38660';
|
||||
|
||||
function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
|
||||
const myTurn = v.phase === 'playing' && v.myColor !== null && v.myColor === v.turn;
|
||||
const [flip, setFlip] = useState(false);
|
||||
const bottom: Color = v.myColor ?? (flip ? 'black' : 'white');
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [hover, setHover] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 6 });
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [promo, setPromo] = useState<{ from: string; to: string } | null>(null);
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const drag = useRef<{ from: string; wasSelected: boolean; sx: number; sy: number; moved: boolean } | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moves.length);
|
||||
const movedRef = useRef<SVGGElement>(null);
|
||||
const dropped = useRef(false);
|
||||
|
||||
const byFrom = useMemo(() => {
|
||||
const m = new Map<string, LegalMove[]>();
|
||||
for (const mv of v.legalMoves ?? []) m.set(mv.from, [...(m.get(mv.from) ?? []), mv]);
|
||||
return m;
|
||||
}, [v.legalMoves]);
|
||||
const targets = selected ? (byFrom.get(selected) ?? []) : [];
|
||||
const showDots = v.hints !== 'off';
|
||||
|
||||
useEffect(() => {
|
||||
if (v.moves.length !== prevCount.current) {
|
||||
const last = v.moves[v.moves.length - 1];
|
||||
if (v.moves.length > prevCount.current && last) {
|
||||
// Slide the moved piece in (~150ms) unless I just dropped it there myself.
|
||||
const el = movedRef.current;
|
||||
if (el && v.lastMove && !dropped.current && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
const a = cellOf(v.lastMove.from, bottom);
|
||||
const b = cellOf(v.lastMove.to, bottom);
|
||||
el.animate([{ transform: `translate(${a.x - b.x}px, ${a.y - b.y}px)` }, { transform: 'translate(0px, 0px)' }], { duration: 150, easing: 'ease-out' });
|
||||
}
|
||||
sounds.stone();
|
||||
setAnnounce(`${COLOR_KO[last.color]}: ${last.san}${v.checkSquare ? ', 체크!' : ''}`);
|
||||
if (myTurn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
} else setAnnounce('수를 물렀어요.');
|
||||
}
|
||||
prevCount.current = v.moves.length;
|
||||
dropped.current = false;
|
||||
setSelected(null);
|
||||
setPromo(null);
|
||||
}, [v.moves.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const send = (from: string, to: string, promotion?: Promo) => {
|
||||
act({ type: 'move', from, to, ...(promotion ? { promotion } : {}) });
|
||||
setSelected(null);
|
||||
setPromo(null);
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
const attempt = (from: string, to: string) => {
|
||||
const mv = (byFrom.get(from) ?? []).find((m) => m.to === to);
|
||||
if (!mv || pending) return;
|
||||
if (mv.promotion && !v.autoQueen) setPromo({ from, to });
|
||||
else send(from, to);
|
||||
};
|
||||
|
||||
const ownMovable = (sq: string) => myTurn && byFrom.has(sq);
|
||||
|
||||
const click = (sq: string, wasSelected: boolean) => {
|
||||
if (!myTurn) return;
|
||||
if (selected && targets.some((m) => m.to === sq)) return attempt(selected, sq);
|
||||
const p = pieceAt(v, sq);
|
||||
if (p && colorOfPiece(p) === v.myColor) {
|
||||
if (!byFrom.has(sq)) {
|
||||
setSelected(null);
|
||||
setAnnounce(`${PIECE_KO[p.toLowerCase() as PieceType]}은 지금 움직일 수 없어요.`);
|
||||
haptic([30, 30, 30]);
|
||||
return;
|
||||
}
|
||||
setSelected(wasSelected ? null : sq);
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
setAnnounce('그 칸으로는 갈 수 없어요.');
|
||||
haptic([30, 30, 30]);
|
||||
}
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const toSvg = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
return pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
};
|
||||
const cellAt = (e: React.PointerEvent<SVGSVGElement>): string | null => {
|
||||
const p = toSvg(e);
|
||||
if (!p) return null;
|
||||
const x = Math.floor(p.x);
|
||||
const y = Math.floor(p.y);
|
||||
return x >= 0 && y >= 0 && x < 8 && y < 8 ? squareAt(x, y, bottom) : null;
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
const step = d[e.key];
|
||||
if (step) {
|
||||
e.preventDefault();
|
||||
setCursor((c) => ({ x: Math.min(7, Math.max(0, c.x + step[0])), y: Math.min(7, Math.max(0, c.y + step[1])) }));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const sq = squareAt(cursor.x, cursor.y, bottom);
|
||||
click(sq, selected === sq);
|
||||
} else if (e.key === 'Escape') setSelected(null);
|
||||
};
|
||||
|
||||
const cell = (sq: string) => cellOf(sq, bottom);
|
||||
const lastFrom = v.lastMove ? cell(v.lastMove.from) : null;
|
||||
const lastTo = v.lastMove ? cell(v.lastMove.to) : null;
|
||||
const check = v.checkSquare ? cell(v.checkSquare) : null;
|
||||
const hoverTarget = hover && selected && targets.some((m) => m.to === hover) ? cell(hover) : null;
|
||||
const dragging = drag.current?.moved && dragPos ? drag.current.from : null;
|
||||
const cursorSq = squareAt(cursor.x, cursor.y, bottom);
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox="-0.02 -0.02 8.04 8.04"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow)',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
cursor: dragging ? 'grabbing' : hover && (ownMovable(hover) || hoverTarget) ? 'pointer' : 'default',
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`체스판. ${v.myColor ? `나는 ${COLOR_KO[v.myColor]}이에요. ` : ''}방향키로 칸을 고르고 Enter로 기물을 집거나 놓아요. 현재 칸 ${cursorSq}${pieceAt(v, cursorSq) ? ` ${COLOR_KO[colorOfPiece(pieceAt(v, cursorSq)!)]} ${PIECE_KO[pieceAt(v, cursorSq)!.toLowerCase() as PieceType]}` : ''}`}
|
||||
onKeyDown={onKey}
|
||||
onPointerDown={(e) => {
|
||||
setKbd(false);
|
||||
const sq = cellAt(e);
|
||||
if (!sq || !ownMovable(sq) || pending) return;
|
||||
drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false };
|
||||
setSelected(sq);
|
||||
svgRef.current?.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const sq = cellAt(e);
|
||||
if (sq !== hover) setHover(sq);
|
||||
const d = drag.current;
|
||||
if (!d) return;
|
||||
if (!d.moved && Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > 6) d.moved = true;
|
||||
if (d.moved) setDragPos(toSvg(e));
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
const sq = cellAt(e);
|
||||
const d = drag.current;
|
||||
drag.current = null;
|
||||
setDragPos(null);
|
||||
if (d?.moved) {
|
||||
if (sq && sq !== d.from) {
|
||||
dropped.current = true;
|
||||
attempt(d.from, sq);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (sq) click(sq, d ? d.wasSelected : false);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
drag.current = null;
|
||||
setDragPos(null);
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="chess-check">
|
||||
<stop offset="0%" stopColor="#ff2a2a" stopOpacity={0.95} />
|
||||
<stop offset="45%" stopColor="#e11d1d" stopOpacity={0.75} />
|
||||
<stop offset="100%" stopColor="#a10000" stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{Array.from({ length: 64 }, (_, i) => {
|
||||
const x = i % 8;
|
||||
const y = Math.floor(i / 8);
|
||||
return <rect key={i} x={x} y={y} width={1} height={1} fill={(x + y) % 2 === 0 ? LIGHT : DARK} />;
|
||||
})}
|
||||
{lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />}
|
||||
{lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />}
|
||||
{selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />}
|
||||
{v.threatened?.map((sq) => {
|
||||
const c = cell(sq);
|
||||
return <rect key={`t${sq}`} x={c.x + 0.04} y={c.y + 0.04} width={0.92} height={0.92} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.14 0.08" />;
|
||||
})}
|
||||
{check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />}
|
||||
{/* Coordinates: files on the bottom row, ranks on the left column. */}
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<g key={`c${i}`} fontSize={0.2} fontWeight={700} style={{ pointerEvents: 'none' }}>
|
||||
<text x={i + 0.94} y={7.94} textAnchor="end" fill={(i + 7) % 2 === 0 ? DARK : LIGHT}>
|
||||
{squareAt(i, 7, bottom)[0]}
|
||||
</text>
|
||||
<text x={0.06} y={i + 0.22} fill={i % 2 === 0 ? DARK : LIGHT}>
|
||||
{squareAt(0, i, bottom)[1]}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{hoverTarget && <rect x={hoverTarget.x} y={hoverTarget.y} width={1} height={1} fill="#4f7a28" opacity={0.35} />}
|
||||
{v.board.map((p, i) => {
|
||||
if (!p) return null;
|
||||
const sq = sqOf(i % 8, 7 - Math.floor(i / 8));
|
||||
if (sq === dragging) return null;
|
||||
const c = cell(sq);
|
||||
const { type, color } = letterPiece(p);
|
||||
return (
|
||||
<g key={sq} transform={`translate(${c.x} ${c.y})`} style={{ pointerEvents: 'none' }}>
|
||||
<g ref={v.lastMove?.to === sq ? movedRef : undefined}>
|
||||
<g transform="translate(0.04 0.04) scale(0.0092)">
|
||||
<PieceShape type={type} color={color} />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{showDots &&
|
||||
targets.map((m) => {
|
||||
const c = cell(m.to);
|
||||
const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]);
|
||||
return capture ? (
|
||||
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.45} fill="none" stroke="rgba(20,40,10,0.45)" strokeWidth={0.08} style={{ pointerEvents: 'none' }} />
|
||||
) : (
|
||||
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.16} fill="rgba(20,40,10,0.38)" style={{ pointerEvents: 'none' }} />
|
||||
);
|
||||
})}
|
||||
{check && (
|
||||
<g transform={`translate(${check.x + 0.5} ${check.y < 1 ? check.y + 1.22 : check.y - 0.22})`} style={{ pointerEvents: 'none' }}>
|
||||
<rect x={-0.42} y={-0.17} width={0.84} height={0.34} rx={0.12} fill="#b91c1c" />
|
||||
<text textAnchor="middle" dominantBaseline="central" fontSize={0.2} fontWeight={800} fill="#fff">
|
||||
체크!
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
{dragging && dragPos && (
|
||||
<g transform={`translate(${dragPos.x - 0.55} ${dragPos.y - 0.6}) scale(0.011)`} style={{ pointerEvents: 'none' }}>
|
||||
<PieceShape {...letterPiece(pieceAt(v, dragging)!)} />
|
||||
</g>
|
||||
)}
|
||||
{kbd && <rect x={cursor.x + 0.03} y={cursor.y + 0.03} width={0.94} height={0.94} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{!v.myColor && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}>
|
||||
⇅ 판 돌리기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{promo && v.myColor && (
|
||||
<Sheet onClose={() => setPromo(null)} label="승진할 기물 고르기">
|
||||
<h2>어떤 기물로 바꿀까요?</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr', gap: 8 }}>
|
||||
{(['q', 'r', 'b', 'n'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`btn ${t === 'q' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ flexDirection: 'column', padding: 8, minHeight: 96 }}
|
||||
autoFocus={t === 'q'}
|
||||
onClick={() => send(promo.from, promo.to, t)}
|
||||
>
|
||||
<PieceIcon type={t} color={v.myColor!} size={t === 'q' ? 60 : 44} />
|
||||
<span>{PIECE_KO[t]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-block" onClick={() => setPromo(null)}>
|
||||
취소
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
const REASON_HELP: Partial<Record<NonNullable<ChessView['result']>['reason'], string>> = {
|
||||
checkmate: '킹이 공격받고 있는데 피할 곳도, 막을 방법도 없어요.',
|
||||
stalemate: '체크는 아니지만 둘 수 있는 수가 하나도 없어서 비겼어요.',
|
||||
insufficientMaterial: '남은 기물로는 어느 쪽도 체크메이트할 수 없어서 비겼어요.',
|
||||
threefold: '같은 국면이 세 번 나와서 비겼어요.',
|
||||
fivefold: '같은 국면이 다섯 번 나와서 비겼어요.',
|
||||
fiftyMove: '50수 동안 폰 이동도 잡기도 없어서 비겼어요.',
|
||||
seventyFiveMove: '75수 동안 폰 이동도 잡기도 없어서 비겼어요.',
|
||||
timeoutVsInsufficient: '시간이 다 됐지만 상대도 체크메이트할 기물이 없어서 비겼어요.',
|
||||
aborted: '첫 수를 두지 않아 대국이 취소됐어요. 승패는 기록되지 않아요.',
|
||||
};
|
||||
|
||||
function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<ChessView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'claim'>(null);
|
||||
const mine = v.myColor;
|
||||
const incoming =
|
||||
v.phase === 'playing' && mine
|
||||
? v.undo.pending && v.undo.pending.by !== mine
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== mine
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = !!mine && (v.undo.pending?.by === mine || v.draw.pendingBy === mine);
|
||||
const undoLeft = mine ? v.undo.remaining[mine] : null;
|
||||
const top: Color = mine ? other(mine) : 'black';
|
||||
const bottom: Color = mine ?? 'white';
|
||||
const beforeFirst = !!mine && !v.started[mine];
|
||||
const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === mine;
|
||||
return (
|
||||
<>
|
||||
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && mine && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={outgoing || !!v.draw.pendingBy || undoLeft === 0 || !v.moves.some((m) => m.color === mine)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={outgoing || !!v.undo.pending} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
{beforeFirst ? '✕ 대국 취소' : '🏳 기권'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canClaim && (
|
||||
<button className="btn btn-primary" onClick={() => setConfirm('claim')}>
|
||||
⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'})
|
||||
</button>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{v.result && REASON_HELP[v.result.reason] && (
|
||||
<div className="card" style={{ padding: 12 }}>
|
||||
<strong>{v.result.summary}</strong>
|
||||
<div className="muted small">{REASON_HELP[v.result.reason]}</div>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">{incoming === 'undo' ? '수락하면 기물이 되돌아가요. 시계는 되돌아가지 않아요.' : '수락하면 이 판은 무승부로 끝나요. 거절하거나 내가 수를 두면 제안이 사라져요.'}</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' &&
|
||||
(beforeFirst ? (
|
||||
<ConfirmSheet title="대국을 취소할까요?" body="아직 첫 수를 두기 전이라 승패 없이 끝나요." confirm="대국 취소" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
) : (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
))}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" body="상대가 수락하면 비겨요." confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
{confirm === 'claim' && (
|
||||
<ConfirmSheet title="무승부를 주장할까요?" body="규칙에 따라 상대 동의 없이 바로 비겨요." confirm="무승부 주장" onConfirm={() => act({ type: 'claimDraw' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: ChessView }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moves.length]);
|
||||
if (v.moves.length === 0) return null;
|
||||
// Rows of [white, black]; a game started by black would show "…" first (not possible in standard chess).
|
||||
const rows: { no: number; w?: string; b?: string }[] = [];
|
||||
for (const m of v.moves) {
|
||||
if (m.color === 'white' || rows.length === 0) rows.push({ no: rows.length + 1 });
|
||||
rows[rows.length - 1]![m.color === 'white' ? 'w' : 'b'] = m.san;
|
||||
}
|
||||
const lastIdx = v.moves.length - 1;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({Math.ceil(v.moves.length / 2)}수)</summary>
|
||||
<div ref={ref} style={{ marginTop: 8, maxHeight: 180, overflow: 'auto', display: 'grid', gridTemplateColumns: '2.5em 1fr 1fr', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{rows.map((r, i) => (
|
||||
<MoveRow key={i} no={r.no} w={r.w} b={r.b} lastW={i * 2 === lastIdx} lastB={i * 2 + 1 === lastIdx} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveRow({ no, w, b, lastW, lastB }: { no: number; w?: string; b?: string; lastW: boolean; lastB: boolean }) {
|
||||
const cell = (san: string | undefined, last: boolean) => (
|
||||
<span className="small" style={{ fontWeight: last ? 800 : 500, background: last ? 'var(--surface-2)' : undefined, borderRadius: 4, padding: '0 4px' }}>
|
||||
{san ?? ''}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<span className="small muted">{no}.</span>
|
||||
{cell(w, lastW)}
|
||||
{cell(b, lastB)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
const clamped = Math.max(0, ms);
|
||||
if (clamped < 20_000) return `${Math.floor(clamped / 1000)}.${Math.floor((clamped % 1000) / 100)}`;
|
||||
const s = Math.ceil(clamped / 1000);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(100);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (active && !v.started[color]) {
|
||||
clock = (
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="small muted">첫 수</span>
|
||||
<TurnTimer deadline={deadline} totalMs={v.firstMoveSec * 1000} mine={id === me} />
|
||||
</span>
|
||||
);
|
||||
} else if (tc.kind === 'fischer') {
|
||||
const ms = v.remainingMs[color] - (active && v.started[color] ? now - v.turnStartedAt : 0);
|
||||
const low = ms <= 10_000;
|
||||
clock = (
|
||||
<span
|
||||
style={{
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontWeight: 800,
|
||||
fontSize: '1.35rem',
|
||||
padding: '2px 10px',
|
||||
borderRadius: 8,
|
||||
background: active ? (low ? 'var(--danger)' : 'var(--text)') : 'var(--surface-2)',
|
||||
color: active ? 'var(--surface)' : low ? 'var(--danger)' : undefined,
|
||||
}}
|
||||
role="timer"
|
||||
aria-label={`${COLOR_KO[color]} 남은 시간 ${fmtClock(ms)}`}
|
||||
>
|
||||
{fmtClock(ms)}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
clock = <TurnTimer deadline={deadline} totalMs={v.idleLimitSec * 1000} mine={id === me} />;
|
||||
}
|
||||
const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b));
|
||||
const adv = color === 'white' ? v.materialDiff : -v.materialDiff;
|
||||
return (
|
||||
<div className="card" style={{ padding: '8px 12px', border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<PieceIcon type="k" color={color} size={30} />
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{COLOR_KO[color]}
|
||||
{active ? ' · 두는 중' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{clock}
|
||||
</div>
|
||||
{(caps.length > 0 || adv > 0) && (
|
||||
<div className="row" style={{ gap: 0, marginTop: 4, minHeight: 20, flexWrap: 'wrap' }} aria-label={`잡은 기물 ${caps.map((c) => PIECE_KO[c as PieceType]).join(', ')}`}>
|
||||
{caps.map((c, i) => (
|
||||
<PieceIcon key={i} type={c as PieceType} color={other(color)} size={20} />
|
||||
))}
|
||||
{adv > 0 && <span className="small muted" style={{ marginLeft: 6, fontWeight: 700 }}>+{adv}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: ChessView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
const mine = v.myColor;
|
||||
if (mine && v.undo.pending && v.undo.pending.by !== mine) return '상대가 무르기를 요청했어요';
|
||||
if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요';
|
||||
if (mine === v.turn) {
|
||||
if (v.checkSquare) return '체크! 킹을 지켜야 해요';
|
||||
if (!v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`;
|
||||
if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요';
|
||||
return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
const check = v.checkSquare ? ' (체크)' : '';
|
||||
return mine ? `${nick}님이 생각 중이에요…${check}` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요${check}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, ChessOptions['timeControl']][] = [
|
||||
['3+2', '3+2 블리츠', { kind: 'fischer', baseMin: 3, incSec: 2 }],
|
||||
['5+0', '5+0', { kind: 'fischer', baseMin: 5, incSec: 0 }],
|
||||
['5+3', '5+3', { kind: 'fischer', baseMin: 5, incSec: 3 }],
|
||||
['10+0', '10+0 래피드', { kind: 'fischer', baseMin: 10, incSec: 0 }],
|
||||
['10+5', '10+5', { kind: 'fischer', baseMin: 10, incSec: 5 }],
|
||||
['15+10', '15+10', { kind: 'fischer', baseMin: 15, incSec: 10 }],
|
||||
['30+0', '30+0 클래식', { kind: 'fischer', baseMin: 30, incSec: 0 }],
|
||||
['none', '시간 제한 없음', { kind: 'none', baseMin: 10, incSec: 5 }],
|
||||
];
|
||||
const timeKey = (tc: ChessOptions['timeControl']) => (tc.kind === 'none' ? 'none' : `${tc.baseMin}+${tc.incSec}`);
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<ChessOptions>) => onChange({ ...o, ...patch });
|
||||
const key = timeKey(o.timeControl);
|
||||
const isPreset = TIME_PRESETS.some((p) => p[0] === key);
|
||||
const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min));
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간 (기본 분 + 한 수마다 추가 초)</strong>
|
||||
<Seg
|
||||
value={key}
|
||||
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(isPreset ? [] : [[key, `${timeLabel(o.timeControl)} (직접 설정)`] as [string, string]])]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: preset[2] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>백(먼저 두는 쪽)</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['random', '무작위'], ['hostWhite', '방장이 백'], ['hostBlack', '방장이 흑'], ['alternate', '판마다 번갈아']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>초보 도움말</strong>
|
||||
<Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['movesAndThreats', '+ 위험한 기물 경고'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
{o.timeControl.kind === 'fischer' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간 직접 정하기</strong>
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
기본
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={o.timeControl.baseMin}
|
||||
onChange={(e) => set({ timeControl: { ...o.timeControl, baseMin: num(Number(e.target.value), 1, 90) } })}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
분
|
||||
</label>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
+ 한 수마다
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
value={o.timeControl.incSec}
|
||||
onChange={(e) => set({ timeControl: { ...o.timeControl, incSec: num(Number(e.target.value), 0, 60) } })}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
초
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{o.timeControl.kind === 'none' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>응답이 없으면 시간패</strong>
|
||||
<Seg value={o.idleLimitSec} options={[[300, '5분'], [600, '10분']]} onChange={(idleLimitSec) => set({ idleLimitSec })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>첫 수 제한 (넘기면 대국 취소)</strong>
|
||||
<Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>3회 반복 · 50수 무승부</strong>
|
||||
<Seg value={o.drawMode} options={[['auto', '자동으로 무승부'], ['fide', 'FIDE (주장해야 무승부)']]} onChange={(drawMode) => set({ drawMode })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기 (상대 동의)</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>그 밖에</strong>
|
||||
<Seg value={o.autoQueen ? 'on' : 'off'} options={[['off', '승진할 때 기물 고르기'], ['on', '승진하면 바로 퀸']]} onChange={(k) => set({ autoQueen: k === 'on' })} />
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeLabel(tc: ChessOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
return `${tc.baseMin}분 + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function optionsSummary(o: ChessOptions): string {
|
||||
const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`;
|
||||
return ['표준', timeLabel(o.timeControl), undo].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
const PIECE_RULES: [PieceType, string][] = [
|
||||
['k', '가로·세로·대각선으로 한 칸. 공격받는 칸으로는 갈 수 없어요. 잡히면 안 되는 가장 중요한 기물이에요.'],
|
||||
['q', '가로·세로·대각선으로 몇 칸이든. 가장 강한 기물이에요(9점).'],
|
||||
['r', '가로·세로로 몇 칸이든(5점).'],
|
||||
['b', '대각선으로 몇 칸이든. 처음 칸과 같은 색 칸만 다녀요(3점).'],
|
||||
['n', "'ㄱ'자(두 칸 + 옆 한 칸)로 움직이고, 다른 기물을 뛰어넘을 수 있어요(3점)."],
|
||||
['p', '앞으로 한 칸(처음 자리에서는 두 칸까지). 잡을 때만 대각선 앞으로 한 칸 가요(1점).'],
|
||||
];
|
||||
|
||||
function Rules({ options }: { options?: ChessOptions }) {
|
||||
const fide = options?.drawMode === 'fide';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요.
|
||||
내 킹이 공격받게 되는 수는 둘 수 없어요.
|
||||
</p>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{PIECE_RULES.map(([t, text]) => (
|
||||
<div key={t} className="row" style={{ gap: 10, alignItems: 'flex-start' }}>
|
||||
<PieceIcon type={t} color="white" size={34} />
|
||||
<div>
|
||||
<strong>{PIECE_KO[t]}</strong> <span className="muted">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
|
||||
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>승진</strong>: 폰이 끝 줄에 닿으면 퀸·룩·비숍·나이트 중 하나로 바꿔요. 보통은 퀸을 골라요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>비기는 경우</strong>: 체크가 아닌데 둘 수가 없을 때(스테일메이트), 남은 기물로 체크메이트가 불가능할 때(예: 킹 대 킹), 같은 국면이 3번 나오거나 50수 동안 폰 이동과 잡기가
|
||||
없을 때{fide ? '(이 방은 차례인 사람이 [무승부 주장]을 눌러야 해요. 5번 반복·75수는 자동)' : '(이 방은 자동으로 비겨요)'}, 그리고 서로 합의했을 때예요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>시간</strong>: 수를 둘 때마다 정해진 초가 더해져요. 시간이 다 되면 져요. 단 상대에게 체크메이트할 기물이 없으면 비겨요. 각자 첫 수는 시계가 줄지 않지만 정해진 시간 안에 두지
|
||||
않으면 대국이 취소돼요.
|
||||
</p>
|
||||
<p>조작: 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 기물을 끌어다 놓으면 둬요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<ChessView, ChessOptions> = {
|
||||
Board: ChessBoard,
|
||||
Side: ChessSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
1059
apps/web/src/games/cockroach-poker/index.tsx
Normal file
1059
apps/web/src/games/cockroach-poker/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1007
apps/web/src/games/dalmuti/index.tsx
Normal file
1007
apps/web/src/games/dalmuti/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
956
apps/web/src/games/davinci-code/index.tsx
Normal file
956
apps/web/src/games/davinci-code/index.tsx
Normal file
@@ -0,0 +1,956 @@
|
||||
/** 다빈치 코드 화면 (docs/games/davinci-code.md §9). */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DavinciGuess, DavinciOptions, DavinciTileView, DavinciView, PhaseKind, TColor, TileValue } from '@bg/games/davinci-code';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
|
||||
const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' };
|
||||
const NUMBERS = Array.from({ length: 12 }, (_, i) => i);
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
|
||||
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const valueKo = (v: TileValue) => (v === 'joker' ? '조커' : String(v));
|
||||
const faceOf = (v: TileValue) => (v === 'joker' ? '−' : String(v));
|
||||
const hiddenLeft = (row: DavinciTileView[]) => row.filter((t) => !t.revealed).length;
|
||||
|
||||
function totalMsOf(v: DavinciView): number {
|
||||
if (v.phase === 'chooseStart') return 15_000;
|
||||
if ((v.idle[v.current] ?? 0) >= 3) return 5_000;
|
||||
return v.phase === 'guess' ? v.options.turnSeconds * 1000 : 10_000;
|
||||
}
|
||||
|
||||
/** Seats starting with the one after `me` (clockwise); spectators see everyone. */
|
||||
function opponentsOf(v: DavinciView, me: string | null): string[] {
|
||||
const i = me ? v.seats.indexOf(me) : -1;
|
||||
if (i < 0) return v.seats;
|
||||
return v.seats.map((_, k) => v.seats[(i + 1 + k) % v.seats.length]!).filter((p) => p !== me);
|
||||
}
|
||||
|
||||
/** Colour+number combinations the viewer can already see (for the "impossible guess" warning). */
|
||||
function visibleCombos(v: DavinciView): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const row of Object.values(v.rows)) for (const t of row) if (t.n !== undefined) out.add(`${t.color}:${t.n}`);
|
||||
if (v.pending?.n !== undefined) out.add(`${v.pending.color}:${v.pending.n}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function guessLine(g: DavinciGuess, players: Record<string, PublicUser>): string {
|
||||
return `${nick(players, g.by)} → ${nick(players, g.target)} ${g.index + 1}번째 ${COLOR_SHORT[g.color]} = ${valueKo(g.said)} (${g.correct ? '맞힘' : '틀림'}${g.auto ? ', 시간 초과' : ''})`;
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.dv-board { width: 100%; max-width: 980px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; }
|
||||
.dv-opps { display: grid; gap: 10px; }
|
||||
.dv-player { background: var(--surface); border-radius: var(--radius-lg); padding: 10px 12px; box-shadow: var(--shadow); border: 3px solid transparent; }
|
||||
.dv-player.turn { border-color: var(--focus); }
|
||||
.dv-player.out { opacity: .7; }
|
||||
.dv-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 32px; }
|
||||
.dv-name { font-weight: 800; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dv-badge { display: inline-block; border-radius: 999px; padding: 1px 9px; font-weight: 800; font-size: .8rem; background: var(--surface-2); color: var(--text); }
|
||||
.dv-badge.turn { background: var(--focus); color: #1c1e21; }
|
||||
.dv-badge.out { background: var(--danger-soft); color: var(--danger); }
|
||||
.dv-badge.ok { background: var(--success-soft); color: var(--success); }
|
||||
.dv-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-end; padding-top: 8px; }
|
||||
.dv-slot { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.dv-pos { font-size: .75rem; color: var(--text-sub); font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.dv-tile { --w: 46px; width: var(--w); height: calc(var(--w) * 1.42); border-radius: 8px; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 900; font-size: calc(var(--w) * .56); line-height: 1; position: relative; user-select: none; font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,.35); transition: transform .15s ease; }
|
||||
.dv-tile.black { background: linear-gradient(160deg, #42454d, #141519); color: #fff; border: 2px solid color-mix(in srgb, var(--text) 30%, #050505); }
|
||||
.dv-tile.white { background: linear-gradient(160deg, #ffffff, #e2ded3); color: #15161a; border: 2px solid #9d978a; }
|
||||
.dv-tile.back.black { background: repeating-linear-gradient(45deg, #2a2c33 0 5px, #1b1c21 5px 10px); }
|
||||
.dv-tile.back.white { background: repeating-linear-gradient(45deg, #fbf9f4 0 5px, #e6e1d6 5px 10px); }
|
||||
.dv-tile .q { opacity: .45; font-size: .62em; }
|
||||
.dv-tile.open::after { content: ''; position: absolute; left: 18%; right: 18%; bottom: 4px; height: 4px; border-radius: 2px; background: #f59e0b; }
|
||||
.dv-tile.secret { outline: 3px dashed var(--primary); outline-offset: 2px; }
|
||||
.dv-tile.sm { --w: 30px; }
|
||||
.dv-tile.lg { --w: 54px; }
|
||||
.dv-tile.hit { animation: dv-flip .5s ease-out, dv-hit 1.2s ease-out; }
|
||||
.dv-tile.miss { animation: dv-flip .5s ease-out, dv-miss 1.2s ease-out; }
|
||||
.dv-tile.new { animation: dv-drop .35s ease-out; }
|
||||
@keyframes dv-flip { 0% { transform: rotateY(90deg); } 100% { transform: rotateY(0); } }
|
||||
@keyframes dv-hit { 0%, 60% { box-shadow: 0 0 0 4px #22c55e, 0 0 18px 6px rgba(34,197,94,.7); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
|
||||
@keyframes dv-miss { 0%, 60% { box-shadow: 0 0 0 4px #ef4444, 0 0 18px 6px rgba(239,68,68,.6); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
|
||||
@keyframes dv-drop { 0% { transform: translateY(-14px); opacity: .3; } 100% { transform: translateY(0); opacity: 1; } }
|
||||
.dv-pick { background: none; border: 0; padding: 3px; border-radius: 11px; cursor: pointer; color: inherit; }
|
||||
.dv-pick:disabled { cursor: default; }
|
||||
.dv-pick.can { box-shadow: 0 0 0 3px var(--primary); }
|
||||
.dv-pick.can:hover .dv-tile { transform: translateY(-3px); }
|
||||
.dv-pick.sel { box-shadow: 0 0 0 4px var(--focus); }
|
||||
.dv-pick.sel .dv-tile { transform: translateY(-6px); }
|
||||
.dv-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-gap { align-self: stretch; min-width: 28px; min-height: 48px; border: 2px dashed var(--primary); background: var(--primary-soft); color: var(--text);
|
||||
border-radius: 8px; font-weight: 800; cursor: pointer; padding: 0 2px; font-size: .8rem; }
|
||||
.dv-gap:focus-visible { outline: 3px solid var(--focus); }
|
||||
.dv-mid { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||
.dv-center { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
||||
.dv-piles { display: flex; gap: 12px; justify-content: center; }
|
||||
.dv-pile { display: flex; flex-direction: column; align-items: center; gap: 4px; background: rgba(0,0,0,.18); border: 2px solid transparent; border-radius: 12px;
|
||||
padding: 8px 10px; color: #fff; font-weight: 800; min-width: 92px; min-height: 48px; cursor: pointer; font-size: .9rem; }
|
||||
.dv-pile:disabled { cursor: default; opacity: .8; }
|
||||
.dv-pile.can { border-color: #fde047; background: rgba(253,224,71,.15); }
|
||||
.dv-pile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-stack { position: relative; width: 44px; height: 50px; }
|
||||
.dv-stack .dv-tile { position: absolute; left: 0; }
|
||||
.dv-pending { display: flex; align-items: center; gap: 10px; font-size: .9rem; }
|
||||
.dv-panel { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.dv-pad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.dv-pad button { min-height: 56px; font-size: 1.35rem; font-weight: 900; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-variant-numeric: tabular-nums; }
|
||||
.dv-pad button.unlikely { opacity: .4; }
|
||||
.dv-pad button[aria-pressed='true'] { background: var(--primary); color: var(--primary-text); border-color: var(--primary); opacity: 1; }
|
||||
.dv-pad button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-pad .joker { grid-column: 1 / -1; font-size: 1.1rem; }
|
||||
.dv-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.dv-big .btn { min-height: 64px; font-size: 1.15rem; }
|
||||
.dv-split { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||
.dv-split button { min-height: 56px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 8px; }
|
||||
.dv-split button[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.dv-split button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.dv-split button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-mini { display: inline-block; width: 12px; height: 17px; border-radius: 3px; vertical-align: middle; }
|
||||
.dv-mini.black { background: #1b1c21; border: 1px solid #000; }
|
||||
.dv-mini.white { background: #fff; border: 1px solid #8f8a7e; }
|
||||
.dv-me { border-color: var(--primary); }
|
||||
.dv-legend { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px 16px; flex-wrap: wrap; align-items: center; }
|
||||
.dv-legend > span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.dv-me .dv-slot { gap: 6px; }
|
||||
.dv-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; font-size: .9rem; }
|
||||
.dv-log li.hit::before { content: '✓ '; color: var(--success); font-weight: 900; }
|
||||
.dv-log li.miss::before { content: '✗ '; color: var(--danger); font-weight: 900; }
|
||||
@media (max-width: 719px) {
|
||||
.dv-tile { --w: 34px; border-radius: 6px; }
|
||||
.dv-tile.lg { --w: 37px; }
|
||||
.dv-row { gap: 4px; }
|
||||
.dv-mid { grid-template-columns: minmax(0, 1fr); }
|
||||
.dv-board { max-height: none; overflow: visible; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dv-tile.hit, .dv-tile.miss, .dv-tile.new { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- tiles
|
||||
|
||||
type Fx = 'hit' | 'miss' | 'new';
|
||||
|
||||
function Tile({ color, n, revealed, mine, size, fx }: { color: TColor; n?: TileValue; revealed: boolean; mine?: boolean; size?: 'sm' | 'lg'; fx?: Fx }) {
|
||||
const known = n !== undefined;
|
||||
const cls = ['dv-tile', color, known ? '' : 'back', revealed ? 'open' : '', mine && !revealed ? 'secret' : '', size ?? '', fx ?? ''].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className={cls} aria-hidden>
|
||||
{known ? faceOf(n) : <span className="q">?</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function tileLabel(t: DavinciTileView, pos: number, mine: boolean): string {
|
||||
const what = t.n === undefined ? '숨김' : t.n === 'joker' ? '조커' : `${t.n}`;
|
||||
return `${pos + 1}번째, ${COLOR_KO[t.color]} ${what}${t.revealed ? ', 공개됨' : mine ? ', 나만 보임' : ''}${t.wrong.length ? `, 틀린 추리 ${t.wrong.map(valueKo).join('·')}` : ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
interface Sel {
|
||||
target: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function DavinciBoard({ view: v, me, players, act, pending, deadline }: BoardProps<DavinciView>) {
|
||||
const amPlayer = !!me && v.seats.includes(me);
|
||||
const myTurn = amPlayer && v.current === me && !!v.alive[me!] && v.phase !== 'chooseStart' && v.phase !== 'finished';
|
||||
const [sel, setSel] = useState<Sel | null>(null);
|
||||
const [val, setVal] = useState<TileValue | null>(null);
|
||||
const [fx, setFx] = useState<Map<string, Fx>>(new Map());
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevHist = useRef(v.history.length);
|
||||
const prevPlaced = useRef(JSON.stringify(v.lastPlaced));
|
||||
const prevTurn = useRef(`${v.current}|${v.phase === 'chooseStart'}`);
|
||||
|
||||
const resetKey = `${v.phase}|${v.current}|${v.history.length}`;
|
||||
useEffect(() => {
|
||||
setSel(null);
|
||||
setVal(null);
|
||||
}, [resetKey]);
|
||||
|
||||
// Effects + screen-reader text for new guesses and newly placed tiles.
|
||||
useEffect(() => {
|
||||
const placedNow = JSON.stringify(v.lastPlaced);
|
||||
const next = new Map<string, Fx>();
|
||||
if (v.history.length > prevHist.current) {
|
||||
const g = v.history[v.history.length - 1]!;
|
||||
if (g.correct) next.set(`${g.target}:${g.index}`, 'hit');
|
||||
else if (v.lastPlaced && v.lastPlaced.player === g.by && v.lastPlaced.revealed) next.set(`${g.by}:${v.lastPlaced.index}`, 'miss');
|
||||
setAnnounce(
|
||||
`${nick(players, g.by)}님이 ${nick(players, g.target)}님 ${g.index + 1}번째 타일을 ${valueKo(g.said)}(으)로 추리해서 ${g.correct ? '맞혔어요' : '틀렸어요'}.`,
|
||||
);
|
||||
if (g.correct && g.by === me) haptic(30);
|
||||
} else if (placedNow !== prevPlaced.current && v.lastPlaced) {
|
||||
next.set(`${v.lastPlaced.player}:${v.lastPlaced.index}`, v.lastPlaced.revealed ? 'miss' : 'new');
|
||||
}
|
||||
prevHist.current = v.history.length;
|
||||
prevPlaced.current = placedNow;
|
||||
if (next.size) {
|
||||
setFx(next);
|
||||
const t = setTimeout(() => setFx(new Map()), 1300);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [v.history.length, JSON.stringify(v.lastPlaced)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const key = `${v.current}|${v.phase === 'chooseStart'}`;
|
||||
if (key !== prevTurn.current && v.phase !== 'chooseStart' && v.phase !== 'finished') {
|
||||
if (v.current === me) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
} else setAnnounce(`${nick(players, v.current)}님 차례예요.`);
|
||||
}
|
||||
prevTurn.current = key;
|
||||
}, [v.current, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canGuess = myTurn && v.phase === 'guess' && !pending;
|
||||
const pick = (target: string, index: number) => {
|
||||
if (sel && sel.target === target && sel.index === index) {
|
||||
setSel(null);
|
||||
setVal(null);
|
||||
} else {
|
||||
setSel({ target, index });
|
||||
setVal(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dv-board" role="region" aria-label="다빈치 코드 판">
|
||||
<style>{CSS}</style>
|
||||
<div className="dv-opps">
|
||||
{opponentsOf(v, me).map((p) => (
|
||||
<PlayerRow key={p} id={p} view={v} me={me} players={players} deadline={deadline} fx={fx} canPick={canGuess && !!v.alive[p]} sel={sel} onPick={pick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="dv-mid">
|
||||
<Center view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} />
|
||||
<ActionPanel view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} sel={sel} val={val} setVal={setVal} onCancel={() => (setSel(null), setVal(null))} />
|
||||
</div>
|
||||
{amPlayer && <MyRow view={v} me={me!} players={players} act={act} pending={pending} myTurn={myTurn} deadline={deadline} fx={fx} />}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerHead({ id, view: v, me, players, deadline }: { id: string; view: DavinciView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
|
||||
const u = userOf(players, id);
|
||||
const row = v.rows[id] ?? [];
|
||||
const st = v.stats[id] ?? { guesses: 0, correct: 0 };
|
||||
const outRank = v.eliminationOrder.indexOf(id);
|
||||
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
|
||||
const choosing = v.phase === 'chooseStart' && v.alive[id] && v.picked && !v.picked[id];
|
||||
return (
|
||||
<div className="dv-head">
|
||||
<Avatar user={u} size={26} />
|
||||
<span className="dv-name">
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted"> (나)</span>}
|
||||
</span>
|
||||
{isTurn && <span className="dv-badge turn">차례</span>}
|
||||
{v.final?.winner === id && <span className="dv-badge ok">승리</span>}
|
||||
{!v.alive[id] && <span className="dv-badge out">{v.left[id] ? '나감' : '탈락'}{outRank >= 0 ? ` · ${v.seats.length - outRank}위` : ''}</span>}
|
||||
{v.alive[id] && (v.idle[id] ?? 0) >= 3 && <span className="dv-badge">자리 비움</span>}
|
||||
{v.phase === 'chooseStart' && v.alive[id] && <span className={`dv-badge${choosing ? '' : ' ok'}`}>{choosing ? '고르는 중' : '다 골랐어요'}</span>}
|
||||
<span className="small muted" style={{ marginLeft: 'auto' }}>
|
||||
{v.phase !== 'chooseStart' && `숨은 타일 ${hiddenLeft(row)}개 · `}맞힘 {st.correct}/{st.guesses}
|
||||
</span>
|
||||
{(isTurn || choosing) && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={id === me} size={30} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerRow({
|
||||
id,
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
deadline,
|
||||
fx,
|
||||
canPick,
|
||||
sel,
|
||||
onPick,
|
||||
}: {
|
||||
id: string;
|
||||
view: DavinciView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
deadline: number | null;
|
||||
fx: Map<string, Fx>;
|
||||
canPick: boolean;
|
||||
sel: Sel | null;
|
||||
onPick: (target: string, index: number) => void;
|
||||
}) {
|
||||
const row = v.rows[id] ?? [];
|
||||
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
|
||||
const name = nick(players, id);
|
||||
return (
|
||||
<section className={`dv-player${isTurn ? ' turn' : ''}${v.alive[id] || v.phase === 'finished' ? '' : ' out'}`} aria-label={`${name}님 타일`}>
|
||||
<PlayerHead id={id} view={v} me={me} players={players} deadline={deadline} />
|
||||
<div className="dv-row" role="group" aria-label={`${name}님 타일 줄`}>
|
||||
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일을 기다리는 중이에요' : '타일이 없어요'}</span>}
|
||||
{row.map((t, i) => {
|
||||
const can = canPick && !t.revealed;
|
||||
const selected = sel?.target === id && sel.index === i;
|
||||
return (
|
||||
<div className="dv-slot" key={i}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dv-pick${can ? ' can' : ''}${selected ? ' sel' : ''}`}
|
||||
disabled={!can}
|
||||
aria-pressed={can ? selected : undefined}
|
||||
aria-label={`${name}님 ${tileLabel(t, i, false)}${can ? ', 눌러서 추리' : ''}`}
|
||||
onClick={() => onPick(id, i)}
|
||||
>
|
||||
<Tile color={t.color} n={t.n} revealed={t.revealed} fx={fx.get(`${id}:${i}`)} />
|
||||
</button>
|
||||
<span className="dv-pos" aria-hidden>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MyRow({
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
act,
|
||||
pending,
|
||||
myTurn,
|
||||
deadline,
|
||||
fx,
|
||||
}: {
|
||||
view: DavinciView;
|
||||
me: string;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
myTurn: boolean;
|
||||
deadline: number | null;
|
||||
fx: Map<string, Fx>;
|
||||
}) {
|
||||
const row = v.rows[me] ?? [];
|
||||
const placing = myTurn && v.phase === 'placeJoker' && !pending;
|
||||
const revealing = myTurn && v.phase === 'revealOwn' && !pending;
|
||||
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === me;
|
||||
const gap = (index: number) =>
|
||||
placing && (
|
||||
<button key={`gap${index}`} type="button" className="dv-gap" aria-label={`${index + 1}번째 자리에 조커 넣기`} onClick={() => act({ type: 'placeJoker', index })}>
|
||||
여기
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<section className={`dv-player dv-me${isTurn ? ' turn' : ''}${v.alive[me] || v.phase === 'finished' ? '' : ' out'}`} aria-label="내 타일">
|
||||
<PlayerHead id={me} view={v} me={me} players={players} deadline={deadline} />
|
||||
<div className="dv-row" role="group" aria-label="내 타일 줄">
|
||||
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일 색을 고르면 여기에 놓여요' : '타일이 없어요'}</span>}
|
||||
{row.map((t, i) => {
|
||||
const can = revealing && !t.revealed;
|
||||
return [
|
||||
gap(i),
|
||||
<div className="dv-slot" key={i}>
|
||||
{can ? (
|
||||
<button type="button" className="dv-pick can" aria-label={`내 ${tileLabel(t, i, true)}, 눌러서 공개`} onClick={() => act({ type: 'revealOwn', index: i })}>
|
||||
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="dv-pick" role="img" aria-label={`내 ${tileLabel(t, i, true)}`}>
|
||||
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
|
||||
</span>
|
||||
)}
|
||||
<span className="dv-pos" aria-hidden>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>,
|
||||
];
|
||||
})}
|
||||
{gap(row.length)}
|
||||
</div>
|
||||
{row.length > 0 && (
|
||||
<div className="dv-legend" style={{ marginTop: 6 }}>
|
||||
<span>
|
||||
<span className="dv-mini white" style={{ outline: '2px dashed var(--primary)', outlineOffset: 1 }} /> 점선: 상대에게 숨김
|
||||
</span>
|
||||
<span>
|
||||
<span className="dv-mini white" style={{ boxShadow: 'inset 0 -4px 0 #f59e0b' }} /> 아래 노란 줄: 모두에게 공개됨
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Center({ view: v, me, players, act, pending, myTurn }: { view: DavinciView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; myTurn: boolean }) {
|
||||
const drawing = myTurn && v.phase === 'draw' && !pending;
|
||||
const total = v.pool.black + v.pool.white;
|
||||
const last = v.history[v.history.length - 1];
|
||||
return (
|
||||
<div className="dv-center" role="group" aria-label="더미">
|
||||
{v.options.chooseColors ? (
|
||||
<div className="dv-piles">
|
||||
{(['black', 'white'] as const).map((c) => {
|
||||
const can = drawing && v.pool[c] > 0;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`dv-pile${can ? ' can' : ''}`}
|
||||
disabled={!can}
|
||||
aria-label={`${COLOR_KO[c]} 타일 가져오기, ${v.pool[c]}개 남음`}
|
||||
onClick={() => act({ type: 'draw', color: c })}
|
||||
>
|
||||
<span className="dv-stack" aria-hidden>
|
||||
{v.pool[c] > 0 ? (
|
||||
[0, 1, 2].slice(0, Math.min(3, v.pool[c])).map((k) => (
|
||||
<span key={k} style={{ position: 'absolute', left: 7 + k * 3, top: 6 - k * 3 }}>
|
||||
<Tile color={c} revealed={false} size="sm" />
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>없음</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{COLOR_SHORT[c]} {v.pool[c]}개
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={`dv-pile${drawing ? ' can' : ''}`} disabled={!drawing} aria-label={`타일 가져오기, ${total}개 남음`} onClick={() => act({ type: 'draw' })}>
|
||||
<span>타일 가져오기</span>
|
||||
<span className="small">
|
||||
남은 타일 {total}개 (검정 {v.pool.black} · 흰색 {v.pool.white})
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{v.pending ? (
|
||||
<div className="dv-pending">
|
||||
<Tile color={v.pending.color} n={v.pending.n} revealed={false} mine={v.current === me} />
|
||||
<span>
|
||||
{v.current === me ? '내가 가져온 타일' : `${nick(players, v.current)}님이 가져온 타일`}
|
||||
<br />
|
||||
<span className="small" style={{ opacity: 0.85 }}>
|
||||
{v.current === me ? '나만 보여요' : '색만 보여요'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
v.phase !== 'chooseStart' &&
|
||||
v.phase !== 'finished' &&
|
||||
total === 0 && <span className="small">더미가 비었어요. 가져오지 않고 바로 추리해요.</span>
|
||||
)}
|
||||
{last && (
|
||||
<span className="small" style={{ textAlign: 'center' }} aria-hidden>
|
||||
최근: {guessLine(last, players)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionPanel({
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
act,
|
||||
pending,
|
||||
myTurn,
|
||||
sel,
|
||||
val,
|
||||
setVal,
|
||||
onCancel,
|
||||
}: {
|
||||
view: DavinciView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
myTurn: boolean;
|
||||
sel: Sel | null;
|
||||
val: TileValue | null;
|
||||
setVal: (v: TileValue | null) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (v.phase === 'finished') {
|
||||
return (
|
||||
<div className="dv-panel" aria-live="polite">
|
||||
<strong>게임이 끝났어요</strong>
|
||||
<span>{v.final?.summary}</span>
|
||||
<span className="small muted">모든 타일을 공개했어요.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (v.phase === 'chooseStart') return <StartChooser view={v} me={me} act={act} pending={pending} />;
|
||||
if (!myTurn) {
|
||||
const who = nick(players, v.current);
|
||||
const doing: Record<PhaseKind, string> = {
|
||||
chooseStart: '',
|
||||
draw: '타일을 가져오는 중이에요',
|
||||
guess: '추리하는 중이에요',
|
||||
afterCorrect: '맞혔어요! 계속할지 고르는 중이에요',
|
||||
placeJoker: '조커를 넣을 자리를 고르는 중이에요',
|
||||
revealOwn: '틀려서 자기 타일을 공개하는 중이에요',
|
||||
finished: '',
|
||||
};
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>
|
||||
{who}님이 {doing[v.phase]}
|
||||
</strong>
|
||||
{me && v.seats.includes(me) && !v.alive[me] && <span className="small muted">나는 탈락했어요. 끝까지 구경할 수 있어요.</span>}
|
||||
<span className="small muted">공개된 숫자와 타일 위치, 추리 기록을 보고 상대의 숫자를 짐작해 보세요.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (v.phase) {
|
||||
case 'draw':
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>더미에서 타일을 하나 가져와 주세요.</strong>
|
||||
<span className="small muted">
|
||||
{v.options.chooseColors ? '검정과 흰색 중 원하는 색 더미를 누르세요. ' : ''}가져온 타일의 숫자는 나만 봐요.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'guess':
|
||||
return sel ? <NumberPad view={v} players={players} act={act} pending={pending} sel={sel} val={val} setVal={setVal} onCancel={onCancel} /> : (
|
||||
<div className="dv-panel">
|
||||
<strong>맞힐 상대 타일을 눌러 주세요.</strong>
|
||||
<span className="small muted">파란 테두리가 있는 숨은 타일만 고를 수 있어요. 틀리면 {v.pending ? '가져온 타일이 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 타일 하나를 공개해야 해요' : '차례가 넘어가요'}.</span>
|
||||
</div>
|
||||
);
|
||||
case 'afterCorrect':
|
||||
return (
|
||||
<div className="dv-panel" aria-live="polite">
|
||||
<strong style={{ color: 'var(--success)', fontSize: '1.15rem' }}>맞혔어요!</strong>
|
||||
<div className="dv-big">
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'continue' })}>
|
||||
한 번 더 맞히기
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'stop' })}>
|
||||
여기서 멈추기
|
||||
</button>
|
||||
</div>
|
||||
<span className="small muted">
|
||||
{v.pending
|
||||
? '멈추면 가져온 타일이 숨겨진 채로 들어가요. 계속하다 틀리면 가져온 타일이 공개돼요.'
|
||||
: '멈추면 차례가 끝나요. 계속하다 틀리면 벌칙이 있을 수 있어요.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'placeJoker':
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>조커를 넣을 자리를 골라 주세요.</strong>
|
||||
<span className="small muted">내 줄의 "여기" 버튼 중 하나를 누르세요. 조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.</span>
|
||||
</div>
|
||||
);
|
||||
case 'revealOwn':
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>틀렸어요. 공개할 내 타일을 하나 골라 주세요.</strong>
|
||||
<span className="small muted">더미가 비어서 가져온 타일 대신 내 숨은 타일 하나를 공개해요. 내 줄에서 파란 테두리 타일을 누르세요.</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function NumberPad({
|
||||
view: v,
|
||||
players,
|
||||
act,
|
||||
pending,
|
||||
sel,
|
||||
val,
|
||||
setVal,
|
||||
onCancel,
|
||||
}: {
|
||||
view: DavinciView;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
sel: Sel;
|
||||
val: TileValue | null;
|
||||
setVal: (v: TileValue | null) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const tile = v.rows[sel.target]?.[sel.index];
|
||||
const padRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
padRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
||||
}, [sel.target, sel.index]);
|
||||
if (!tile) return null;
|
||||
const seen = visibleCombos(v);
|
||||
const cands = tile.candidates ? new Set(tile.candidates.map(String)) : null;
|
||||
const values: TileValue[] = [...NUMBERS, ...(v.options.jokers ? ['joker' as const] : [])];
|
||||
const impossible = (x: TileValue) => seen.has(`${tile.color}:${x}`) || tile.wrong.includes(x) || (!!cands && !cands.has(String(x)));
|
||||
const warn = val === null ? null : seen.has(`${tile.color}:${val}`) ? `${COLOR_SHORT[tile.color]} ${valueKo(val)}은(는) 이미 보이는 곳에 있어요.` : tile.wrong.includes(val) ? '이 타일에 이미 틀린 숫자예요.' : cands && !cands.has(String(val)) ? '공개된 정보로 보면 어려운 숫자예요.' : null;
|
||||
return (
|
||||
<div
|
||||
className="dv-panel"
|
||||
role="group"
|
||||
aria-label="숫자 고르기"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{nick(players, sel.target)}님 {sel.index + 1}번째 타일({COLOR_SHORT[tile.color]})은 몇일까요?
|
||||
</strong>
|
||||
{tile.wrong.length > 0 && <span className="small muted">이미 틀린 추리: {tile.wrong.map(valueKo).join(', ')}</span>}
|
||||
<div className="dv-pad" ref={padRef}>
|
||||
{values.map((x) => (
|
||||
<button
|
||||
key={String(x)}
|
||||
type="button"
|
||||
className={`${x === 'joker' ? 'joker' : ''}${(v.options.deductionHelper || seen.has(`${tile.color}:${x}`)) && impossible(x) ? ' unlikely' : ''}`}
|
||||
aria-pressed={val === x}
|
||||
aria-label={x === 'joker' ? '조커' : String(x)}
|
||||
onClick={() => setVal(x)}
|
||||
>
|
||||
{x === 'joker' ? '조커 (−)' : x}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="small" style={{ minHeight: '1.4em', color: 'var(--warning)' }}>
|
||||
{warn ? `${warn} 그래도 말할 수 있어요.` : ''}
|
||||
</span>
|
||||
<div className="row" style={{ flexWrap: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 2 }}
|
||||
disabled={val === null || pending}
|
||||
onClick={() => act({ type: 'guess', target: sel.target, index: sel.index, value: val })}
|
||||
>
|
||||
{val === null ? '숫자를 고르세요' : `${valueKo(val)}, 이 숫자!`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartChooser({ view: v, me, act, pending }: { view: DavinciView; me: string | null; act: (a: unknown) => void; pending: boolean }) {
|
||||
const n = v.startCount;
|
||||
const lim = v.pickLimits ?? { black: n, white: n };
|
||||
const fallbackBlack = Math.min(Math.ceil(n / 2), lim.black);
|
||||
const [black, setBlack] = useState<number>(fallbackBlack);
|
||||
const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0;
|
||||
const total = v.picked ? v.seats.filter((p) => v.alive[p]).length : 0;
|
||||
if (!me || !v.seats.includes(me) || !v.alive[me]) {
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>모두 시작 타일 색을 고르는 중이에요 ({done}/{total})</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (v.myPick) {
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>
|
||||
검정 {v.myPick.black}개 · 흰색 {v.myPick.white}개를 골랐어요.
|
||||
</strong>
|
||||
<span className="small muted">
|
||||
다른 사람을 기다리는 중이에요 ({done}/{total}). 다 고르면 숫자가 정해져 내 줄에 놓여요.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const okPick = (b: number) => b <= lim.black && n - b <= lim.white;
|
||||
const chosen = okPick(black) ? black : null;
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>시작 타일 {n}개의 색을 골라 주세요.</strong>
|
||||
<span className="small muted">숫자는 고른 색 안에서 무작위로 정해져요. 15초 안에 고르지 않으면 반반으로 정해져요.</span>
|
||||
<div className="dv-split" role="group" aria-label="시작 타일 색">
|
||||
{Array.from({ length: n + 1 }, (_, i) => n - i).map((b) => (
|
||||
<button key={b} type="button" aria-pressed={chosen === b} disabled={!okPick(b)} onClick={() => setBlack(b)} aria-label={`검정 ${b}개 · 흰색 ${n - b}개`}>
|
||||
<span aria-hidden>
|
||||
{Array.from({ length: n }, (_, k) => (
|
||||
<span key={k} className={`dv-mini ${k < b ? 'black' : 'white'}`} style={{ marginRight: 2 }} />
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
검 {b} · 흰 {n - b}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(lim.black < n || lim.white < n) && (
|
||||
<span className="small muted">
|
||||
먼저 고른 사람 때문에 검정은 {lim.black}개, 흰색은 {lim.white}개까지 고를 수 있어요.
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary" disabled={chosen === null || pending} onClick={() => act({ type: 'chooseStart', black: chosen, white: n - chosen! })}>
|
||||
이렇게 받기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function DavinciSide({ view: v, me, players, deadline }: BoardProps<DavinciView>) {
|
||||
const running = v.phase !== 'finished';
|
||||
const ranking = v.final?.ranking ?? null;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 8, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{running ? (v.phase === 'chooseStart' ? '시작 타일 고르기' : `${nick(players, v.current)}님 차례`) : '게임 끝'}</strong>
|
||||
{running && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">{optionsSummary(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }} aria-label="참가자">
|
||||
{v.seats.map((p) => {
|
||||
const st = v.stats[p] ?? { guesses: 0, correct: 0 };
|
||||
const out = v.eliminationOrder.indexOf(p);
|
||||
return (
|
||||
<li key={p} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: v.alive[p] || !running ? 1 : 0.6 }}>
|
||||
<Avatar user={userOf(players, p)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p === me ? 800 : 600 }}>
|
||||
{nick(players, p)}
|
||||
{running && v.phase !== 'chooseStart' && v.current === p ? ' · 차례' : ''}
|
||||
{v.final?.winner === p ? ' · 1위' : out >= 0 ? ` · ${v.left[p] ? '나감' : '탈락'} ${v.seats.length - out}위` : ''}
|
||||
</span>
|
||||
<span className="small muted" style={{ whiteSpace: 'nowrap' }}>
|
||||
숨김 {hiddenLeft(v.rows[p] ?? [])} · 맞힘 {st.correct}/{st.guesses}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
{ranking && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{ranking.map((g, i) => (
|
||||
<span key={i}>
|
||||
{i + 1}위 {g.map((id) => `${nick(players, id)} (맞힘 ${v.stats[id]?.correct ?? 0} / 추리 ${v.stats[id]?.guesses ?? 0})`).join(', ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details className="card" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>추리 기록 ({v.history.length})</summary>
|
||||
{v.history.length === 0 ? (
|
||||
<p className="small muted" style={{ margin: '8px 0 0' }}>
|
||||
아직 추리가 없어요.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="dv-log" style={{ marginTop: 8 }} tabIndex={0} aria-label="추리 기록 목록">
|
||||
{[...v.history].reverse().map((g, i) => (
|
||||
<li key={v.history.length - i} className={g.correct ? 'hit' : 'miss'}>
|
||||
{guessLine(g, players)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</details>
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>정렬 규칙</summary>
|
||||
<SortPicture tieOrder={v.options.tieOrder} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortPicture({ tieOrder }: { tieOrder: DavinciOptions['tieOrder'] }) {
|
||||
const tiles: [TColor, number][] = tieOrder === 'black-left' ? [['black', 2], ['black', 5], ['white', 5], ['white', 9]] : [['black', 2], ['white', 5], ['black', 5], ['white', 9]];
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6, marginTop: 8 }}>
|
||||
<div className="row" style={{ gap: 6 }} role="img" aria-label={`정렬 예: ${tiles.map(([c, n]) => `${COLOR_SHORT[c]} ${n}`).join(', ')}`}>
|
||||
{tiles.map(([c, n], i) => (
|
||||
<Tile key={i} color={c} n={n} revealed={false} />
|
||||
))}
|
||||
</div>
|
||||
<span className="small muted">작은 수가 왼쪽이에요. 같은 숫자면 {tieOrder === 'black-left' ? '검정' : '흰색'}이 왼쪽이에요. 새 타일은 서버가 알맞은 자리에 끼워 줘요.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: DavinciView, me: string | null): boolean {
|
||||
if (!me || !v.seats.includes(me) || v.phase === 'finished' || !v.alive[me]) return false;
|
||||
if (v.phase === 'chooseStart') return !!v.picked && !v.picked[me];
|
||||
return v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: DavinciView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'chooseStart') return isMyTurn(v, me) ? '내 차례예요. 시작 타일 색을 골라 주세요' : '다른 사람이 시작 타일 색을 고르는 중이에요';
|
||||
if (isMyTurn(v, me)) {
|
||||
switch (v.phase) {
|
||||
case 'draw':
|
||||
return '내 차례예요. 더미에서 타일을 가져와 주세요';
|
||||
case 'guess':
|
||||
return '내 차례예요. 상대 타일을 골라 숫자를 맞혀 보세요';
|
||||
case 'afterCorrect':
|
||||
return '내 차례예요. 맞혔어요! 한 번 더 할지 멈출지 골라 주세요';
|
||||
case 'placeJoker':
|
||||
return '내 차례예요. 조커를 넣을 자리를 골라 주세요';
|
||||
case 'revealOwn':
|
||||
return '내 차례예요. 공개할 내 타일을 골라 주세요';
|
||||
}
|
||||
}
|
||||
const out = !!me && v.seats.includes(me) && !v.alive[me] ? '탈락했어요 · ' : '';
|
||||
return `${out}${nick(players, v.current)}님 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<DavinciOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<DavinciOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="조커">
|
||||
<Seg label="조커" value={o.jokers} options={[[false, '안 씀'], [true, '씀(검정·흰색 각 1개)']]} onChange={(jokers) => set({ jokers })} />
|
||||
</Field>
|
||||
<Field label="시작 타일 수">
|
||||
<Seg label="시작 타일 수" value={o.startTiles} options={[['auto', '자동(2·3명 4개, 4명 3개)'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(startTiles) => set({ startTiles })} />
|
||||
</Field>
|
||||
<Field label="추리 제한 시간">
|
||||
<Seg label="추리 제한 시간" value={o.turnSeconds} options={[[20, '20초'], [30, '30초'], [45, '45초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<Field label="가능한 숫자 후보 표시(초보 도움)">
|
||||
<Seg label="후보 표시" value={o.deductionHelper} options={[[false, '끄기'], [true, '켜기']]} onChange={(deductionHelper) => set({ deductionHelper })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="같은 숫자일 때 색 순서">
|
||||
<Seg label="같은 숫자일 때 색 순서" value={o.tieOrder} options={[['black-left', '검정이 왼쪽'], ['white-left', '흰색이 왼쪽']]} onChange={(tieOrder) => set({ tieOrder })} />
|
||||
</Field>
|
||||
<Field label="타일을 가져올 때">
|
||||
<Seg label="타일을 가져올 때" value={o.chooseColors} options={[[true, '색을 골라서'], [false, '무작위로']]} onChange={(chooseColors) => set({ chooseColors })} />
|
||||
</Field>
|
||||
<Field label="더미가 빈 뒤 틀렸을 때">
|
||||
<Seg label="더미가 빈 뒤 틀렸을 때" value={o.emptyPoolPenalty} options={[['reveal-own', '내 숨은 타일 1개 공개'], ['none', '벌칙 없음']]} onChange={(emptyPoolPenalty) => set({ emptyPoolPenalty })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: DavinciOptions): string {
|
||||
return [
|
||||
o.jokers ? '조커 사용' : '조커 없음',
|
||||
o.startTiles === 'auto' ? '시작 타일 자동' : `시작 타일 ${o.startTiles}개`,
|
||||
o.chooseColors ? '색 골라 가져오기' : '무작위로 가져오기',
|
||||
`추리 ${o.turnSeconds}초`,
|
||||
...(o.tieOrder === 'white-left' ? ['같은 숫자는 흰색 먼저'] : []),
|
||||
...(o.deductionHelper ? ['후보 표시'] : []),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: DavinciOptions }) {
|
||||
const o = options;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, <strong>상대 타일의 숫자를 추리해 맞히는</strong> 게임이에요. 2~4명이 할 수 있어요. 숨은 타일이 모두 드러나면 탈락하고, 마지막까지 숨은
|
||||
타일이 남은 사람이 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>준비</strong>: 각자 시작 타일 {o && o.startTiles !== 'auto' ? `${o.startTiles}개` : '(2·3명은 4개, 4명은 3개)'}를 받아요.
|
||||
{o?.chooseColors === false ? ' 색은 무작위로 정해져요.' : ' 검정과 흰색을 몇 개씩 받을지 직접 골라요.'} 타일 뒷면이 그 타일의 색이라서, 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨져요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>정렬</strong>: 내 줄은 왼쪽에서 오른쪽으로 숫자가 커져요. 같은 숫자면 {o?.tieOrder === 'white-left' ? '흰색' : '검정'}이 왼쪽이에요. 새 타일은 알맞은 자리에 저절로 끼워지고, 이미 놓인 타일의 순서는 바뀌지
|
||||
않아요.
|
||||
</p>
|
||||
<SortPicture tieOrder={o?.tieOrder ?? 'black-left'} />
|
||||
<p>
|
||||
<strong>내 차례</strong>: ① 더미에서 타일을 하나 가져와요(숫자는 나만 봐요). ② 상대의 숨은 타일 하나를 눌러 숫자를 말해요.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>맞히면</strong> 그 타일이 공개돼요. "한 번 더 맞히기"로 계속하거나 "여기서 멈추기"로 끝낼 수 있어요. 멈추면 가져온 타일이 숨겨진 채로 내 줄에 들어가요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>틀리면</strong> 가져온 타일이 공개된 채로 내 줄에 들어가고 차례가 끝나요.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>더미가 비면</strong> 가져오지 않고 바로 추리해요. 이때 틀리면{' '}
|
||||
{o?.emptyPoolPenalty === 'none' ? '벌칙 없이 차례만 넘어가요.' : '내 숨은 타일 하나를 골라 공개해야 해요. 마지막 숨은 타일을 공개하면 탈락해요.'}
|
||||
</p>
|
||||
{o?.jokers && (
|
||||
<p>
|
||||
<strong>조커</strong>: 숫자 대신 "−"가 그려진 타일이에요(검정·흰색 각 1개). 시작 타일에는 들어가지 않아요. 조커를 가져오면 내 줄의 원하는 틈에 직접 넣고, 한 번 놓으면 옮길 수 없어요. 추리할 때
|
||||
"조커"라고 말할 수도 있어요.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
이미 공개된 같은 색·같은 숫자를 말하는 것처럼 말이 안 되는 추리도 할 수는 있어요(화면에서 알려 줘요). 오른쪽 <strong>추리 기록</strong>에서 누가 어떤 숫자를 말했는지 다시 볼 수 있어요.
|
||||
</p>
|
||||
<p className="muted">
|
||||
시간 안에 추리하지 않으면 공개된 정보로 가장 가능성 있는 작은 숫자를 자동으로 말해요. 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 결과 화면에는 각자 맞힌 횟수와 추리 횟수가 나와요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<DavinciView, DavinciOptions> = {
|
||||
Board: DavinciBoard,
|
||||
Side: DavinciSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
8
apps/web/src/games/dobble/art.test.ts
Normal file
8
apps/web/src/games/dobble/art.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { SYMBOL_NAMES } from '@bg/games/dobble';
|
||||
import { ICONS, ICON_NAMES } from './art';
|
||||
|
||||
test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => {
|
||||
expect(ICONS).toHaveLength(57);
|
||||
expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]);
|
||||
});
|
||||
556
apps/web/src/games/dobble/art.tsx
Normal file
556
apps/web/src/games/dobble/art.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box.
|
||||
* Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly).
|
||||
* Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble).
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const O = '#1f2937';
|
||||
/** Outline props for filled shapes. */
|
||||
const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const;
|
||||
/** Thick stroked lines (no fill). */
|
||||
const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const;
|
||||
|
||||
const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) =>
|
||||
Array.from({ length: n }, (_, i) => {
|
||||
const a = a0 + (i * 2 * Math.PI) / n;
|
||||
return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) =>
|
||||
Array.from({ length: n * 2 }, (_, i) => {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / n;
|
||||
const r = i % 2 ? ri : ro;
|
||||
return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */
|
||||
export const ICON_NAMES = [
|
||||
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
|
||||
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
|
||||
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
|
||||
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
|
||||
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
|
||||
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
|
||||
];
|
||||
|
||||
export const ICONS: (() => ReactNode)[] = [
|
||||
// 0 사과
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 30 C36 18 13 26 14 52 C15 76 33 93 50 84 C67 93 85 76 86 52 C87 26 64 18 50 30 Z" fill="#ef4444" {...L} />
|
||||
<path d="M50 30 L55 10" {...W('#78350f', 5)} />
|
||||
<path d="M57 18 C64 6 78 8 80 12 C74 22 64 22 57 18 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
|
||||
<path d="M28 44 C30 38 34 35 38 34" {...W('#fecaca', 4)} />
|
||||
</>
|
||||
),
|
||||
// 1 해
|
||||
() => (
|
||||
<>
|
||||
<path d={rays(8, 28, 44)} {...W('#f59e0b', 7)} />
|
||||
<circle cx="50" cy="50" r="22" fill="#fbbf24" {...L} />
|
||||
</>
|
||||
),
|
||||
// 2 달
|
||||
() => <path d="M60 10 A40 40 0 1 0 90 64 A31 31 0 1 1 60 10 Z" fill="#fde047" {...L} />,
|
||||
// 3 별
|
||||
() => <polygon points={starPts(5, 44, 19)} fill="#a78bfa" {...L} />,
|
||||
// 4 하트
|
||||
() => <path d="M50 86 C20 64 8 48 10 32 C12 16 32 8 50 26 C68 8 88 16 90 32 C92 48 80 64 50 86 Z" fill="#ec4899" {...L} />,
|
||||
// 5 물방울
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 8 C44 20 22 44 22 64 A28 28 0 0 0 78 64 C78 44 56 20 50 8 Z" fill="#38bdf8" {...L} />
|
||||
<path d="M36 62 C36 70 40 76 46 78" {...W('#e0f2fe', 5)} />
|
||||
</>
|
||||
),
|
||||
// 6 불꽃
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 92 C26 92 16 74 22 56 C26 44 34 40 34 26 C44 32 46 40 46 46 C52 36 54 22 50 8 C70 20 84 42 80 64 C78 82 66 92 50 92 Z" fill="#f97316" {...L} />
|
||||
<path d="M50 88 C38 88 34 78 38 70 C41 64 46 62 46 54 C54 60 62 68 60 78 C59 84 55 88 50 88 Z" fill="#fde047" />
|
||||
</>
|
||||
),
|
||||
// 7 눈송이
|
||||
() => (
|
||||
<>
|
||||
<path d={rays(6, 0, 42, 50, 50, Math.PI / 2)} {...W('#0284c7', 6)} />
|
||||
<path d={[0, 1, 2, 3, 4, 5].map((i) => {
|
||||
const a = Math.PI / 2 + (i * Math.PI) / 3;
|
||||
const bx = 50 + Math.cos(a) * 26;
|
||||
const by = 50 + Math.sin(a) * 26;
|
||||
const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`;
|
||||
return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`;
|
||||
}).join(' ')} {...W('#0284c7', 5)} />
|
||||
</>
|
||||
),
|
||||
// 8 번개
|
||||
() => <polygon points="60,6 20,56 46,56 36,94 82,40 56,40 70,6" fill="#eab308" {...L} />,
|
||||
// 9 구름
|
||||
() => <path d="M24 76 C10 76 6 60 18 54 C14 40 30 30 40 38 C44 22 66 20 72 36 C86 32 96 48 88 58 C96 66 90 78 78 76 Z" fill="#cbd5e1" {...L} />,
|
||||
// 10 나무 (pine)
|
||||
() => (
|
||||
<>
|
||||
<rect x="44" y="76" width="12" height="18" fill="#92400e" {...L} />
|
||||
<polygon points="50,6 24,40 38,40 18,62 34,62 12,80 88,80 66,62 82,62 62,40 76,40" fill="#16a34a" {...L} />
|
||||
</>
|
||||
),
|
||||
// 11 꽃
|
||||
() => (
|
||||
<>
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
|
||||
return <circle key={i} cx={50 + Math.cos(a) * 24} cy={50 + Math.sin(a) * 24} r="17" fill="#f472b6" stroke={O} strokeWidth="3" />;
|
||||
})}
|
||||
<circle cx="50" cy="50" r="13" fill="#fde047" stroke={O} strokeWidth="3" />
|
||||
</>
|
||||
),
|
||||
// 12 나뭇잎
|
||||
() => (
|
||||
<>
|
||||
<path d="M14 86 C14 40 40 12 88 12 C88 60 62 86 14 86 Z" fill="#4ade80" {...L} />
|
||||
<path d="M14 86 L70 30 M40 60 L40 44 M54 46 L54 32 M40 60 L56 60 M54 46 L68 46" {...W('#166534', 3.5)} />
|
||||
</>
|
||||
),
|
||||
// 13 선인장
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W(O, 22)} />
|
||||
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W('#22c55e', 15)} />
|
||||
<path d="M44 30 L40 28 M56 40 L60 38 M44 72 L40 70 M56 78 L60 76" {...W(O, 2.5)} />
|
||||
<rect x="30" y="86" width="40" height="10" rx="3" fill="#c2410c" {...L} />
|
||||
</>
|
||||
),
|
||||
// 14 버섯
|
||||
() => (
|
||||
<>
|
||||
<path d="M40 54 L36 88 C36 92 64 92 64 88 L60 54 Z" fill="#fef3c7" {...L} />
|
||||
<path d="M8 56 C8 26 28 12 50 12 C72 12 92 26 92 56 Z" fill="#dc2626" {...L} />
|
||||
<circle cx="32" cy="34" r="6" fill="#fff" />
|
||||
<circle cx="56" cy="24" r="5" fill="#fff" />
|
||||
<circle cx="70" cy="42" r="6" fill="#fff" />
|
||||
<circle cx="46" cy="44" r="4" fill="#fff" />
|
||||
</>
|
||||
),
|
||||
// 15 당근
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 30 C44 18 34 10 26 10 M50 30 C50 16 52 8 56 4 M50 30 C58 18 70 12 78 14" {...W('#16a34a', 7)} />
|
||||
<path d="M32 32 C40 26 60 26 68 32 L52 94 C51 97 49 97 48 94 Z" fill="#f97316" {...L} />
|
||||
<path d="M40 46 L48 46 M54 60 L60 60 M44 72 L50 72" {...W('#9a3412', 3)} />
|
||||
</>
|
||||
),
|
||||
// 16 레몬
|
||||
() => (
|
||||
<>
|
||||
<path d="M6 50 C14 46 18 22 50 20 C82 22 86 46 94 50 C86 54 82 78 50 80 C18 78 14 54 6 50 Z" fill="#fde047" {...L} />
|
||||
<path d="M30 40 C34 34 40 32 46 31" {...W('#fefce8', 5)} />
|
||||
</>
|
||||
),
|
||||
// 17 체리
|
||||
() => (
|
||||
<>
|
||||
<path d="M30 66 C34 40 46 22 62 10 M70 62 C66 40 64 22 62 10" {...W('#15803d', 5)} />
|
||||
<path d="M62 10 C72 4 86 8 88 14 C78 20 68 18 62 10 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
|
||||
<circle cx="28" cy="72" r="18" fill="#dc2626" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="72" cy="70" r="18" fill="#b91c1c" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="22" cy="66" r="4" fill="#fecaca" />
|
||||
<circle cx="66" cy="64" r="4" fill="#fecaca" />
|
||||
</>
|
||||
),
|
||||
// 18 고양이
|
||||
() => (
|
||||
<>
|
||||
<path d="M16 40 L18 8 L40 26 C46 24 54 24 60 26 L82 8 L84 40 C90 52 88 82 50 86 C12 82 10 52 16 40 Z" fill="#fb923c" {...L} />
|
||||
<ellipse cx="36" cy="52" rx="5" ry="7" fill={O} />
|
||||
<ellipse cx="64" cy="52" rx="5" ry="7" fill={O} />
|
||||
<path d="M46 64 L54 64 L50 69 Z" fill="#be185d" />
|
||||
<path d="M8 60 L32 64 M8 72 L32 70 M92 60 L68 64 M92 72 L68 70" {...W(O, 2.5)} />
|
||||
</>
|
||||
),
|
||||
// 19 강아지
|
||||
() => (
|
||||
<>
|
||||
<ellipse cx="50" cy="54" rx="28" ry="32" fill="#d6a46b" {...L} />
|
||||
<path d="M26 30 C10 30 6 52 12 70 C20 72 26 60 28 46 Z M74 30 C90 30 94 52 88 70 C80 72 74 60 72 46 Z" fill="#78350f" {...L} />
|
||||
<circle cx="40" cy="50" r="4.5" fill={O} />
|
||||
<circle cx="60" cy="50" r="4.5" fill={O} />
|
||||
<ellipse cx="50" cy="66" rx="8" ry="6" fill={O} />
|
||||
<path d="M50 72 L50 78 M42 80 C46 84 54 84 58 80" {...W(O, 3)} />
|
||||
</>
|
||||
),
|
||||
// 20 물고기
|
||||
() => (
|
||||
<>
|
||||
<path d="M8 50 C24 24 58 22 74 50 C58 78 24 76 8 50 Z" fill="#14b8a6" {...L} />
|
||||
<path d="M72 50 L94 30 L94 70 Z" fill="#0f766e" {...L} />
|
||||
<circle cx="26" cy="46" r="5" fill="#fff" stroke={O} strokeWidth="2.5" />
|
||||
<path d="M40 36 C46 44 46 56 40 64" {...W('#99f6e4', 3.5)} />
|
||||
</>
|
||||
),
|
||||
// 21 새
|
||||
() => (
|
||||
<>
|
||||
<path d="M20 62 C20 36 40 24 58 30 C64 20 76 18 84 26 L96 30 L84 36 C84 64 66 80 44 80 L14 86 L26 72 C22 70 20 66 20 62 Z" fill="#60a5fa" {...L} />
|
||||
<circle cx="74" cy="30" r="3.5" fill={O} />
|
||||
<path d="M34 54 C44 46 58 50 62 60 C52 66 40 64 34 54 Z" fill="#1d4ed8" {...L} strokeWidth={2.5} />
|
||||
</>
|
||||
),
|
||||
// 22 거북이
|
||||
() => (
|
||||
<>
|
||||
<circle cx="86" cy="58" r="9" fill="#86efac" stroke={O} strokeWidth="3" />
|
||||
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W(O, 11)} />
|
||||
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W('#86efac', 6)} />
|
||||
<path d="M10 70 C10 36 30 22 48 22 C68 22 84 36 84 70 Z" fill="#15803d" {...L} />
|
||||
<path d="M48 32 L60 42 L56 58 L40 58 L36 42 Z M48 32 L48 24 M60 42 L76 40 M56 58 L64 70 M40 58 L32 70 M36 42 L20 42" {...W('#bbf7d0', 3)} />
|
||||
</>
|
||||
),
|
||||
// 23 무당벌레
|
||||
() => (
|
||||
<>
|
||||
<path d="M28 22 L20 10 M72 22 L80 10" {...W(O, 3.5)} />
|
||||
<circle cx="50" cy="28" r="16" fill={O} />
|
||||
<circle cx="50" cy="58" r="34" fill="#ef4444" stroke={O} strokeWidth="3.5" />
|
||||
<path d="M50 26 L50 92" {...W(O, 3.5)} />
|
||||
<circle cx="34" cy="48" r="6" fill={O} />
|
||||
<circle cx="66" cy="48" r="6" fill={O} />
|
||||
<circle cx="30" cy="70" r="5" fill={O} />
|
||||
<circle cx="70" cy="70" r="5" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 24 거미
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 4 L50 30" {...W('#9ca3af', 2)} />
|
||||
<path d="M40 50 L22 34 L12 44 M40 56 L16 52 L8 64 M42 62 L22 72 L18 88 M44 66 L36 82 L36 94 M60 50 L78 34 L88 44 M60 56 L84 52 L92 64 M58 62 L78 72 L82 88 M56 66 L64 82 L64 94" {...W(O, 4)} />
|
||||
<circle cx="50" cy="38" r="10" fill="#374151" stroke={O} strokeWidth="3" />
|
||||
<ellipse cx="50" cy="62" rx="15" ry="18" fill="#4b5563" stroke={O} strokeWidth="3" />
|
||||
<circle cx="46" cy="36" r="2.5" fill="#fff" />
|
||||
<circle cx="54" cy="36" r="2.5" fill="#fff" />
|
||||
</>
|
||||
),
|
||||
// 25 공룡
|
||||
() => (
|
||||
<path
|
||||
d="M88 16 C80 8 70 10 68 18 L64 46 C50 40 32 42 22 54 C14 62 8 64 2 62 C8 72 18 74 26 70 L26 88 L36 88 L38 74 L54 74 L56 88 L66 88 L66 66 C72 58 74 46 76 26 L86 26 C92 24 92 20 88 16 Z"
|
||||
fill="#65a30d"
|
||||
{...L}
|
||||
/>
|
||||
),
|
||||
// 26 토끼
|
||||
() => (
|
||||
<>
|
||||
<path d="M38 46 C28 30 26 6 34 4 C42 2 46 26 46 44 Z M62 46 C72 30 74 6 66 4 C58 2 54 26 54 44 Z" fill="#f5f5f4" {...L} />
|
||||
<path d="M38 40 C33 28 32 14 35 10 M62 40 C67 28 68 14 65 10" {...W('#f9a8d4', 4)} />
|
||||
<circle cx="50" cy="64" r="26" fill="#f5f5f4" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="40" cy="60" r="4" fill={O} />
|
||||
<circle cx="60" cy="60" r="4" fill={O} />
|
||||
<path d="M46 70 L54 70 L50 75 Z" fill="#ec4899" />
|
||||
</>
|
||||
),
|
||||
// 27 펭귄
|
||||
() => (
|
||||
<>
|
||||
<ellipse cx="50" cy="54" rx="30" ry="40" fill="#111827" stroke={O} strokeWidth="3" />
|
||||
<ellipse cx="50" cy="60" rx="20" ry="30" fill="#f8fafc" />
|
||||
<circle cx="42" cy="34" r="4" fill="#fff" />
|
||||
<circle cx="58" cy="34" r="4" fill="#fff" />
|
||||
<circle cx="42" cy="34" r="2" fill={O} />
|
||||
<circle cx="58" cy="34" r="2" fill={O} />
|
||||
<path d="M44 42 L56 42 L50 50 Z" fill="#f97316" {...L} strokeWidth={2} />
|
||||
<path d="M32 94 L44 94 L40 88 Z M56 94 L68 94 L60 88 Z" fill="#f97316" {...L} strokeWidth={2} />
|
||||
</>
|
||||
),
|
||||
// 28 열쇠
|
||||
() => (
|
||||
<>
|
||||
<circle cx="28" cy="50" r="18" fill="#f59e0b" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="28" cy="50" r="7" fill="#fffbeb" stroke={O} strokeWidth="3" />
|
||||
<path d="M46 44 L92 44 L92 56 L84 56 L84 66 L76 66 L76 56 L68 56 L68 64 L60 64 L60 56 L46 56 Z" fill="#f59e0b" {...L} />
|
||||
</>
|
||||
),
|
||||
// 29 자물쇠
|
||||
() => (
|
||||
<>
|
||||
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W(O, 14)} />
|
||||
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W('#94a3b8', 8)} />
|
||||
<rect x="18" y="44" width="64" height="48" rx="8" fill="#eab308" {...L} />
|
||||
<circle cx="50" cy="62" r="7" fill={O} />
|
||||
<path d="M50 64 L50 80" {...W(O, 6)} />
|
||||
</>
|
||||
),
|
||||
// 30 시계
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="42" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="50" cy="50" r="33" fill="#fff" stroke={O} strokeWidth="2.5" />
|
||||
<path d={rays(12, 27, 31)} {...W(O, 3)} />
|
||||
<path d="M50 50 L50 28 M50 50 L66 58" {...W(O, 5)} />
|
||||
<circle cx="50" cy="50" r="4" fill="#dc2626" />
|
||||
</>
|
||||
),
|
||||
// 31 전구
|
||||
() => (
|
||||
<>
|
||||
<path d="M38 70 C38 58 18 50 20 32 C22 14 36 6 50 6 C64 6 78 14 80 32 C82 50 62 58 62 70 Z" fill="#fde047" {...L} />
|
||||
<path d="M44 66 L44 50 L50 56 L56 50 L56 66" {...W('#ca8a04', 3)} />
|
||||
<rect x="36" y="70" width="28" height="18" rx="3" fill="#9ca3af" {...L} />
|
||||
<path d="M36 76 L64 76 M36 82 L64 82" {...W(O, 2.5)} />
|
||||
<path d="M44 88 L56 88 L52 96 L48 96 Z" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 32 연필
|
||||
() => (
|
||||
<g transform="rotate(-45 50 50)">
|
||||
<rect x="14" y="38" width="16" height="24" rx="4" fill="#f9a8d4" {...L} />
|
||||
<rect x="28" y="38" width="8" height="24" fill="#9ca3af" {...L} />
|
||||
<rect x="36" y="38" width="36" height="24" fill="#facc15" {...L} />
|
||||
<path d="M72 38 L94 50 L72 62 Z" fill="#fde68a" {...L} />
|
||||
<path d="M86 46 L94 50 L86 54 Z" fill={O} />
|
||||
</g>
|
||||
),
|
||||
// 33 가위
|
||||
() => (
|
||||
<>
|
||||
<path d="M36 62 L82 12 M64 62 L18 12" {...W(O, 10)} />
|
||||
<path d="M36 62 L82 12 M64 62 L18 12" {...W('#cbd5e1', 5)} />
|
||||
<circle cx="30" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
|
||||
<circle cx="30" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
|
||||
<circle cx="70" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
|
||||
<circle cx="70" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
|
||||
<circle cx="50" cy="45" r="4" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 34 안경
|
||||
() => (
|
||||
<>
|
||||
<path d="M8 40 L4 30 M92 40 L96 30 M42 50 C46 44 54 44 58 50" {...W(O, 4)} />
|
||||
<circle cx="26" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
|
||||
<circle cx="74" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
|
||||
<path d="M18 46 L24 40 M66 46 L72 40" {...W('#fff', 3.5)} />
|
||||
</>
|
||||
),
|
||||
// 35 우산
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 50 L50 82 C50 92 36 92 36 84" {...W(O, 6)} />
|
||||
<path d="M6 52 C6 26 28 10 50 10 C72 10 94 26 94 52 C88 46 80 46 72 52 C66 46 56 46 50 52 C44 46 34 46 28 52 C20 46 12 46 6 52 Z" fill="#8b5cf6" {...L} />
|
||||
<path d="M50 10 L50 50 M28 52 C30 30 40 16 50 10 M72 52 C70 30 60 16 50 10" {...W('#ddd6fe', 2.5)} />
|
||||
</>
|
||||
),
|
||||
// 36 모자 (top hat)
|
||||
() => (
|
||||
<>
|
||||
<path d="M28 70 L32 14 L68 14 L72 70 Z" fill="#1f2937" {...L} />
|
||||
<path d="M30 56 L70 56 L71 68 L29 68 Z" fill="#dc2626" />
|
||||
<ellipse cx="50" cy="74" rx="44" ry="10" fill="#374151" {...L} />
|
||||
</>
|
||||
),
|
||||
// 37 왕관
|
||||
() => (
|
||||
<>
|
||||
<path d="M10 30 L28 54 L50 18 L72 54 L90 30 L82 80 L18 80 Z" fill="#facc15" {...L} />
|
||||
<rect x="18" y="80" width="64" height="10" fill="#eab308" {...L} />
|
||||
<circle cx="50" cy="62" r="7" fill="#dc2626" stroke={O} strokeWidth="2.5" />
|
||||
<circle cx="30" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
|
||||
<circle cx="70" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
|
||||
</>
|
||||
),
|
||||
// 38 종
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="86" r="8" fill="#92400e" stroke={O} strokeWidth="3" />
|
||||
<path d="M14 80 C22 72 22 60 24 44 C26 24 38 14 50 14 C62 14 74 24 76 44 C78 60 78 72 86 80 Z" fill="#f59e0b" {...L} />
|
||||
<path d="M50 14 L50 6" {...W(O, 6)} />
|
||||
<path d="M34 66 C34 50 36 36 44 28" {...W('#fde68a', 4)} />
|
||||
</>
|
||||
),
|
||||
// 39 닻
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="16" r="9" fill="none" stroke="#1e3a8a" strokeWidth="6" />
|
||||
<path d="M50 25 L50 88 M32 38 L68 38 M14 58 C16 78 32 88 50 88 C68 88 84 78 86 58 M14 58 L8 66 M14 58 L22 64 M86 58 L92 66 M86 58 L78 64" {...W('#1e3a8a', 7)} />
|
||||
</>
|
||||
),
|
||||
// 40 자전거
|
||||
() => (
|
||||
<>
|
||||
<circle cx="22" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
|
||||
<circle cx="78" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
|
||||
<path d="M22 66 L40 40 L66 40 L78 66 M40 40 L50 66 L66 40 M50 66 L22 66 M36 30 L48 30 M40 40 L37 30 M66 40 L62 26 L72 24" {...W('#e11d48', 5)} />
|
||||
<circle cx="50" cy="66" r="4" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 41 자동차
|
||||
() => (
|
||||
<>
|
||||
<path d="M6 66 L8 50 L24 46 L34 28 L68 28 L80 46 L94 50 L94 66 Z" fill="#2563eb" {...L} />
|
||||
<path d="M38 34 L62 34 L72 46 L30 46 Z" fill="#bae6fd" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 34 L50 46" {...W(O, 2.5)} />
|
||||
<circle cx="26" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
|
||||
<circle cx="74" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
|
||||
<circle cx="26" cy="68" r="4" fill="#d1d5db" />
|
||||
<circle cx="74" cy="68" r="4" fill="#d1d5db" />
|
||||
</>
|
||||
),
|
||||
// 42 로켓
|
||||
() => (
|
||||
<>
|
||||
<path d="M36 66 L18 84 L20 62 Z M64 66 L82 84 L80 62 Z" fill="#dc2626" {...L} />
|
||||
<path d="M42 80 L50 96 L58 80 Z" fill="#f97316" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 4 C66 18 70 40 66 80 L34 80 C30 40 34 18 50 4 Z" fill="#e5e7eb" {...L} />
|
||||
<path d="M50 4 C58 10 62 18 64 26 L36 26 C38 18 42 10 50 4 Z" fill="#dc2626" {...L} />
|
||||
<circle cx="50" cy="46" r="9" fill="#38bdf8" stroke={O} strokeWidth="3" />
|
||||
</>
|
||||
),
|
||||
// 43 배 (sailboat)
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 8 L50 66" {...W(O, 4)} />
|
||||
<path d="M54 12 L84 58 L54 58 Z" fill="#f8fafc" {...L} />
|
||||
<path d="M46 22 L22 58 L46 58 Z" fill="#fbbf24" {...L} />
|
||||
<path d="M6 66 L94 66 L80 86 L20 86 Z" fill="#b45309" {...L} />
|
||||
</>
|
||||
),
|
||||
// 44 공 (soccer ball)
|
||||
() => (
|
||||
<>
|
||||
<circle cx="50" cy="50" r="42" fill="#fff" stroke={O} strokeWidth="3.5" />
|
||||
<polygon points={starPts(5, 14, 14, 50, 50).split(' ').filter((_, i) => i % 2 === 0).join(' ')} fill={O} />
|
||||
<path d={rays(5, 14, 30, 50, 50, -Math.PI / 2)} {...W(O, 3)} />
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
|
||||
return <circle key={i} cx={50 + Math.cos(a) * 36} cy={50 + Math.sin(a) * 36} r="7" fill={O} />;
|
||||
})}
|
||||
</>
|
||||
),
|
||||
// 45 주사위
|
||||
() => (
|
||||
<>
|
||||
<rect x="12" y="12" width="76" height="76" rx="14" fill="#fff" {...L} />
|
||||
{[
|
||||
[30, 30],
|
||||
[70, 30],
|
||||
[50, 50],
|
||||
[30, 70],
|
||||
[70, 70],
|
||||
].map(([x, y]) => (
|
||||
<circle key={`${x}${y}`} cx={x} cy={y} r="7.5" fill="#dc2626" />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
// 46 음표
|
||||
() => (
|
||||
<>
|
||||
<path d="M62 72 L62 10 C70 22 86 26 84 46 C80 34 72 32 62 30" {...W(O, 7)} />
|
||||
<ellipse cx="46" cy="74" rx="18" ry="13" transform="rotate(-24 46 74)" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 47 카메라
|
||||
() => (
|
||||
<>
|
||||
<path d="M34 24 L40 14 L60 14 L66 24 Z" fill="#4b5563" {...L} />
|
||||
<rect x="8" y="24" width="84" height="58" rx="10" fill="#6b7280" {...L} />
|
||||
<circle cx="50" cy="53" r="20" fill="#e5e7eb" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="50" cy="53" r="11" fill="#1e3a8a" />
|
||||
<circle cx="46" cy="49" r="3" fill="#fff" />
|
||||
<rect x="72" y="32" width="12" height="7" rx="2" fill="#fde047" stroke={O} strokeWidth="2" />
|
||||
</>
|
||||
),
|
||||
// 48 집
|
||||
() => (
|
||||
<>
|
||||
<rect x="20" y="46" width="60" height="44" fill="#fde68a" {...L} />
|
||||
<path d="M8 50 L50 12 L92 50 Z" fill="#dc2626" {...L} />
|
||||
<rect x="42" y="64" width="16" height="26" fill="#92400e" {...L} />
|
||||
<rect x="26" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
|
||||
<rect x="62" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
|
||||
</>
|
||||
),
|
||||
// 49 성
|
||||
() => (
|
||||
<>
|
||||
<path d="M8 92 L8 30 L14 30 L14 22 L22 22 L22 30 L28 30 L28 22 L36 22 L36 46 L64 46 L64 22 L72 22 L72 30 L78 30 L78 22 L86 22 L86 30 L92 30 L92 92 Z" fill="#a8a29e" {...L} />
|
||||
<path d="M40 92 L40 72 C40 62 60 62 60 72 L60 92 Z" fill="#57534e" {...L} />
|
||||
<path d="M50 46 L50 8 M50 8 L66 14 L50 20" fill="#ef4444" {...L} strokeWidth={3} />
|
||||
<rect x="18" y="44" width="8" height="12" fill={O} />
|
||||
<rect x="74" y="44" width="8" height="12" fill={O} />
|
||||
</>
|
||||
),
|
||||
// 50 선물상자
|
||||
() => (
|
||||
<>
|
||||
<rect x="14" y="40" width="72" height="50" fill="#16a34a" {...L} />
|
||||
<rect x="8" y="28" width="84" height="16" fill="#22c55e" {...L} />
|
||||
<path d="M44 28 L56 28 L56 90 L44 90 Z" fill="#facc15" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 28 C38 8 18 14 26 26 C30 30 40 30 50 28 C60 30 70 30 74 26 C82 14 62 8 50 28 Z" fill="#facc15" {...L} />
|
||||
</>
|
||||
),
|
||||
// 51 촛불
|
||||
() => (
|
||||
<>
|
||||
<path d="M50 8 C40 20 40 32 50 34 C60 32 60 20 50 8 Z" fill="#f97316" {...L} strokeWidth={2.5} />
|
||||
<path d="M50 34 L50 40" {...W(O, 3)} />
|
||||
<rect x="36" y="40" width="28" height="48" rx="3" fill="#f8fafc" {...L} />
|
||||
<path d="M36 50 C42 50 42 60 46 60 C50 60 50 50 54 50" {...W('#e0e7ff', 4)} />
|
||||
<ellipse cx="50" cy="90" rx="30" ry="6" fill="#64748b" {...L} />
|
||||
</>
|
||||
),
|
||||
// 52 망치
|
||||
() => (
|
||||
<g transform="rotate(-35 50 50)">
|
||||
<rect x="44" y="32" width="12" height="62" rx="4" fill="#b45309" {...L} />
|
||||
<path d="M18 14 L76 14 L82 20 L82 32 L18 32 C12 32 10 26 12 22 Z" fill="#6b7280" {...L} />
|
||||
</g>
|
||||
),
|
||||
// 53 느낌표
|
||||
() => (
|
||||
<>
|
||||
<path d="M38 8 L62 8 L56 64 L44 64 Z" fill="#ef4444" {...L} />
|
||||
<circle cx="50" cy="82" r="10" fill="#ef4444" stroke={O} strokeWidth="3.5" />
|
||||
</>
|
||||
),
|
||||
// 54 물음표
|
||||
() => (
|
||||
<>
|
||||
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W(O, 18)} />
|
||||
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W('#3b82f6', 11)} />
|
||||
<circle cx="56" cy="84" r="10" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
|
||||
</>
|
||||
),
|
||||
// 55 눈사람
|
||||
() => (
|
||||
<>
|
||||
<path d="M24 60 L6 48 M76 60 L94 48 M10 51 L8 42 M90 51 L92 42" {...W('#78350f', 4)} />
|
||||
<circle cx="50" cy="70" r="24" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="50" cy="30" r="17" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
|
||||
<circle cx="44" cy="26" r="2.6" fill={O} />
|
||||
<circle cx="56" cy="26" r="2.6" fill={O} />
|
||||
<path d="M50 32 L64 36 L50 37 Z" fill="#f97316" />
|
||||
<circle cx="50" cy="62" r="3" fill={O} />
|
||||
<circle cx="50" cy="74" r="3" fill={O} />
|
||||
<path d="M36 44 C44 50 56 50 64 44" {...W('#dc2626', 6)} />
|
||||
</>
|
||||
),
|
||||
// 56 무지개
|
||||
() => (
|
||||
<>
|
||||
{['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => (
|
||||
<path key={c} d={`M${8 + i * 6} 78 A${42 - i * 6} ${42 - i * 6} 0 0 1 ${92 - i * 6} 78`} {...W(c, 6.5)} strokeLinecap="butt" />
|
||||
))}
|
||||
<path d="M2 80 C2 70 18 68 22 76 C30 74 34 84 26 88 L6 88 C0 88 0 82 2 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
|
||||
<path d="M98 80 C98 70 82 68 78 76 C70 74 66 84 74 88 L94 88 C100 88 100 82 98 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
|
||||
</>
|
||||
),
|
||||
];
|
||||
|
||||
/** One icon in its own 100×100 SVG. */
|
||||
export function SymbolArt({ art, className }: { art: number; className?: string }) {
|
||||
const draw = ICONS[art];
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} aria-hidden focusable="false">
|
||||
{draw ? draw() : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
854
apps/web/src/games/dobble/index.tsx
Normal file
854
apps/web/src/games/dobble/index.tsx
Normal file
@@ -0,0 +1,854 @@
|
||||
/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar } from '../../components/ui';
|
||||
import { useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { socket, useRoom } from '../../net/socket';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { ICON_NAMES, SymbolArt } from './art';
|
||||
|
||||
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
|
||||
const TAP_GRACE_MS = 250;
|
||||
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
|
||||
const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? ''));
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
|
||||
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const who = (players: Record<string, PublicUser>, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
|
||||
|
||||
const CSS = `
|
||||
.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); }
|
||||
.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; }
|
||||
.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.db-pill.warn { background: var(--warning-soft); color: var(--warning); }
|
||||
.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
|
||||
.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; }
|
||||
.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; }
|
||||
button.db-seat { cursor: pointer; }
|
||||
.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.db-seat[data-out='true'] { opacity: .55; }
|
||||
.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
|
||||
.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; }
|
||||
.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; }
|
||||
.db-tag.ok { background: #15803d; }
|
||||
.db-tag.warn { background: #f59e0b; color: #111827; }
|
||||
.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; }
|
||||
.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); }
|
||||
.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); }
|
||||
.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; }
|
||||
.db-card[data-shake='true'] { animation: db-shake .4s ease; }
|
||||
.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
.db-sym:disabled { cursor: default; }
|
||||
@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } }
|
||||
.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; }
|
||||
.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); }
|
||||
.db-sym .ic svg { width: 100%; height: 100%; display: block; }
|
||||
.db-sym .ic.in { animation: db-in .25s ease-out; }
|
||||
.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; }
|
||||
.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); }
|
||||
.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; }
|
||||
.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; }
|
||||
.db-thumb .db-sym { cursor: inherit; }
|
||||
.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; }
|
||||
.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; }
|
||||
.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px;
|
||||
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; }
|
||||
.db-count small { font-size: 1.1rem; padding: 0 12px; }
|
||||
.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
|
||||
font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; }
|
||||
.db-bubble .bi { width: 34px; height: 34px; flex: none; }
|
||||
.db-bubble.ok { border: 4px solid #16a34a; }
|
||||
.db-bubble.bad { border: 4px solid #dc2626; }
|
||||
.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; }
|
||||
.db-bubble.info { border: 4px solid #2563eb; }
|
||||
.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; }
|
||||
.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
|
||||
.db-sus { color: var(--warning); font-weight: 800; }
|
||||
@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } }
|
||||
@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } }
|
||||
@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
|
||||
@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } }
|
||||
@media (max-width: 719px) {
|
||||
.db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; }
|
||||
.db-slot { position: relative; }
|
||||
.db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; }
|
||||
.db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; }
|
||||
.db-pill { font-size: .8rem; padding: 2px 8px; }
|
||||
.db-strip { justify-content: flex-start; }
|
||||
.db-seat { padding: 3px 8px; gap: 6px; }
|
||||
.db-seat .meta.rtt, .db-pill.mode { display: none; }
|
||||
.db-seat .who { flex-direction: row; align-items: center; gap: 5px; }
|
||||
.db-seat .nm { max-width: 72px; font-size: .8rem; }
|
||||
.db-thumb { width: 38px; height: 38px; }
|
||||
.db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; }
|
||||
.db-bubble .bi { width: 26px; height: 26px; }
|
||||
.db-count { font-size: 4rem; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; }
|
||||
.db-sym[data-hit='true'] .ic { transition: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- card
|
||||
|
||||
function usePhone(): boolean {
|
||||
const q = '(max-width: 719px)';
|
||||
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(q);
|
||||
const on = () => setM(mq.matches);
|
||||
mq.addEventListener('change', on);
|
||||
return () => mq.removeEventListener('change', on);
|
||||
}, []);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes
|
||||
* (the picture inside is keyed by art and pops in). `onPick` null = look only.
|
||||
*/
|
||||
function DobbleCard({
|
||||
card,
|
||||
label,
|
||||
side,
|
||||
onPick,
|
||||
hint,
|
||||
hit,
|
||||
shake,
|
||||
children,
|
||||
}: {
|
||||
card: CardView;
|
||||
label: string;
|
||||
side: string;
|
||||
onPick: ((art: number) => void) | null;
|
||||
hint?: number[];
|
||||
hit?: number | null;
|
||||
shake?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="db-card" role="group" aria-label={label} data-card={side} data-key={card.key} data-shake={!!shake}>
|
||||
{card.items.map((it, i) => {
|
||||
const d = it.r * 100;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="db-sym"
|
||||
data-art={it.art}
|
||||
data-hint={!!hint?.includes(it.art)}
|
||||
data-hit={hit === it.art}
|
||||
disabled={!onPick}
|
||||
aria-label={`${artName(it.art)}${onPick ? ' 누르기' : ''}`}
|
||||
style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}
|
||||
onPointerDown={(e) => {
|
||||
// Fire on press, not release: every millisecond counts.
|
||||
if (!onPick || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPick(it.art);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Keyboard activation; pointer presses were handled on pointerdown.
|
||||
if (onPick && e.detail === 0) onPick(it.art);
|
||||
}}
|
||||
>
|
||||
<span key={it.art} className="ic in" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
|
||||
<SymbolArt art={it.art} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small look-only card (target picker, rules). */
|
||||
function Thumb({ card }: { card: CardView | null }) {
|
||||
if (!card) return <span className="db-thumb" aria-hidden style={{ opacity: 0.4 }} />;
|
||||
return (
|
||||
<span className="db-thumb" aria-hidden>
|
||||
{card.items.map((it, i) => {
|
||||
const d = it.r * 100;
|
||||
return (
|
||||
<span key={i} className="db-sym" style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}>
|
||||
<span className="ic" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
|
||||
<SymbolArt art={it.art} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seats
|
||||
|
||||
function countText(mode: MiniGame, s: SeatView): string {
|
||||
switch (mode) {
|
||||
case 'tower':
|
||||
return `탑 ${s.cards}장`;
|
||||
case 'well':
|
||||
return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`;
|
||||
case 'poisonedGift':
|
||||
return `받은 카드 ${s.cards}장`;
|
||||
case 'hotPotato':
|
||||
return `${s.cards}장 · 벌점 ${s.penalty}`;
|
||||
}
|
||||
}
|
||||
|
||||
function statusTag(s: SeatView): { text: string; cls: string } | null {
|
||||
if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' };
|
||||
if (s.status === 'escaped') return { text: '탈출', cls: 'ok' };
|
||||
if (s.status === 'forfeit') return { text: '기권', cls: '' };
|
||||
if (s.status === 'left') return { text: '나감', cls: '' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function Rtt({ ms }: { ms: number | null }) {
|
||||
if (ms === null) return null;
|
||||
const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626';
|
||||
return (
|
||||
<span className="meta rtt" aria-label={`연결 지연 ${ms}밀리초`}>
|
||||
<i className="db-dot" style={{ background: color }} aria-hidden />
|
||||
{ms}ms
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function seatLabel(v: DobbleView, s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)];
|
||||
const tag = statusTag(s);
|
||||
if (tag) parts.push(tag.text);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
interface Bubble {
|
||||
key: string;
|
||||
kind: 'ok' | 'bad' | 'late' | 'info';
|
||||
text: string;
|
||||
art?: number | null;
|
||||
}
|
||||
|
||||
function DobbleBoard({ view: v, me, players, act, pending }: BoardProps<DobbleView>) {
|
||||
const now = useServerNow(100);
|
||||
const phone = usePhone();
|
||||
const rtt = useRoom((s) => s.rttMs);
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [hitArt, setHitArt] = useState<number | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prev = useRef<DobbleView | null>(null);
|
||||
const changed = useRef({ sig: '', at: 0 });
|
||||
|
||||
const mode = v.options.miniGame;
|
||||
const targetMode = mode === 'hotPotato' || mode === 'poisonedGift';
|
||||
const mine = v.seats.find((s) => s.id === me) ?? null;
|
||||
const others = v.seats.filter((s) => s.id !== me);
|
||||
const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null);
|
||||
// The opponent whose card is shown big (also for spectators / while waiting).
|
||||
const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null;
|
||||
const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null;
|
||||
// Clamped to the lock length: the client clock estimate may be a little off.
|
||||
const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0;
|
||||
const playing = v.phase === 'playing';
|
||||
const canTap = !!pair && playing && lockedLeft === 0 && !pending;
|
||||
|
||||
const claim = (art: number) => {
|
||||
if (!pair) return;
|
||||
// A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty.
|
||||
if (performance.now() - changed.current.at < TAP_GRACE_MS) {
|
||||
setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
|
||||
return;
|
||||
}
|
||||
if (!canTap) {
|
||||
if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` });
|
||||
return;
|
||||
}
|
||||
haptic(15);
|
||||
act({
|
||||
type: 'claim',
|
||||
base: pair.base,
|
||||
symbol: art,
|
||||
...(pair.target ? { target: pair.target } : {}),
|
||||
...(pair.mine !== null ? { mine: pair.mine } : {}),
|
||||
...(rtt !== null ? { rtt } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// "늦었어요" rejects come back as `reject` messages.
|
||||
useEffect(
|
||||
() =>
|
||||
socket.subscribe((m) => {
|
||||
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
|
||||
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
|
||||
setAnnounce(m.reason);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Diff against the previous view: my judgement, other people's finds, rounds.
|
||||
useEffect(() => {
|
||||
const p = prev.current;
|
||||
prev.current = v;
|
||||
if (!p) return;
|
||||
const f = v.feedback;
|
||||
if (f && f.seq !== p.feedback?.seq) {
|
||||
if (f.kind === 'ok') {
|
||||
sounds.ding();
|
||||
haptic([30, 30, 60]);
|
||||
setHitArt(f.art);
|
||||
const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art };
|
||||
setBubble(b);
|
||||
setAnnounce(`찾았어요! ${artName(f.art)}`);
|
||||
} else if (f.kind === 'wrong') {
|
||||
sounds.buzz();
|
||||
haptic([90, 50, 90]);
|
||||
setShake(true);
|
||||
const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : '';
|
||||
const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art };
|
||||
setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`);
|
||||
setBubble(b);
|
||||
} else if (f.kind === 'late') {
|
||||
setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' });
|
||||
setAnnounce('늦었어요');
|
||||
} else if (f.kind === 'locked') {
|
||||
setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const h = v.lastHit;
|
||||
if (h && h.seq !== p.lastHit?.seq && h.player !== me) {
|
||||
sounds.stone();
|
||||
let text = `${nick(players, h.player)}님이 찾았어요`;
|
||||
if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`;
|
||||
if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`;
|
||||
setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art });
|
||||
setAnnounce(`${text} · ${artName(h.art)}`);
|
||||
}
|
||||
const r = v.lastRound;
|
||||
if (r && r.round !== p.lastRound?.round) {
|
||||
const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`;
|
||||
setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text });
|
||||
setAnnounce(text);
|
||||
}
|
||||
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700);
|
||||
return () => clearTimeout(t);
|
||||
}, [bubble]);
|
||||
useEffect(() => {
|
||||
if (!shake) return;
|
||||
const t = setTimeout(() => setShake(false), 450);
|
||||
return () => clearTimeout(t);
|
||||
}, [shake]);
|
||||
useEffect(() => {
|
||||
if (hitArt === null) return;
|
||||
const t = setTimeout(() => setHitArt(null), 500);
|
||||
return () => clearTimeout(t);
|
||||
}, [hitArt]);
|
||||
|
||||
const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0;
|
||||
const hint = v.hintUntil > now ? v.hint : [];
|
||||
const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null;
|
||||
|
||||
// Which two cards are compared (§5.2): A = top/left, B = bottom/right.
|
||||
const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드');
|
||||
let a: { card: CardView | null; cap: string; side: string };
|
||||
let b: { card: CardView | null; cap: string; side: string };
|
||||
if (mode === 'hotPotato') {
|
||||
a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' };
|
||||
b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' };
|
||||
} else if (mode === 'poisonedGift') {
|
||||
a = { card: v.center, cap: '가운데 카드', side: 'center' };
|
||||
b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' };
|
||||
} else {
|
||||
a = { card: v.center, cap: '가운데 카드', side: 'center' };
|
||||
b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' };
|
||||
}
|
||||
const emptyText = (side: string): string => {
|
||||
if (v.phase === 'countdown') return '곧 공개돼요';
|
||||
if (side === 'mine') {
|
||||
if (!mine) return '관전 중이에요';
|
||||
if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`;
|
||||
if (mine.status === 'escaped') return '이번 라운드 탈출!';
|
||||
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요';
|
||||
}
|
||||
if (v.phase === 'roundEnd') return '다음 라운드를 준비해요';
|
||||
return '카드가 없어요';
|
||||
};
|
||||
const slot = (x: typeof a, withLock: boolean) => (
|
||||
<div className="db-slot">
|
||||
<span className="cap">{x.cap}</span>
|
||||
{x.card ? (
|
||||
<DobbleCard card={x.card} label={x.cap} side={x.side} onPick={tapper} hint={hint} hit={hitArt} shake={withLock && shake}>
|
||||
{withLock && lockedLeft > 0 && (
|
||||
<span
|
||||
className="db-lock"
|
||||
aria-hidden
|
||||
style={{ background: `conic-gradient(rgba(17,24,39,.45) ${(lockedLeft / Math.max(1, v.options.wrongLockoutMs)) * 360}deg, transparent 0)` }}
|
||||
>
|
||||
<b>잠깐! {Math.ceil(lockedLeft / 1000)}초</b>
|
||||
</span>
|
||||
)}
|
||||
</DobbleCard>
|
||||
) : (
|
||||
<div className="db-card empty" data-card={x.side}>
|
||||
{emptyText(x.side)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`;
|
||||
if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() };
|
||||
|
||||
const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`;
|
||||
const pickable = new Set(v.pairs.map((p) => p.target));
|
||||
|
||||
return (
|
||||
<div className="db">
|
||||
<style>{CSS}</style>
|
||||
<div className="db-top">
|
||||
<div className="grp">
|
||||
<span className="db-pill mode">{MINI_KO[mode]}</span>
|
||||
{remaining && <span className="db-pill">{remaining}</span>}
|
||||
{mine && <span className="db-pill">내 카드 · {countText(mode, mine)}</span>}
|
||||
</div>
|
||||
<div className="grp">
|
||||
{v.options.showRtt && rtt !== null && (
|
||||
<span className={`db-pill${rtt >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}>
|
||||
<i className="db-dot" style={{ background: rtt < 80 ? '#16a34a' : rtt < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
|
||||
{rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{others.length > 0 && (
|
||||
<ul className="db-strip" aria-label={targetMode ? '누구 카드에서 찾을지 고르기' : '다른 사람'}>
|
||||
{others.map((s) => {
|
||||
const tag = statusTag(s);
|
||||
const canPick = targetMode && pickable.has(s.id);
|
||||
const inner = (
|
||||
<>
|
||||
{targetMode && <Thumb card={s.top} />}
|
||||
{!targetMode && <Avatar user={userOf(players, s.id)} size={28} />}
|
||||
<span className="who">
|
||||
<span className="nm">
|
||||
{targetMode && <Avatar user={userOf(players, s.id)} size={16} />}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{nick(players, s.id)}</span>
|
||||
</span>
|
||||
<span className="meta">
|
||||
{countText(mode, s)}
|
||||
{s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''}
|
||||
</span>
|
||||
{v.options.showRtt && <Rtt ms={s.rtt} />}
|
||||
</span>
|
||||
{tag && <span className={`db-tag ${tag.cls}`}>{tag.text}</span>}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={s.id}>
|
||||
{canPick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="db-seat"
|
||||
data-seat={s.id}
|
||||
aria-pressed={targetId === s.id}
|
||||
aria-label={`${seatLabel(v, s, players, me)} · 이 사람 카드 고르기`}
|
||||
onClick={() => setSel(s.id)}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div className="db-seat" data-seat={s.id} data-out={s.status !== 'playing'} aria-label={seatLabel(v, s, players, me)} aria-pressed={targetMode ? targetId === s.id : undefined}>
|
||||
{inner}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="db-table" role="region" aria-label="카드 테이블">
|
||||
{slot(a, false)}
|
||||
{slot(b, true)}
|
||||
{bubble && (
|
||||
<div key={bubble.key} className={`db-bubble ${bubble.kind}`} data-bubble={bubble.key} aria-hidden>
|
||||
{bubble.art !== undefined && bubble.art !== null && <SymbolArt art={bubble.art} className="bi" />}
|
||||
<span>{bubble.text}</span>
|
||||
</div>
|
||||
)}
|
||||
{countdown > 0 && (
|
||||
<div className="db-count" aria-hidden>
|
||||
{countdown}
|
||||
<small>{v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{targetMode && v.pairs.length > 1 && playing && (
|
||||
<div className="db-panel small" style={{ fontWeight: 600 }}>
|
||||
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: <strong>{targetSeat ? nick(players, targetSeat.id) : ''}님</strong>
|
||||
</div>
|
||||
)}
|
||||
{!phone && pair && playing && (
|
||||
<span className="small muted" style={{ textAlign: 'center' }}>
|
||||
Tab 키로 그림을 고르고 Enter 키로 눌러요
|
||||
</span>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function logText(e: LogEntry, mode: MiniGame, players: Record<string, PublicUser>, me: string | null): string {
|
||||
switch (e.t) {
|
||||
case 'hit': {
|
||||
const n = who(players, me, e.p);
|
||||
const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`;
|
||||
if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`;
|
||||
if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`;
|
||||
if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`;
|
||||
return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`;
|
||||
}
|
||||
case 'done':
|
||||
return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`;
|
||||
case 'round':
|
||||
return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`;
|
||||
case 'out':
|
||||
return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`;
|
||||
case 'hint':
|
||||
return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요';
|
||||
}
|
||||
}
|
||||
|
||||
function rulesLine(o: DobbleOptions): string {
|
||||
const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`];
|
||||
if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`);
|
||||
parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음');
|
||||
if (o.symbolStyle === 'easy') parts.push('그림 크기 같게');
|
||||
if (o.latencyComp === 'light') parts.push('지연 보정');
|
||||
if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝');
|
||||
if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function DobbleSide({ view: v, me, players }: BoardProps<DobbleView>) {
|
||||
const mode = v.options.miniGame;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<strong>도블{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
|
||||
<span className="small muted">{rulesLine(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{v.seats.map((s) => {
|
||||
const sus = v.suspects.includes(s.id);
|
||||
const tag = statusTag(s);
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="row"
|
||||
style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'forfeit' || s.status === 'left' ? 0.55 : 1 }}
|
||||
aria-label={`${seatLabel(v, s, players, me)}, 찾기 ${s.correct}번, 틀림 ${s.wrong}번${sus ? ', 너무 빨리 맞혀서 확인이 필요해요' : ''}`}
|
||||
>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.id === me ? ' (나)' : ''}
|
||||
{tag ? ` · ${tag.text}` : ''}
|
||||
</span>
|
||||
{sus && (
|
||||
<span className="db-sus small" aria-hidden title="카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요">
|
||||
⚠ 의심
|
||||
</span>
|
||||
)}
|
||||
{v.options.showRtt && s.rtt !== null && (
|
||||
<span className="small" aria-hidden style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
|
||||
<i className="db-dot" style={{ background: s.rtt < 80 ? '#16a34a' : s.rtt < 200 ? '#eab308' : '#dc2626' }} />
|
||||
{s.rtt}
|
||||
</span>
|
||||
)}
|
||||
<span className="small" aria-hidden>
|
||||
✓{s.correct} ✗{s.wrong}
|
||||
</span>
|
||||
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
|
||||
{mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
|
||||
</strong>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{v.suspects.length > 0 && <span className="small muted">⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.</span>}
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((r) => (
|
||||
<span key={r.id}>
|
||||
{r.rank}등 {nick(players, r.id)}
|
||||
<span className="muted small">
|
||||
{' '}
|
||||
· {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">{v.result.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol className="db-log">
|
||||
{[...v.log].reverse().map((e) => (
|
||||
<li key={e.n} className="small">
|
||||
{logText(e, mode, players, me)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: DobbleView, me: string | null): boolean {
|
||||
return !!me && v.phase === 'playing' && v.pairs.length > 0;
|
||||
}
|
||||
|
||||
function statusText(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요';
|
||||
if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요';
|
||||
const mine = v.seats.find((s) => s.id === me);
|
||||
if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`;
|
||||
if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요';
|
||||
if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요';
|
||||
if (isMyTurn(v, me)) {
|
||||
return '지금 찾아요! 같은 그림을 눌러요';
|
||||
}
|
||||
const leader = v.lastHit ? nick(players, v.lastHit.player) : null;
|
||||
return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onOff: [boolean, string][] = [
|
||||
[true, '켜기'],
|
||||
[false, '끄기'],
|
||||
];
|
||||
|
||||
const MINI_HELP: Record<MiniGame, string> = {
|
||||
tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!',
|
||||
well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!',
|
||||
hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!',
|
||||
poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!',
|
||||
};
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<DobbleOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<DobbleOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="미니게임" help={MINI_HELP[o.miniGame]}>
|
||||
<Seg
|
||||
label="미니게임"
|
||||
value={o.miniGame}
|
||||
options={[
|
||||
['tower', '탑 쌓기'],
|
||||
['well', '우물'],
|
||||
['hotPotato', '뜨거운 감자'],
|
||||
['poisonedGift', '독이 든 선물'],
|
||||
]}
|
||||
onChange={(miniGame) => set({ miniGame })}
|
||||
/>
|
||||
</Field>
|
||||
{o.miniGame === 'hotPotato' && (
|
||||
<Field label="뜨거운 감자 라운드 수">
|
||||
<Seg
|
||||
label="뜨거운 감자 라운드 수"
|
||||
value={o.hotPotatoRounds}
|
||||
options={[
|
||||
[3, '3라운드'],
|
||||
[5, '5라운드'],
|
||||
[7, '7라운드'],
|
||||
]}
|
||||
onChange={(hotPotatoRounds) => set({ hotPotatoRounds })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="틀린 그림을 눌렀을 때 잠금" help="잠금 동안에는 맞는 그림을 눌러도 받아 주지 않아요.">
|
||||
<Seg
|
||||
label="틀린 그림을 눌렀을 때 잠금"
|
||||
value={o.wrongLockoutMs}
|
||||
options={[
|
||||
[0, '없음'],
|
||||
[1000, '1초'],
|
||||
[2000, '2초'],
|
||||
]}
|
||||
onChange={(wrongLockoutMs) => set({ wrongLockoutMs })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="그림 크기·회전" help={o.symbolStyle === 'easy' ? '어린이 모드: 그림 크기가 같고 돌아가 있지 않아요.' : '그림마다 크기와 방향이 달라요.'}>
|
||||
<Seg
|
||||
label="그림 크기·회전"
|
||||
value={o.symbolStyle}
|
||||
options={[
|
||||
['classic', '원작처럼'],
|
||||
['easy', '쉽게'],
|
||||
]}
|
||||
onChange={(symbolStyle) => set({ symbolStyle })}
|
||||
/>
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="카드 장수">
|
||||
<Seg
|
||||
label="카드 장수"
|
||||
value={o.deckSize}
|
||||
options={[
|
||||
[57, '57장'],
|
||||
[55, '55장'],
|
||||
]}
|
||||
onChange={(deckSize) => set({ deckSize })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="지연 보정" help="켜면 0.06초 동안 모인 정답 가운데, 연결 지연의 절반(최대 0.08초)을 빼 준 시각이 가장 빠른 사람이 이겨요.">
|
||||
<Seg
|
||||
label="지연 보정"
|
||||
value={o.latencyComp}
|
||||
options={[
|
||||
['off', '끄기'],
|
||||
['light', '가볍게'],
|
||||
]}
|
||||
onChange={(latencyComp) => set({ latencyComp })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="각자 핑 표시">
|
||||
<Seg label="각자 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
|
||||
</Field>
|
||||
{o.miniGame === 'well' && (
|
||||
<Field label="우물: 처음 다 비운 사람이 나오면 바로 끝내기">
|
||||
<Seg label="우물 첫 완주에 끝내기" value={o.wellEndAtFirst} options={onOff} onChange={(wellEndAtFirst) => set({ wellEndAtFirst })} />
|
||||
</Field>
|
||||
)}
|
||||
{o.miniGame === 'tower' && (
|
||||
<Field label="탑 쌓기: 틀리면 내 탑 맨 위 카드를 반납">
|
||||
<Seg label="틀리면 카드 반납" value={o.wrongGivesCard} options={onOff} onChange={(wrongGivesCard) => set({ wrongGivesCard })} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: DobbleOptions): string {
|
||||
return rulesLine(o);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */
|
||||
const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]];
|
||||
const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) });
|
||||
const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])];
|
||||
|
||||
function Rules({ options }: { options?: DobbleOptions }) {
|
||||
const lock = options?.wrongLockoutMs ?? 1000;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 <strong>똑같은 그림이 딱 하나</strong> 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.
|
||||
</p>
|
||||
<div className="row" style={{ gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 96, display: 'inline-block' }}>
|
||||
<DobbleCard card={SAMPLE[0]!} label="예시 카드 1" side="r1" onPick={null} hint={[18]} />
|
||||
</span>
|
||||
<span style={{ width: 96, display: 'inline-block' }}>
|
||||
<DobbleCard card={SAMPLE[1]!} label="예시 카드 2" side="r2" onPick={null} hint={[18]} />
|
||||
</span>
|
||||
<span>→ 같은 그림은 고양이!</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>탑 쌓기</strong>: {MINI_HELP.tower}
|
||||
</li>
|
||||
<li>
|
||||
<strong>우물</strong>: {MINI_HELP.well}
|
||||
</li>
|
||||
<li>
|
||||
<strong>뜨거운 감자</strong>: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>독이 든 선물</strong>: {MINI_HELP.poisonedGift}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).
|
||||
</p>
|
||||
<p>PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<DobbleView, DobbleOptions> = {
|
||||
Board: DobbleBoard,
|
||||
Side: DobbleSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
1174
apps/web/src/games/gostop/index.tsx
Normal file
1174
apps/web/src/games/gostop/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
960
apps/web/src/games/halli-galli/index.tsx
Normal file
960
apps/web/src/games/halli-galli/index.tsx
Normal file
@@ -0,0 +1,960 @@
|
||||
/** 할리갈리 화면 (docs/games/halli-galli.md §9). */
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import type { Fruit, FruitCount, HalliGalliOptions, HalliGalliView, LogEntry, SeatView, TopCard } from '@bg/games/halli-galli';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar } from '../../components/ui';
|
||||
import { useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { socket, useRoom } from '../../net/socket';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum'];
|
||||
const FRUIT_KO: Record<Fruit, string> = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' };
|
||||
const FRUIT_INK: Record<Fruit, string> = { strawberry: '#e11d48', banana: '#ca8a04', lime: '#4d7c0f', plum: '#7e22ce' };
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
|
||||
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const cardName = (c: Pick<TopCard, 'fruit' | 'count'>) => `${FRUIT_KO[c.fruit]} ${c.count}개`;
|
||||
|
||||
const CSS = `
|
||||
.hg { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
|
||||
.hg-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; justify-content: space-between; font-weight: 700; }
|
||||
.hg-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.hg-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; }
|
||||
.hg-pill.warn { background: var(--warning-soft); color: var(--warning); }
|
||||
.hg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.hg-table { position: relative; width: 100%; height: clamp(440px, 64vh, 600px); border-radius: 28px; border: 8px solid var(--wood); box-shadow: var(--shadow);
|
||||
background: radial-gradient(ellipse at center, rgba(255,255,255,.14), rgba(255,255,255,0) 62%), var(--felt); overflow: hidden; touch-action: manipulation; }
|
||||
.hg-seat { position: absolute; transform: translate(-50%, -50%); width: var(--sw); display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
padding: 5px 5px 6px; border-radius: 14px; background: rgba(0,0,0,.28); color: #fff; border: 3px solid transparent; z-index: 2; }
|
||||
.hg-seat[data-me='true'] { background: rgba(0,0,0,.42); }
|
||||
.hg-seat[data-turn='true'] { border-color: #fde047; animation: hg-blink 1s ease-in-out infinite; }
|
||||
.hg-seat[data-out='true'] { opacity: .6; }
|
||||
.hg-seat .nm { display: flex; align-items: center; gap: 4px; max-width: 100%; font-weight: 800; font-size: .86rem; line-height: 1.2; }
|
||||
.hg-seat .nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hg-pile { position: relative; width: var(--cw); height: calc(var(--cw) * 1.4); }
|
||||
.hg-pile .stack { position: absolute; inset: 0; border-radius: 9px; }
|
||||
.hg-pile .empty { position: absolute; inset: 0; border-radius: 9px; border: 2px dashed rgba(255,255,255,.45); }
|
||||
.hg-pile .shadow1 { position: absolute; inset: 0; transform: translate(3px, 3px); border-radius: 9px; background: #e7e2d3; border: 1px solid #b9b2a0; }
|
||||
.hg-pile .face { position: absolute; inset: 0; animation: hg-flip .22s ease-out; }
|
||||
.hg-pile .gauge { position: absolute; right: -12px; bottom: -8px; width: 30px; height: 30px; }
|
||||
.hg-meta { display: flex; gap: 6px; align-items: center; font-size: .82rem; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.hg-back { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; vertical-align: -2px; }
|
||||
.hg-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; }
|
||||
.hg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; z-index: 3; }
|
||||
.hg-bell { width: var(--bell); height: var(--bell); border-radius: 50%; border: 0; padding: 0; background: radial-gradient(circle at 40% 35%, #fff7d1, #fbbf24 45%, #b45309 100%);
|
||||
box-shadow: 0 8px 0 #7c2d12, 0 12px 24px rgba(0,0,0,.35); cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center;
|
||||
transition: transform .06s ease, box-shadow .06s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; }
|
||||
.hg-bell .art { width: 70%; height: 70%; display: block; pointer-events: none; }
|
||||
.hg-bell .art svg { width: 100%; height: 100%; display: block; }
|
||||
.hg-bell .art[data-ring='true'] { animation: hg-ring .45s ease-in-out; }
|
||||
.hg-bell:active:not(:disabled) { transform: translateY(6px) scale(.97); box-shadow: 0 2px 0 #7c2d12, 0 4px 10px rgba(0,0,0,.35); }
|
||||
.hg-bell:disabled { cursor: not-allowed; filter: grayscale(.85) brightness(.85); }
|
||||
.hg-bell:focus-visible { outline: 4px solid var(--focus); outline-offset: 6px; }
|
||||
.hg-bell .lock { position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; }
|
||||
.hg-bell .lbl { position: absolute; bottom: 14%; left: 0; right: 0; text-align: center; font-weight: 900; color: #451a03; font-size: calc(var(--bell) * .11); pointer-events: none; }
|
||||
.hg-keys { color: var(--text-sub); font-weight: 600; font-size: .85rem; }
|
||||
.hg-pot { position: absolute; left: 50%; top: -2px; transform: translateX(-50%); color: #fff; font-weight: 800; font-size: .78rem; z-index: 4; pointer-events: none;
|
||||
background: #1e3a8a; border: 2px solid #93c5fd; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
|
||||
.hg-bubble { position: absolute; left: 50%; top: 2px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
|
||||
font-weight: 900; font-size: 1.25rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: hg-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; }
|
||||
.hg-bubble.ok { border: 4px solid #16a34a; }
|
||||
.hg-bubble.bad { border: 4px solid #dc2626; }
|
||||
.hg-bubble.late { border: 4px solid #6b7280; font-size: .95rem; }
|
||||
.hg-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.35); color: #fff;
|
||||
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; }
|
||||
.hg-count small { font-size: 1.1rem; }
|
||||
.hg-ghost { position: absolute; width: var(--cw); height: calc(var(--cw) * 1.4); transform: translate(-50%, -50%); z-index: 5; pointer-events: none;
|
||||
animation: hg-fly .6s cubic-bezier(.5,.1,.3,1) forwards; animation-delay: var(--d, 0ms); left: var(--fx); top: var(--fy); }
|
||||
.hg-mine { display: flex; gap: 10px; align-items: stretch; }
|
||||
.hg-mine .info { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 8px 12px; display: flex; flex-direction: column; justify-content: center; min-width: 96px; }
|
||||
.hg-mine .info b { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
|
||||
.hg-flipbtn { flex: 1; min-height: 64px; font-size: 1.25rem !important; }
|
||||
.hg-kid { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: grid; gap: 6px; }
|
||||
.hg-bar { display: grid; grid-template-columns: 28px 52px 1fr 28px; gap: 8px; align-items: center; font-weight: 800; }
|
||||
.hg-bar .track { position: relative; height: 14px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
|
||||
.hg-bar .track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; }
|
||||
.hg-bar .track::after { content: ''; position: absolute; left: calc(100% * 5 / 8); top: -2px; bottom: -2px; width: 2px; background: var(--text); }
|
||||
.hg-bar[data-five='true'] { color: var(--success); }
|
||||
.hg-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; }
|
||||
.hg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
|
||||
.hg-fruit-ic { width: 26px; height: 26px; display: inline-block; vertical-align: middle; }
|
||||
@keyframes hg-blink { 0%,100% { box-shadow: 0 0 0 0 rgba(253,224,71,.0); } 50% { box-shadow: 0 0 0 6px rgba(253,224,71,.55); } }
|
||||
@keyframes hg-flip { from { transform: rotateY(85deg) scale(.9); } to { transform: none; } }
|
||||
@keyframes hg-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
|
||||
@keyframes hg-ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(-14deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(5deg); } }
|
||||
@keyframes hg-fly { from { left: var(--fx); top: var(--fy); opacity: 1; } 85% { opacity: .9; } to { left: var(--tx); top: var(--ty); opacity: 0; transform: translate(-50%, -50%) scale(.6); } }
|
||||
@media (max-width: 719px) {
|
||||
.hg-table { height: 450px; border-width: 6px; border-radius: 22px; }
|
||||
.hg-seat { padding: 3px 3px 4px; gap: 2px; }
|
||||
.hg-seat .nm { font-size: .78rem; }
|
||||
.hg-meta { font-size: .76rem; gap: 4px; }
|
||||
.hg-bubble { font-size: .95rem; padding: 6px 12px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hg-seat[data-turn='true'] { animation: none; box-shadow: 0 0 0 4px rgba(253,224,71,.55); }
|
||||
.hg-pile .face, .hg-bell .art[data-ring='true'], .hg-bubble { animation: none; }
|
||||
.hg-ghost { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- fruit art
|
||||
|
||||
/** One fruit in a 40×40 box. Distinct silhouettes so colour is never the only cue. */
|
||||
function FruitShape({ fruit }: { fruit: Fruit }) {
|
||||
switch (fruit) {
|
||||
case 'strawberry':
|
||||
return (
|
||||
<g>
|
||||
<path d="M20 38 C9 30 4 20 7.5 13.5 C10.5 8.5 29.5 8.5 32.5 13.5 C36 20 31 30 20 38 Z" fill="#e11d48" stroke="#9f1239" strokeWidth="1.5" />
|
||||
{[
|
||||
[14, 17],
|
||||
[20, 15],
|
||||
[26, 17],
|
||||
[12, 23],
|
||||
[18, 22],
|
||||
[24, 22],
|
||||
[28, 23],
|
||||
[16, 28],
|
||||
[22, 28],
|
||||
[19, 33],
|
||||
].map(([x, y]) => (
|
||||
<ellipse key={`${x}-${y}`} cx={x} cy={y} rx="1.1" ry="1.6" fill="#fde68a" />
|
||||
))}
|
||||
<path d="M11 11 L16 9 L20 4 L24 9 L29 11 L23.5 12.5 L20 15 L16.5 12.5 Z" fill="#16a34a" stroke="#14532d" strokeWidth="1" />
|
||||
</g>
|
||||
);
|
||||
case 'banana':
|
||||
return (
|
||||
<g>
|
||||
<path d="M7 9 C7 27 22 36 36 30 C37 28 36 27 34 27.5 C22 30 13 22 12 8 C11 6 8 6 7 9 Z" fill="#facc15" stroke="#a16207" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M11 12 C12 22 19 28 30 29" fill="none" stroke="#eab308" strokeWidth="1.4" />
|
||||
<path d="M7 9 L8.5 4.5 L11.5 5.5 L12 8" fill="#78350f" />
|
||||
<circle cx="35.6" cy="29" r="1.6" fill="#78350f" />
|
||||
</g>
|
||||
);
|
||||
case 'lime':
|
||||
return (
|
||||
<g>
|
||||
<circle cx="20" cy="20" r="16" fill="#3f6212" />
|
||||
<circle cx="20" cy="20" r="13.5" fill="#f7fee7" />
|
||||
<circle cx="20" cy="20" r="12" fill="#a3e635" />
|
||||
{Array.from({ length: 8 }, (_, i) => {
|
||||
const a = (i * Math.PI) / 4;
|
||||
return <line key={i} x1="20" y1="20" x2={20 + Math.cos(a) * 12} y2={20 + Math.sin(a) * 12} stroke="#f7fee7" strokeWidth="1.6" />;
|
||||
})}
|
||||
<circle cx="20" cy="20" r="2.2" fill="#f7fee7" />
|
||||
</g>
|
||||
);
|
||||
case 'plum':
|
||||
return (
|
||||
<g>
|
||||
<path d="M20 8 C30 8 34 17 33 25 C32 33 26 37 20 37 C14 37 8 33 7 25 C6 17 10 8 20 8 Z" fill="#7e22ce" stroke="#4c1d95" strokeWidth="1.5" />
|
||||
<path d="M20 10 C17 18 17 28 20 36" fill="none" stroke="#4c1d95" strokeWidth="1.6" />
|
||||
<ellipse cx="13" cy="18" rx="2.4" ry="4.5" fill="#c4b5fd" opacity=".75" />
|
||||
<path d="M20 9 L21 3" stroke="#78350f" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M21 5 C25 2 30 3 31 5 C28 8 24 8 21 5 Z" fill="#16a34a" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SPOTS: Record<FruitCount, [number, number][]> = {
|
||||
1: [[50, 70]],
|
||||
2: [
|
||||
[50, 38],
|
||||
[50, 102],
|
||||
],
|
||||
3: [
|
||||
[28, 34],
|
||||
[50, 70],
|
||||
[72, 106],
|
||||
],
|
||||
4: [
|
||||
[29, 38],
|
||||
[71, 38],
|
||||
[29, 102],
|
||||
[71, 102],
|
||||
],
|
||||
5: [
|
||||
[27, 32],
|
||||
[73, 32],
|
||||
[50, 70],
|
||||
[27, 108],
|
||||
[73, 108],
|
||||
],
|
||||
};
|
||||
|
||||
export function FruitCardArt({ card }: { card: Pick<TopCard, 'fruit' | 'count'> | null }) {
|
||||
if (!card) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
|
||||
<rect x="2" y="2" width="96" height="136" rx="11" fill="#1e3a8a" stroke="#93c5fd" strokeWidth="3" />
|
||||
<rect x="12" y="12" width="76" height="116" rx="7" fill="none" stroke="#93c5fd" strokeWidth="2" strokeDasharray="6 5" />
|
||||
<circle cx="50" cy="70" r="16" fill="#fbbf24" stroke="#fde68a" strokeWidth="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const size = card.count === 1 ? 54 : card.count === 5 ? 37 : 41;
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
|
||||
<rect x="2" y="2" width="96" height="136" rx="11" fill="#fffef8" stroke={FRUIT_INK[card.fruit]} strokeWidth="4" />
|
||||
{SPOTS[card.count].map(([x, y], i) => (
|
||||
<g key={i} transform={`translate(${x - size / 2} ${y - size / 2}) scale(${size / 40})`}>
|
||||
<FruitShape fruit={card.fruit} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FruitIcon({ fruit }: { fruit: Fruit }) {
|
||||
return (
|
||||
<svg viewBox="0 0 40 40" className="hg-fruit-ic" aria-hidden>
|
||||
<FruitShape fruit={fruit} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BellArt() {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" aria-hidden>
|
||||
<ellipse cx="50" cy="80" rx="40" ry="9" fill="#78350f" />
|
||||
<path d="M18 76 C18 50 30 26 50 26 C70 26 82 50 82 76 Z" fill="#fde68a" stroke="#92400e" strokeWidth="3" />
|
||||
<path d="M30 70 C30 52 37 36 48 33" fill="none" stroke="#fffbeb" strokeWidth="5" strokeLinecap="round" opacity=".8" />
|
||||
<rect x="43" y="14" width="14" height="13" rx="4" fill="#92400e" />
|
||||
<circle cx="50" cy="12" r="8" fill="#b91c1c" stroke="#7f1d1d" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- geometry
|
||||
|
||||
interface Pos {
|
||||
left: string;
|
||||
top: string;
|
||||
}
|
||||
const CENTER: Pos = { left: '50%', top: '50%' };
|
||||
|
||||
/**
|
||||
* Seat centres on an ellipse (me at the bottom, then clockwise: left → top → right). The radii are
|
||||
* "half the table minus half a seat", so seats always stay inside the felt whatever its size.
|
||||
* `tall` (narrow phone table): seats that are not level with the bell move further up/down so they clear it.
|
||||
*/
|
||||
function seatPositions(n: number, seat: { w: number; h: number }, maxRx: number, tall: boolean): Pos[] {
|
||||
const f = (x: number) => (Math.abs(x) < 1e-9 ? 0 : Number(x.toFixed(4)));
|
||||
return Array.from({ length: n }, (_, k) => {
|
||||
const t = (2 * Math.PI * k) / n;
|
||||
const c = f(Math.cos(t));
|
||||
const y = tall && c !== 0 ? Math.sign(c) * Math.max(Math.abs(c), 0.75) : c;
|
||||
return {
|
||||
left: `calc(50% - ${f(Math.sin(t))} * min(50% - ${seat.w / 2 + 6}px, ${maxRx}px))`,
|
||||
top: `calc(50% + ${y} * (50% - ${seat.h / 2 + 6}px))`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function usePhone(): boolean {
|
||||
const q = '(max-width: 719px)';
|
||||
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(q);
|
||||
const on = () => setM(mq.matches);
|
||||
mq.addEventListener('change', on);
|
||||
return () => mq.removeEventListener('change', on);
|
||||
}, []);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seat
|
||||
|
||||
function Gauge({ start, end, now }: { start: number; end: number; now: number }) {
|
||||
const frac = Math.max(0, Math.min(1, (end - now) / Math.max(1, end - start)));
|
||||
const r = 12;
|
||||
const c = 2 * Math.PI * r;
|
||||
const color = frac < 0.34 ? '#ef4444' : frac < 0.67 ? '#f59e0b' : '#22c55e';
|
||||
return (
|
||||
<svg className="gauge" viewBox="0 0 30 30" aria-hidden>
|
||||
<circle cx="15" cy="15" r={r + 2} fill="#111827" />
|
||||
<circle cx="15" cy="15" r={r} fill="none" stroke="#374151" strokeWidth="4" />
|
||||
<circle cx="15" cy="15" r={r} fill="none" stroke={color} strokeWidth="4" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} transform="rotate(-90 15 15)" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function seatLabel(s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
|
||||
parts.push(s.top ? `펼친 카드 ${cardName(s.top)}` : '펼친 카드 없음');
|
||||
parts.push(`덱 ${s.deck}장`);
|
||||
if (s.pile > 1) parts.push(`펼친 더미 ${s.pile}장`);
|
||||
if (s.status === 'out') parts.push('탈락');
|
||||
if (s.status === 'left') parts.push('나감');
|
||||
if (s.isTurn) parts.push('뒤집을 차례');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function Seat({ s, v, pos, players, me, now }: { s: SeatView; v: HalliGalliView; pos: Pos; players: Record<string, PublicUser>; me: string | null; now: number }) {
|
||||
const u = userOf(players, s.id);
|
||||
const locked = s.lockedUntil > now && s.status === 'playing';
|
||||
return (
|
||||
<li
|
||||
className="hg-seat"
|
||||
data-turn={s.isTurn && v.phase === 'playing'}
|
||||
data-out={s.status !== 'playing'}
|
||||
data-me={s.id === me}
|
||||
data-seat={s.id}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
aria-label={seatLabel(s, players, me)}
|
||||
>
|
||||
<div className="nm">
|
||||
<Avatar user={u} size={18} />
|
||||
<span>{s.id === me ? '나' : u.nickname}</span>
|
||||
</div>
|
||||
<div className="hg-pile">
|
||||
{s.pile > 1 && <span className="shadow1" />}
|
||||
{s.top ? (
|
||||
<span key={s.top.key} className="face">
|
||||
<FruitCardArt card={s.top} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="empty" />
|
||||
)}
|
||||
{s.isTurn && v.phase === 'playing' && <Gauge start={v.turnStartedAt} end={v.turnDeadline} now={now} />}
|
||||
</div>
|
||||
<div className="hg-meta" aria-hidden>
|
||||
<span>
|
||||
<i className="hg-back" /> {s.deck}
|
||||
</span>
|
||||
{s.status === 'out' && (
|
||||
<span className="hg-tag" style={{ background: '#dc2626' }}>
|
||||
탈락
|
||||
</span>
|
||||
)}
|
||||
{s.status === 'left' && (
|
||||
<span className="hg-tag" style={{ background: '#6b7280' }}>
|
||||
나감
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="hg-tag" style={{ background: '#f59e0b', color: '#111827' }}>
|
||||
잠금
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- bell feedback
|
||||
|
||||
interface Bubble {
|
||||
key: string;
|
||||
kind: 'ok' | 'bad' | 'late';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Ghost {
|
||||
key: string;
|
||||
from: Pos;
|
||||
to: Pos;
|
||||
card: Pick<TopCard, 'fruit' | 'count'> | null;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
function bellText(v: HalliGalliView, players: Record<string, PublicUser>): Bubble | null {
|
||||
const b = v.lastBell;
|
||||
if (!b) return null;
|
||||
const who = b.player === v.me ? '내가' : `${nick(players, b.player)}님이`;
|
||||
if (b.correct) return { key: `b${b.seq}`, kind: 'ok', text: `땡! ${who} 먼저 쳤어요 · ${b.fruit ? FRUIT_KO[b.fruit] : ''} 5개 · ${b.cards}장 가져가요` };
|
||||
const duel = v.phase === 'ended' && (v.options.duelWrongLoses || v.officialDuel);
|
||||
if (duel) return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요 · 승부가 끝났어요` };
|
||||
const lost = b.cards === 0 ? '' : v.options.wrongToPot ? ` · 종 밑에 ${b.cards}장` : ` · ${b.cards}장 나눠 줘요`;
|
||||
return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요${lost}` };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function HalliGalliBoard({ view: v, me, players, act, pending }: BoardProps<HalliGalliView>) {
|
||||
const now = useServerNow(100);
|
||||
const phone = usePhone();
|
||||
const rtt = useRoom((s) => s.rttMs);
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null);
|
||||
const [ghosts, setGhosts] = useState<Ghost[]>([]);
|
||||
const [hit, setHit] = useState(0);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const sentFor = useRef<number | null>(null);
|
||||
const prev = useRef<HalliGalliView | null>(null);
|
||||
|
||||
// Seats rotated so that I sit at the bottom (spectators: first seat at the bottom).
|
||||
const myIdx = v.seats.findIndex((s) => s.id === me);
|
||||
const rot = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx), ...v.seats.slice(0, myIdx)];
|
||||
const many = rot.length >= 5;
|
||||
const cw = phone ? (many ? 46 : 54) : many ? 70 : 86;
|
||||
const sw = phone ? (many ? 74 : 86) : many ? 116 : 132;
|
||||
const pos = seatPositions(rot.length, { w: sw, h: cw * 1.4 + (phone ? 50 : 58) }, phone ? 400 : 380, phone);
|
||||
const posOf = (id: string) => pos[rot.findIndex((s) => s.id === id)] ?? CENTER;
|
||||
const mine = v.seats.find((s) => s.id === me) ?? null;
|
||||
const playing = v.phase === 'playing';
|
||||
const lockedLeft = mine ? Math.max(0, mine.lockedUntil - now) : 0;
|
||||
const canRing = !!mine && mine.status === 'playing' && playing && lockedLeft === 0;
|
||||
const myTurn = !!mine && playing && v.turn === me;
|
||||
const canFlip = myTurn && !v.options.autoFlip && !v.pendingFlip && mine.deck > 0 && !pending;
|
||||
|
||||
const ring = () => {
|
||||
if (!canRing || sentFor.current === v.tableVersion) return;
|
||||
sentFor.current = v.tableVersion;
|
||||
setHit((h) => h + 1);
|
||||
sounds.ding();
|
||||
haptic(30);
|
||||
act({ type: 'ring', version: v.tableVersion, ...(v.options.latencyComp === 'light' && rtt !== null ? { rtt } : {}) });
|
||||
};
|
||||
const flip = () => {
|
||||
if (!canFlip) return;
|
||||
act({ type: 'flip' });
|
||||
};
|
||||
const ringRef = useRef(ring);
|
||||
const flipRef = useRef(flip);
|
||||
ringRef.current = ring;
|
||||
flipRef.current = flip;
|
||||
|
||||
// Keyboard: Space = bell, Enter = flip (not while typing; Enter on a focused button keeps its own meaning).
|
||||
useEffect(() => {
|
||||
const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (typing(e.target) || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
if (e.code === 'Space' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) ringRef.current();
|
||||
} else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement)) {
|
||||
if (!e.repeat) flipRef.current();
|
||||
}
|
||||
};
|
||||
const up = (e: KeyboardEvent) => {
|
||||
if ((e.code === 'Space' || e.key === ' ') && !typing(e.target)) e.preventDefault();
|
||||
};
|
||||
window.addEventListener('keydown', down);
|
||||
window.addEventListener('keyup', up);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', down);
|
||||
window.removeEventListener('keyup', up);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// "늦었어요" and other bell rejects come back as `reject` messages.
|
||||
useEffect(
|
||||
() =>
|
||||
socket.subscribe((m) => {
|
||||
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
|
||||
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
|
||||
setAnnounce(m.reason);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Diff against the previous view: flips, bells, card flights.
|
||||
useEffect(() => {
|
||||
const p = prev.current;
|
||||
prev.current = v;
|
||||
if (!p) return;
|
||||
const b = v.lastBell;
|
||||
if (b && b.seq !== p.lastBell?.seq) {
|
||||
const bub = bellText(v, players);
|
||||
if (bub) {
|
||||
setBubble(bub);
|
||||
setAnnounce(bub.text);
|
||||
}
|
||||
const to = posOf(b.player);
|
||||
const fly: Ghost[] = [];
|
||||
if (b.correct) {
|
||||
if (b.player !== me) sounds.ding();
|
||||
else haptic([40, 30, 80]);
|
||||
p.seats.forEach((s, i) => s.top && fly.push({ key: `g${b.seq}-${s.id}`, from: posOf(s.id), to, card: s.top, delay: i * 40 }));
|
||||
if (p.pot > 0) fly.push({ key: `g${b.seq}-pot`, from: CENTER, to, card: null, delay: 0 });
|
||||
} else {
|
||||
sounds.buzz();
|
||||
if (b.player === me) haptic([90, 50, 90]);
|
||||
const from = posOf(b.player);
|
||||
const targets = v.options.wrongToPot ? [CENTER] : p.seats.filter((s) => s.id !== b.player && s.status === 'playing').map((s) => posOf(s.id));
|
||||
targets.slice(0, Math.max(1, b.cards)).forEach((t, i) => fly.push({ key: `w${b.seq}-${i}`, from, to: t, card: null, delay: i * 60 }));
|
||||
}
|
||||
setGhosts(fly);
|
||||
return;
|
||||
}
|
||||
if (v.tableVersion !== p.tableVersion && v.lastFlipBy) {
|
||||
sounds.stone();
|
||||
const s = v.seats.find((x) => x.id === v.lastFlipBy);
|
||||
if (s?.top) setAnnounce(`${s.id === me ? '내가' : `${nick(players, s.id)}님이`} ${cardName(s.top)}를 뒤집었어요`);
|
||||
}
|
||||
if (v.turn === me && p.turn !== me && playing) haptic(20);
|
||||
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A new table (or a judged bell) lets me ring again.
|
||||
useEffect(() => {
|
||||
if (sentFor.current !== null && sentFor.current !== v.tableVersion) sentFor.current = null;
|
||||
}, [v.tableVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ghosts.length) return;
|
||||
const t = setTimeout(() => setGhosts([]), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [ghosts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1900);
|
||||
return () => clearTimeout(t);
|
||||
}, [bubble]);
|
||||
|
||||
const bellSize = phone ? (rot.length >= 5 ? 128 : 'min(40vw, 156px)') : 'clamp(140px, 20vh, 190px)';
|
||||
const countdown = v.phase === 'countdown' ? Math.max(0, Math.ceil((v.countdownEndsAt - now) / 1000)) : 0;
|
||||
const totals = FRUITS.map((f) => ({ f, n: v.seats.reduce((a, s) => a + (s.top && s.top.fruit === f ? s.top.count : 0), 0) }));
|
||||
const timeLeft = v.endsAt !== null ? Math.max(0, v.endsAt - Math.max(now, v.countdownEndsAt)) : null;
|
||||
const ping = rtt;
|
||||
|
||||
return (
|
||||
<div className="hg">
|
||||
<style>{CSS}</style>
|
||||
<div className="hg-top">
|
||||
<div className="grp">
|
||||
{timeLeft !== null && (
|
||||
<span className={`hg-pill${timeLeft < 60_000 ? ' warn' : ''}`} role="timer" aria-label={`남은 시간 ${Math.floor(timeLeft / 60_000)}분 ${Math.floor((timeLeft % 60_000) / 1000)}초`}>
|
||||
⏱ {Math.floor(timeLeft / 60_000)}:{String(Math.floor((timeLeft % 60_000) / 1000)).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
{v.officialDuel && playing && <span className="hg-pill warn">2명 남음 · 다음 종으로 끝나요</span>}
|
||||
</div>
|
||||
<div className="grp">
|
||||
{!phone && <span className="hg-pill hg-keys">스페이스 바 = 종 · Enter = 뒤집기</span>}
|
||||
{v.options.showRtt && ping !== null && (
|
||||
<span className={`hg-pill${ping >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${ping}밀리초`}>
|
||||
<i className="hg-dot" style={{ background: ping < 80 ? '#16a34a' : ping < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
|
||||
{ping >= 200 ? `연결이 느려요 · ${ping}ms` : `핑 ${ping}ms`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hg-table"
|
||||
role="region"
|
||||
aria-label="카드 테이블"
|
||||
style={{ '--bell': typeof bellSize === 'number' ? `${bellSize}px` : bellSize, '--cw': `${cw}px`, '--sw': `${sw}px` } as CSSProperties}
|
||||
>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }} aria-label="사람들의 펼친 카드">
|
||||
{rot.map((s, i) => (
|
||||
<Seat key={s.id} s={s} v={v} pos={pos[i]!} players={players} me={me} now={now} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="hg-center">
|
||||
<button
|
||||
type="button"
|
||||
className="hg-bell"
|
||||
disabled={!canRing}
|
||||
onPointerDown={(e) => {
|
||||
// Fire on press, not release: every millisecond counts.
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
ring();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Keyboard activation (Enter on the focused bell); pointer presses were handled on pointerdown.
|
||||
if (e.detail === 0) ring();
|
||||
}}
|
||||
aria-label={canRing ? '종 치기 (스페이스 바)' : lockedLeft > 0 ? '종 잠금 중이에요' : '종 (지금은 칠 수 없어요)'}
|
||||
aria-keyshortcuts="Space"
|
||||
>
|
||||
<span key={hit} className="art" data-ring={hit > 0}>
|
||||
<BellArt />
|
||||
</span>
|
||||
<span className="lbl" aria-hidden>
|
||||
종!
|
||||
</span>
|
||||
{lockedLeft > 0 && (
|
||||
<span
|
||||
className="lock"
|
||||
aria-hidden
|
||||
style={{ background: `conic-gradient(rgba(17,24,39,.55) ${(lockedLeft / Math.max(1, v.options.wrongRingLockoutMs)) * 360}deg, transparent 0)` }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{v.pot > 0 && (
|
||||
<span className="hg-pot" aria-label={`종 밑 카드 ${v.pot}장`}>
|
||||
종 밑 {v.pot}장
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ghosts.map((g) => (
|
||||
<span
|
||||
key={g.key}
|
||||
className="hg-ghost"
|
||||
style={{ '--fx': g.from.left, '--fy': g.from.top, '--tx': g.to.left, '--ty': g.to.top, '--d': `${g.delay}ms` } as CSSProperties}
|
||||
>
|
||||
<FruitCardArt card={g.card} />
|
||||
</span>
|
||||
))}
|
||||
{bubble && (
|
||||
<div key={bubble.key} className={`hg-bubble ${bubble.kind}`} aria-hidden>
|
||||
{bubble.text}
|
||||
</div>
|
||||
)}
|
||||
{countdown > 0 && (
|
||||
<div className="hg-count" aria-hidden>
|
||||
{countdown}
|
||||
<small>과일이 딱 5개가 되면 종을 쳐요!</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mine && mine.status === 'playing' && v.phase !== 'ended' && (
|
||||
<div className="hg-mine">
|
||||
<div className="info" aria-label={`내 덱 ${mine.deck}장`}>
|
||||
<span className="small muted">내 덱</span>
|
||||
<b>
|
||||
<i className="hg-back" /> {mine.deck}장
|
||||
</b>
|
||||
</div>
|
||||
<button type="button" className={`btn ${myTurn ? 'btn-primary' : 'btn-secondary'} hg-flipbtn`} disabled={!canFlip} onClick={flip} aria-keyshortcuts="Enter">
|
||||
{v.options.autoFlip ? (myTurn ? '곧 자동으로 뒤집혀요' : '자동 뒤집기') : v.pendingFlip && myTurn ? '곧 뒤집혀요…' : myTurn ? '뒤집기' : `${v.turn ? nick(players, v.turn) : ''}님 차례`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mine && mine.status !== 'playing' && v.phase !== 'ended' && (
|
||||
<div className="hg-panel">
|
||||
<strong>{mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
|
||||
</div>
|
||||
)}
|
||||
{v.options.kidMode && v.phase !== 'ended' && (
|
||||
<div className="hg-kid" aria-label="과일 합계">
|
||||
<strong>5개가 되면 종을 쳐요</strong>
|
||||
{totals.map(({ f, n }) => (
|
||||
<div key={f} className="hg-bar" data-five={n === 5}>
|
||||
<FruitIcon fruit={f} />
|
||||
<span>{FRUIT_KO[f]}</span>
|
||||
<span className="track">
|
||||
<i style={{ width: `${Math.min(100, (n / 8) * 100)}%`, background: FRUIT_INK[f] }} />
|
||||
</span>
|
||||
<span>{n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const n = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
|
||||
switch (e.t) {
|
||||
case 'collect':
|
||||
return `${n(e.p)} 종을 쳐서 ${e.cards}장을 가져갔어요 (${FRUIT_KO[e.fruit]} 5개)`;
|
||||
case 'wrong':
|
||||
return e.toPot ? `${n(e.p)} 틀려서 ${e.gave}장을 종 밑에 넣었어요` : `${n(e.p)} 틀려서 ${e.gave}장을 나눠 줬어요`;
|
||||
case 'duelWrong':
|
||||
return `${n(e.p)} 틀려서 ${nick(players, e.by)}님이 ${e.cards}장을 가져갔어요`;
|
||||
case 'out':
|
||||
return e.why === 'left' ? `${n(e.p)} 나갔어요` : e.why === 'strikeOut' ? `${n(e.p)} 3번 틀려서 탈락했어요` : `${n(e.p)} 탈락했어요`;
|
||||
}
|
||||
}
|
||||
|
||||
function rulesLine(o: HalliGalliOptions): string {
|
||||
const parts = [o.endMode === 'official' ? '2명 남으면 다음 종에서 끝' : '한 명 남을 때까지', o.timeLimitMin ? `${o.timeLimitMin}분 제한` : '시간 제한 없음'];
|
||||
parts.push(o.autoFlip ? `자동 뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초` : `뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초`);
|
||||
if (o.wrongRingLockoutMs) parts.push(`틀리면 종 ${o.wrongRingLockoutMs / 1000}초 잠금`);
|
||||
if (o.penaltyEscalation) parts.push('벌칙 점점 늘어남');
|
||||
if (o.wrongToPot) parts.push('벌칙은 종 밑으로');
|
||||
if (o.strikeOut) parts.push('3번 틀리면 탈락');
|
||||
if (o.duelWrongLoses) parts.push('1대1 오답은 패배');
|
||||
if (o.latencyComp === 'light') parts.push('지연 보정');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function HalliGalliSide({ view: v, me, players }: BoardProps<HalliGalliView>) {
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<strong>할리갈리{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
|
||||
<span className="small muted">{rulesLine(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{v.seats.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'playing' ? 1 : 0.55 }} aria-label={`${seatLabel(s, players, me)}, 정답 ${s.correct}번, 오답 ${s.wrong}번`}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{s.isTurn ? '▶ ' : ''}
|
||||
{nick(players, s.id)}
|
||||
{s.id === me ? ' (나)' : ''}
|
||||
{s.status === 'out' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : ''}
|
||||
</span>
|
||||
<span className="small" aria-hidden>
|
||||
✓{s.correct} ✗{s.wrong}
|
||||
</span>
|
||||
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
|
||||
{s.cards}장
|
||||
</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{(v.pot > 0 || v.removed > 0) && (
|
||||
<span className="small muted">
|
||||
{v.pot > 0 ? `종 밑 ${v.pot}장` : ''}
|
||||
{v.pot > 0 && v.removed > 0 ? ' · ' : ''}
|
||||
{v.removed > 0 ? `게임에서 뺀 카드 ${v.removed}장` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((r) => (
|
||||
<span key={r.id}>
|
||||
{r.rank}등 {nick(players, r.id)}
|
||||
<span className="muted small">
|
||||
{' '}
|
||||
· {r.cards}장 · 정답 {r.correct} · 오답 {r.wrong}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">{v.result.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol className="hg-log">
|
||||
{[...v.log].reverse().map((e) => (
|
||||
<li key={e.n} className="small">
|
||||
{logText(e, players, me)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: HalliGalliView, me: string | null): boolean {
|
||||
return !!me && v.phase === 'playing' && v.turn === me && v.seats.some((s) => s.id === me && s.status === 'playing');
|
||||
}
|
||||
|
||||
function statusText(v: HalliGalliView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'countdown') return '곧 시작해요! 과일이 딱 5개가 되면 종을 쳐요';
|
||||
const mine = v.seats.find((s) => s.id === me);
|
||||
if (mine && mine.status !== 'playing') return '탈락했어요 · 남은 게임을 지켜봐요';
|
||||
if (isMyTurn(v, me)) return v.options.autoFlip ? '내 차례예요 · 곧 자동으로 뒤집혀요' : '내 차례예요 · 카드를 뒤집어 주세요';
|
||||
return `${v.turn ? nick(players, v.turn) : ''}님 차례예요 · 5개가 되면 종!`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean | null>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onOff: [boolean, string][] = [
|
||||
[true, '켜기'],
|
||||
[false, '끄기'],
|
||||
];
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<HalliGalliOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<HalliGalliOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="끝나는 방식" help={o.endMode === 'official' ? '3명 이상일 때, 2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '한 사람이 남을 때까지 해요.'}>
|
||||
<Seg
|
||||
label="끝나는 방식"
|
||||
value={o.endMode}
|
||||
options={[
|
||||
['lastStanding', '한 명 남을 때까지'],
|
||||
['official', '2명 남으면 다음 종에서'],
|
||||
]}
|
||||
onChange={(endMode) => set({ endMode })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="전체 제한 시간" help="시간이 다 되면 카드가 많은 사람이 이겨요.">
|
||||
<Seg
|
||||
label="전체 제한 시간"
|
||||
value={o.timeLimitMin}
|
||||
options={[
|
||||
[5, '5분'],
|
||||
[10, '10분'],
|
||||
[15, '15분'],
|
||||
[null, '없음'],
|
||||
]}
|
||||
onChange={(timeLimitMin) => set({ timeLimitMin })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="뒤집기 제한 시간" help="시간 안에 안 뒤집으면 자동으로 뒤집혀요.">
|
||||
<Seg
|
||||
label="뒤집기 제한 시간"
|
||||
value={o.flipSeconds}
|
||||
options={[
|
||||
[2, '2초'],
|
||||
[3, '3초'],
|
||||
[5, '5초'],
|
||||
[8, '8초'],
|
||||
]}
|
||||
onChange={(flipSeconds) => set({ flipSeconds })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="어린이 모드" help="8초 뒤집기, 과일 합계 막대를 보여 줘요.">
|
||||
<Seg label="어린이 모드" value={o.kidMode} options={onOff} onChange={(kidMode) => set({ kidMode })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="카드를 자동으로 뒤집기">
|
||||
<Seg label="카드를 자동으로 뒤집기" value={o.autoFlip} options={onOff} onChange={(autoFlip) => set({ autoFlip })} />
|
||||
</Field>
|
||||
<Field label="뒤집은 뒤 다음 뒤집기까지 최소 간격" help="연결이 느린 사람도 같은 카드를 보고 종을 칠 시간이에요.">
|
||||
<Seg
|
||||
label="뒤집기 최소 간격"
|
||||
value={o.flipGuardMs}
|
||||
options={[
|
||||
[400, '0.4초'],
|
||||
[600, '0.6초'],
|
||||
[1000, '1초'],
|
||||
]}
|
||||
onChange={(flipGuardMs) => set({ flipGuardMs })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="틀린 종 뒤 종 잠금">
|
||||
<Seg
|
||||
label="틀린 종 뒤 종 잠금"
|
||||
value={o.wrongRingLockoutMs}
|
||||
options={[
|
||||
[0, '없음'],
|
||||
[1000, '1초'],
|
||||
[2000, '2초'],
|
||||
]}
|
||||
onChange={(wrongRingLockoutMs) => set({ wrongRingLockoutMs })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="나누고 남은 카드">
|
||||
<Seg
|
||||
label="나누고 남은 카드"
|
||||
value={o.leftoverCards}
|
||||
options={[
|
||||
['pot', '종 밑에 두기'],
|
||||
['remove', '빼기'],
|
||||
]}
|
||||
onChange={(leftoverCards) => set({ leftoverCards })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="지연 보정" help="켜면 연결 지연의 절반(최대 0.08초)만큼 먼저 친 것으로 봐요.">
|
||||
<Seg
|
||||
label="지연 보정"
|
||||
value={o.latencyComp}
|
||||
options={[
|
||||
['off', '끄기'],
|
||||
['light', '가볍게'],
|
||||
]}
|
||||
onChange={(latencyComp) => set({ latencyComp })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="내 핑 표시">
|
||||
<Seg label="내 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
|
||||
</Field>
|
||||
<Field label="1대1에서 틀린 종을 치면 바로 패배">
|
||||
<Seg label="1대1 오답 패배" value={o.duelWrongLoses} options={onOff} onChange={(duelWrongLoses) => set({ duelWrongLoses })} />
|
||||
</Field>
|
||||
<Field label="틀린 종 3번이면 탈락">
|
||||
<Seg label="오답 3번 탈락" value={o.strikeOut} options={onOff} onChange={(strikeOut) => set({ strikeOut })} />
|
||||
</Field>
|
||||
<Field label="틀릴 때마다 벌칙 장수 늘리기">
|
||||
<Seg label="벌칙 늘리기" value={o.penaltyEscalation} options={onOff} onChange={(penaltyEscalation) => set({ penaltyEscalation })} />
|
||||
</Field>
|
||||
<Field label="벌칙 카드를 종 밑으로">
|
||||
<Seg label="벌칙 카드를 종 밑으로" value={o.wrongToPot} options={onOff} onChange={(wrongToPot) => set({ wrongToPot })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: HalliGalliOptions): string {
|
||||
return rulesLine(o) + (o.kidMode ? ' · 어린이 모드' : '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Rules({ options }: { options?: HalliGalliOptions }) {
|
||||
const o = options;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
과일 카드 56장(딸기·바나나·라임·자두)을 똑같이 나눠 받고, 차례대로 내 덱 맨 위 카드를 뒤집어 내 앞에 펼쳐요. 펼쳐진 카드 맨 위에 보이는 과일 가운데 <strong>한 종류가 딱 5개</strong>가 되면 가운데 종을 쳐요.
|
||||
</p>
|
||||
<div className="row" style={{ gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
|
||||
<FruitCardArt card={{ fruit: 'strawberry', count: 2 }} />
|
||||
</span>
|
||||
+
|
||||
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
|
||||
<FruitCardArt card={{ fruit: 'strawberry', count: 3 }} />
|
||||
</span>
|
||||
+
|
||||
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
|
||||
<FruitCardArt card={{ fruit: 'banana', count: 4 }} />
|
||||
</span>
|
||||
<span>= 딸기 5개 → 땡!</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>먼저 친 사람</strong>(서버에 먼저 도착한 사람)이 펼쳐진 카드를 모두 가져가 덱 맨 아래에 넣고, 다음에 뒤집어요. 6개나 4개일 때 치면 틀린 거예요. 틀리면 다른 사람에게 1장씩 줘요
|
||||
{o?.wrongToPot ? '(이 방에서는 종 밑에 넣어요)' : ''}. 조금 늦게 친 종은 "늦었어요"로 무시되고 벌칙은 없어요.
|
||||
</p>
|
||||
<p>
|
||||
차례가 왔는데 덱이 비어 있으면 탈락해요. 덱이 비어도 차례가 오기 전에 종을 맞히면 다시 살아나요.{' '}
|
||||
{o?.endMode === 'official' ? '2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '마지막까지 남은 사람이 이겨요.'}{' '}
|
||||
{o?.timeLimitMin === null ? '' : `${o?.timeLimitMin ?? 10}분이 지나면 카드(덱 + 내 펼친 카드)가 많은 사람이 이겨요.`}
|
||||
</p>
|
||||
<p>
|
||||
PC에서는 <strong>스페이스 바</strong>로 종을, <strong>Enter</strong>로 뒤집기를 할 수 있어요. 휴대폰은 종 그림을 누르면 바로 울려요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<HalliGalliView, HalliGalliOptions> = {
|
||||
Board: HalliGalliBoard,
|
||||
Side: HalliGalliSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
988
apps/web/src/games/janggi/index.tsx
Normal file
988
apps/web/src/games/janggi/index.tsx
Normal file
@@ -0,0 +1,988 @@
|
||||
/** 장기 화면 (docs/games/janggi.md §9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Arrangement, JanggiMove, JanggiOptions, JanggiView, Piece, PieceKind, Side, Sq } from '@bg/games/janggi';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
|
||||
const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' };
|
||||
const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho');
|
||||
const HANJA: Record<PieceKind, Record<Side, string>> = {
|
||||
king: { cho: '楚', han: '漢' },
|
||||
cha: { cho: '車', han: '車' },
|
||||
po: { cho: '包', han: '包' },
|
||||
ma: { cho: '馬', han: '馬' },
|
||||
sang: { cho: '象', han: '象' },
|
||||
sa: { cho: '士', han: '士' },
|
||||
jol: { cho: '卒', han: '兵' },
|
||||
};
|
||||
const HANGUL: Record<PieceKind, Record<Side, string>> = {
|
||||
king: { cho: '궁', han: '궁' },
|
||||
cha: { cho: '차', han: '차' },
|
||||
po: { cho: '포', han: '포' },
|
||||
ma: { cho: '마', han: '마' },
|
||||
sang: { cho: '상', han: '상' },
|
||||
sa: { cho: '사', han: '사' },
|
||||
jol: { cho: '졸', han: '병' },
|
||||
};
|
||||
const VALUE: Record<PieceKind, number> = { king: 0, cha: 13, po: 7, ma: 5, sang: 3, sa: 3, jol: 2 };
|
||||
const ARRS: Arrangement[] = ['MSSM', 'SMMS', 'SMSM', 'MSMS'];
|
||||
const ARR_NAME: Record<Arrangement, [string, string]> = {
|
||||
MSSM: ['안상차림', '원앙마'],
|
||||
SMMS: ['바깥상차림', '양귀마'],
|
||||
SMSM: ['왼상차림', '귀마'],
|
||||
MSMS: ['오른상차림', '귀마'],
|
||||
};
|
||||
/** Screen x of my four back-row slots (left→right from my seat; same for both sides once flipped). */
|
||||
const SLOT_X = [1, 2, 6, 7];
|
||||
|
||||
const sqKey = (q: Sq) => q.r * 9 + q.f;
|
||||
const fromKey = (i: number): Sq => ({ f: i % 9, r: Math.floor(i / 9) });
|
||||
/** Korean-style square code: row 1–9,0 from 한's back rank, then file 1–9 (초 seat). */
|
||||
const sqCode = (q: Sq) => `${(10 - q.r) % 10}${q.f + 1}`;
|
||||
const pieceName = (p: { side: Side; kind: PieceKind }) => HANGUL[p.kind][p.side];
|
||||
const fmtScore = (n: number) => String(n);
|
||||
|
||||
function pieceRadius(kind: PieceKind): number {
|
||||
return kind === 'king' ? 0.47 : kind === 'sa' || kind === 'jol' ? 0.33 : 0.41;
|
||||
}
|
||||
|
||||
function octagon(cx: number, cy: number, r: number): string {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = Math.PI / 8 + (i * Math.PI) / 4;
|
||||
pts.push(`${(cx + r * Math.cos(a)).toFixed(3)},${(cy + r * Math.sin(a)).toFixed(3)}`);
|
||||
}
|
||||
return pts.join(' ');
|
||||
}
|
||||
|
||||
function PieceGlyph({ piece, cx, cy, ghost, hangul, scale = 1 }: { piece: Piece; cx: number; cy: number; ghost?: boolean; hangul?: boolean; scale?: number }) {
|
||||
const r = pieceRadius(piece.kind) * scale;
|
||||
const ink = INK[piece.side];
|
||||
const fs = r * (piece.kind === 'king' ? 1.05 : 1.08);
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1} style={{ pointerEvents: 'none' }}>
|
||||
<polygon points={octagon(cx + 0.03 * scale, cy + 0.05 * scale, r)} fill="#000" opacity={0.18} />
|
||||
<polygon points={octagon(cx, cy, r)} fill="#fbf3dc" stroke={ink} strokeWidth={0.05 * scale} />
|
||||
<polygon points={octagon(cx, cy, r * 0.84)} fill="none" stroke={ink} strokeWidth={0.015 * scale} opacity={0.6} />
|
||||
<text
|
||||
x={cx}
|
||||
y={hangul ? cy - r * 0.16 : cy}
|
||||
fill={ink}
|
||||
fontSize={hangul ? fs * 0.82 : fs}
|
||||
fontWeight={700}
|
||||
fontFamily="'Noto Serif KR','Noto Serif CJK KR','Batang','Times New Roman',serif"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{HANJA[piece.kind][piece.side]}
|
||||
</text>
|
||||
{hangul && (
|
||||
<text x={cx} y={cy + r * 0.56} fill={ink} fontSize={r * 0.42} fontWeight={700} textAnchor="middle" dominantBaseline="central">
|
||||
{HANGUL[piece.kind][piece.side]}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function useHangulLabels(): [boolean, (v: boolean) => void] {
|
||||
const [on, setOn] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('janggi.hangul') !== '0';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return [
|
||||
on,
|
||||
(v: boolean) => {
|
||||
setOn(v);
|
||||
try {
|
||||
localStorage.setItem('janggi.hangul', v ? '1' : '0');
|
||||
} catch {}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Small corner ticks at the 졸/포 starting points, like a printed board. */
|
||||
const MARKS: [number, number][] = [
|
||||
[1, 2], [7, 2], [0, 3], [2, 3], [4, 3], [6, 3], [8, 3],
|
||||
[1, 7], [7, 7], [0, 6], [2, 6], [4, 6], [6, 6], [8, 6],
|
||||
];
|
||||
|
||||
function BoardLines() {
|
||||
const d = 0.08;
|
||||
const l = 0.16;
|
||||
return (
|
||||
<g stroke="var(--wood-line)" strokeLinecap="round">
|
||||
{Array.from({ length: 10 }, (_, y) => (
|
||||
<line key={`h${y}`} x1={0} y1={y} x2={8} y2={y} strokeWidth={0.035} />
|
||||
))}
|
||||
{Array.from({ length: 9 }, (_, x) => (
|
||||
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={9} strokeWidth={0.035} />
|
||||
))}
|
||||
<rect x={0} y={0} width={8} height={9} fill="none" strokeWidth={0.07} />
|
||||
{[0, 7].map((y) => (
|
||||
<g key={`p${y}`} strokeWidth={0.035}>
|
||||
<line x1={3} y1={y} x2={5} y2={y + 2} />
|
||||
<line x1={5} y1={y} x2={3} y2={y + 2} />
|
||||
</g>
|
||||
))}
|
||||
{MARKS.map(([x, y]) => (
|
||||
<g key={`m${x}-${y}`} strokeWidth={0.03} fill="none">
|
||||
{[-1, 1].map((sx) =>
|
||||
x + sx < 0 || x + sx > 8
|
||||
? null
|
||||
: [-1, 1].map((sy) => (
|
||||
<polyline key={`${sx}${sy}`} points={`${x + sx * d},${y + sy * (d + l)} ${x + sx * d},${y + sy * d} ${x + sx * (d + l)},${y + sy * d}`} />
|
||||
)),
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
type Bubble = { text: string; color: string; id: number };
|
||||
|
||||
function JanggiBoard({ view: v, act, pending, tapConfirm }: BoardProps<JanggiView>) {
|
||||
const flip = v.mySide === 'han';
|
||||
const toScreen = (q: Sq) => (flip ? { x: 8 - q.f, y: q.r } : { x: q.f, y: 9 - q.r });
|
||||
const fromScreen = (x: number, y: number): Sq => (flip ? { f: 8 - x, r: y } : { f: x, r: 9 - y });
|
||||
const myTurn = v.phase === 'playing' && v.mySide === v.turn;
|
||||
const [hangul, setHangul] = useHangulLabels();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const [preview, setPreview] = useState<number | null>(null);
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 8 });
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prev = useRef({ count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase });
|
||||
|
||||
const legal = useMemo(() => {
|
||||
const m = new Map<number, { to: number; via: number | null }[]>();
|
||||
for (const x of v.legalMoves ?? []) {
|
||||
const k = sqKey(x.from);
|
||||
if (!m.has(k)) m.set(k, []);
|
||||
m.get(k)!.push({ to: sqKey(x.to), via: x.via ? sqKey(x.via) : null });
|
||||
}
|
||||
return m;
|
||||
}, [v.legalMoves]);
|
||||
const showHints = v.rules.hints !== 'off';
|
||||
|
||||
// Sounds, bubbles and announcements when the position changes.
|
||||
useEffect(() => {
|
||||
const p = prev.current;
|
||||
const show = (text: string, color: string) => setBubble({ text, color, id: Date.now() });
|
||||
if (v.moveCount > p.count) {
|
||||
const m = v.moves[v.moves.length - 1]!;
|
||||
if (m.kind === 'move') {
|
||||
sounds.stone();
|
||||
setAnnounce(`${SIDE_KO[m.side]} ${HANGUL[m.piece][m.side]}이 ${sqCode(m.from)}에서 ${sqCode(m.to)}로 갔어요${m.captured ? ` (${HANGUL[m.captured][other(m.side)]} 잡음)` : ''}.`);
|
||||
} else {
|
||||
setAnnounce(`${SIDE_KO[m.side]}이 한수쉼했어요.`);
|
||||
}
|
||||
if (v.inCheck && v.inCheck !== p.inCheck) {
|
||||
show('장군!', '#b91c1c');
|
||||
setAnnounce((a) => `${a} 장군!`);
|
||||
} else if (p.inCheck && !v.inCheck && v.phase === 'playing') {
|
||||
show('멍군!', '#1d4ed8');
|
||||
}
|
||||
if (v.bikjang && !p.bikjang) show('빅장!', '#7c3aed');
|
||||
if (myTurn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}
|
||||
if (p.phase === 'setup' && v.phase === 'playing') setAnnounce('상차림이 끝났어요. 초부터 둬요.');
|
||||
prev.current = { count: v.moveCount, inCheck: v.inCheck, bikjang: v.bikjang, phase: v.phase };
|
||||
setSelected(null);
|
||||
setPreview(null);
|
||||
}, [v.moveCount, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const t = setTimeout(() => setBubble(null), 1600);
|
||||
return () => clearTimeout(t);
|
||||
}, [bubble]);
|
||||
|
||||
const doMove = (from: number, to: number) => {
|
||||
act({ type: 'move', from: fromKey(from), to: fromKey(to) });
|
||||
setSelected(null);
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
const tap = (i: number, direct: boolean) => {
|
||||
const sq = fromKey(i);
|
||||
setCursor(toScreen(sq));
|
||||
if (!myTurn || pending) return;
|
||||
const piece = v.board[i];
|
||||
if (piece && piece.side === v.mySide) {
|
||||
setPreview(null);
|
||||
if (selected === i) {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
setSelected(i);
|
||||
const n = legal.get(i)?.length ?? 0;
|
||||
setAnnounce(`${pieceName(piece)} ${sqCode(sq)} 선택. ${n ? `갈 수 있는 곳 ${n}곳` : '지금은 움직일 수 없어요'}`);
|
||||
if (!n) haptic([30, 30, 30]);
|
||||
return;
|
||||
}
|
||||
if (selected === null) return;
|
||||
const ok = legal.get(selected)?.some((m) => m.to === i);
|
||||
if (!ok && showHints) {
|
||||
// Targets are drawn, so a click elsewhere just deselects.
|
||||
setSelected(null);
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
// Without hints, an illegal target goes to the server, which explains why (멱, 포다리, 장군 …).
|
||||
if (!ok || direct || !tapConfirm) {
|
||||
doMove(selected, i);
|
||||
return;
|
||||
}
|
||||
if (preview === i) doMove(selected, i);
|
||||
else setPreview(i);
|
||||
};
|
||||
|
||||
const pointToIndex = (e: React.PointerEvent<SVGSVGElement>): number | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
const x = Math.round(loc.x);
|
||||
const y = Math.round(loc.y);
|
||||
if (x < 0 || y < 0 || x > 8 || y > 9) return null;
|
||||
return sqKey(fromScreen(x, y));
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
setCursor((c) => ({ x: Math.min(8, Math.max(0, c.x + d[e.key]![0])), y: Math.min(9, Math.max(0, c.y + d[e.key]![1])) }));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
tap(sqKey(fromScreen(cursor.x, cursor.y)), true);
|
||||
} else if (e.key === 'Escape') {
|
||||
setSelected(null);
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup: card hover previews my 마·상 on the board.
|
||||
const [setupHover, setSetupHover] = useState<Arrangement | null>(null);
|
||||
const mustChoose = v.phase === 'setup' && !!v.mySide && v.setup.waitingFor.includes(v.mySide);
|
||||
const myChoice = v.mySide ? v.setup.choice[v.mySide] : null;
|
||||
const setupGhost = v.phase === 'setup' && v.mySide ? (setupHover ?? (v.setup.order === 'simultaneous' ? myChoice : null)) : null;
|
||||
|
||||
const focusFrom = selected ?? (hover !== null && v.board[hover]?.side === v.mySide && myTurn ? hover : null);
|
||||
const targets = focusFrom !== null && showHints ? (legal.get(focusFrom) ?? []) : [];
|
||||
const faint = selected === null;
|
||||
const ghostTo = selected !== null ? (preview ?? (hover !== null && legal.get(selected)?.some((m) => m.to === hover) ? hover : null)) : null;
|
||||
const vias = new Set<number>();
|
||||
for (const t of targets) if (t.via !== null && (ghostTo === null || t.to === ghostTo)) vias.add(t.via);
|
||||
const kingInCheck = v.inCheck ? v.board.findIndex((p) => p?.kind === 'king' && p.side === v.inCheck) : -1;
|
||||
const last = v.lastMove;
|
||||
const pad = 0.95;
|
||||
const setupMaxW = v.phase === 'setup' ? 'min(100%, calc((100vh - 400px) * 0.9))' : 'min(100%, calc((100vh - 250px) * 0.9))';
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10, maxWidth: setupMaxW, minWidth: 'min(100%, 300px)' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${8 + 2 * pad} ${9 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||
const i = pointToIndex(e);
|
||||
if (i !== hover) setHover(i);
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const i = pointToIndex(e);
|
||||
if (i !== null) tap(i, e.pointerType === 'mouse');
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`장기판. ${flip ? '한' : '초'} 쪽이 아래예요. 방향키로 움직이고 Enter로 기물을 고르거나 둡니다. 현재 위치 ${sqCode(fromScreen(cursor.x, cursor.y))}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<BoardLines />
|
||||
{Array.from({ length: 9 }, (_, x) => (
|
||||
<text key={`fx${x}`} x={x} y={9 + 0.72} fontSize={0.26} fill="var(--wood-line)" textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
{fromScreen(x, 0).f + 1}
|
||||
</text>
|
||||
))}
|
||||
{Array.from({ length: 10 }, (_, y) => (
|
||||
<text key={`ry${y}`} x={-0.74} y={y} fontSize={0.26} fill="var(--wood-line)" textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
{(10 - fromScreen(0, y).r) % 10}
|
||||
</text>
|
||||
))}
|
||||
{last &&
|
||||
[last.from, last.to].map((q, k) => {
|
||||
const s = toScreen(q);
|
||||
return <rect key={`lm${k}`} x={s.x - 0.46} y={s.y - 0.46} width={0.92} height={0.92} rx={0.12} fill={k ? 'rgba(250,204,21,0.45)' : 'none'} stroke="#ca8a04" strokeWidth={0.05} strokeDasharray={k ? undefined : '0.12 0.08'} />;
|
||||
})}
|
||||
{kingInCheck >= 0 && (() => {
|
||||
const s = toScreen(fromKey(kingInCheck));
|
||||
return <circle cx={s.x} cy={s.y} r={0.58} fill="rgba(220,38,38,0.25)" stroke="#dc2626" strokeWidth={0.07} />;
|
||||
})()}
|
||||
{v.board.map((p, i) => {
|
||||
if (!p) return null;
|
||||
const s = toScreen(fromKey(i));
|
||||
return <PieceGlyph key={i} piece={p} cx={s.x} cy={s.y} hangul={hangul} />;
|
||||
})}
|
||||
{setupGhost &&
|
||||
v.mySide &&
|
||||
SLOT_X.map((x, k) => {
|
||||
const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang';
|
||||
return <PieceGlyph key={`sg${x}`} piece={{ side: v.mySide!, kind }} cx={x} cy={9} hangul={hangul} ghost={!!setupHover || mustChoose} />;
|
||||
})}
|
||||
{myTurn &&
|
||||
v.threatened.map((q) => {
|
||||
const s = toScreen(q);
|
||||
return <circle key={`t${sqKey(q)}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#f59e0b" strokeWidth={0.06} strokeDasharray="0.14 0.09" />;
|
||||
})}
|
||||
{[...vias].map((i) => {
|
||||
const s = toScreen(fromKey(i));
|
||||
return <circle key={`via${i}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#7c3aed" strokeWidth={0.05} strokeDasharray="0.1 0.08" opacity={0.8} />;
|
||||
})}
|
||||
{selected !== null && (() => {
|
||||
const s = toScreen(fromKey(selected));
|
||||
return <circle cx={s.x} cy={s.y} r={0.52} fill="none" stroke="#16a34a" strokeWidth={0.08} />;
|
||||
})()}
|
||||
{targets.map((t) => {
|
||||
const s = toScreen(fromKey(t.to));
|
||||
return v.board[t.to] ? (
|
||||
<circle key={`g${t.to}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08} opacity={faint ? 0.5 : 1} />
|
||||
) : (
|
||||
<circle key={`g${t.to}`} cx={s.x} cy={s.y} r={0.15} fill="#16a34a" opacity={faint ? 0.45 : 0.85} />
|
||||
);
|
||||
})}
|
||||
{ghostTo !== null && selected !== null && v.board[selected] && (() => {
|
||||
const s = toScreen(fromKey(ghostTo));
|
||||
return <PieceGlyph piece={v.board[selected]!} cx={s.x} cy={s.y} hangul={hangul} ghost />;
|
||||
})()}
|
||||
{kbd && (myTurn || mustChoose) && (
|
||||
<rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />
|
||||
)}
|
||||
</svg>
|
||||
{bubble && (
|
||||
<div
|
||||
key={bubble.id}
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '42%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'var(--surface)',
|
||||
color: bubble.color,
|
||||
border: `4px solid ${bubble.color}`,
|
||||
borderRadius: 24,
|
||||
padding: '12px 28px',
|
||||
fontSize: 'clamp(1.8rem, 5vw, 3rem)',
|
||||
fontWeight: 900,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{bubble.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{v.phase === 'playing' && v.inCheck && (
|
||||
<div className="card" role="alert" style={{ padding: '8px 12px', background: 'var(--danger-soft)', color: 'var(--danger)', fontWeight: 700, textAlign: 'center' }}>
|
||||
{v.inCheck === v.mySide ? '장군이에요! 궁을 지켜야 해요.' : `${SIDE_KO[v.inCheck]} 장군!`}
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'playing' && v.bikjang && (
|
||||
<div className="card" style={{ padding: '8px 12px', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 700, textAlign: 'center' }}>
|
||||
{v.bikjang.calledBy === v.mySide
|
||||
? '빅장을 불렀어요. 상대가 막지 못하면 점수로 판정해요.'
|
||||
: v.mySide
|
||||
? '빅장이에요! 궁 사이를 막거나 궁을 옆으로 옮기지 않으면 점수로 판정해요.'
|
||||
: `${SIDE_KO[v.bikjang.calledBy]} 빅장!`}
|
||||
</div>
|
||||
)}
|
||||
{myTurn && v.repeatWarning && (
|
||||
<div className="card" style={{ padding: '8px 12px', background: 'var(--warning-soft)', color: 'var(--warning)', fontWeight: 700, textAlign: 'center' }}>
|
||||
{v.rules.repetition === 'forbid'
|
||||
? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.'
|
||||
: v.rules.repetition === 'lose'
|
||||
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
|
||||
: '같은 국면이 3번 나왔어요. 한 번 더 반복하면 점수로 판정해요.'}
|
||||
</div>
|
||||
)}
|
||||
{preview !== null && selected !== null && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(selected, preview)}>
|
||||
{`${v.board[selected] ? pieceName(v.board[selected]!) : ''} ${sqCode(fromKey(preview))}로 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{v.phase === 'setup' && (
|
||||
<SetupPicker view={v} act={act} pending={pending} tapConfirm={tapConfirm} mustChoose={mustChoose} onHover={setSetupHover} />
|
||||
)}
|
||||
<label className="row small muted" style={{ justifyContent: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={hangul} onChange={(e) => setHangul(e.target.checked)} />
|
||||
기물에 한글 이름 같이 보기
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrangementMini({ arr, side }: { arr: Arrangement; side: Side }) {
|
||||
return (
|
||||
<svg viewBox="-0.55 -0.55 4.5 1.1" style={{ width: '100%', display: 'block' }} aria-hidden>
|
||||
<line x1={0} y1={0} x2={3.4} y2={0} stroke="var(--wood-line)" strokeWidth={0.04} />
|
||||
{[0, 1, 2.4, 3.4].map((x, k) => (
|
||||
<PieceGlyph key={x} piece={{ side, kind: arr[k] === 'M' ? 'ma' : 'sang' }} cx={x} cy={0} scale={1.15} hangul />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupPicker({
|
||||
view: v,
|
||||
act,
|
||||
pending,
|
||||
tapConfirm,
|
||||
mustChoose,
|
||||
onHover,
|
||||
}: {
|
||||
view: JanggiView;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
tapConfirm: boolean;
|
||||
mustChoose: boolean;
|
||||
onHover: (a: Arrangement | null) => void;
|
||||
}) {
|
||||
const [picked, setPicked] = useState<Arrangement | null>(null);
|
||||
const me = v.mySide;
|
||||
const choose = (a: Arrangement) => {
|
||||
act({ type: 'chooseSetup', arrangement: a });
|
||||
setPicked(null);
|
||||
onHover(null);
|
||||
};
|
||||
if (!me || !mustChoose) {
|
||||
const mine = me ? v.setup.choice[me] : null;
|
||||
return (
|
||||
<div className="card" style={{ padding: 12, textAlign: 'center' }}>
|
||||
{mine ? `${ARR_NAME[mine][0]}(${ARR_NAME[mine][1]})을 골랐어요. ` : ''}
|
||||
{v.setup.waitingFor.length ? `${v.setup.waitingFor.map((s) => SIDE_KO[s]).join('·')}이 상차림을 고르는 중이에요…` : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const hanChose = v.setup.order === 'hanFirst' && me === 'cho' ? v.setup.choice.han : null;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<strong style={{ textAlign: 'center' }}>
|
||||
상차림을 골라 주세요 <span className="muted small">(마·상 놓는 자리, 내 쪽에서 본 왼쪽→오른쪽)</span>
|
||||
</strong>
|
||||
{hanChose && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>한은 {ARR_NAME[hanChose][0]}({ARR_NAME[hanChose][1]})을 골랐어요.</p>}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: 8 }}>
|
||||
{ARRS.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
className="card"
|
||||
disabled={pending}
|
||||
aria-pressed={picked === a}
|
||||
aria-label={`${ARR_NAME[a][0]}, ${ARR_NAME[a][1]}`}
|
||||
style={{ padding: 10, cursor: 'pointer', border: `3px solid ${picked === a ? 'var(--primary)' : 'var(--border)'}`, textAlign: 'center', font: 'inherit', color: 'inherit' }}
|
||||
onPointerEnter={(e) => e.pointerType === 'mouse' && onHover(a)}
|
||||
onPointerLeave={(e) => e.pointerType === 'mouse' && onHover(picked)}
|
||||
onFocus={() => onHover(a)}
|
||||
onClick={(e) => {
|
||||
const pt = (e.nativeEvent as PointerEvent).pointerType;
|
||||
if (pt === 'touch' && tapConfirm) {
|
||||
setPicked(a);
|
||||
onHover(a);
|
||||
} else choose(a);
|
||||
}}
|
||||
>
|
||||
<ArrangementMini arr={a} side={me} />
|
||||
<div style={{ fontWeight: 800, marginTop: 6 }}>{ARR_NAME[a][0]}</div>
|
||||
<div className="small muted">{ARR_NAME[a][1]}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{picked && (
|
||||
<button className="btn btn-primary btn-block" disabled={pending} onClick={() => choose(picked)}>
|
||||
{ARR_NAME[picked][0]}으로 정하기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function JanggiSide({ view: v, me, players, act, pending, deadline }: BoardProps<JanggiView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'pass' | 'abort'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.mySide
|
||||
? v.undo.pending && v.undo.pending.by !== v.mySide
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.mySide
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = v.mySide && (v.undo.pending?.by === v.mySide || v.draw.pendingBy === v.mySide);
|
||||
const undoLeft = v.mySide ? v.undo.remaining[v.mySide] : null;
|
||||
const top: Side = v.mySide ? other(v.mySide) : 'han';
|
||||
const bottom: Side = v.mySide ?? 'cho';
|
||||
const myTurn = v.phase === 'playing' && v.mySide === v.turn;
|
||||
const lastWasPass = v.moves.length > 0 && v.moves[v.moves.length - 1]!.kind === 'pass';
|
||||
return (
|
||||
<>
|
||||
<ScoreBar view={v} />
|
||||
<PlayerBar view={v} side={top} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} side={bottom} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'setup' && v.mySide && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setConfirm('abort')}>
|
||||
대국 취소
|
||||
</button>
|
||||
)}
|
||||
{v.phase === 'playing' && v.mySide && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
|
||||
{(v.rules.allowPass || v.canPass) && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!myTurn || !v.canPass || pending} onClick={() => setConfirm('pass')} title={v.inCheck === v.mySide ? '장군을 받은 상태에서는 쉴 수 없어요' : undefined}>
|
||||
⏸ 한수쉼
|
||||
</button>
|
||||
)}
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.side === v.mySide)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outgoing && v.phase === 'playing' && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">{incoming === 'undo' ? '수락하면 기물이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'abort' && (
|
||||
<ConfirmSheet title="대국을 취소할까요?" body="상차림 중에 취소하면 승패 없이 끝나요." confirm="취소하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
{confirm === 'pass' && (
|
||||
<ConfirmSheet
|
||||
title="한수쉼할까요?"
|
||||
body={
|
||||
lastWasPass
|
||||
? `상대도 방금 쉬었어요. 지금 쉬면 대국이 끝나고 ${v.rules.scoring ? '점수로 판정해요' : '무승부가 돼요'}.`
|
||||
: '이번 차례에 기물을 움직이지 않고 넘겨요.'
|
||||
}
|
||||
confirm="한수쉼"
|
||||
onConfirm={() => act({ type: 'pass' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreBar({ view: v }: { view: JanggiView }) {
|
||||
const { cho, han } = v.scores;
|
||||
const pct = (cho / (cho + han)) * 100;
|
||||
return (
|
||||
<div className="card" style={{ padding: '8px 12px' }} aria-label={`점수 초 ${fmtScore(cho)}, 한 ${fmtScore(han)}`}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}>
|
||||
<span style={{ color: INK.cho }}>초 {fmtScore(cho)}</span>
|
||||
<span className="small muted">{v.rules.scoring ? '기물 점수 (한 덤 1.5)' : '기물 점수 (참고)'}</span>
|
||||
<span style={{ color: INK.han }}>{fmtScore(han)} 한</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 6 }} aria-hidden>
|
||||
<div style={{ width: `${pct}%`, background: INK.cho }} />
|
||||
<div style={{ flex: 1, background: INK.han }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mmss(ms: number): string {
|
||||
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function Clock({ view: v, side, deadline, mine }: { view: JanggiView; side: Side; deadline: number | null; mine: boolean }) {
|
||||
const now = useServerNow(250);
|
||||
const tc = v.rules.timeControl;
|
||||
if (v.phase === 'setup') {
|
||||
return v.setup.waitingFor.includes(side) ? <TurnTimer deadline={deadline} totalMs={v.rules.setupTimeSec * 1000} mine={mine} /> : null;
|
||||
}
|
||||
const active = v.phase === 'playing' && v.turn === side;
|
||||
if (tc.kind === 'none') return active ? <TurnTimer deadline={deadline} totalMs={v.rules.idleLimitSec * 1000} mine={mine} /> : null;
|
||||
const periodMs = tc.periodSec * 1000;
|
||||
const elapsed = active ? Math.max(0, now - v.clock.turnStartedAt) : 0;
|
||||
const main = v.clock.mainMs[side] - elapsed;
|
||||
let big: string;
|
||||
let periods = v.clock.periodsLeft[side];
|
||||
let danger = false;
|
||||
if (main > 0) {
|
||||
big = mmss(main);
|
||||
} else {
|
||||
const over = -main;
|
||||
const used = Math.floor(over / periodMs);
|
||||
periods = Math.max(0, periods - used);
|
||||
const left = periods > 0 ? periodMs - (over % periodMs) : 0;
|
||||
big = active ? String(Math.ceil(left / 1000)) : `${tc.periodSec}`;
|
||||
danger = active && left <= 10_000;
|
||||
}
|
||||
return (
|
||||
<div style={{ textAlign: 'right' }} role="timer" aria-label={`${SIDE_KO[side]} 남은 시간 ${big}, 초읽기 ${periods}회`}>
|
||||
<div style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 800, fontSize: '1.2rem', color: danger ? 'var(--danger)' : undefined }}>{big}</div>
|
||||
<div className="small muted" aria-hidden>
|
||||
{main > 0 ? `초읽기 ${tc.periodSec}초 ` : '초읽기 '}
|
||||
{Array.from({ length: Math.max(periods, 0) }, () => '●').join('')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerBar({ view: v, side, players, deadline, me }: { view: JanggiView; side: Side; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[side];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = (v.phase === 'playing' && v.turn === side) || (v.phase === 'setup' && v.setup.waitingFor.includes(side));
|
||||
const caps = [...v.captured[side]].sort((a, b) => VALUE[b] - VALUE[a]);
|
||||
return (
|
||||
<div className="card" style={{ padding: '8px 12px', border: `3px solid ${active ? INK[side] : 'transparent'}`, transition: 'border-color 0.2s' }}>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<svg width={32} height={32} viewBox="-0.55 -0.55 1.1 1.1" aria-hidden>
|
||||
<PieceGlyph piece={{ side, kind: 'king' }} cx={0} cy={0} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="small" style={{ color: INK[side], fontWeight: 700 }}>
|
||||
{SIDE_KO[side]} {side === 'cho' ? '· 선수' : '· 후수 덤 1.5'}
|
||||
{v.phase === 'playing' && v.turn === side ? ' · 두는 중' : ''}
|
||||
{v.inCheck === side ? ' · 장군!' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Clock view={v} side={side} deadline={deadline} mine={id === me} />
|
||||
</div>
|
||||
{caps.length > 0 && (
|
||||
<div className="row" style={{ gap: 2, marginTop: 6, flexWrap: 'wrap' }} aria-label={`잡은 기물 ${caps.map((k) => HANGUL[k][other(side)]).join(', ')}`}>
|
||||
<span className="small muted" style={{ marginRight: 4 }}>
|
||||
잡은 기물
|
||||
</span>
|
||||
{caps.map((k, i) => (
|
||||
<svg key={i} width={22} height={22} viewBox="-0.5 -0.5 1 1" aria-hidden>
|
||||
<PieceGlyph piece={{ side: other(side), kind: k }} cx={0} cy={0} scale={k === 'king' ? 1 : 1.1} />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function moveText(m: JanggiMove): string {
|
||||
if (m.kind === 'pass') return '한수쉼';
|
||||
return `${HANGUL[m.piece][m.side]} ${sqCode(m.from)}→${sqCode(m.to)}${m.captured ? ` ×${HANGUL[m.captured][other(m.side)]}` : ''}`;
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: JanggiView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 160, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small" style={{ color: INK[m.side] }}>
|
||||
<span className="muted">{i + 1}.</span> {moveText(m)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: JanggiView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요';
|
||||
const nick = (s: Side) => players[v.players[s]]?.nickname ?? SIDE_KO[s];
|
||||
if (v.phase === 'setup') {
|
||||
if (v.mySide && v.setup.waitingFor.includes(v.mySide)) {
|
||||
const han = v.setup.choice.han;
|
||||
if (v.mySide === 'cho' && v.setup.order === 'hanFirst' && han) return `한이 ${ARR_NAME[han][0]}을 골랐어요. 내 상차림을 골라 주세요`;
|
||||
return `상차림을 골라 주세요 (나는 ${SIDE_KO[v.mySide]})`;
|
||||
}
|
||||
const w = v.setup.waitingFor;
|
||||
return w.length ? `${w.map(nick).join('·')}님이 상차림을 고르는 중이에요…` : '곧 대국이 시작돼요';
|
||||
}
|
||||
if (v.mySide && v.undo.pending && v.undo.pending.by !== v.mySide) return '상대가 무르기를 요청했어요';
|
||||
if (v.mySide && v.draw.pendingBy && v.draw.pendingBy !== v.mySide) return '상대가 무승부를 제안했어요';
|
||||
if (v.mySide === v.turn) {
|
||||
if (v.inCheck === v.mySide) return '장군이에요! 궁을 지켜 주세요';
|
||||
if (v.bikjang && v.bikjang.calledBy !== v.mySide) return '빅장이에요! 막지 않으면 점수로 판정해요';
|
||||
return `내 차례예요 (${SIDE_KO[v.turn]}). 움직일 기물을 눌러 주세요`;
|
||||
}
|
||||
return v.mySide ? `${nick(v.turn)}님이 생각 중이에요…` : `${nick(v.turn)}님(${SIDE_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
function isMyTurn(v: JanggiView, me: string | null): boolean {
|
||||
if (!me || !v.mySide || v.players[v.mySide] !== me) return false;
|
||||
if (v.phase === 'setup') return v.setup.waitingFor.includes(v.mySide);
|
||||
return v.phase === 'playing' && v.turn === v.mySide;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{title}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BIKJANG_SHORT: Record<JanggiOptions['bikjang'], string> = { off: '빅장 없음', kjf: '빅장(연맹식)', kja30: '빅장(30점 미만)' };
|
||||
|
||||
function timeLabel(tc: JanggiOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
const m = Math.floor(tc.mainSec / 60);
|
||||
const s = tc.mainSec % 60;
|
||||
return `${m ? `${m}분` : ''}${s ? ` ${s}초` : ''}${tc.mainSec === 0 ? '0분' : ''} + 초읽기 ${tc.periodSec}초×${tc.periods}`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<JanggiOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<JanggiOptions>) => onChange({ ...o, ...patch });
|
||||
const setTc = (patch: Partial<JanggiOptions['timeControl']>) => set({ timeControl: { ...o.timeControl, ...patch } });
|
||||
const timeKey = o.timeControl.kind === 'none' ? 'none' : `m${o.timeControl.mainSec}`;
|
||||
const presets: [string, string][] = [
|
||||
['m300', '5분'],
|
||||
['m600', '10분'],
|
||||
['m1200', '20분'],
|
||||
['none', '시간 제한 없음'],
|
||||
];
|
||||
if (!presets.some((p) => p[0] === timeKey)) presets.unshift([timeKey, `${timeLabel(o.timeControl)}(관리자 설정)`]);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field title="대국 방식">
|
||||
<Seg value={o.scoring ? 'on' : 'off'} options={[['on', '점수제(판정은 기물 점수로)'], ['off', '비점수제(판정 상황은 무승부)']]} onChange={(k) => set({ scoring: k === 'on' })} />
|
||||
</Field>
|
||||
<Field title="빅장">
|
||||
<Seg value={o.bikjang} options={[['kjf', '연맹식(기물 잡힌 뒤부터)'], ['kja30', '양쪽 30점 미만일 때'], ['off', '빅장 없음']]} onChange={(bikjang) => set({ bikjang })} />
|
||||
</Field>
|
||||
<Field title="기본 시간 (+ 초읽기)">
|
||||
<Seg
|
||||
value={timeKey}
|
||||
options={presets}
|
||||
onChange={(k) => {
|
||||
if (k === 'none') setTc({ kind: 'none' });
|
||||
else setTc({ kind: 'byoyomi', mainSec: Number(k.slice(1)) });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field title="초·한 정하기 (초가 먼저 둬요)">
|
||||
<Seg
|
||||
value={o.sideAssignment}
|
||||
options={[['random', '무작위'], ['hostCho', '방장이 초'], ['hostHan', '방장이 한'], ['alternate', '판마다 번갈아']]}
|
||||
onChange={(sideAssignment) => set({ sideAssignment })}
|
||||
/>
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
{o.timeControl.kind === 'byoyomi' && (
|
||||
<>
|
||||
<Field title="초읽기 한 번">
|
||||
<Seg value={o.timeControl.periodSec} options={[[20, '20초'], [30, '30초'], [60, '60초']]} onChange={(periodSec) => setTc({ periodSec })} />
|
||||
</Field>
|
||||
<Field title="초읽기 횟수">
|
||||
<Seg value={o.timeControl.periods} options={[[1, '1번'], [2, '2번'], [3, '3번'], [4, '4번'], [5, '5번']]} onChange={(periods) => setTc({ periods })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field title="상차림 순서">
|
||||
<Seg value={o.setupOrder} options={[['hanFirst', '한이 먼저 공개(연맹식)'], ['simultaneous', '동시에 몰래 고르기']]} onChange={(setupOrder) => set({ setupOrder })} />
|
||||
</Field>
|
||||
<Field title="같은 국면 4번째">
|
||||
<Seg value={o.repetition} options={[['forbid', '그 수를 못 둠'], ['lose', '만든 쪽 실격패'], ['scoreEnd', '점수로 판정']]} onChange={(repetition) => set({ repetition })} />
|
||||
</Field>
|
||||
<Field title="수 제한 (양쪽 합산)">
|
||||
<Seg value={o.moveLimit} options={[[0, '없음'], [150, '150수'], [200, '200수'], [250, '250수']]} onChange={(moveLimit) => set({ moveLimit })} />
|
||||
</Field>
|
||||
<Field title="무르기">
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</Field>
|
||||
<Field title="도움 표시">
|
||||
<Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['movesAndThreats', '+ 잡힐 위험'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</Field>
|
||||
<Field title="그 밖에">
|
||||
<Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} />
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: JanggiOptions): string {
|
||||
return [o.scoring ? '점수제' : '비점수제', BIKJANG_SHORT[o.bikjang], timeLabel(o.timeControl)].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: JanggiOptions }) {
|
||||
const o = options;
|
||||
const bik = o?.bikjang ?? 'kjf';
|
||||
const rep = o?.repetition ?? 'forbid';
|
||||
const scoring = o?.scoring ?? true;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
초(파랑)와 한(빨강)이 16개씩 기물을 움직여 상대 <strong>궁</strong>을 잡을 수밖에 없게(외통) 만들면 이겨요. <strong>초가 먼저</strong> 둬요. 기물은 칸이 아니라 선이
|
||||
만나는 점에 놓여요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>상차림</strong>: 시작 전에 뒷줄 마·상 네 개의 자리를 골라요(안상차림·바깥상차림·왼상차림·오른상차림).{' '}
|
||||
{o?.setupOrder === 'simultaneous' ? '두 사람이 동시에 몰래 고르고, 둘 다 고르면 공개돼요.' : '한이 먼저 골라 공개하고, 초가 보고 골라요.'}
|
||||
</p>
|
||||
<div>
|
||||
<strong>기물 움직임</strong>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 20 }}>
|
||||
<li>
|
||||
<strong>궁(楚·漢)·사(士)</strong>: 궁성(X자가 그려진 3×3) 안에서 선을 따라 한 칸. 대각선은 X 선이 있는 곳에서만 가요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>차(車)</strong>: 가로·세로로 막히지 않으면 몇 칸이든. 궁성 안에서는 X 선을 따라서도 가요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>포(包)</strong>: 반드시 기물 하나(포다리)를 넘어서 가요. 포다리 없이는 못 움직이고, 포는 포를 넘거나 잡을 수 없어요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>마(馬)</strong>: 한 칸 곧게 간 뒤 대각선으로 한 칸. 처음 곧게 가는 칸(멱)에 기물이 있으면 못 가요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>상(象)</strong>: 한 칸 곧게 간 뒤 대각선으로 두 칸. 지나가는 두 칸 중 하나라도 막히면 못 가요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>졸(卒)·병(兵)</strong>: 앞이나 옆으로 한 칸. 뒤로는 못 가요. 상대 궁성 안에서는 앞쪽 대각선으로도 가요.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
<strong>장군</strong>: 다음 수에 궁을 잡을 수 있는 상태예요. 장군을 받으면 반드시 피해야 하고(멍군), 내 궁이 잡히게 되는 수는 둘 수 없어요. 피할 수 없으면{' '}
|
||||
<strong>외통</strong>으로 져요.
|
||||
</p>
|
||||
{bik !== 'off' && (
|
||||
<p>
|
||||
<strong>빅장</strong>: 두 궁이 같은 세로줄에서 사이에 아무것도 없이 마주보게 만들면 빅장이에요
|
||||
{bik === 'kjf' ? '(기물이 하나라도 잡힌 다음부터)' : '(양쪽 기물 점수가 모두 30점 미만일 때만)'}. 빅장을 받은 쪽이 다음 수로 막지 못하면{' '}
|
||||
{scoring ? '대국이 끝나고 점수로 승패를 가려요' : '무승부로 끝나요'}.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>한수쉼</strong>: {o?.allowPass === false ? '이 방에서는 쓰지 않아요(움직일 수 있는 기물이 없을 때만 쉬어요).' : '기물을 움직이지 않고 차례를 넘길 수 있어요. 장군을 받았을 때는 안 돼요.'} 두 사람이 연달아
|
||||
쉬면 {scoring ? '점수로 판정해요' : '무승부예요'}.
|
||||
</p>
|
||||
<p>
|
||||
<strong>반복수</strong>: 같은 국면은 3번까지 괜찮아요. 4번째는{' '}
|
||||
{rep === 'forbid' ? '만들 수 없어요(그 수를 둘 수 없어요).' : rep === 'lose' ? '만든 쪽이 실격패해요.' : '나오는 순간 점수로 판정해요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>점수</strong>: 차 13, 포 7, 마 5, 상 3, 사 3, 졸·병 2점. 후수인 한은 덤 1.5점을 더 받아 시작 점수는 초 72 : 한 73.5예요. 점수 판정은 남은 기물 점수가 높은 쪽이 이기고,
|
||||
덤 때문에 동점은 없어요.
|
||||
</p>
|
||||
{o?.timeControl.kind !== 'none' && (
|
||||
<p>
|
||||
<strong>시간</strong>: 기본 시간을 다 쓰면 초읽기에 들어가요. 초읽기 시간 안에 두면 횟수가 줄지 않고, 넘기면 한 번씩 줄어요. 다 쓰면 시간패예요.
|
||||
</p>
|
||||
)}
|
||||
<p className="small muted">(사)대한장기연맹 규정을 바탕으로 하고, 온라인에 맞게 일부 방식을 정했어요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<JanggiView, JanggiOptions> = {
|
||||
Board: JanggiBoard,
|
||||
Side: JanggiSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
816
apps/web/src/games/las-vegas/index.tsx
Normal file
816
apps/web/src/games/las-vegas/index.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
/** 라스베가스 화면 (docs/games/las-vegas.md §9). Bills are play points of this room only (docs/04 §6). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { LasVegasChoice, LasVegasOptions, LasVegasPayoutView, LasVegasView } from '@bg/games/las-vegas';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const ROLL_MS = 650;
|
||||
const HOUSE = 'house';
|
||||
/** Seat colours (dice), plus white for the neutral "house" dice. */
|
||||
const COLORS = ['#e5484d', '#2f7cf6', '#1f9d55', '#f2a10c', '#8e5cf0'];
|
||||
const COLOR_NAMES = ['빨강', '파랑', '초록', '노랑', '보라'];
|
||||
/** Play-bill colours per face value (deliberately toy-like). */
|
||||
const BILL: Record<number, [string, string]> = {
|
||||
1: ['#fde68a', '#78350f'],
|
||||
2: ['#bbf7d0', '#14532d'],
|
||||
3: ['#bae6fd', '#0c4a6e'],
|
||||
4: ['#fbcfe8', '#831843'],
|
||||
5: ['#ddd6fe', '#3b0764'],
|
||||
6: ['#fed7aa', '#7c2d12'],
|
||||
7: ['#a7f3d0', '#064e3b'],
|
||||
8: ['#c7d2fe', '#1e1b4b'],
|
||||
9: ['#fecaca', '#7f1d1d'],
|
||||
};
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (players[id]?.nickname ?? '알 수 없음'));
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const seatColor = (v: LasVegasView, owner: string) => (owner === HOUSE ? '#ffffff' : (COLORS[v.seats.indexOf(owner)] ?? '#888'));
|
||||
const initial = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '흰' : ([...nick(players, id)][0] ?? '?'));
|
||||
|
||||
const CSS = `
|
||||
.lv-wrap { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.lv-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; padding: 10px 14px; }
|
||||
.lv-help { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.lv-help span { background: var(--warning-soft); color: var(--text); border: 2px solid var(--warning); border-radius: 999px; padding: 4px 14px; font-weight: 800; font-size: 1.02rem; }
|
||||
.lv-casinos { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
|
||||
.lv-casino { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 8px; min-height: 196px; border-radius: var(--radius); border: 3px solid var(--border);
|
||||
background: var(--surface); box-shadow: var(--shadow); text-align: left; color: var(--text); font: inherit; transition: border-color .15s, transform .15s; min-width: 0; }
|
||||
button.lv-casino { cursor: pointer; }
|
||||
button.lv-casino:hover { border-color: var(--primary); }
|
||||
.lv-casino:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.lv-casino.can { border-style: dashed; border-color: var(--primary); }
|
||||
.lv-casino.sel { border-style: solid; border-color: var(--primary); transform: translateY(-3px); box-shadow: 0 0 0 4px var(--primary-soft), var(--shadow); }
|
||||
.lv-casino.first { border-color: var(--success); box-shadow: 0 0 0 4px var(--success-soft), var(--shadow); }
|
||||
.lv-casino.tie { border-color: var(--warning); box-shadow: 0 0 0 4px var(--warning-soft), var(--shadow); }
|
||||
.lv-ctop { display: flex; align-items: center; gap: 6px; }
|
||||
.lv-cnum { font-size: 1.5rem; font-weight: 900; line-height: 1; }
|
||||
.lv-cname { font-size: .8rem; color: var(--text-sub); font-weight: 700; white-space: nowrap; }
|
||||
.lv-bills { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; }
|
||||
.lv-bill { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 6px; border-radius: 6px;
|
||||
font-weight: 900; font-size: 1.05rem; border: 2px dashed rgba(0,0,0,.35); box-shadow: inset 0 0 0 2px rgba(255,255,255,.55); font-variant-numeric: tabular-nums; }
|
||||
.lv-bill::before { content: '★'; font-size: .55rem; position: absolute; left: 3px; top: 1px; opacity: .6; }
|
||||
.lv-bill.sm { min-width: 26px; height: 22px; font-size: .85rem; padding: 0 4px; }
|
||||
.lv-bill.hidden { background: repeating-linear-gradient(45deg, var(--surface-2), var(--surface-2) 4px, var(--border) 4px, var(--border) 8px); color: var(--text-sub); }
|
||||
.lv-nobill { color: var(--text-sub); font-size: .8rem; }
|
||||
.lv-stacks { display: flex; flex-direction: column; gap: 3px; margin-top: auto; }
|
||||
.lv-stack { display: flex; align-items: center; gap: 4px; font-size: .82rem; font-weight: 800; min-width: 0; }
|
||||
.lv-pips { display: flex; flex-wrap: wrap; gap: 2px; min-width: 0; }
|
||||
.lv-dot { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid rgba(0,0,0,.45); flex: none; }
|
||||
.lv-tag { position: absolute; top: -11px; right: 6px; font-size: .78rem; font-weight: 900; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
|
||||
.lv-tag.first { background: var(--success); color: #fff; }
|
||||
.lv-tag.tie { background: #facc15; color: #422006; }
|
||||
.lv-tag.award { background: var(--primary); color: var(--primary-text); }
|
||||
.lv-tag.none { background: var(--surface-2); color: var(--text-sub); border: 1px solid var(--border); }
|
||||
@keyframes lv-hit { 0% { transform: scale(1); } 40% { transform: scale(1.05); box-shadow: 0 0 0 6px var(--primary-soft); } 100% { transform: scale(1); } }
|
||||
.lv-casino.hit { animation: lv-hit .55s ease-out; }
|
||||
.lv-lower { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 10px; align-items: start; }
|
||||
.lv-tray { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
|
||||
.lv-groups { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
||||
.lv-group { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 92px; min-height: 64px; padding: 6px 10px; border-radius: 12px;
|
||||
border: 3px solid var(--border); background: var(--surface); color: var(--text); font: inherit; cursor: pointer; }
|
||||
.lv-group:disabled { cursor: default; opacity: 1; }
|
||||
.lv-group[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.lv-group:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.lv-group b { font-size: 1.15rem; }
|
||||
.lv-group small { color: var(--text-sub); font-weight: 700; }
|
||||
.lv-mdice { display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; }
|
||||
.lv-die { width: 28px; height: 28px; border-radius: 7px; border: 2px solid rgba(0,0,0,.4); flex: none; display: inline-block; }
|
||||
.lv-die svg { display: block; width: 100%; height: 100%; }
|
||||
.lv-die.lg { width: 40px; height: 40px; border-radius: 9px; }
|
||||
@keyframes lv-tumble { 0% { transform: translateY(-16px) rotate(-40deg); } 50% { transform: translateY(3px) rotate(190deg); } 100% { transform: translateY(0) rotate(360deg); } }
|
||||
.lv-die.rolling { animation: lv-tumble ${ROLL_MS}ms ease-out both; }
|
||||
.lv-preview { border-radius: 10px; padding: 8px 12px; font-weight: 700; text-align: center; }
|
||||
.lv-preview.first { background: var(--success-soft); color: var(--success); }
|
||||
.lv-preview.tie { background: var(--warning-soft); color: var(--warning); }
|
||||
.lv-preview.award, .lv-preview.none { background: var(--surface-2); }
|
||||
.lv-roll { min-height: 60px; font-size: 1.15rem; }
|
||||
.lv-settle { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.lv-settle h3 { margin: 0; font-size: 1rem; }
|
||||
.lv-prow { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 6px; align-items: center; font-size: .85rem; padding: 3px 0; border-bottom: 1px solid var(--border); }
|
||||
.lv-prow:last-child { border-bottom: 0; }
|
||||
.lv-pbody { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-width: 0; }
|
||||
.lv-chip { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; font-weight: 700; }
|
||||
.lv-tieout { display: inline-flex; align-items: center; gap: 3px; color: var(--warning); font-weight: 800; }
|
||||
.lv-tieout .lv-dot { opacity: .55; }
|
||||
@keyframes lv-poof { 0% { transform: scale(1.4); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
|
||||
.lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: lv-poof .6s ease-out both; }
|
||||
.lv-pname { max-width: 84px; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; }
|
||||
.lv-final { padding: 12px; }
|
||||
.lv-final table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||
.lv-final th, .lv-final td { padding: 4px 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
|
||||
.lv-final td.notes { white-space: normal; text-align: left; }
|
||||
@media (max-width: 1099px) {
|
||||
.lv-lower { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.lv-casinos { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||
.lv-casino { min-height: 132px; padding: 6px; gap: 4px; }
|
||||
.lv-wide { display: none; }
|
||||
.lv-cnum { font-size: 1.25rem; }
|
||||
.lv-cname { display: none; }
|
||||
.lv-bill { min-width: 26px; height: 24px; font-size: .9rem; padding: 0 4px; }
|
||||
.lv-dot { width: 11px; height: 11px; }
|
||||
.lv-help { gap: 6px; flex-wrap: nowrap; }
|
||||
.lv-help span { font-size: .8rem; padding: 2px 8px; white-space: nowrap; }
|
||||
.lv-group { min-width: 76px; padding: 6px 8px; }
|
||||
.lv-die { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.lv-die.lg { width: 32px; height: 32px; }
|
||||
/* The tray sticks to the bottom of the screen so rolling / placing is always in reach. */
|
||||
.lv-lower { display: contents; }
|
||||
.lv-tray { position: sticky; bottom: 0; z-index: 4; box-shadow: 0 -4px 14px rgba(0,0,0,.12); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lv-casino, .lv-casino.sel { transition: none; transform: none; }
|
||||
.lv-casino.hit, .lv-die.rolling, .lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- small pieces
|
||||
|
||||
// prettier-ignore
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[50, 50]],
|
||||
2: [[28, 28], [72, 72]],
|
||||
3: [[26, 26], [50, 50], [74, 74]],
|
||||
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
|
||||
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
|
||||
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
|
||||
};
|
||||
|
||||
/** Pip colour that reads on the die colour (white dice → dark pips). */
|
||||
const pipOn = (bg: string) => (['#ffffff', '#fffdf8', '#f2a10c'].includes(bg) ? '#1c1e21' : '#ffffff');
|
||||
|
||||
function Die({ value, color, size = '', rolling = false }: { value: number; color: string; size?: '' | 'lg'; rolling?: boolean }) {
|
||||
return (
|
||||
<span className={`lv-die${size ? ` ${size}` : ''}${rolling ? ' rolling' : ''}`} style={{ background: color }} aria-hidden>
|
||||
<svg viewBox="0 0 100 100">
|
||||
{PIPS[value]!.map(([x, y], i) => (
|
||||
<circle key={i} cx={x} cy={y} r={value === 1 ? 14 : 10} fill={pipOn(color)} />
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Bill({ note, small }: { note: number | null; small?: boolean }) {
|
||||
if (note === null)
|
||||
return (
|
||||
<span className={`lv-bill hidden${small ? ' sm' : ''}`} aria-label="금액 비공개 지폐">
|
||||
?
|
||||
</span>
|
||||
);
|
||||
const [bg, fg] = BILL[note] ?? ['#eee', '#111'];
|
||||
return (
|
||||
<span className={`lv-bill${small ? ' sm' : ''}`} style={{ background: bg, color: fg }} aria-label={`지폐 ${note}점`}>
|
||||
{note}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
return <span className="lv-dot" style={{ background: color }} aria-hidden />;
|
||||
}
|
||||
|
||||
function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record<string, PublicUser>; owner: string; me: string | null }) {
|
||||
return (
|
||||
<span className="lv-chip">
|
||||
<Dot color={seatColor(v, owner)} />
|
||||
<span className="lv-pname">{owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const STANDING_TAG: Record<LasVegasChoice['standing'], string> = { first: '1등!', tie: '동점 주의', award: '지폐 받음', none: '못 받아요' };
|
||||
|
||||
function previewText(c: LasVegasChoice): string {
|
||||
const head = `${c.value}번 카지노에 ${c.own + c.neutral}개를 놓으면`;
|
||||
switch (c.standing) {
|
||||
case 'first':
|
||||
return `${head} 지금은 1등이에요! (${c.note}점 지폐)`;
|
||||
case 'award':
|
||||
return `${head} 지금은 ${c.note}점 지폐를 받을 수 있어요.`;
|
||||
case 'tie':
|
||||
return `${head} 개수가 같아져서 지금은 둘 다 꽝이에요. 동점 주의!`;
|
||||
default:
|
||||
return c.own === 0 ? `${head} 흰 주사위만 놓여요. 내 주사위는 그대로예요.` : `${head} 지금은 받을 지폐가 없어요.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function LasVegasBoard({ view: v, me, players, act, pending }: BoardProps<LasVegasView>) {
|
||||
const myTurn = v.phase !== 'finished' && !!me && v.current === me;
|
||||
const [sel, setSel] = useState<number | null>(null);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [flick, setFlick] = useState<number[] | null>(null);
|
||||
const [hit, setHit] = useState<{ value: number; key: number } | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevRoll = useRef(v.rollSeq);
|
||||
const prevTurn = useRef(v.turnSeq);
|
||||
const prevRounds = useRef(v.roundResults.length);
|
||||
|
||||
// New roll: tumble animation + screen-reader text.
|
||||
useLayoutEffect(() => {
|
||||
if (v.rollSeq <= prevRoll.current || !v.lastRoll) {
|
||||
prevRoll.current = v.rollSeq;
|
||||
return;
|
||||
}
|
||||
prevRoll.current = v.rollSeq;
|
||||
setSel(null);
|
||||
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
|
||||
const all = [...v.lastRoll.own, ...v.lastRoll.neutral].sort((a, b) => a - b);
|
||||
setAnnounce(`${who} 주사위 ${all.length}개를 굴렸어요: ${all.join(', ')}`);
|
||||
if (reducedMotion()) {
|
||||
sounds.stone();
|
||||
return;
|
||||
}
|
||||
setRolling(true);
|
||||
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!rolling || !v.lastRoll) return;
|
||||
const n = v.lastRoll.own.length + v.lastRoll.neutral.length;
|
||||
const t = setInterval(() => setFlick(Array.from({ length: n }, () => 1 + Math.floor(Math.random() * 6))), 70);
|
||||
const end = setTimeout(() => {
|
||||
setRolling(false);
|
||||
setFlick(null);
|
||||
sounds.stone();
|
||||
}, ROLL_MS);
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
clearTimeout(end);
|
||||
};
|
||||
}, [rolling]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Turn changes: placement flash, round settlement and my-turn cue.
|
||||
useEffect(() => {
|
||||
if (v.turnSeq === prevTurn.current) return;
|
||||
prevTurn.current = v.turnSeq;
|
||||
setSel(null);
|
||||
const parts: string[] = [];
|
||||
const lp = v.lastPlace;
|
||||
if (lp) {
|
||||
setHit({ value: lp.value, key: v.turnSeq });
|
||||
const who = lp.player === me ? '내가' : `${nick(players, lp.player)}님이`;
|
||||
parts.push(
|
||||
`${who} ${lp.value}번 카지노에 주사위 ${lp.own + lp.neutral}개를 놓았어요${lp.neutral ? `(흰 ${lp.neutral}개 포함)` : ''}${lp.auto ? ' (시간 초과로 자동)' : ''}.`,
|
||||
);
|
||||
}
|
||||
if (v.roundResults.length > prevRounds.current) {
|
||||
prevRounds.current = v.roundResults.length;
|
||||
const rr = v.roundResults[v.roundResults.length - 1]!;
|
||||
const ties = rr.payouts.filter((p) => p.removedTies.length > 0).length;
|
||||
parts.push(`${rr.round}라운드 정산이 끝났어요.${ties ? ` 동점 무효가 ${ties}곳에서 나왔어요.` : ''}`);
|
||||
haptic([50, 30, 50]);
|
||||
}
|
||||
if (myTurn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
if (parts.length) setAnnounce(parts.join(' '));
|
||||
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const choices = v.phase === 'place' && !rolling ? v.choices : [];
|
||||
const selChoice = choices.find((c) => c.value === sel) ?? null;
|
||||
const canPick = myTurn && v.phase === 'place' && !pending && !rolling;
|
||||
const pick = (value: number) => {
|
||||
if (!canPick || !choices.some((c) => c.value === value)) return;
|
||||
setSel(sel === value ? null : value);
|
||||
};
|
||||
const place = () => {
|
||||
if (!canPick || sel === null) return;
|
||||
act({ type: 'PLACE', value: sel });
|
||||
setSel(null);
|
||||
};
|
||||
const roll = () => {
|
||||
if (myTurn && v.phase === 'roll' && !pending) act({ type: 'ROLL' });
|
||||
};
|
||||
|
||||
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
|
||||
const hand = me ? v.hand[me] : undefined;
|
||||
const lastRR = v.roundResults[v.roundResults.length - 1];
|
||||
|
||||
return (
|
||||
<div className="lv-wrap">
|
||||
<style>{CSS}</style>
|
||||
<div className="card lv-head">
|
||||
<strong>{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`}</strong>
|
||||
{v.current && (
|
||||
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
<Dot color={seatColor(v, v.current)} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="small muted">
|
||||
{v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `}
|
||||
<span className="lv-wide">남은 지폐 </span>더미 {v.pileCount}장
|
||||
</span>
|
||||
</div>
|
||||
{v.round === 1 && v.phase !== 'finished' && (
|
||||
<div className="lv-help" aria-label="처음 하는 분을 위한 도움말">
|
||||
<span>🎲 같은 숫자는 한꺼번에</span>
|
||||
<span>⚠️ 개수가 같으면 둘 다 꽝</span>
|
||||
</div>
|
||||
)}
|
||||
{v.result && lastRR && (
|
||||
<div className="lv-lower">
|
||||
<FinalTable v={v} me={me} players={players} />
|
||||
<Settlement round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
|
||||
</div>
|
||||
)}
|
||||
{!v.result && (
|
||||
<section className="lv-casinos" aria-label="카지노 6곳">
|
||||
{v.casinos.map((c) => {
|
||||
const ch = choices.find((x) => x.value === c.value);
|
||||
const isSel = sel === c.value;
|
||||
const owners = Object.entries(c.dice)
|
||||
.filter(([, n]) => (n ?? 0) > 0)
|
||||
.sort((a, b) => b[1]! - a[1]! || v.seats.indexOf(a[0]) - v.seats.indexOf(b[0]));
|
||||
const label = `${c.value}번 카지노. 지폐 ${c.notes.length ? c.notes.map((n) => `${n}점`).join(', ') : '없음'}. 주사위 ${
|
||||
owners.length ? owners.map(([o, n]) => `${o === me ? '나' : nick(players, o)} ${n}개`).join(', ') : '없음'
|
||||
}${ch && isSel ? `. ${STANDING_TAG[ch.standing]}` : ''}`;
|
||||
const cls = `lv-casino${ch && canPick ? ' can' : ''}${isSel ? ` sel ${ch?.standing === 'first' ? 'first' : ch?.standing === 'tie' ? 'tie' : ''}` : ''}${hit?.value === c.value ? ' hit' : ''}`;
|
||||
const body = (
|
||||
<>
|
||||
<div className="lv-ctop">
|
||||
<Die value={c.value} color="#fffdf8" />
|
||||
<span className="lv-cnum">{c.value}</span>
|
||||
<span className="lv-cname">번 카지노</span>
|
||||
</div>
|
||||
<div className="lv-bills">
|
||||
{c.notes.length ? c.notes.map((n, i) => <Bill key={i} note={n} />) : <span className="lv-nobill">지폐 없음</span>}
|
||||
</div>
|
||||
<div className="lv-stacks">
|
||||
{owners.map(([o, n]) => (
|
||||
<div key={o} className="lv-stack" title={nick(players, o)}>
|
||||
<span className="lv-pips">
|
||||
{Array.from({ length: Math.min(n!, 8) }, (_, i) => (
|
||||
<Dot key={i} color={seatColor(v, o)} />
|
||||
))}
|
||||
</span>
|
||||
<span>{n! > 8 ? `${n}` : ''}</span>
|
||||
<span className="muted" style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{initial(players, o)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{ch && isSel && (
|
||||
<div className="lv-stack" style={{ opacity: 0.85 }}>
|
||||
<span className="lv-pips">
|
||||
{Array.from({ length: ch.own }, (_, i) => (
|
||||
<Dot key={`o${i}`} color={seatColor(v, v.current!)} />
|
||||
))}
|
||||
{Array.from({ length: ch.neutral }, (_, i) => (
|
||||
<Dot key={`n${i}`} color="#ffffff" />
|
||||
))}
|
||||
</span>
|
||||
<span className="small">+{ch.own + ch.neutral}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{ch && isSel && <span className={`lv-tag ${ch.standing}`}>{STANDING_TAG[ch.standing]}</span>}
|
||||
</>
|
||||
);
|
||||
return ch && canPick ? (
|
||||
<button
|
||||
key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`}
|
||||
type="button"
|
||||
className={cls}
|
||||
aria-pressed={isSel}
|
||||
aria-label={`${label}. 누르면 여기에 놓을 숫자로 골라요`}
|
||||
onClick={() => pick(c.value)}
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
<div key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`} className={cls} role="group" aria-label={label}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{!v.result && (
|
||||
<div className="lv-lower">
|
||||
<section className="card lv-tray" aria-label="주사위 굴리기와 놓기">
|
||||
{v.phase === 'finished' ? (
|
||||
<p className="muted" style={{ textAlign: 'center' }}>
|
||||
게임이 끝났어요
|
||||
</p>
|
||||
) : rolling && v.lastRoll ? (
|
||||
<div className="lv-mdice" aria-hidden>
|
||||
{[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => (
|
||||
<Die key={`${v.rollSeq}-${i}`} value={flick?.[i] ?? 1} color={col} size="lg" rolling />
|
||||
))}
|
||||
</div>
|
||||
) : v.phase === 'place' && v.current ? (
|
||||
<>
|
||||
<div className="lv-groups" role="group" aria-label={canPick ? '나온 숫자. 하나를 골라 주세요' : `${nick(players, v.current)}님이 굴린 주사위`}>
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
className="lv-group"
|
||||
data-standing={c.standing}
|
||||
aria-pressed={canPick ? sel === c.value : undefined}
|
||||
disabled={!canPick}
|
||||
aria-label={`${c.value} 주사위 ${c.own + c.neutral}개${c.neutral ? `(흰 ${c.neutral}개 포함)` : ''}. ${STANDING_TAG[c.standing]}`}
|
||||
onClick={() => pick(c.value)}
|
||||
>
|
||||
<span className="lv-mdice">
|
||||
{Array.from({ length: c.own }, (_, i) => (
|
||||
<Die key={`o${i}`} value={c.value} color={seatColor(v, v.current!)} />
|
||||
))}
|
||||
{Array.from({ length: c.neutral }, (_, i) => (
|
||||
<Die key={`n${i}`} value={c.value} color="#ffffff" />
|
||||
))}
|
||||
</span>
|
||||
<b>
|
||||
{c.value} × {c.own + c.neutral}개
|
||||
</b>
|
||||
{c.neutral > 0 && <small>흰 {c.neutral} 포함</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{canPick && selChoice && <div className={`lv-preview ${selChoice.standing}`}>{previewText(selChoice)}</div>}
|
||||
{canPick ? (
|
||||
<button className="btn btn-primary btn-block lv-roll" disabled={!selChoice} onClick={place}>
|
||||
{selChoice ? `${selChoice.value}번 카지노에 ${selChoice.own + selChoice.neutral}개 놓기` : '놓을 숫자를 골라 주세요'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted small" style={{ textAlign: 'center' }}>
|
||||
{myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : myTurn ? (
|
||||
<button className="btn btn-primary btn-block lv-roll" disabled={pending} onClick={roll}>
|
||||
🎲 주사위 굴리기 ({(hand?.own ?? 0) + (hand?.neutral ?? 0)}개)
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
|
||||
{v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''}
|
||||
</p>
|
||||
)}
|
||||
{hand && v.phase !== 'finished' && !v.conceded.includes(me!) && (
|
||||
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
|
||||
내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음
|
||||
</p>
|
||||
)}
|
||||
{v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && (
|
||||
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
|
||||
라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{lastRR ? (
|
||||
<Settlement key={lastRR.round} round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
|
||||
) : (
|
||||
<section className="card lv-settle" aria-label="정산 안내">
|
||||
<h3>라운드가 끝나면</h3>
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Settlement({
|
||||
round,
|
||||
payouts,
|
||||
v,
|
||||
me,
|
||||
players,
|
||||
}: {
|
||||
round: number;
|
||||
payouts: LasVegasPayoutView[];
|
||||
v: LasVegasView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
}) {
|
||||
const fresh = v.phase !== 'finished' && round === v.round - 1;
|
||||
return (
|
||||
<section className={`card lv-settle${fresh ? ' lv-fresh' : ''}`} aria-label={`${round}라운드 정산`}>
|
||||
<h3>{round}라운드 정산</h3>
|
||||
{payouts.map((p) => (
|
||||
<div key={p.casino} className="lv-prow">
|
||||
<strong>{p.casino}번</strong>
|
||||
<div className="lv-pbody">
|
||||
{p.removedTies.length > 0 && (
|
||||
<span className="lv-tieout" aria-label={`동점 무효: ${p.removedTies.map((o) => (o === me ? '나' : nick(players, o))).join(', ')}`}>
|
||||
💥 동점 무효
|
||||
{p.removedTies.map((o) => (
|
||||
<Dot key={o} color={seatColor(v, o)} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{p.awards.map((a, i) => (
|
||||
<span key={a.key ?? `${a.owner}-${i}`} className="lv-chip">
|
||||
<OwnerChip v={v} players={players} owner={a.owner} me={me} />
|
||||
<Bill note={a.note} small />
|
||||
{a.note === null && <span className="muted">1장</span>}
|
||||
</span>
|
||||
))}
|
||||
{p.returned.length > 0 && <span className="lv-chip muted">↩ 더미 아래로 {p.returned.map((n) => n).join('·')}</span>}
|
||||
{p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && <span className="muted">아무도 없었어요</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record<string, PublicUser> }) {
|
||||
const r = v.result!;
|
||||
return (
|
||||
<section className="card lv-final" aria-label="최종 결과">
|
||||
<h3 style={{ margin: '0 0 6px' }}>최종 결과</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">순위</th>
|
||||
<th scope="col">이름</th>
|
||||
{v.roundResults.map((rr) => (
|
||||
<th key={rr.round} scope="col">
|
||||
{rr.round}R
|
||||
</th>
|
||||
))}
|
||||
<th scope="col">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.ranking.map((row) => (
|
||||
<tr key={row.player}>
|
||||
<td>{row.rank}등</td>
|
||||
<td style={{ textAlign: 'left' }}>
|
||||
<OwnerChip v={v} players={players} owner={row.player} me={me} />
|
||||
{row.conceded && <span className="muted small"> (기권)</span>}
|
||||
</td>
|
||||
{(r.roundTotals[row.player] ?? []).map((t, i) => (
|
||||
<td key={i}>{t}</td>
|
||||
))}
|
||||
<td>
|
||||
<strong>{row.total}점</strong>
|
||||
<div className="small muted">{row.notes.length}장</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps<LasVegasView>) {
|
||||
const [ask, setAsk] = useState(false);
|
||||
const total = v.options.turnSeconds ? v.options.turnSeconds * 1000 : 30_000;
|
||||
const mine = me ? v.money[me] : undefined;
|
||||
const playing = v.phase !== 'finished';
|
||||
return (
|
||||
<>
|
||||
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
|
||||
{v.seats.map((p, i) => {
|
||||
const turn = v.current === p;
|
||||
const m = v.money[p]!;
|
||||
const h = v.hand[p]!;
|
||||
const out = v.conceded.includes(p);
|
||||
return (
|
||||
<div
|
||||
key={p}
|
||||
className="row"
|
||||
style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}
|
||||
>
|
||||
<span aria-label={`${COLOR_NAMES[i]} 주사위`} role="img">
|
||||
<Die value={i + 1} color={COLORS[i]!} />
|
||||
</span>
|
||||
<Avatar user={userOf(players, p)} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{nick(players, p)}
|
||||
{p === me && <span className="muted small"> (나)</span>}
|
||||
{v.startPlayer === p && playing && (
|
||||
<span className="badge" style={{ marginLeft: 4, padding: '0 6px' }}>
|
||||
선
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="small muted">
|
||||
{out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
|
||||
aria-label={`${nick(players, p)} 지폐 ${m.count}장${m.total !== null ? `, ${m.total}점` : ''}`}
|
||||
>
|
||||
<strong style={{ fontSize: '1.2rem' }}>{m.total !== null ? `${m.total}점` : '?'}</strong>
|
||||
<div className="small muted">{m.count}장</div>
|
||||
</span>
|
||||
{turn && playing && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
{mine && (
|
||||
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="내 지폐">
|
||||
<div className="row small" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>내 지폐</strong>
|
||||
<span>
|
||||
{mine.count}장 · {mine.total ?? 0}점
|
||||
</span>
|
||||
</div>
|
||||
<div className="lv-bills">
|
||||
{mine.notes && mine.notes.length ? mine.notes.map((n, i) => <Bill key={i} note={n} small />) : <span className="lv-nobill">아직 없어요</span>}
|
||||
</div>
|
||||
{v.options.hideMoney && playing && (
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
다른 사람에게는 장 수만 보여요.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{playing && me && v.money[me] && !v.conceded.includes(me) && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
|
||||
🏳 기권
|
||||
</button>
|
||||
)}
|
||||
{ask && (
|
||||
<ConfirmSheet
|
||||
title="기권할까요?"
|
||||
body="남은 주사위는 모두 버려지고 이후 차례에서 빠져요. 이미 받은 지폐는 결과에 남아요."
|
||||
confirm="기권하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'CONCEDE' })}
|
||||
onClose={() => setAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: LasVegasView, me: string | null): boolean {
|
||||
return v.phase !== 'finished' && !!me && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (me && v.conceded.includes(me)) return '기권했어요. 구경하는 중이에요';
|
||||
if (isMyTurn(v, me)) return v.phase === 'roll' ? '내 차례예요. 주사위를 굴려 주세요' : '내 차례예요. 놓을 숫자를 골라 주세요';
|
||||
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.rounds}라운드` : '잠시만 기다려 주세요';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<LasVegasOptions>) {
|
||||
const set = (patch: Partial<LasVegasOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="라운드 수">
|
||||
<Seg
|
||||
label="라운드 수"
|
||||
value={o.rounds}
|
||||
options={[
|
||||
[3, '3라운드'],
|
||||
[4, '4라운드'],
|
||||
[5, '5라운드'],
|
||||
]}
|
||||
onChange={(rounds) => set({ rounds })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="중립(흰) 주사위">
|
||||
<Seg
|
||||
label="중립(흰) 주사위"
|
||||
value={o.neutralDice}
|
||||
options={[
|
||||
['auto', '자동(2명일 때만)'],
|
||||
['on', '켜기(2~4명)'],
|
||||
['off', '끄기'],
|
||||
]}
|
||||
onChange={(neutralDice) => set({ neutralDice })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="다른 사람이 받은 지폐 금액">
|
||||
<Seg
|
||||
label="다른 사람이 받은 지폐 금액"
|
||||
value={o.hideMoney ? 'hide' : 'show'}
|
||||
options={[
|
||||
['hide', '숨기기(장 수만)'],
|
||||
['show', '보여 주기'],
|
||||
]}
|
||||
onChange={(k) => set({ hideMoney: k === 'hide' })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="굴리기·놓기 제한 시간">
|
||||
<Seg
|
||||
label="굴리기·놓기 제한 시간"
|
||||
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
|
||||
options={[
|
||||
['20', '20초'],
|
||||
['30', '30초'],
|
||||
['60', '60초'],
|
||||
['none', '무제한'],
|
||||
]}
|
||||
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })}
|
||||
/>
|
||||
</Field>
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
흰 주사위 "켜기"는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: LasVegasOptions): string {
|
||||
return [
|
||||
`${o.rounds}라운드`,
|
||||
o.neutralDice === 'auto' ? '흰 주사위 자동' : o.neutralDice === 'on' ? '흰 주사위 켜기' : '흰 주사위 끄기',
|
||||
o.hideMoney ? '금액 숨김' : '금액 공개',
|
||||
o.turnSeconds === null ? '시간 제한 없음' : `${o.turnSeconds}초`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Rules({ options }: { options?: LasVegasOptions }) {
|
||||
const rounds = options?.rounds ?? 4;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 <strong>8개</strong>를 가지고
|
||||
시작해요.
|
||||
</p>
|
||||
<p>
|
||||
내 차례에는 남은 주사위를 <strong>모두 굴린 뒤</strong>, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 <strong>한꺼번에</strong> 같은 번호 카지노에
|
||||
놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요.
|
||||
</p>
|
||||
<p>
|
||||
모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '}
|
||||
<strong>개수가 같은 사람끼리는 모두 무효</strong>(꽝)예요.
|
||||
</p>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<Bill note={8} />
|
||||
<Bill note={3} />
|
||||
<Bill note={1} />
|
||||
<span className="small">가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요.</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>흰 주사위</strong>(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 "하우스"라는
|
||||
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요.
|
||||
</p>
|
||||
<p>{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.</p>
|
||||
<p className="muted">
|
||||
시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로
|
||||
놓아요.
|
||||
</p>
|
||||
<p className="muted small">지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<LasVegasView, LasVegasOptions> = {
|
||||
Board: LasVegasBoard,
|
||||
Side: LasVegasSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
1030
apps/web/src/games/mafia/index.tsx
Normal file
1030
apps/web/src/games/mafia/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
323
apps/web/src/games/mahjong/Tile.tsx
Normal file
323
apps/web/src/games/mahjong/Tile.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글.
|
||||
* 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리
|
||||
* 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다.
|
||||
*/
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
const SUIT_KO: Record<string, string> = { m: '만수', p: '통수', s: '삭수' };
|
||||
const SUIT_SHORT: Record<string, string> = { m: '만', p: '통', s: '삭' };
|
||||
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
|
||||
|
||||
const INK = '#1c1e21';
|
||||
const RED = '#c8202a';
|
||||
const BLUE = '#1f5fbf';
|
||||
const GREEN = '#17804a';
|
||||
|
||||
/** "5m0r" → { n: 5, suit: 'm', red: true }; honors use suit 'z' and n 1..7. */
|
||||
export function parseCode(code: string): { n: number; suit: 'm' | 'p' | 's' | 'z'; red: boolean; kind: string } {
|
||||
return { n: Number(code[0]), suit: code[1] as 'm' | 'p' | 's' | 'z', red: code.endsWith('r'), kind: code.slice(0, 2) };
|
||||
}
|
||||
|
||||
/** Screen-reader name: "만수 5", "적도라 통수 5", "동", "중". */
|
||||
export function tileName(code: string): string {
|
||||
const t = parseCode(code);
|
||||
if (t.suit === 'z') return HONOR_KO[t.n - 1]!;
|
||||
return `${t.red ? '적도라 ' : ''}${SUIT_KO[t.suit]} ${t.n}`;
|
||||
}
|
||||
/** Short name: "5만", "3통", "7삭", "동" (kind codes like "5m" work too). */
|
||||
export function tileShort(code: string): string {
|
||||
const t = parseCode(code);
|
||||
return t.suit === 'z' ? HONOR_KO[t.n - 1]! : `${t.n}${SUIT_SHORT[t.suit]}`;
|
||||
}
|
||||
|
||||
/** Sort key: 만 → 통 → 삭 → 자, by number, red five first. */
|
||||
export function tileOrder(code: string): number {
|
||||
const t = parseCode(code);
|
||||
return 'mpsz'.indexOf(t.suit) * 100 + t.n * 10 + (t.red ? 0 : 1) + Number(code[2] ?? 0) / 10;
|
||||
}
|
||||
|
||||
const PIN_POS: Record<number, [number, number][]> = {
|
||||
1: [[30, 38]],
|
||||
2: [
|
||||
[30, 22],
|
||||
[30, 54],
|
||||
],
|
||||
3: [
|
||||
[16, 18],
|
||||
[30, 38],
|
||||
[44, 58],
|
||||
],
|
||||
4: [
|
||||
[18, 22],
|
||||
[42, 22],
|
||||
[18, 54],
|
||||
[42, 54],
|
||||
],
|
||||
5: [
|
||||
[17, 18],
|
||||
[43, 18],
|
||||
[30, 38],
|
||||
[17, 58],
|
||||
[43, 58],
|
||||
],
|
||||
6: [
|
||||
[19, 16],
|
||||
[41, 16],
|
||||
[19, 38],
|
||||
[41, 38],
|
||||
[19, 60],
|
||||
[41, 60],
|
||||
],
|
||||
7: [
|
||||
[14, 13],
|
||||
[30, 21],
|
||||
[46, 29],
|
||||
[19, 46],
|
||||
[41, 46],
|
||||
[19, 63],
|
||||
[41, 63],
|
||||
],
|
||||
8: [
|
||||
[19, 12],
|
||||
[41, 12],
|
||||
[19, 30],
|
||||
[41, 30],
|
||||
[19, 47],
|
||||
[41, 47],
|
||||
[19, 64],
|
||||
[41, 64],
|
||||
],
|
||||
9: [
|
||||
[14, 16],
|
||||
[30, 16],
|
||||
[46, 16],
|
||||
[14, 38],
|
||||
[30, 38],
|
||||
[46, 38],
|
||||
[14, 60],
|
||||
[30, 60],
|
||||
[46, 60],
|
||||
],
|
||||
};
|
||||
|
||||
function pinColor(n: number, i: number, red: boolean): string {
|
||||
if (red) return RED;
|
||||
if (n === 1) return RED;
|
||||
if (n === 2) return i === 0 ? GREEN : BLUE;
|
||||
if (n === 3) return [BLUE, RED, GREEN][i]!;
|
||||
if (n === 4) return i === 0 || i === 3 ? BLUE : GREEN;
|
||||
if (n === 5) return i === 2 ? RED : i === 0 || i === 4 ? BLUE : GREEN;
|
||||
if (n === 6) return i < 2 ? GREEN : RED;
|
||||
if (n === 7) return i < 3 ? GREEN : RED;
|
||||
if (n === 8) return BLUE;
|
||||
return i < 3 ? BLUE : i < 6 ? RED : GREEN;
|
||||
}
|
||||
|
||||
function Pins({ n, red }: { n: number; red: boolean }) {
|
||||
const pos = PIN_POS[n]!;
|
||||
const r = n === 1 ? 17 : n <= 3 ? 9.5 : n <= 5 ? 8.5 : n <= 7 ? 7 : 7;
|
||||
return (
|
||||
<>
|
||||
{pos.map(([x, y], i) => {
|
||||
const c = pinColor(n, i, red);
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle cx={x} cy={y} r={r} fill="none" stroke={c} strokeWidth={n === 1 ? 4 : 2.6} />
|
||||
<circle cx={x} cy={y} r={r * 0.42} fill={c} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bamboo sticks: rows of [x positions], with red sticks for some centres. */
|
||||
const SOU_ROWS: Record<number, number[][]> = {
|
||||
2: [[30], [30]],
|
||||
3: [[30], [18, 42]],
|
||||
4: [
|
||||
[20, 40],
|
||||
[20, 40],
|
||||
],
|
||||
5: [[18, 42], [30], [18, 42]],
|
||||
6: [
|
||||
[15, 30, 45],
|
||||
[15, 30, 45],
|
||||
],
|
||||
7: [[30], [15, 30, 45], [15, 30, 45]],
|
||||
8: [
|
||||
[12, 24, 36, 48],
|
||||
[12, 24, 36, 48],
|
||||
],
|
||||
9: [
|
||||
[15, 30, 45],
|
||||
[15, 30, 45],
|
||||
[15, 30, 45],
|
||||
],
|
||||
};
|
||||
|
||||
function Sticks({ n, red }: { n: number; red: boolean }) {
|
||||
if (n === 1) {
|
||||
// 1삭: one big stick with knots and a red top.
|
||||
return (
|
||||
<g>
|
||||
<rect x={24} y={12} width={12} height={54} rx={6} fill={GREEN} />
|
||||
<rect x={24} y={12} width={12} height={12} rx={6} fill={RED} />
|
||||
<line x1={24} y1={39} x2={36} y2={39} stroke="#fff" strokeWidth={2} />
|
||||
<line x1={24} y1={52} x2={36} y2={52} stroke="#fff" strokeWidth={2} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const rows = SOU_ROWS[n]!;
|
||||
const top = 8;
|
||||
const bottom = 70;
|
||||
const gap = 4;
|
||||
const h = (bottom - top - gap * (rows.length - 1)) / rows.length;
|
||||
const w = rows.some((r) => r.length === 4) ? 7 : 8;
|
||||
return (
|
||||
<>
|
||||
{rows.map((xs, ri) =>
|
||||
xs.map((x, ci) => {
|
||||
const y = top + ri * (h + gap);
|
||||
const center = (n === 5 && ri === 1) || (n === 7 && ri === 0) || (n === 9 && ci === 1);
|
||||
const c = red ? RED : center ? RED : GREEN;
|
||||
return (
|
||||
<g key={`${ri}-${ci}`}>
|
||||
<rect x={x - w / 2} y={y} width={w} height={h} rx={w / 2} fill={c} />
|
||||
<line x1={x - w / 2} y1={y + h / 2} x2={x + w / 2} y2={y + h / 2} stroke="#fff" strokeWidth={1.4} />
|
||||
</g>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inner frame pattern per suit (colour-blind help, §9). */
|
||||
const FRAME_DASH: Record<string, string | undefined> = { m: undefined, p: '1.5 3', s: '6 3' };
|
||||
|
||||
function Face({ code, compact }: { code: string; compact: boolean }) {
|
||||
const t = parseCode(code);
|
||||
const frame = t.suit !== 'z' && (
|
||||
<rect x={5} y={5} width={50} height={66} rx={5} fill="none" stroke="#c9bfa8" strokeWidth={1.6} strokeDasharray={FRAME_DASH[t.suit]} />
|
||||
);
|
||||
if (t.suit === 'z') {
|
||||
const ch = HONOR_KO[t.n - 1]!;
|
||||
if (t.n === 5) {
|
||||
return (
|
||||
<>
|
||||
<rect x={10} y={10} width={40} height={56} rx={4} fill="none" stroke={BLUE} strokeWidth={4} />
|
||||
<text x={30} y={48} textAnchor="middle" fontSize={compact ? 28 : 22} fontWeight={800} fill={BLUE}>
|
||||
{ch}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK;
|
||||
return (
|
||||
<text x={30} y={compact ? 53 : 52} textAnchor="middle" fontSize={compact ? 40 : 36} fontWeight={900} fill={color}>
|
||||
{ch}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
const numColor = t.red ? RED : INK;
|
||||
if (compact) {
|
||||
if (t.suit === 'm') {
|
||||
return (
|
||||
<>
|
||||
{frame}
|
||||
<text x={30} y={50} textAnchor="middle" fontSize={44} fontWeight={900} fill={numColor}>
|
||||
{t.n}
|
||||
</text>
|
||||
<rect x={14} y={58} width={32} height={7} rx={3.5} fill={RED} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (t.suit === 'p') {
|
||||
return (
|
||||
<>
|
||||
{frame}
|
||||
<circle cx={30} cy={38} r={23} fill="none" stroke={t.red ? RED : BLUE} strokeWidth={4} />
|
||||
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : BLUE}>
|
||||
{t.n}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{frame}
|
||||
<rect x={7} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
|
||||
<rect x={47} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
|
||||
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : GREEN}>
|
||||
{t.n}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (t.suit === 'm') {
|
||||
return (
|
||||
<>
|
||||
{frame}
|
||||
<text x={30} y={40} textAnchor="middle" fontSize={34} fontWeight={900} fill={numColor}>
|
||||
{t.n}
|
||||
</text>
|
||||
<text x={30} y={66} textAnchor="middle" fontSize={21} fontWeight={800} fill={RED}>
|
||||
만
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{frame}
|
||||
{t.suit === 'p' ? <Pins n={t.n} red={t.red} /> : <Sticks n={t.n} red={t.red} />}
|
||||
<text x={52} y={14} textAnchor="end" fontSize={11} fontWeight={800} fill={t.red ? RED : '#6b6352'}>
|
||||
{t.n}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TileProps {
|
||||
/** Tile code ("5m0r") or kind code ("5m"); null draws the back. */
|
||||
code: string | null;
|
||||
/** Width in px (height is 4:3). */
|
||||
w: number;
|
||||
sideways?: boolean;
|
||||
dim?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** aria-hidden by default: the parent describes the tile. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Tile({ code, w, sideways, dim, className, style, label }: TileProps) {
|
||||
const h = Math.round((w * 4) / 3);
|
||||
const compact = w < 30;
|
||||
const svg = (
|
||||
<svg viewBox="0 0 60 80" width={w} height={h} style={{ display: 'block', ...(sideways ? { transform: 'rotate(-90deg)' } : {}) }} aria-hidden>
|
||||
{code === null ? (
|
||||
<>
|
||||
<rect x={1} y={1} width={58} height={78} rx={8} fill="#1d5a8c" stroke="#123b5c" strokeWidth={2} />
|
||||
<rect x={7} y={7} width={46} height={62} rx={5} fill="#2a73ad" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<rect x={1} y={1} width={58} height={78} rx={8} fill="#d9cdb0" stroke="#9c8f72" strokeWidth={2} />
|
||||
<rect x={2} y={2} width={56} height={71} rx={7} fill="#fffcf3" />
|
||||
<Face code={code} compact={compact} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
const box: CSSProperties = sideways
|
||||
? { width: h, height: w, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none', ...style }
|
||||
: { width: w, height: h, display: 'inline-block', flex: 'none', ...style };
|
||||
return (
|
||||
<span className={`mj-tile${dim ? ' dim' : ''}${className ? ` ${className}` : ''}`} style={box} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
|
||||
{svg}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1271
apps/web/src/games/mahjong/index.tsx
Normal file
1271
apps/web/src/games/mahjong/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1393
apps/web/src/games/marble/index.tsx
Normal file
1393
apps/web/src/games/marble/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
855
apps/web/src/games/ninety-nine/index.tsx
Normal file
855
apps/web/src/games/ninety-nine/index.tsx
Normal file
@@ -0,0 +1,855 @@
|
||||
/** 구십구 화면 (docs/games/ninety-nine.md §9). */
|
||||
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import type { CardFace, CardKind, ChoiceView, HandCardView, LogEntry, NNOptions, NinetyNineView, SeatView } from '@bg/games/ninety-nine';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { PlayingCard } from '../../components/cards/PlayingCard';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const MODE_KO: Record<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
|
||||
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
|
||||
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
|
||||
const TRUMP_RANK: Record<string, string> = { '10': 'T' };
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const resolvedLives = (o: NNOptions) => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
|
||||
|
||||
/** Short name, e.g. "±9", "조커", "♥K". */
|
||||
function faceName(f: CardFace): string {
|
||||
return f.suit ? `${SUIT_SYMBOL[f.suit]}${f.label}` : f.label;
|
||||
}
|
||||
/** Screen-reader name, e.g. "하트 K", "±9 카드". */
|
||||
function ariaFace(f: CardFace): string {
|
||||
if (f.suit) return `${SUIT_KO[f.suit]} ${f.label}`;
|
||||
return f.label === '조커' ? '조커' : `${f.label} 카드`;
|
||||
}
|
||||
const fmt = (n: number) => String(n);
|
||||
/** "−9" etc. use a real minus sign; "+9 → 95". */
|
||||
const arrow = (c: ChoiceView) => `${c.label} → ${c.legal ? fmt(c.total) : c.total > 99 ? '99 초과' : '0 미만'}`;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Counts up/down to `target` (instant when reduced motion is preferred). */
|
||||
function useCountUp(target: number): number {
|
||||
const [shown, setShown] = useState(target);
|
||||
const from = useRef(target);
|
||||
useEffect(() => {
|
||||
const start = from.current;
|
||||
from.current = target;
|
||||
if (start === target || prefersReducedMotion()) {
|
||||
setShown(target);
|
||||
return;
|
||||
}
|
||||
let raf = 0;
|
||||
const t0 = performance.now();
|
||||
const step = (t: number) => {
|
||||
const k = Math.min(1, (t - t0) / 420);
|
||||
setShown(Math.round(start + (target - start) * (1 - (1 - k) * (1 - k))));
|
||||
if (k < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [target]);
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** Green → yellow → red as the total nears 99. */
|
||||
const gaugeColor = (t: number) => `hsl(${Math.max(0, 130 - (t / 99) * 130)} 78% 46%)`;
|
||||
|
||||
const CSS = `
|
||||
.nn { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
|
||||
.nn-opps { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 6px; margin: 0; list-style: none; scrollbar-width: thin; }
|
||||
.nn-seat { flex: none; min-width: 118px; max-width: 176px; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px;
|
||||
border: 3px solid transparent; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 3px; font-size: .9rem; transition: filter .4s ease, opacity .4s ease; }
|
||||
.nn-seat.turn { border-color: var(--focus); }
|
||||
.nn-seat.out { filter: grayscale(1); opacity: .6; }
|
||||
.nn-seat .nm { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
||||
.nn-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.nn-count i { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #7c2d12; border: 1.5px solid #fdba74; }
|
||||
.nn-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .76rem; line-height: 1.6; color: #fff; }
|
||||
.nn-hearts { display: inline-flex; gap: 1px; font-size: 1rem; line-height: 1; }
|
||||
.nn-heart { color: #e11d48; display: inline-block; }
|
||||
.nn-heart.lost { color: var(--text-sub); opacity: .55; animation: nn-break .7s ease; }
|
||||
.nn-table { position: relative; border-radius: 24px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt);
|
||||
border: 8px solid var(--wood); box-shadow: var(--shadow); padding: 14px 18px; display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: center; color: #fff; }
|
||||
.nn-left, .nn-right { display: flex; flex-direction: column; gap: 8px; min-width: 0; text-shadow: 0 1px 2px rgba(0,0,0,.5); }
|
||||
.nn-left { align-items: flex-start; }
|
||||
.nn-right { align-items: flex-end; }
|
||||
.nn-dial { position: relative; width: var(--dial); height: var(--dial); display: grid; place-items: center; }
|
||||
.nn-dial svg { position: absolute; inset: 0; transform: rotate(-90deg); }
|
||||
.nn-dial svg circle { transition: stroke-dashoffset .4s ease, stroke .4s ease; }
|
||||
.nn-total { font-size: calc(var(--dial) * .42); font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em; color: #fff; text-shadow: 0 2px 3px rgba(0,0,0,.35); }
|
||||
.nn-total-cap { position: absolute; bottom: calc(var(--dial) * .2); font-size: .8rem; font-weight: 700; opacity: .9; }
|
||||
.nn-dir { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; background: rgba(0,0,0,.3); border-radius: 999px; padding: 3px 12px 3px 6px; }
|
||||
.nn-dir b { display: inline-block; font-size: 1.6rem; line-height: 1; transition: transform .45s ease; }
|
||||
.nn-dir[data-dir='-1'] b { transform: scaleX(-1) rotate(-30deg); }
|
||||
.nn-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.32); border-radius: 999px; padding: 2px 10px; font-weight: 700; font-size: .9rem; }
|
||||
.nn-rule { font-weight: 800; color: #fde68a; }
|
||||
.nn-recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
|
||||
.nn-recent li { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.28); border-radius: 10px; padding: 2px 8px; font-size: .88rem; max-width: 100%; }
|
||||
.nn-recent li:last-child { background: rgba(0,0,0,.5); font-weight: 800; animation: nn-pop .3s ease; }
|
||||
.nn-recent .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
|
||||
.nn-mini { display: inline-grid; place-items: center; min-width: 30px; height: 22px; border-radius: 5px; background: #fffef9; color: #1f2937; font-weight: 900; font-size: .8rem; padding: 0 4px; text-shadow: none; }
|
||||
.nn-mini[data-k='plusMinus'] { background: #ede9fe; color: #5b21b6; }
|
||||
.nn-mini[data-k='reverse'] { background: #ccfbf1; color: #115e59; }
|
||||
.nn-mini[data-k='joker'], .nn-mini[data-k='to99'] { background: #fef3c7; color: #92400e; }
|
||||
.nn-mini[data-red='true'] { color: #dc2626; }
|
||||
.nn-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.nn-hand-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
|
||||
.nn-hand { display: flex; gap: 10px; justify-content: center; padding-top: 30px; flex-wrap: wrap; row-gap: 34px; }
|
||||
.nn-card { position: relative; flex: none; width: var(--cw); height: calc(var(--cw) * 1.4); padding: 0; border: 0; background: none; border-radius: 10px;
|
||||
transition: transform .14s ease, opacity .14s ease; cursor: pointer; }
|
||||
.nn-card[data-legal='false'] { opacity: .5; filter: grayscale(.5); }
|
||||
.nn-card[data-legal='true'] { box-shadow: 0 0 0 3px var(--success); }
|
||||
.nn-card[aria-pressed='true'] { transform: translateY(-10px); box-shadow: 0 0 0 4px var(--primary); z-index: 2; }
|
||||
.nn-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; z-index: 3; }
|
||||
.nn-prev { position: absolute; left: 50%; top: -27px; transform: translateX(-50%); white-space: nowrap; border-radius: 999px; padding: 1px 9px;
|
||||
font-size: .82rem; font-weight: 800; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); pointer-events: none; }
|
||||
.nn-prev.ok { background: var(--success-soft); color: var(--success); border-color: var(--success); }
|
||||
.nn-prev.no { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); }
|
||||
.nn-face { display: block; width: 100%; height: 100%; }
|
||||
.nn-face svg, .nn-face > span:not(.nn-art) { width: 100% !important; height: 100% !important; display: block; }
|
||||
.nn-art { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; border-radius: 10px;
|
||||
background: #fffef9; border: 2px solid #9ca3af; color: #1f2937; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
|
||||
.nn-art::before { content: ''; position: absolute; inset: 6px; border-radius: 7px; border: 2px solid var(--band); opacity: .55; }
|
||||
.nn-art .big { font-size: calc(var(--cw, 90px) * .44); font-weight: 900; line-height: 1; color: var(--band); letter-spacing: -.03em; }
|
||||
.nn-art .sub { margin-top: 4px; font-size: calc(var(--cw, 90px) * .13); font-weight: 800; color: var(--band); opacity: .9; }
|
||||
.nn-art .corner { position: absolute; top: 5px; left: 8px; font-size: calc(var(--cw, 90px) * .17); font-weight: 900; color: var(--band); }
|
||||
.nn-art .corner.br { top: auto; left: auto; bottom: 5px; right: 8px; transform: rotate(180deg); }
|
||||
.nn-art[data-k='add'] { --band: #1d4ed8; }
|
||||
.nn-art[data-k='add'][data-big='true'] { --band: #b91c1c; }
|
||||
.nn-art[data-k='plusMinus'] { --band: #6d28d9; background: linear-gradient(160deg, #fffef9 55%, #ede9fe); }
|
||||
.nn-art[data-k='reverse'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
|
||||
.nn-art[data-k='joker'] { --band: #b45309; background: radial-gradient(circle at 50% 40%, #fef3c7, #fffef9 70%); }
|
||||
.nn-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
|
||||
.nn-pick .btn { flex: 1 1 140px; max-width: 280px; font-size: 1.1rem; font-weight: 800; }
|
||||
.nn-joker { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
|
||||
.nn-joker .val { min-width: 84px; text-align: center; font-size: 2rem; font-weight: 900; font-variant-numeric: tabular-nums; border-radius: 12px;
|
||||
border: 2px solid var(--border); padding: 2px 8px; background: var(--surface-2); color: var(--text); }
|
||||
.nn-joker .val:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.nn-joker .step { width: 52px; min-width: 52px; padding: 0; font-size: 1.5rem; font-weight: 900; }
|
||||
.nn-joker .quick { min-width: 60px; }
|
||||
.nn-quick { display: inline-flex; gap: 8px; }
|
||||
.nn-joker .btn[aria-pressed='true'] { border-color: var(--primary); background: var(--primary); color: var(--primary-text); }
|
||||
.nn-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.nn-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 240px; overflow: auto; }
|
||||
.nn-rules td, .nn-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }
|
||||
.nn-rules .nn-face { width: 34px; height: 48px; --cw: 34px; }
|
||||
.nn-rules .nn-art::before { inset: 3px; }
|
||||
@keyframes nn-pop { 0% { transform: scale(.7); } 70% { transform: scale(1.08); } 100% { transform: scale(1); } }
|
||||
@keyframes nn-break { 0% { transform: scale(1); color: #e11d48; opacity: 1; } 40% { transform: scale(1.5) rotate(-15deg); } 100% { transform: scale(1); } }
|
||||
@media (max-width: 719px) {
|
||||
.nn-table { grid-template-columns: 1fr auto; grid-template-areas: 'dial left' 'right right'; padding: 10px; gap: 8px 10px; border-width: 6px; }
|
||||
.nn-dial { grid-area: dial; }
|
||||
.nn-left { grid-area: left; }
|
||||
.nn-right { grid-area: right; align-items: stretch; }
|
||||
.nn-recent { flex-direction: row; flex-wrap: wrap; justify-content: center; }
|
||||
.nn-recent li:not(:nth-last-child(-n+3)) { display: none; }
|
||||
.nn-seat { min-width: 100px; padding: 5px 6px; }
|
||||
.nn-hand { gap: 8px; }
|
||||
.nn-quick { flex-basis: 100%; justify-content: center; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nn-card, .nn-dir b, .nn-dial svg circle, .nn-seat { transition: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- cards
|
||||
|
||||
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' };
|
||||
|
||||
function trumpId(f: CardFace): string {
|
||||
return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`;
|
||||
}
|
||||
|
||||
function Face({ face, kind, jokerRange }: { face: CardFace; kind: CardKind; jokerRange?: { min: number; max: number } }) {
|
||||
if (face.suit) {
|
||||
return (
|
||||
<span className="nn-face" aria-hidden>
|
||||
<PlayingCard card={trumpId(face)} size="lg" style={{ width: '100%', height: '100%', filter: 'none' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const sub = kind === 'joker' ? (jokerRange ? `${jokerRange.min}~${jokerRange.max}` : '원하는 수') : (KR_SUB[kind] ?? '');
|
||||
const big = kind === 'joker' ? '★' : face.label;
|
||||
return (
|
||||
<span className="nn-face" aria-hidden>
|
||||
<span className="nn-art" data-k={kind} data-big={kind === 'add' && face.label === '10'}>
|
||||
<span className="corner">{kind === 'joker' ? '조커' : face.label}</span>
|
||||
<span className="big">{big}</span>
|
||||
{sub && <span className="sub">{sub}</span>}
|
||||
<span className="corner br">{kind === 'joker' ? '조커' : face.label}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ face, kind }: { face: CardFace; kind: CardKind }) {
|
||||
return (
|
||||
<span className="nn-mini" data-k={kind} data-red={face.suit === 'H' || face.suit === 'D'} aria-hidden>
|
||||
{faceName(face)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- seats
|
||||
|
||||
function Hearts({ s, start }: { s: SeatView; start: number }) {
|
||||
if (start <= 0) return null;
|
||||
return (
|
||||
<span className="nn-hearts" aria-hidden>
|
||||
{Array.from({ length: start }, (_, i) => (
|
||||
<span key={i} className={`nn-heart${i >= s.lives ? ' lost' : ''}`}>
|
||||
{i >= s.lives ? '♡' : '♥'}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function seatLabel(s: SeatView, v: NinetyNineView, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
|
||||
if (!s.alive) parts.push(s.left ? '나감' : '탈락');
|
||||
else parts.push(`카드 ${s.cards}장`);
|
||||
if (v.livesStart > 0) parts.push(`목숨 ${s.lives}개`);
|
||||
if (s.onBoard && s.alive) parts.push('목숨 없이 마지막 기회');
|
||||
if (s.away) parts.push('자리 비움');
|
||||
if (s.isTurn) parts.push('차례');
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function SeatChip({ s, v, players, me, deadline }: { s: SeatView; v: NinetyNineView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
|
||||
const u = userOf(players, s.id);
|
||||
const rank = v.result?.ranking.find((r) => r.player === s.id)?.rank;
|
||||
return (
|
||||
<li className={`nn-seat${s.isTurn ? ' turn' : ''}${s.alive ? '' : ' out'}`} aria-label={seatLabel(s, v, players, me)}>
|
||||
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={u} size={24} />
|
||||
<span className="nm">{u.nickname}</span>
|
||||
{s.isTurn && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={s.id === me} size={28} />}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }} aria-hidden>
|
||||
{s.alive ? (
|
||||
<span className="nn-count">
|
||||
<i />
|
||||
{s.cards}
|
||||
</span>
|
||||
) : (
|
||||
<span className="nn-badge" style={{ background: s.left ? 'var(--text-sub)' : 'var(--danger)' }}>
|
||||
{s.left ? '나감' : '탈락'}
|
||||
</span>
|
||||
)}
|
||||
<Hearts s={s} start={v.livesStart} />
|
||||
{s.onBoard && s.alive && <span className="nn-badge" style={{ background: 'var(--warning)' }}>마지막 기회</span>}
|
||||
{s.away && <span className="nn-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
|
||||
{rank && <span className="nn-badge" style={{ background: rank === 1 ? 'var(--success)' : 'var(--text-sub)' }}>{rank}등</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- table
|
||||
|
||||
function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
|
||||
const shown = useCountUp(v.total);
|
||||
const dial = 'clamp(150px, 20vw, 196px)';
|
||||
const r = 44;
|
||||
const c = 2 * Math.PI * r;
|
||||
const frac = Math.min(1, v.total / 99);
|
||||
const cur = v.current;
|
||||
return (
|
||||
<div className="nn-table" role="region" aria-label="카드 판" style={{ '--dial': dial } as CSSProperties}>
|
||||
<div className="nn-left">
|
||||
<span className="nn-dir" data-dir={v.direction} role="img" aria-label={v.direction === 1 ? '진행 방향: 시계 방향' : '진행 방향: 반대 방향'}>
|
||||
<b aria-hidden>↻</b>
|
||||
{v.direction === 1 ? '시계 방향' : '반대 방향'}
|
||||
</span>
|
||||
{cur && (
|
||||
<span className="nn-chip">
|
||||
{cur === me ? '내 차례' : `${nick(players, cur)}님 차례`}
|
||||
<TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={cur === me} size={40} />
|
||||
</span>
|
||||
)}
|
||||
<span className="nn-chip" aria-label={`뽑을 카드 ${v.drawPileCount}장`}>
|
||||
더미 {v.drawPileCount}장
|
||||
</span>
|
||||
{v.livesStart > 0 && <span className="nn-chip">{v.round}판째</span>}
|
||||
</div>
|
||||
<div className="nn-dial" role="img" aria-label={`지금 합계 ${v.total}`}>
|
||||
<svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden>
|
||||
<circle cx="50" cy="50" r={r} fill="rgba(0,0,0,.28)" stroke="rgba(255,255,255,.18)" strokeWidth="8" />
|
||||
<circle cx="50" cy="50" r={r} fill="none" stroke={gaugeColor(v.total)} strokeWidth="8" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} />
|
||||
</svg>
|
||||
<span className="nn-total" aria-hidden>
|
||||
{shown}
|
||||
</span>
|
||||
<span className="nn-total-cap" aria-hidden>
|
||||
/ 99
|
||||
</span>
|
||||
</div>
|
||||
<div className="nn-right">
|
||||
<span className="nn-rule">99를 넘기면 안 돼요!</span>
|
||||
{v.recent.length > 0 ? (
|
||||
<ol className="nn-recent" aria-label="최근에 낸 카드">
|
||||
{v.recent.slice(-4).map((e) => (
|
||||
<li key={`${e.seq}-${e.p}`}>
|
||||
<span className="who">{nick(players, e.p)}</span>
|
||||
<Mini face={e.face} kind={e.kind} />
|
||||
{e.choice && e.kind !== 'joker' ? <span>{e.choice}</span> : null}
|
||||
<span aria-hidden>→</span>
|
||||
<strong>{e.total}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- my hand
|
||||
|
||||
function cardAria(c: HandCardView, myTurn: boolean): string {
|
||||
const name = ariaFace(c.face);
|
||||
if (c.joker) return `${name}, ${c.joker.min}부터 ${c.joker.max} 사이 수로 만들어요`;
|
||||
const parts = c.choices.map((x) => (c.choices.length > 1 ? `${x.label}면 ` : '내면 ') + (x.legal ? `${x.total}` : x.total > 99 ? `${x.total}이라 낼 수 없음` : '0 미만이라 낼 수 없음'));
|
||||
return `${name}, ${parts.join(', ')}${myTurn ? '' : ' (내 차례가 아니에요)'}`;
|
||||
}
|
||||
|
||||
function previewOf(c: HandCardView): { text: string; cls: string } {
|
||||
if (c.joker) return { text: `${c.joker.min}~${c.joker.max}`, cls: 'ok' };
|
||||
if (c.badge) return { text: c.badge, cls: 'no' };
|
||||
const ok = c.choices.filter((x) => x.legal);
|
||||
if (ok.length === 1) return { text: `→ ${ok[0]!.total}`, cls: 'ok' };
|
||||
return { text: ok.map((x) => `${x.total}`).join(' / '), cls: 'ok' };
|
||||
}
|
||||
|
||||
function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) {
|
||||
const { min, max } = c.joker!;
|
||||
const [n, setN] = useState(() => Math.min(max, Math.max(min, total)));
|
||||
const clamp = (x: number) => Math.min(max, Math.max(min, x));
|
||||
const quick = [...new Set([min, 90, 99].filter((x) => x >= min && x <= max))];
|
||||
const onKey = (e: KeyboardEvent<HTMLSpanElement>) => {
|
||||
const d: Record<string, number> = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 };
|
||||
if (e.key in d) setN(clamp(n + d[e.key]!));
|
||||
else if (e.key === 'Home') setN(min);
|
||||
else if (e.key === 'End') setN(max);
|
||||
else if (e.key === 'Enter') onPlay(n);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<div className="nn-joker" role="group" aria-label="조커로 만들 수 고르기">
|
||||
<button type="button" className="btn btn-secondary step" aria-label="1 작게" disabled={n <= min} onClick={() => setN(clamp(n - 1))}>
|
||||
−
|
||||
</button>
|
||||
<span className="val" role="spinbutton" tabIndex={0} aria-label="조커로 만들 수" aria-valuemin={min} aria-valuemax={max} aria-valuenow={n} onKeyDown={onKey}>
|
||||
{n}
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary step" aria-label="1 크게" disabled={n >= max} onClick={() => setN(clamp(n + 1))}>
|
||||
+
|
||||
</button>
|
||||
<span className="nn-quick" role="group" aria-label="빠른 선택">
|
||||
{quick.map((q) => (
|
||||
<button key={q} type="button" className="btn btn-secondary quick" aria-pressed={n === q} onClick={() => setN(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="nn-pick">
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => onPlay(n)}>
|
||||
조커 내기 → {n}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => void; pending: boolean }) {
|
||||
const my = v.my!;
|
||||
const [sel, setSel] = useState<string | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const myTurn = my.myTurn;
|
||||
useEffect(() => {
|
||||
setSel(null);
|
||||
setNote('');
|
||||
}, [v.turnNo]);
|
||||
const chosen = my.hand.find((c) => c.id === sel) ?? null;
|
||||
const send = (c: HandCardView, extra: Record<string, unknown> = {}) => {
|
||||
if (pending) return;
|
||||
act({ type: 'play', cardId: c.id, ...extra });
|
||||
setSel(null);
|
||||
};
|
||||
const single = (c: HandCardView) => c.choices.length === 1 && !c.joker;
|
||||
const tap = (c: HandCardView) => {
|
||||
if (!myTurn) {
|
||||
setNote('지금은 내 차례가 아니에요. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.');
|
||||
setSel(sel === c.id ? null : c.id);
|
||||
return;
|
||||
}
|
||||
if (!c.legal) {
|
||||
setNote(`${ariaFace(c.face)}은(는) ${c.badge === '0 미만' ? '0보다 작아져서' : '99를 넘어서'} 낼 수 없어요.`);
|
||||
setSel(null);
|
||||
return;
|
||||
}
|
||||
setNote('');
|
||||
if (sel === c.id) {
|
||||
if (single(c)) send(c);
|
||||
else setSel(null);
|
||||
return;
|
||||
}
|
||||
setSel(c.id);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSel(null);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
const btns = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('button.nn-card'));
|
||||
const i = btns.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (i < 0) return;
|
||||
e.preventDefault();
|
||||
btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus();
|
||||
};
|
||||
const n = my.hand.length;
|
||||
const cw = n <= 3 ? 'clamp(84px, 24vw, 104px)' : n === 4 ? 'clamp(72px, 20vw, 96px)' : 'clamp(62px, 16.5vw, 90px)';
|
||||
const meSeat = v.seats.find((s) => s.id === v.me);
|
||||
const legalCount = my.hand.filter((c) => c.legal).length;
|
||||
return (
|
||||
<div className="nn-hand-wrap">
|
||||
<div className="nn-hand-head">
|
||||
<strong>
|
||||
내 카드 {n}장{myTurn ? ` · 낼 수 있는 카드 ${legalCount}장` : ''}
|
||||
</strong>
|
||||
{meSeat && v.livesStart > 0 && (
|
||||
<span className="row small" style={{ gap: 6 }} aria-label={`내 목숨 ${meSeat.lives}개`}>
|
||||
목숨 <Hearts s={meSeat} start={v.livesStart} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="nn-hand" role="group" aria-label="내 카드. 좌우 방향키로 고르고 Enter로 고른 뒤 한 번 더 누르면 내요" onKeyDown={onKey} style={{ '--cw': cw } as CSSProperties}>
|
||||
{my.hand.map((c) => {
|
||||
const p = previewOf(c);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="nn-card"
|
||||
data-legal={myTurn ? c.legal : undefined}
|
||||
aria-pressed={sel === c.id}
|
||||
aria-label={`${cardAria(c, myTurn)}${sel === c.id && myTurn && single(c) ? '. 한 번 더 누르면 내요' : ''}`}
|
||||
onClick={() => tap(c)}
|
||||
>
|
||||
<span className={`nn-prev ${p.cls}`} aria-hidden>
|
||||
{p.text}
|
||||
</span>
|
||||
<Face face={c.face} kind={c.kind} jokerRange={c.joker ?? undefined} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{n === 0 && <span className="muted">손에 카드가 없어요.</span>}
|
||||
</div>
|
||||
{note && (
|
||||
<p className="small" style={{ margin: 0 }} aria-live="polite">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
{chosen && myTurn && chosen.legal && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{chosen.joker ? (
|
||||
<JokerDial key={chosen.id} c={chosen} total={v.total} pending={pending} onPlay={(jokerValue) => send(chosen, { jokerValue })} />
|
||||
) : (
|
||||
<div className="nn-pick" role="group" aria-label={`${ariaFace(chosen.face)} 내는 방법`}>
|
||||
{chosen.choices.map((x) => (
|
||||
<button
|
||||
key={x.label}
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!x.legal || pending}
|
||||
aria-label={x.legal ? `${x.label}로 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
||||
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) })}
|
||||
>
|
||||
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{myTurn && !chosen && <p className="small muted" style={{ margin: 0 }}>카드를 누르면 낼 때 합계가 보여요. 한 번 더 누르면 내요.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- log
|
||||
|
||||
function logText(e: LogEntry, players: Record<string, PublicUser>): string {
|
||||
const n = (id: string) => nick(players, id);
|
||||
switch (e.t) {
|
||||
case 'play': {
|
||||
const how = e.kind === 'joker' ? `${e.total}로 만들었어요` : e.kind === 'reverse' ? `방향을 바꿨어요(${e.total} 그대로)` : e.kind === 'hold' ? `그대로 넘겼어요(${e.total})` : `${e.from} → ${e.total}`;
|
||||
return `${n(e.p)}: ${faceName(e.face)}${e.choice && e.kind !== 'joker' ? `(${e.choice})` : ''} ${how}${e.auto ? ' · 자동' : ''}`;
|
||||
}
|
||||
case 'noCard':
|
||||
return `${n(e.p)}님은 낼 수 있는 카드가 없어요`;
|
||||
case 'life':
|
||||
return `${n(e.p)}님이 목숨 1개를 잃었어요(남은 목숨 ${e.left}개)`;
|
||||
case 'out':
|
||||
return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`;
|
||||
case 'round':
|
||||
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`;
|
||||
case 'reshuffle':
|
||||
return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`;
|
||||
case 'timeout':
|
||||
return `${n(e.p)}님 시간 초과 · 가장 작은 수가 되는 카드를 자동으로 냈어요`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function Board({ view: v, me, players, act, pending, deadline }: BoardProps<NinetyNineView>) {
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const mine = isMyTurn(v, me);
|
||||
const prevMine = useRef(false);
|
||||
const last = v.log[v.log.length - 1];
|
||||
useEffect(() => {
|
||||
if (mine && !prevMine.current) {
|
||||
haptic(60);
|
||||
sounds.myTurn();
|
||||
setAnnounce(`내 차례예요. 지금 합계 ${v.total}`);
|
||||
}
|
||||
prevMine.current = mine;
|
||||
}, [mine]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (last && !mine) setAnnounce(logText(last, players));
|
||||
}, [last?.seq, last?.t]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const myIdx = v.seats.findIndex((s) => s.id === me);
|
||||
const others = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx + 1), ...v.seats.slice(0, myIdx)];
|
||||
const meSeat = v.seats.find((s) => s.id === me);
|
||||
return (
|
||||
<div className="nn">
|
||||
<style>{CSS}</style>
|
||||
<ul className="nn-opps" aria-label="다른 사람">
|
||||
{others.map((s) => (
|
||||
<SeatChip key={s.id} s={s} v={v} players={players} me={me} deadline={deadline} />
|
||||
))}
|
||||
</ul>
|
||||
<Table v={v} players={players} me={me} deadline={deadline} />
|
||||
{v.my && meSeat?.alive && v.phase === 'playing' && <MyHand v={v} act={act} pending={pending} />}
|
||||
{meSeat && !meSeat.alive && v.phase === 'playing' && (
|
||||
<div className="nn-panel">
|
||||
<strong>{meSeat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
|
||||
</div>
|
||||
)}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function rulesLine(o: NNOptions): string {
|
||||
const lives = resolvedLives(o);
|
||||
const parts = [`손패 ${o.handSize}장`, o.refill ? '내고 1장 보충' : '보충 없음', lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락'];
|
||||
if (lives === 0) parts.push(o.afterElimination === 'reset' ? '탈락 뒤 0부터' : '탈락 뒤 합계 그대로');
|
||||
parts.push(o.floorAtZero === 'forbid' ? '0 미만 빼기 안 됨' : '0 미만은 0');
|
||||
if (o.mode === 'kr-product') parts.push(`조커 ${o.jokerRange === '0-99' ? '0~99' : '60~99'}`);
|
||||
else parts.push(`A는 1 또는 ${o.aceHigh}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function Side({ view: v, me, players, deadline }: BoardProps<NinetyNineView>) {
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
구십구 · {MODE_KO[v.mode]}
|
||||
{v.livesStart > 0 ? ` · ${v.round}판` : ''}
|
||||
{v.phase === 'finished' ? ' · 게임 끝' : ''}
|
||||
</strong>
|
||||
{v.current && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">{rulesLine(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{v.seats.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.alive ? 1 : 0.55 }} aria-label={seatLabel(s, v, players, me)}>
|
||||
<span style={{ filter: s.alive ? 'none' : 'grayscale(1)', display: 'inline-flex' }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{s.isTurn ? '▶ ' : ''}
|
||||
{nick(players, s.id)}
|
||||
{s.id === me ? ' (나)' : ''}
|
||||
{!s.alive ? (s.left ? ' · 나감' : ' · 탈락') : s.away ? ' · 자리 비움' : ''}
|
||||
</span>
|
||||
<Hearts s={s} start={v.livesStart} />
|
||||
{s.alive && (
|
||||
<span className="nn-count" aria-hidden>
|
||||
<i />
|
||||
{s.cards}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((r) => (
|
||||
<span key={r.player}>
|
||||
{r.rank}등 {nick(players, r.player)}
|
||||
<span className="muted small">
|
||||
{r.eliminatedAtRound === null ? ' · 끝까지 살아남음' : r.left ? ' · 나감' : v.livesStart > 0 ? ` · ${r.eliminatedAtRound}판에서 탈락` : ' · 탈락'}
|
||||
{v.livesStart > 0 ? ` · 잃은 목숨 ${r.livesLost}개` : ''}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">{v.result.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol className="nn-log">
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={`${e.seq}-${e.t}-${i}`} className="small">
|
||||
{logText(e, players)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>특수 카드</summary>
|
||||
<SpecialTable o={v.options} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: NinetyNineView, me: string | null): boolean {
|
||||
return !!me && v.phase === 'playing' && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: NinetyNineView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (isMyTurn(v, me)) return `내 차례예요 · 지금 합계 ${v.total}`;
|
||||
const meSeat = v.seats.find((s) => s.id === me);
|
||||
const who = v.current ? `${nick(players, v.current)}님 차례예요 · 합계 ${v.total}` : '잠시만 기다려 주세요';
|
||||
return meSeat && !meSeat.alive && !meSeat.left ? `탈락했어요 · ${who}` : who;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<NNOptions>) => onChange({ ...o, ...patch });
|
||||
const kr = o.mode === 'kr-product';
|
||||
const lives = resolvedLives(o);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="규칙" help={kr ? '1~8·10 숫자 카드와 ±9·±10·0·조커 전용 카드로 해요.' : '트럼프 카드로 해요. K는 99, 4는 방향 바꾸기, 9는 그대로, 10은 빼기 10.'}>
|
||||
<Seg label="규칙" value={o.mode} options={[['kr-product', '전용 카드'], ['classic', '트럼프 카드']]} onChange={(mode) => set({ mode })} />
|
||||
</Field>
|
||||
<Field label="손패 장수">
|
||||
<Seg label="손패 장수" value={o.handSize} options={[[3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
||||
</Field>
|
||||
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
|
||||
<Seg
|
||||
label="목숨"
|
||||
value={o.lives}
|
||||
options={[['auto', kr ? '없음(기본)' : '3개(기본)'], [0, '없음'], [1, '1개'], [2, '2개'], [3, '3개'], [5, '5개']]}
|
||||
onChange={(v) => set({ lives: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="한 턴 제한 시간">
|
||||
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
{kr && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 4 })}>
|
||||
어린이 추천 설정(조커 0~99, 손패 4장)
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="카드를 낸 뒤 1장 보충">
|
||||
<Seg label="카드를 낸 뒤 1장 보충" value={o.refill} options={[[true, '보충해요'], [false, '보충 안 해요']]} onChange={(refill) => set({ refill })} />
|
||||
</Field>
|
||||
<Field label="목숨을 다 잃어도 한 번 더" help="켜면 목숨이 0개여도 남고, 한 번 더 지면 탈락해요.">
|
||||
<Seg label="목숨을 다 잃어도 한 번 더" value={o.onTheBoard} options={[[false, '끄기'], [true, '켜기']]} onChange={(onTheBoard) => set({ onTheBoard })} />
|
||||
</Field>
|
||||
<Field label="(목숨 없음) 탈락 뒤 합계">
|
||||
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', '0으로 하고 다시 나누기']]} onChange={(afterElimination) => set({ afterElimination })} />
|
||||
</Field>
|
||||
<Field label="빼서 0보다 작아질 때">
|
||||
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
|
||||
</Field>
|
||||
{kr ? (
|
||||
<>
|
||||
<Field label="±9·±10·0 카드 장수(각각)">
|
||||
<Seg label="특수 카드 장수" value={o.specialCounts} options={[[2, '2장'], [3, '3장'], [4, '4장'], [5, '5장'], [6, '6장']]} onChange={(specialCounts) => set({ specialCounts })} />
|
||||
</Field>
|
||||
<Field label="조커로 부를 수 있는 수">
|
||||
<Seg label="조커로 부를 수 있는 수" value={o.jokerRange} options={[['60-99', '60~99'], ['0-99', '0~99(어린이용)']]} onChange={(jokerRange) => set({ jokerRange })} />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="A의 큰 값">
|
||||
<Seg label="A의 큰 값" value={o.aceHigh} options={[[11, '11'], [10, '10']]} onChange={(aceHigh) => set({ aceHigh })} />
|
||||
</Field>
|
||||
<Field label="덱 수">
|
||||
<Seg label="덱 수" value={o.deckCount} options={[['auto', '자동(7명부터 2벌)'], [1, '1벌'], [2, '2벌']]} onChange={(deckCount) => set({ deckCount })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: NNOptions): string {
|
||||
const lives = resolvedLives(o);
|
||||
const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
||||
if (!o.refill) parts.push('보충 없음');
|
||||
if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99');
|
||||
parts.push(`${o.turnSeconds}초`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function SpecialTable({ o }: { o: NNOptions }) {
|
||||
const kr = o.mode === 'kr-product';
|
||||
const C = (label: string, kind: CardKind, suit?: CardFace['suit']) => <Face face={suit ? { label, suit } : { label }} kind={kind} />;
|
||||
const floor = o.floorAtZero === 'forbid' ? '0보다 작아지게는 못 빼요.' : '0보다 작아지면 0이 돼요.';
|
||||
const rows: [ReactNode, string, string][] = kr
|
||||
? [
|
||||
[C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'],
|
||||
[C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`],
|
||||
[C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`],
|
||||
[C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
|
||||
[C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`],
|
||||
]
|
||||
: [
|
||||
[C('A', 'ace', 'S'), 'A', `1 또는 ${o.aceHigh}을 더해요(고를 수 있어요).`],
|
||||
[C('4', 'reverse', 'H'), '4', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
|
||||
[C('9', 'hold', 'C'), '9', '합계는 그대로 넘겨요.'],
|
||||
[C('10', 'minus', 'D'), '10', `10을 빼요. ${floor}`],
|
||||
[C('J', 'add', 'S'), 'J·Q', '10을 더해요.'],
|
||||
[C('K', 'to99', 'H'), 'K', '합계를 바로 99로 만들어요.'],
|
||||
];
|
||||
return (
|
||||
<table className="nn-rules small" style={{ borderCollapse: 'collapse', width: '100%', marginTop: 8 }}>
|
||||
<tbody>
|
||||
{rows.map(([pic, name, desc]) => (
|
||||
<tr key={name}>
|
||||
<td style={{ width: 44 }}>{pic}</td>
|
||||
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
|
||||
{name}
|
||||
</th>
|
||||
<td>{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: NNOptions }) {
|
||||
const o: NNOptions = {
|
||||
mode: 'kr-product',
|
||||
handSize: 3,
|
||||
refill: true,
|
||||
lives: 'auto',
|
||||
onTheBoard: false,
|
||||
afterElimination: 'continue',
|
||||
floorAtZero: 'forbid',
|
||||
specialCounts: 4,
|
||||
jokerRange: '60-99',
|
||||
aceHigh: 11,
|
||||
deckCount: 'auto',
|
||||
turnSeconds: 15,
|
||||
...options,
|
||||
};
|
||||
const lives = resolvedLives(o);
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
|
||||
</p>
|
||||
<p>
|
||||
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
|
||||
</p>
|
||||
<p>
|
||||
<strong>낼 카드가 없으면</strong>:{' '}
|
||||
{lives > 0
|
||||
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
||||
: o.afterElimination === 'reset'
|
||||
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
|
||||
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
|
||||
마지막까지 남은 1명이 이겨요.
|
||||
</p>
|
||||
<SpecialTable o={o} />
|
||||
<p className="small muted">
|
||||
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<NinetyNineView, NNOptions> = {
|
||||
Board,
|
||||
Side,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
488
apps/web/src/games/omok/index.tsx
Normal file
488
apps/web/src/games/omok/index.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
/** 오목 화면 (docs/games/omok.md §9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { OmokOptions, OmokView, Pt } from '@bg/games/omok';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
const COLS = 'ABCDEFGHIJKLMNOPQRS';
|
||||
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
|
||||
const RULE_KO: Record<OmokOptions['ruleSet'], string> = { korean: '한국식', renju: '렌주룰', free: '자유룰' };
|
||||
|
||||
function stars(size: number): Pt[] {
|
||||
const s = size === 19 ? [3, 9, 15] : [3, 7, 11];
|
||||
return s.flatMap((x) => s.map((y) => ({ x, y })));
|
||||
}
|
||||
|
||||
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
|
||||
return (
|
||||
<g opacity={ghost ? 0.55 : 1}>
|
||||
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.06} />
|
||||
{color === 'black' ? (
|
||||
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
|
||||
) : (
|
||||
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function OmokBoard({ view: v, act, pending, tapConfirm }: BoardProps<OmokView>) {
|
||||
const n = v.size;
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) / 2, y: (n - 1) / 2 });
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
const prevTurn = useRef(v.turn);
|
||||
|
||||
const forbidden = useMemo(() => new Set(v.forbidden.map((p) => p.y * n + p.x)), [v.forbidden, n]);
|
||||
|
||||
// Sounds, vibration and screen-reader announcements on new moves.
|
||||
useEffect(() => {
|
||||
if (v.moveCount > prevCount.current && v.lastMove) {
|
||||
sounds.stone();
|
||||
const who = v.moves[v.moves.length - 1]!.color;
|
||||
setAnnounce(`${COLOR_KO[who]}이 ${label(n, v.lastMove)}에 두었어요.`);
|
||||
}
|
||||
if (v.phase === 'playing' && myTurn && (prevTurn.current !== v.turn || v.moveCount === 0) && v.moveCount !== prevCount.current) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
prevCount.current = v.moveCount;
|
||||
prevTurn.current = v.turn;
|
||||
setPreview(null);
|
||||
}, [v.moveCount, v.turn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canPlace = (p: Pt) =>
|
||||
myTurn && !pending && v.board[p.y * n + p.x] === 0 && !forbidden.has(p.y * n + p.x) && (!v.firstMoveCenter || v.moveCount > 0 || (p.x === (n - 1) / 2 && p.y === (n - 1) / 2));
|
||||
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
const tap = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (!myTurn || v.board[p.y * n + p.x] !== 0) return;
|
||||
if (forbidden.has(p.y * n + p.x)) {
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce('여기는 금수 자리라 둘 수 없어요.');
|
||||
return;
|
||||
}
|
||||
// Mouse: one click places (hover already shows the preview). Touch: optional two-step confirm.
|
||||
if (mouse || !tapConfirm) {
|
||||
if (canPlace(p)) place(p);
|
||||
return;
|
||||
}
|
||||
if (preview && preview.x === p.x && preview.y === p.y) place(p);
|
||||
else setPreview(p);
|
||||
};
|
||||
|
||||
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
const x = Math.round(loc.x);
|
||||
const y = Math.round(loc.y);
|
||||
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (canPlace(cursor)) place(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const winSet = new Set((v.winLine ?? []).map((p) => p.y * n + p.x));
|
||||
const ghost = preview ?? (hover && canPlace(hover) ? hover : null);
|
||||
const pad = 0.9;
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 12, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||
const p = pointToCell(e);
|
||||
if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p);
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p, e.pointerType === 'mouse');
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`오목판 ${n}줄. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(n, cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.38} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
|
||||
<text x={i} y={-0.6}>
|
||||
{COLS[i]}
|
||||
</text>
|
||||
<text x={-0.62} y={i}>
|
||||
{n - i}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{stars(n).map((p) => (
|
||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||
))}
|
||||
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||
{v.forbidden.map((p) => (
|
||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||
<line x1={p.x + 0.2} y1={p.y - 0.2} x2={p.x - 0.2} y2={p.y + 0.2} />
|
||||
</g>
|
||||
))}
|
||||
{v.board.map((c, i) => (c === 0 ? null : <Stone key={i} color={c === 1 ? 'black' : 'white'} cx={i % n} cy={Math.floor(i / n)} r={0.46} />))}
|
||||
{v.lastMove && <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.13} fill="#ef4444" />}
|
||||
{[...winSet].map((i) => (
|
||||
<circle key={`win${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.5} fill="none" stroke="#22c55e" strokeWidth={0.1} />
|
||||
))}
|
||||
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.46} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{`${label(n, preview)}에 두기`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-hand panel on PC (below the board on narrow screens). */
|
||||
function OmokSide({ view: v, me, players, act, pending, deadline }: BoardProps<OmokView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
|
||||
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
|
||||
const opp: Color = v.myColor === 'white' ? 'black' : 'white';
|
||||
const mine: Color = v.myColor ?? 'black';
|
||||
return (
|
||||
<>
|
||||
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">{incoming === 'undo' ? '수락하면 돌이 되돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: OmokView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {label(v.size, m.pt)}
|
||||
{m.auto ? '*' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(500);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (tc.kind === 'fischer') {
|
||||
const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0));
|
||||
const s = Math.ceil(ms / 1000);
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000;
|
||||
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="card row"
|
||||
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
|
||||
>
|
||||
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
|
||||
<Stone color={color} cx={0} cy={0} r={0.9} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">{COLOR_KO[color]}{active ? ' · 두는 중' : ''}</div>
|
||||
</div>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: OmokView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
|
||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||
if (v.myColor === v.turn) {
|
||||
if (v.moveCount === 0 && v.firstMoveCenter) return '내 차례예요. 첫 수는 가운데에 둬요';
|
||||
return `내 차례예요 (${COLOR_KO[v.turn]}). 놓을 곳을 눌러 주세요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, Partial<OmokOptions['timeControl']>][] = [
|
||||
['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }],
|
||||
['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }],
|
||||
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||
['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: OmokOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||
return `f${Math.round(tc.baseSec / 60)}`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<OmokOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>규칙</strong>
|
||||
<Seg value={o.ruleSet} options={[['korean', '한국식(쌍삼 금지)'], ['renju', '렌주룰'], ['free', '자유룰']]} onChange={(ruleSet) => set({ ruleSet })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={timeKey(o.timeControl)}
|
||||
options={[
|
||||
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
|
||||
...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]),
|
||||
]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>흑(먼저 두는 쪽)</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['random', '무작위'], ['hostBlack', '방장이 흑'], ['hostWhite', '방장이 백'], ['alternate', '판마다 번갈아']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>판 크기</strong>
|
||||
<Seg value={o.boardSize} options={[[15, '15줄'], [19, '19줄']]} onChange={(boardSize) => set({ boardSize })} />
|
||||
</div>
|
||||
{o.ruleSet === 'free' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>자유룰 승리</strong>
|
||||
<Seg value={o.freeWin} options={[['fivePlus', '5목 이상'], ['exactFive', '정확히 5목']]} onChange={(freeWin) => set({ freeWin })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
{o.timeControl.kind === 'perMove' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간이 다 되면</strong>
|
||||
<Seg value={o.timeoutPolicy} options={[['autoMove', '자동으로 두기'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>첫 수 가운데</strong>
|
||||
<Seg value={o.firstMoveCenter} options={[['auto', '렌주만'], ['on', '항상'], ['off', '안 함']]} onChange={(firstMoveCenter) => set({ firstMoveCenter })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>도움 표시</strong>
|
||||
<Seg value={o.showForbidden ? 'on' : 'off'} options={[['on', '금수 자리 표시'], ['off', '금수 표시 안 함']]} onChange={(k) => set({ showForbidden: k === 'on' })} />
|
||||
<Seg value={o.hints} options={[['warn', '위험한 자리 알려 주기'], ['off', '알려 주지 않기']]} onChange={(hints) => set({ hints })} />
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timeLabel(tc: OmokOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||
const m = Math.floor(tc.baseSec / 60);
|
||||
const sec = tc.baseSec % 60;
|
||||
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function optionsSummary(o: OmokOptions): string {
|
||||
const t = timeLabel(o.timeControl);
|
||||
return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: OmokOptions }) {
|
||||
const rule = options?.ruleSet ?? 'korean';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>흑과 백이 번갈아 한 수씩 판의 빈 교차점에 돌을 놓아요. 가로, 세로, 대각선 중 한 줄로 내 돌 <strong>5개</strong>를 먼저 이으면 이겨요. 흑이 먼저 둬요.</p>
|
||||
{rule === 'korean' && (
|
||||
<p>
|
||||
<strong>한국식</strong>: 흑과 백 모두 <strong>쌍삼(열린 3을 두 개 동시에 만드는 수)</strong>을 둘 수 없어요. 6개 이상 이어지면 이긴 것이 아니에요.
|
||||
</p>
|
||||
)}
|
||||
{rule === 'renju' && (
|
||||
<p>
|
||||
<strong>렌주룰</strong>: 흑만 쌍삼, 사사(4를 두 개 동시에), 6목 이상을 둘 수 없어요. 백은 제한이 없고 6목 이상도 이겨요. 첫 수는 가운데에 둬요.
|
||||
</p>
|
||||
)}
|
||||
{rule === 'free' && (
|
||||
<p>
|
||||
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
|
||||
</p>
|
||||
)}
|
||||
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).</p>
|
||||
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<OmokView, OmokOptions> = {
|
||||
Board: OmokBoard,
|
||||
Side: OmokSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
153
apps/web/src/games/one-card/ColorCard.tsx
Normal file
153
apps/web/src/games/one-card/ColorCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 색깔 카드 artwork (own design, docs/games/one-card.md §9, §12): solid colour field, a white diamond in the
|
||||
* middle, a colour-blind pattern per colour (빨강 점 · 노랑 줄 · 초록 격자 · 파랑 물결) and the colour name.
|
||||
*/
|
||||
import { useId } from 'react';
|
||||
import type { CardFace, Color } from '@bg/games/one-card';
|
||||
|
||||
export const COLOR_HEX: Record<Color, string> = { R: '#d32f2f', Y: '#f4b400', G: '#2e7d32', B: '#1565c0' };
|
||||
export const COLOR_INK: Record<Color, string> = { R: '#ffffff', Y: '#1f2937', G: '#ffffff', B: '#ffffff' };
|
||||
export const COLOR_KO: Record<Color, string> = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' };
|
||||
const PATTERN_KO: Record<Color, string> = { R: '점무늬', Y: '줄무늬', G: '격자무늬', B: '물결무늬' };
|
||||
|
||||
function Pattern({ color, id }: { color: Color; id: string }) {
|
||||
const ink = color === 'Y' ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.22)';
|
||||
switch (color) {
|
||||
case 'R':
|
||||
return (
|
||||
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="6" cy="6" r="2.2" fill={ink} />
|
||||
</pattern>
|
||||
);
|
||||
case 'Y':
|
||||
return (
|
||||
<pattern id={id} width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<rect width="4" height="10" fill={ink} />
|
||||
</pattern>
|
||||
);
|
||||
case 'G':
|
||||
return (
|
||||
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<path d="M0 0H12M0 0V12" stroke={ink} strokeWidth="2.4" fill="none" />
|
||||
</pattern>
|
||||
);
|
||||
case 'B':
|
||||
return (
|
||||
<pattern id={id} width="20" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M0 6 Q5 1 10 6 T20 6" stroke={ink} strokeWidth="2.2" fill="none" />
|
||||
</pattern>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Four colour quadrants used by the wild cards. */
|
||||
function Quadrants({ cx, cy, r }: { cx: number; cy: number; r: number }) {
|
||||
const q = (dx: number, dy: number, c: Color) => (
|
||||
<path d={`M${cx} ${cy} L${cx + dx * r} ${cy} A${r} ${r} 0 0 ${dx * dy > 0 ? 1 : 0} ${cx} ${cy + dy * r} Z`} fill={COLOR_HEX[c]} />
|
||||
);
|
||||
return (
|
||||
<g>
|
||||
{q(1, -1, 'R')}
|
||||
{q(1, 1, 'B')}
|
||||
{q(-1, 1, 'G')}
|
||||
{q(-1, -1, 'Y')}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipIcon({ cx, cy, r, color }: { cx: number; cy: number; r: number; color: string }) {
|
||||
return (
|
||||
<g stroke={color} strokeWidth={r * 0.28} fill="none" strokeLinecap="round">
|
||||
<circle cx={cx} cy={cy} r={r} />
|
||||
<path d={`M${cx - r * 0.7} ${cy + r * 0.7} L${cx + r * 0.7} ${cy - r * 0.7}`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function ReverseIcon({ cx, cy, s, color }: { cx: number; cy: number; s: number; color: string }) {
|
||||
return (
|
||||
<g stroke={color} strokeWidth={s * 0.22} fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={`M${cx - s} ${cy - s * 0.35} H${cx + s * 0.8} M${cx + s * 0.35} ${cy - s * 0.8} L${cx + s * 0.8} ${cy - s * 0.35} L${cx + s * 0.35} ${cy + s * 0.1}`} />
|
||||
<path d={`M${cx + s} ${cy + s * 0.35} H${cx - s * 0.8} M${cx - s * 0.35} ${cy + s * 0.8} L${cx - s * 0.8} ${cy + s * 0.35} L${cx - s * 0.35} ${cy - s * 0.1}`} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function colorCardLabel(c: CardFace): string {
|
||||
switch (c.kind) {
|
||||
case 'num':
|
||||
return `${COLOR_KO[c.color]} ${c.value}`;
|
||||
case 'skip':
|
||||
return `${COLOR_KO[c.color]} 건너뛰기`;
|
||||
case 'reverse':
|
||||
return `${COLOR_KO[c.color]} 방향 바꾸기`;
|
||||
case 'draw2':
|
||||
return `${COLOR_KO[c.color]} +2`;
|
||||
case 'wild':
|
||||
return '색 바꾸기';
|
||||
case 'wild4':
|
||||
return '색 바꾸기+4';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure artwork (no interaction); size comes from the parent box (viewBox 100 × 140). */
|
||||
export function ColorCardArt({ card }: { card: CardFace | null }) {
|
||||
const pid = `occ${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
||||
if (!card) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
|
||||
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill="#1f2433" stroke="#f8fafc" strokeWidth="3" />
|
||||
<rect x="9" y="9" width="82" height="122" rx="7" fill="none" stroke="#475569" strokeWidth="2" />
|
||||
<Quadrants cx={50} cy={70} r={22} />
|
||||
<circle cx="50" cy="70" r="9" fill="#1f2433" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const wild = card.kind === 'wild' || card.kind === 'wild4';
|
||||
const color: Color | null = 'color' in card ? card.color : null;
|
||||
const bg = color ? COLOR_HEX[color] : '#1f2433';
|
||||
const ink = color ? COLOR_INK[color] : '#f8fafc';
|
||||
const corner = card.kind === 'num' ? String(card.value) : card.kind === 'draw2' ? '+2' : card.kind === 'wild4' ? '+4' : card.kind === 'skip' ? '⊘' : card.kind === 'reverse' ? '⇄' : '★';
|
||||
const centerInk = color ? COLOR_HEX[color] : '#1f2433';
|
||||
return (
|
||||
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
|
||||
{color && (
|
||||
<defs>
|
||||
<Pattern color={color} id={pid} />
|
||||
</defs>
|
||||
)}
|
||||
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={bg} stroke="#f8fafc" strokeWidth="3" />
|
||||
{color && <rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={`url(#${pid})`} />}
|
||||
{/* white diamond (not an oval) */}
|
||||
<path d="M50 22 L86 70 L50 118 L14 70 Z" fill="#fffef9" stroke={ink} strokeOpacity="0.25" strokeWidth="1.5" />
|
||||
{wild ? (
|
||||
<>
|
||||
<Quadrants cx={50} cy={70} r={card.kind === 'wild4' ? 19 : 24} />
|
||||
{card.kind === 'wild4' && (
|
||||
<text x="50" y="79" fontSize="26" fontWeight="900" textAnchor="middle" fill="#fffef9" stroke="#1f2433" strokeWidth="1.5" fontFamily="Pretendard Variable, sans-serif">
|
||||
+4
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
) : card.kind === 'skip' ? (
|
||||
<SkipIcon cx={50} cy={70} r={17} color={centerInk} />
|
||||
) : card.kind === 'reverse' ? (
|
||||
<ReverseIcon cx={50} cy={70} s={19} color={centerInk} />
|
||||
) : (
|
||||
<text x="50" y={card.kind === 'draw2' ? 82 : 86} fontSize={card.kind === 'draw2' ? 34 : 46} fontWeight="900" textAnchor="middle" fill={centerInk} fontFamily="Pretendard Variable, sans-serif">
|
||||
{card.kind === 'draw2' ? '+2' : corner}
|
||||
</text>
|
||||
)}
|
||||
<text x="9" y="25" fontSize="20" fontWeight="900" fill={ink} fontFamily="Pretendard Variable, sans-serif">
|
||||
{corner}
|
||||
</text>
|
||||
<text x="50" y="134" fontSize="12" fontWeight="800" textAnchor="middle" fill={ink} fontFamily="Pretendard Variable, sans-serif">
|
||||
{color ? COLOR_KO[color] : wild ? '색 바꾸기' : ''}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const patternName = (c: Color): string => PATTERN_KO[c];
|
||||
1114
apps/web/src/games/one-card/index.tsx
Normal file
1114
apps/web/src/games/one-card/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
618
apps/web/src/games/othello/index.tsx
Normal file
618
apps/web/src/games/othello/index.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/** 오셀로 화면 (docs/games/othello.md §9). */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { OthelloLegalMove, OthelloOptions, OthelloView } from '@bg/games/othello';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { toast } from '../../store/toast';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
const N = 8;
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const label = (p: Pt) => `${'abcdefgh'[p.x]}${p.y + 1}`;
|
||||
const FLIP_MS = 240;
|
||||
|
||||
const CSS = `
|
||||
@keyframes oth-flip { 0% { transform: scaleX(1); } 50% { transform: scaleX(0.05); } 100% { transform: scaleX(1); } }
|
||||
@keyframes oth-old { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
|
||||
@keyframes oth-drop { 0% { transform: scale(0.4); opacity: 0.4; } 100% { transform: scale(1); opacity: 1; } }
|
||||
.oth-flip { transform-box: fill-box; transform-origin: center; animation: oth-flip ${FLIP_MS}ms ease-in-out both; }
|
||||
.oth-old { animation: oth-old ${FLIP_MS}ms linear both; }
|
||||
.oth-drop { transform-box: fill-box; transform-origin: center; animation: oth-drop 160ms ease-out both; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.oth-flip, .oth-drop { animation: none; }
|
||||
.oth-old { display: none; }
|
||||
}`;
|
||||
|
||||
function Disc({ color, cx, cy, r = 0.4, ghost }: { color: Color; cx: number; cy: number; r?: number; ghost?: boolean }) {
|
||||
const black = color === 'black';
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1}>
|
||||
<circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={ghost ? 0 : 0.25} />
|
||||
<circle cx={cx} cy={cy} r={r} fill={black ? '#161616' : '#fafaf7'} stroke={black ? '#000' : '#9a9a92'} strokeWidth={0.03} />
|
||||
<circle cx={cx - r * 0.28} cy={cy - r * 0.3} r={r * 0.32} fill="#fff" opacity={black ? 0.14 : 0.6} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface Anim {
|
||||
key: number;
|
||||
at: number;
|
||||
from: Color;
|
||||
delay: Map<number, number>;
|
||||
}
|
||||
|
||||
function OthelloBoard({ view: v, act, pending, tapConfirm }: BoardProps<OthelloView>) {
|
||||
const myTurn = v.phase === 'playing' && v.myColor === v.turn && v.legalMoves !== null;
|
||||
const [hover, setHover] = useState<Pt | null>(null);
|
||||
const [preview, setPreview] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: 3, y: 3 });
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [anim, setAnim] = useState<Anim | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
|
||||
const legal = useMemo(() => {
|
||||
const m = new Map<number, OthelloLegalMove>();
|
||||
for (const l of v.legalMoves ?? []) m.set(l.y * N + l.x, l);
|
||||
return m;
|
||||
}, [v.legalMoves]);
|
||||
|
||||
// Flip animation, sounds, pass toasts and screen-reader text for new moves (before paint, so no flash).
|
||||
useLayoutEffect(() => {
|
||||
const prev = prevCount.current;
|
||||
prevCount.current = v.moveCount;
|
||||
setPreview(null);
|
||||
if (v.moveCount <= prev) {
|
||||
setAnim(null);
|
||||
return;
|
||||
}
|
||||
const added = v.moves.slice(prev);
|
||||
const texts: string[] = [];
|
||||
for (const m of added) {
|
||||
if (m.kind === 'place') texts.push(`${COLOR_KO[m.color]}이 ${label(m)}에 두어 ${m.flipped.length}개를 뒤집었어요.`);
|
||||
else {
|
||||
const msg =
|
||||
m.color === v.myColor
|
||||
? '둘 곳이 없어서 차례가 상대에게 넘어갔어요'
|
||||
: v.myColor
|
||||
? '상대가 둘 곳이 없어서 내 차례가 한 번 더 와요'
|
||||
: `${COLOR_KO[m.color]}이 둘 곳이 없어서 차례가 넘어갔어요`;
|
||||
texts.push(msg + '.');
|
||||
toast(msg);
|
||||
}
|
||||
}
|
||||
setAnnounce(texts.join(' '));
|
||||
const lm = v.lastMove;
|
||||
if (lm && added.some((m) => m.kind === 'place')) {
|
||||
sounds.stone();
|
||||
const dist = (i: number) => Math.max(Math.abs((i % N) - lm.x), Math.abs(Math.floor(i / N) - lm.y));
|
||||
const maxD = Math.max(1, ...lm.flipped.map(dist));
|
||||
const step = maxD > 1 ? Math.min(60, (400 - FLIP_MS) / (maxD - 1)) : 0;
|
||||
setAnim({ key: v.moveCount, at: lm.y * N + lm.x, from: other(lm.color), delay: new Map(lm.flipped.map((i) => [i, (dist(i) - 1) * step])) });
|
||||
} else setAnim(null);
|
||||
if (v.phase === 'playing' && v.myColor === v.turn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canPlace = (p: Pt) => myTurn && !pending && legal.has(p.y * N + p.x);
|
||||
|
||||
const place = (p: Pt) => {
|
||||
act({ type: 'place', x: p.x, y: p.y });
|
||||
setPreview(null);
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
const refuse = (p: Pt) => {
|
||||
const msg = v.board[p.y * N + p.x] !== 0 ? '이미 돌이 있는 칸이에요.' : '여기에 두면 뒤집을 돌이 없어요.';
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce(msg);
|
||||
toast(msg);
|
||||
};
|
||||
|
||||
const tap = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (!myTurn) return;
|
||||
if (!legal.has(p.y * N + p.x)) {
|
||||
refuse(p);
|
||||
return;
|
||||
}
|
||||
// Mouse: one click (hover already previews). Touch: optional two-step confirm.
|
||||
if (mouse || !tapConfirm) {
|
||||
if (canPlace(p)) place(p);
|
||||
return;
|
||||
}
|
||||
if (preview && preview.x === p.x && preview.y === p.y) place(p);
|
||||
else setPreview(p);
|
||||
};
|
||||
|
||||
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
const x = Math.floor(loc.x);
|
||||
const y = Math.floor(loc.y);
|
||||
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
const next = { x: Math.min(N - 1, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(N - 1, Math.max(0, cursor.y + d[e.key]![1])) };
|
||||
const l = legal.get(next.y * N + next.x);
|
||||
const cell = v.board[next.y * N + next.x];
|
||||
setCursor(next);
|
||||
setAnnounce(`${label(next)} ${cell === 1 ? '흑' : cell === 2 ? '백' : l && v.showLegalMoves ? `둘 수 있음, ${l.flips}개 뒤집힘` : '빈칸'}`);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!myTurn) return;
|
||||
if (canPlace(cursor)) place(cursor);
|
||||
else refuse(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
const target = preview ?? (hover && myTurn ? hover : null);
|
||||
const targetMove = target ? legal.get(target.y * N + target.x) : undefined;
|
||||
const showFlips = !!targetMove && (v.showLegalMoves || !!preview);
|
||||
const flipSet = new Set(showFlips ? targetMove!.flipped : []);
|
||||
const showGhost = !!target && v.myColor && v.board[target.y * N + target.x] === 0 && (!!targetMove || !v.showLegalMoves);
|
||||
const total = v.discs.black + v.discs.white;
|
||||
const pad = 0.55;
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10 }}>
|
||||
<style>{CSS}</style>
|
||||
<div
|
||||
aria-label={`돌 수: 흑 ${v.discs.black}개, 백 ${v.discs.white}개`}
|
||||
role="img"
|
||||
style={{ display: 'flex', height: 30, borderRadius: 15, overflow: 'hidden', border: '2px solid var(--border)', fontWeight: 800, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
<div style={{ flex: `${v.discs.black} 1 0`, minWidth: 48, background: '#161616', color: '#fff', display: 'flex', alignItems: 'center', paddingLeft: 12, transition: 'flex-grow 0.4s' }}>
|
||||
● {v.discs.black}
|
||||
</div>
|
||||
<div
|
||||
style={{ flex: `${total ? v.discs.white : 1} 1 0`, minWidth: 48, background: '#fafaf7', color: '#161616', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: 12, transition: 'flex-grow 0.4s' }}
|
||||
>
|
||||
{v.discs.white} ○
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-pad} ${-pad} ${N + 2 * pad} ${N + 2 * pad}`}
|
||||
style={{ width: '100%', display: 'block', background: '#123d2b', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||
const p = pointToCell(e);
|
||||
if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p);
|
||||
}}
|
||||
onPointerLeave={() => setHover(null)}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointToCell(e);
|
||||
if (p) tap(p, e.pointerType === 'mouse');
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`오셀로 판 8×8. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${label(cursor)}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<rect x={0} y={0} width={N} height={N} fill="var(--felt)" />
|
||||
{Array.from({ length: N + 1 }, (_, i) => (
|
||||
<g key={i} stroke="#0b2a1d" strokeWidth={i === 0 || i === N ? 0.05 : 0.03} opacity={0.75}>
|
||||
<line x1={0} y1={i} x2={N} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={N} />
|
||||
</g>
|
||||
))}
|
||||
{[2, 6].flatMap((x) => [2, 6].map((y) => <circle key={`s${x}${y}`} cx={x} cy={y} r={0.07} fill="#0b2a1d" opacity={0.8} />))}
|
||||
{Array.from({ length: N }, (_, i) => (
|
||||
<g key={`c${i}`} fill="#d7eadf" fontSize={0.3} fontWeight={700} textAnchor="middle" dominantBaseline="central">
|
||||
<text x={i + 0.5} y={-0.28}>
|
||||
{'abcdefgh'[i]}
|
||||
</text>
|
||||
<text x={-0.28} y={i + 0.5}>
|
||||
{i + 1}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{myTurn &&
|
||||
v.showLegalMoves &&
|
||||
[...legal.values()].map((l) =>
|
||||
v.showFlipCount ? (
|
||||
<g key={`l${l.x}-${l.y}`}>
|
||||
<circle cx={l.x + 0.5} cy={l.y + 0.5} r={0.2} fill={v.myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)'} />
|
||||
<text x={l.x + 0.5} y={l.y + 0.52} fontSize={0.26} fontWeight={800} textAnchor="middle" dominantBaseline="central" fill={v.myColor === 'black' ? '#fff' : '#111'}>
|
||||
{l.flips}
|
||||
</text>
|
||||
</g>
|
||||
) : (
|
||||
<circle key={`l${l.x}-${l.y}`} cx={l.x + 0.5} cy={l.y + 0.5} r={0.14} fill={v.myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)'} />
|
||||
),
|
||||
)}
|
||||
{v.board.map((c, i) => {
|
||||
if (c === 0) return null;
|
||||
const color: Color = c === 1 ? 'black' : 'white';
|
||||
const cx = (i % N) + 0.5;
|
||||
const cy = Math.floor(i / N) + 0.5;
|
||||
const delay = anim?.delay.get(i);
|
||||
if (anim && delay !== undefined) {
|
||||
return (
|
||||
<g key={`a${anim.key}-${i}`} className="oth-flip" style={{ animationDelay: `${delay}ms` }}>
|
||||
<Disc color={color} cx={cx} cy={cy} />
|
||||
<g className="oth-old" style={{ animationDelay: `${delay}ms` }}>
|
||||
<Disc color={anim.from} cx={cx} cy={cy} />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (anim && anim.at === i) {
|
||||
return (
|
||||
<g key={`a${anim.key}-${i}`} className="oth-drop">
|
||||
<Disc color={color} cx={cx} cy={cy} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return <Disc key={i} color={color} cx={cx} cy={cy} />;
|
||||
})}
|
||||
{[...flipSet].map((i) => (
|
||||
<circle key={`h${i}`} cx={(i % N) + 0.5} cy={Math.floor(i / N) + 0.5} r={0.44} fill="rgba(255, 224, 102, 0.45)" stroke="#ffd84d" strokeWidth={0.05} />
|
||||
))}
|
||||
{v.lastMove && <circle cx={v.lastMove.x + 0.5} cy={v.lastMove.y + 0.5} r={0.1} fill="#ef4444" stroke="#fff" strokeWidth={0.025} />}
|
||||
{showGhost && target && v.myColor && <Disc color={v.myColor} cx={target.x + 0.5} cy={target.y + 0.5} ghost />}
|
||||
{myTurn && kbd && <rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.08} fill="none" stroke="var(--focus)" strokeWidth={0.07} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{preview && myTurn && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
|
||||
{`${label(preview)}에 두기${targetMove ? ` (${targetMove.flips}개 뒤집기)` : ''}`}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right-hand panel on PC (below the board on narrow screens). */
|
||||
function OthelloSide({ view: v, me, players, act, pending, deadline }: BoardProps<OthelloView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const incoming =
|
||||
v.phase === 'playing' && v.myColor
|
||||
? v.undo.pending && v.undo.pending.by !== v.myColor
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
|
||||
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
|
||||
const top: Color = v.myColor ? other(v.myColor) : 'black';
|
||||
const bottom: Color = v.myColor ?? 'white';
|
||||
const undoPlaces = v.undo.pending ? v.moves.slice(v.moves.length - v.undo.pending.plies).filter((m) => m.kind === 'place').length : 0;
|
||||
return (
|
||||
<>
|
||||
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && v.myColor && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.kind === 'place' && m.color === v.myColor)}
|
||||
onClick={() => act({ type: 'requestUndo' })}
|
||||
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
|
||||
>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{outgoing && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">상대의 답을 기다리는 중…</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && (
|
||||
<Sheet onClose={() => {}} label="상대의 요청">
|
||||
<h2>{incoming === 'undo' ? '상대가 무르기를 요청했어요' : '상대가 무승부를 제안했어요'}</h2>
|
||||
<p className="muted">
|
||||
{incoming === 'undo' ? `수락하면 상대가 마지막으로 둔 수 전으로 돌아가요(돌 ${undoPlaces}수).` : '수락하면 이 판은 무승부로 끝나요.'}
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: OthelloView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
let n = 0;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moves.filter((m) => m.kind === 'place').length}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className="small">
|
||||
{m.kind === 'place' ? (
|
||||
<>
|
||||
<span className="muted">{++n}.</span> {m.color === 'black' ? '●' : '○'} {label(m)}
|
||||
{m.auto ? '*' : ''}
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">
|
||||
{m.color === 'black' ? '●' : '○'} 패스
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerBar({ view: v, color, players, deadline, me }: { view: OthelloView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const id = v.players[color];
|
||||
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === color;
|
||||
const now = useServerNow(500);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (tc.kind === 'fischer') {
|
||||
const ms = Math.max(0, v.remainingMs[color] - (active ? now - v.turnStartedAt : 0));
|
||||
const s = Math.ceil(ms / 1000);
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
|
||||
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
|
||||
}
|
||||
const score = v.result ? v.result.displayScore[color] : v.discs[color];
|
||||
return (
|
||||
<div className="card row" style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}>
|
||||
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
|
||||
<Disc color={color} cx={0} cy={0} r={0.45} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{COLOR_KO[color]}
|
||||
{active ? ' · 두는 중' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span aria-label={`${COLOR_KO[color]} 돌 ${score}개`} style={{ fontWeight: 800, fontSize: '1.4rem', fontVariantNumeric: 'tabular-nums', minWidth: '2ch', textAlign: 'right' }}>
|
||||
{score}
|
||||
</span>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: OthelloView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
|
||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||
if (v.myColor === v.turn) {
|
||||
return v.showLegalMoves ? `내 차례예요 (${COLOR_KO[v.turn]}). 점이 있는 칸에 둘 수 있어요` : `내 차례예요 (${COLOR_KO[v.turn]}). 상대 돌을 끼울 수 있는 칸에 둬요`;
|
||||
}
|
||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, Partial<OthelloOptions['timeControl']>][] = [
|
||||
['pm10', '한 수 10초', { kind: 'perMove', perMoveSec: 10 }],
|
||||
['pm20', '한 수 20초', { kind: 'perMove', perMoveSec: 20 }],
|
||||
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||
['f10', '10분 + 5초', { kind: 'fischer', baseSec: 600, incSec: 5 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: OthelloOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||
return `f${Math.round(tc.baseSec / 60)}`;
|
||||
}
|
||||
|
||||
function timeLabel(tc: OthelloOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||
const m = Math.floor(tc.baseSec / 60);
|
||||
const sec = tc.baseSec % 60;
|
||||
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<OthelloOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<OthelloOptions>) => onChange({ ...o, ...patch });
|
||||
const onOff = (v: boolean) => (v ? 'on' : 'off');
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={timeKey(o.timeControl)}
|
||||
options={[
|
||||
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
|
||||
...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]),
|
||||
]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>흑(먼저 두는 쪽)</strong>
|
||||
<Seg
|
||||
value={o.colorAssignment}
|
||||
options={[['random', '무작위'], ['hostBlack', '방장이 흑'], ['hostWhite', '방장이 백'], ['alternate', '판마다 번갈아']]}
|
||||
onChange={(colorAssignment) => set({ colorAssignment })}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>둘 수 있는 칸</strong>
|
||||
<Seg value={onOff(o.showLegalMoves)} options={[['on', '점으로 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showLegalMoves: k === 'on' })} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>뒤집히는 개수 숫자</strong>
|
||||
<Seg value={onOff(o.showFlipCount)} options={[['on', '보여 주기(초보 도움)'], ['off', '안 보여 주기']]} onChange={(k) => set({ showFlipCount: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>점수 계산</strong>
|
||||
<Seg value={onOff(o.emptyToWinner)} options={[['off', '돌 개수 그대로'], ['on', '남은 빈칸은 이긴 쪽에(대회 방식)']]} onChange={(k) => set({ emptyToWinner: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무르기</strong>
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
{o.timeControl.kind === 'perMove' && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간이 다 되면</strong>
|
||||
<Seg value={o.timeoutPolicy} options={[['autoMove', '자동으로 두기'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||
</div>
|
||||
)}
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>무승부 제안</strong>
|
||||
<Seg value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: OthelloOptions): string {
|
||||
return ['8×8', timeLabel(o.timeControl), o.showLegalMoves ? '둘 곳 표시' : '둘 곳 숨김', ...(o.emptyToWinner ? ['대회 점수'] : [])].join(' · ');
|
||||
}
|
||||
|
||||
/** One-row diagram for the rules: cells are 'B' | 'W' | '.' with optional highlighted cell. */
|
||||
function RowDiagram({ cells, mark, caption }: { cells: string; mark?: number; caption: string }) {
|
||||
return (
|
||||
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||
<svg viewBox={`0 0 ${cells.length} 1`} style={{ width: '100%', maxWidth: 220, display: 'block', margin: '0 auto', background: 'var(--felt)', borderRadius: 6 }} aria-hidden>
|
||||
{cells.split('').map((c, i) => (
|
||||
<g key={i}>
|
||||
<rect x={i} y={0} width={1} height={1} fill="none" stroke="#0b2a1d" strokeWidth={0.04} />
|
||||
{c !== '.' && <Disc color={c === 'B' ? 'black' : 'white'} cx={i + 0.5} cy={0.5} r={0.38} />}
|
||||
</g>
|
||||
))}
|
||||
{mark !== undefined && <circle cx={mark + 0.5} cy={0.5} r={0.1} fill="#ef4444" stroke="#fff" strokeWidth={0.025} />}
|
||||
</svg>
|
||||
<figcaption className="small muted" style={{ marginTop: 4 }}>
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: OthelloOptions }) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
흑과 백이 번갈아 8×8 판의 빈칸에 돌을 하나씩 놓아요. 흑이 먼저 둬요. 오셀로와 같은 규칙이에요. 게임이 끝났을 때 <strong>내 색 돌이 더 많으면</strong> 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>끼워서 뒤집기</strong>: 상대 돌을 내 돌 두 개 사이에 끼우면, 끼인 상대 돌이 모두 내 색으로 뒤집혀요. 가로, 세로, 대각선 어느 방향이든 돼요.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}>
|
||||
<RowDiagram cells="BWW." caption="① 흑 돌 옆에 백 돌이 있어요" />
|
||||
<RowDiagram cells="BWWB" mark={3} caption="② 끝에 흑을 놓아 백을 끼워요" />
|
||||
<RowDiagram cells="BBBB" mark={3} caption="③ 끼인 백이 흑으로 뒤집혀요" />
|
||||
</div>
|
||||
<p>상대 돌을 하나도 뒤집을 수 없는 칸에는 둘 수 없어요. 뒤집을 수 있는 칸이 있으면 꼭 그중 한 곳에 둬야 해요.</p>
|
||||
<p>
|
||||
<strong>패스</strong>: 둘 곳이 하나도 없으면 자동으로 차례가 상대에게 넘어가요. 누를 것은 없어요. 둘 다 둘 곳이 없으면(판이 가득 차도) 게임이 끝나요.
|
||||
</p>
|
||||
{options?.emptyToWinner && <p>이 방은 대회 방식이라, 빈칸이 남은 채 끝나면 남은 빈칸 수를 이긴 쪽 점수에 더해요(승패는 그대로예요).</p>}
|
||||
<p>판의 작은 점은 지금 둘 수 있는 칸이에요(설정에서 끌 수 있어요). PC에서는 마우스를 올리면 뒤집힐 돌이 노랗게 보이고, 한 번 누르면 놓여요.</p>
|
||||
<p className="muted">팁: 네 귀퉁이(모서리)에 놓은 돌은 다시 뒤집히지 않아서 아주 유리해요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<OthelloView, OthelloOptions> = {
|
||||
Board: OthelloBoard,
|
||||
Side: OthelloSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
990
apps/web/src/games/poker/index.tsx
Normal file
990
apps/web/src/games/poker/index.tsx
Normal file
@@ -0,0 +1,990 @@
|
||||
/** 포커 화면 (docs/games/poker.md §9). Chips are room-only play points. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { BetActionType, LegalBetView, PokerMode, PokerOptions, PokerView, SeatView, Side, ViewCard } from '@bg/games/poker';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { PlayingCard } from '../../components/cards/PlayingCard';
|
||||
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const MODE_KO: Record<PokerMode, string> = {
|
||||
holdem: '텍사스 홀덤',
|
||||
omaha: '오마하',
|
||||
sevenPoker: '7포커',
|
||||
sevenHiLo: '7포커 하이로우',
|
||||
badugi: '바둑이',
|
||||
fiveDraw: '5카드 드로우',
|
||||
indian: '인디언 포커',
|
||||
};
|
||||
const KOREAN_ORDER: BetActionType[] = ['k.die', 'k.check', 'k.ping', 'k.call', 'k.ddadang', 'k.quarter', 'k.half', 'k.full'];
|
||||
const BET_KO: Record<BetActionType, string> = {
|
||||
'k.check': '체크',
|
||||
'k.ping': '삥',
|
||||
'k.call': '콜',
|
||||
'k.ddadang': '따당',
|
||||
'k.quarter': '쿼터',
|
||||
'k.half': '하프',
|
||||
'k.full': '풀',
|
||||
'k.die': '다이',
|
||||
'nl.fold': '폴드',
|
||||
'nl.check': '체크',
|
||||
'nl.call': '콜',
|
||||
'nl.raise': '레이즈',
|
||||
'nl.allIn': '올인',
|
||||
};
|
||||
const BET_HELP: Partial<Record<BetActionType, string>> = {
|
||||
'k.check': '칩을 걸지 않고 넘겨요',
|
||||
'k.ping': '기본 칩만큼 걸어요',
|
||||
'k.call': '앞사람만큼 맞춰요',
|
||||
'k.ddadang': '앞사람이 건 만큼의 두 배',
|
||||
'k.quarter': '모인 칩의 4분의 1만큼 더',
|
||||
'k.half': '모인 칩의 절반만큼 더',
|
||||
'k.full': '모인 칩만큼 더',
|
||||
'k.die': '이번 판을 포기해요',
|
||||
};
|
||||
const SIDE_KO: Record<Side, string> = { high: '하이', low: '로우', swing: '스윙' };
|
||||
const AWARD_KO = { high: '하이', low: '로우', scoop: '스윙 성공', carry: '넘어온 칩', penalty: '벌칙 칩', refund: '돌려받음' } as const;
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
|
||||
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const isCommunity = (m: PokerMode) => m === 'holdem' || m === 'omaha';
|
||||
const isSevenMode = (m: PokerMode) => m === 'sevenPoker' || m === 'sevenHiLo';
|
||||
const isDrawMode = (m: PokerMode) => m === 'badugi' || m === 'fiveDraw';
|
||||
/** Rank of a card id for the client-side "A를 남기면 4장" check. */
|
||||
const isAce = (c: string) => c[0] === 'A';
|
||||
|
||||
const CSS = `
|
||||
.pk-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
|
||||
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
|
||||
.pk-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 172px); z-index: 1;
|
||||
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
|
||||
.pk-seat.turn { border-color: var(--focus); }
|
||||
.pk-seat.me { border-color: var(--primary); }
|
||||
.pk-seat.me.turn { border-color: var(--focus); }
|
||||
.pk-seat.win { border-color: #f59e0b; }
|
||||
.pk-seat.out { opacity: 0.6; }
|
||||
.pk-center { position: absolute; left: 50%; top: 48%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 52%; }
|
||||
.pk-board { display: flex; gap: 6px; justify-content: center; margin: 6px 0; }
|
||||
.pk-slot { width: 64px; height: 90px; border-radius: 8px; border: 2px dashed rgba(255,255,255,.35); flex: none; }
|
||||
.pk-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.pk-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; font-size: 1.1rem; }
|
||||
.pk-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
|
||||
.pk-mine { display: flex; gap: 4px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
|
||||
.pk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
|
||||
.pk-x { position: relative; }
|
||||
.pk-x::after { content: '✕'; position: absolute; inset: 0; display: grid; place-items: center; color: #dc2626; font-size: 3rem; font-weight: 900; pointer-events: none; }
|
||||
.pk-raise { display: grid; gap: 8px; }
|
||||
.pk-raise input[type=range] { width: 100%; min-height: 40px; }
|
||||
@media (max-width: 719px) {
|
||||
.pk-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
|
||||
.pk-seat { position: static; transform: none; width: auto; }
|
||||
.pk-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
|
||||
.pk-slot { width: 44px; height: 62px; }
|
||||
.pk-actions { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- seats
|
||||
|
||||
function seatBadges(seat: SeatView, v: PokerView): [string, string][] {
|
||||
const b: [string, string][] = [];
|
||||
if (seat.status === 'left') b.push(['나감', 'var(--danger)']);
|
||||
else if (seat.away) b.push(['자리 비움', 'var(--warning)']);
|
||||
if (seat.status === 'eliminated') b.push(['탈락', 'var(--text-sub)']);
|
||||
if (seat.status === 'busted' || seat.rebuyPending) b.push(['리바이 대기', 'var(--warning)']);
|
||||
if (seat.folded) b.push([v.betStyle === 'korean' ? '다이' : '폴드', 'var(--danger)']);
|
||||
if (seat.allIn) b.push(['올인', 'var(--primary)']);
|
||||
const contesting = seat.inHand && !seat.folded;
|
||||
if (contesting && v.handPhase === 'choice') b.push(seat.choiceDone ? ['고름', 'var(--success)'] : ['고르는 중', 'var(--text-sub)']);
|
||||
if (contesting && v.handPhase === 'draw') b.push(seat.drawDone ? ['냄', 'var(--success)'] : ['바꿀 카드 고르는 중', 'var(--text-sub)']);
|
||||
if (contesting && v.handPhase === 'declare') b.push(seat.declared ? ['선언함', 'var(--success)'] : ['선언 고르는 중', 'var(--text-sub)']);
|
||||
if (seat.declare) b.push([`선언: ${SIDE_KO[seat.declare]}`, '#7c3aed']);
|
||||
return b;
|
||||
}
|
||||
|
||||
function SeatCards({ seat, v, me }: { seat: SeatView; v: PokerView; me: string | null }) {
|
||||
const sd = v.showdown?.find((e) => e.id === seat.id);
|
||||
const cards: { c: ViewCard; key: string; title?: string }[] = [];
|
||||
if (seat.forehead !== undefined) cards.push({ c: seat.forehead, key: 'f', title: seat.forehead ? undefined : seat.id === me ? '내 이마 카드(나만 못 봐요)' : '이마 카드' });
|
||||
// Hidden cards arrive as null; at showdown the view already reveals them.
|
||||
seat.hole.forEach((c, i) => cards.push({ c, key: `h${i}` }));
|
||||
for (let i = 0; i < seat.choiceCount; i++) cards.push({ c: null, key: `c${i}` });
|
||||
seat.up.forEach((c, i) => cards.push({ c, key: `u${i}` }));
|
||||
if (!cards.length) return null;
|
||||
const best = new Set(sd?.best ?? []);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'nowrap', justifyContent: 'center' }}>
|
||||
{cards.map((x, i) => (
|
||||
<PlayingCard
|
||||
key={x.key}
|
||||
card={x.c}
|
||||
size="sm"
|
||||
highlight={!!x.c && best.has(x.c)}
|
||||
title={x.title}
|
||||
style={i > 0 ? { marginLeft: cards.length > 5 ? -24 : -16 } : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeatBox({ seat, v, players, me, deadline, x, y }: { seat: SeatView; v: PokerView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
|
||||
const u = userOf(players, seat.id);
|
||||
const badges = seatBadges(seat, v);
|
||||
const waiting = v.waiting.includes(seat.id) && v.handPhase !== 'handEnd';
|
||||
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
|
||||
const won = v.handPhase === 'handEnd' && v.winners.includes(seat.id);
|
||||
const sd = v.showdown?.find((e) => e.id === seat.id);
|
||||
const marks: [string, string, string][] = [];
|
||||
if (seat.dealer) marks.push(['D', '#facc15', '딜러']);
|
||||
if (seat.sb) marks.push(['SB', '#93c5fd', '스몰 블라인드']);
|
||||
if (seat.bb) marks.push(['BB', '#60a5fa', '빅 블라인드']);
|
||||
if (seat.boss) marks.push(['보스', '#fb923c', '보스(먼저 베팅)']);
|
||||
return (
|
||||
<div
|
||||
className={`pk-seat${waiting ? ' turn' : ''}${seat.id === me ? ' me' : ''}${won ? ' win' : ''}${out ? ' out' : ''}`}
|
||||
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
|
||||
aria-label={`${u.nickname} ${formatChips(seat.stack)}${marks.length ? `, ${marks.map((m) => m[2]).join(', ')}` : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
|
||||
>
|
||||
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={u} size={22} />
|
||||
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{seat.id === me && <span className="muted"> (나)</span>}
|
||||
</strong>
|
||||
{marks.map(([t, c, title]) => (
|
||||
<span key={t} className="pk-badge" style={{ background: c, color: '#1f2937' }} title={title}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{waiting && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={seat.id === me} size={28} />}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, justifyContent: 'space-between' }}>
|
||||
<Chips n={seat.stack} strong />
|
||||
{seat.roundBet > 0 && <span className="small" title="이번 라운드에 건 칩">+{seat.roundBet.toLocaleString('ko-KR')}</span>}
|
||||
</div>
|
||||
{seat.lastAction && !seat.folded && <span className="small muted">{seat.lastAction}</span>}
|
||||
{seat.drawCounts.length > 0 && <span className="small muted">바꾼 장수: {seat.drawCounts.join(' · ')}</span>}
|
||||
{badges.length > 0 && (
|
||||
<div className="row" style={{ gap: 3 }}>
|
||||
{badges.map(([t, c]) => (
|
||||
<span key={t} className="pk-badge" style={{ background: c, color: '#fff' }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SeatCards seat={seat} v={v} me={me} />
|
||||
{sd && (
|
||||
<strong style={{ color: 'var(--primary)', textAlign: 'center' }}>
|
||||
{sd.handName}
|
||||
{sd.lowName !== undefined && <span className="small"> / 로우 {sd.lowName ?? '없음'}</span>}
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterInfo({ v, players }: { v: PokerView; players: Record<string, PublicUser> }) {
|
||||
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
|
||||
const penalty = v.penalties.reduce((a, p) => a + p.amount, 0);
|
||||
return (
|
||||
<div className="pk-center">
|
||||
{v.streetName && <div className="pk-badge" style={{ background: '#fde047', color: '#422006', fontSize: '0.95rem', padding: '2px 12px' }}>{v.streetName}</div>}
|
||||
{isCommunity(v.mode) && (
|
||||
<div className="pk-board" aria-label="공용 카드">
|
||||
{Array.from({ length: 5 }, (_, i) => (v.board[i] ? <PlayingCard key={v.board[i]} card={v.board[i]!} size="md" /> : <span key={`e${i}`} className="pk-slot" aria-hidden />))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
|
||||
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
|
||||
{formatChips(v.pot)}
|
||||
</div>
|
||||
{v.pots.length > 1 && (
|
||||
<div className="small" style={{ opacity: 0.9 }}>
|
||||
{v.pots.map((p, i) => (
|
||||
<div key={i}>
|
||||
{i === 0 ? '메인' : `사이드 ${i}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(v.carryOver > 0 || penalty > 0) && (
|
||||
<div className="small" style={{ opacity: 0.9 }}>
|
||||
{v.carryOver > 0 && `지난 판에서 넘어온 칩 ${formatChips(v.carryOver)}`}
|
||||
{v.carryOver > 0 && penalty > 0 && ' · '}
|
||||
{penalty > 0 && `벌칙 칩 ${formatChips(penalty)}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 기본 {formatChips(v.baseUnit)}
|
||||
{v.level > 1 ? ` (레벨 ${v.level})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function PokerBoard({ view: v, me, players, act, pending, deadline }: BoardProps<PokerView>) {
|
||||
const n = v.seats.length;
|
||||
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevWaiting = useRef(false);
|
||||
const mine = isMyTurn(v, me);
|
||||
const showKey = `${v.handNo}-${v.handPhase}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (mine && !prevWaiting.current) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
} else if (v.toAct && v.toAct !== me) setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
|
||||
prevWaiting.current = mine;
|
||||
}, [mine, v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (v.handPhase !== 'handEnd') return;
|
||||
setAnnounce(resultLine(v, players));
|
||||
if (me && v.winners.includes(me)) sounds.win();
|
||||
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 12 }}>
|
||||
<style>{CSS}</style>
|
||||
<div className="pk-table" role="region" aria-label="포커 테이블" style={{ '--seat-w': n > 6 ? '150px' : '176px' } as React.CSSProperties}>
|
||||
{v.seats.map((s, i) => {
|
||||
const rel = (i - myIdx + n) % n;
|
||||
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
|
||||
return <SeatBox key={s.id} seat={s} v={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
|
||||
})}
|
||||
<CenterInfo v={v} players={players} />
|
||||
</div>
|
||||
<MyArea v={v} me={me} players={players} act={act} pending={pending} />
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resultLine(v: PokerView, players: Record<string, PublicUser>): string {
|
||||
if (!v.awards) return '';
|
||||
const won = new Map<string, number>();
|
||||
for (const a of v.awards) if (a.side !== 'refund') won.set(a.to, (won.get(a.to) ?? 0) + a.amount);
|
||||
if (!won.size) return v.carryOver > 0 ? `동점이라 ${formatChips(v.carryOver)}이 다음 판으로 넘어가요` : '이번 판은 칩을 모두 돌려받았어요';
|
||||
const parts = [...won].map(([id, amt]) => {
|
||||
const name = v.showdown?.find((e) => e.id === id)?.handName;
|
||||
return `${nick(players, id)}님이 ${name ? `${name}(으)로 ` : ''}${formatChips(amt)}을 가져갔어요`;
|
||||
});
|
||||
return parts.join(' / ') + (v.foldWin ? ' (모두 포기)' : '');
|
||||
}
|
||||
|
||||
function ResultPanel({ v, players }: { v: PokerView; players: Record<string, PublicUser> }) {
|
||||
if (!v.awards) return null;
|
||||
return (
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
|
||||
<strong>이번 판 결과</strong>
|
||||
{v.foldWin && <span className="small muted">한 명만 남아서 패는 공개하지 않아요.</span>}
|
||||
{v.awards.map((a, i) => {
|
||||
const sd = v.showdown?.find((e) => e.id === a.to);
|
||||
const hand = !sd ? '' : a.side === 'low' ? (sd.lowName ? `로우 ${sd.lowName}` : '') : !a.side || a.side === 'high' || a.side === 'scoop' ? sd.handName : '';
|
||||
return (
|
||||
<span key={i}>
|
||||
<strong>{nick(players, a.to)}</strong> {formatChips(a.amount)}
|
||||
{hand && ` · ${hand}`}
|
||||
<span className="muted">
|
||||
{v.awards!.some((x) => x.potIndex > 0) ? ` · ${a.potIndex === 0 ? '메인' : `사이드 ${a.potIndex}`}` : ''}
|
||||
{a.side ? ` · ${AWARD_KO[a.side]}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{v.awards.every((a) => a.side === 'refund') && v.carryOver > 0 && <span>동점이라 {formatChips(v.carryOver)}이 다음 판으로 넘어가요.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- my area
|
||||
|
||||
interface AreaProps {
|
||||
v: PokerView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
function MyArea({ v, me, players, act, pending }: AreaProps) {
|
||||
const seat = v.seats.find((s) => s.id === me);
|
||||
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel v={v} players={players} /> : null;
|
||||
const m = v.my;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
{seat.inHand && (
|
||||
<div className="card stack" style={{ gap: 10, padding: 12 }}>
|
||||
{m.canChoose && m.choice ? (
|
||||
<ChoicePicker key={v.handNo} cards={m.choice} act={act} pending={pending} />
|
||||
) : m.canDraw ? (
|
||||
<DrawPicker key={`${v.handNo}-${seat.drawCounts.length}`} v={v} hole={seat.hole.filter((c): c is string => !!c)} act={act} pending={pending} />
|
||||
) : (
|
||||
<MyCards v={v} seat={seat} />
|
||||
)}
|
||||
{(m.handName || m.lowName) && (
|
||||
<div style={{ textAlign: 'center', fontSize: '1.4rem', fontWeight: 900 }}>
|
||||
지금: {m.handName}
|
||||
{m.lowName && <span className="small" style={{ marginLeft: 8 }}>로우 {m.lowName}</span>}
|
||||
</div>
|
||||
)}
|
||||
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 포기했어요. 내 카드는 나만 볼 수 있어요.</p>}
|
||||
{v.handPhase === 'choice' && seat.choiceDone && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
|
||||
{v.handPhase === 'draw' && seat.drawDone && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>바꿀 카드를 냈어요. 다른 사람을 기다리는 중이에요.</p>}
|
||||
{m.canDeclare && <DeclarePicker v={v} act={act} pending={pending} />}
|
||||
{v.handPhase === 'declare' && seat.declared && !m.canDeclare && (
|
||||
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{m.declare ? `${SIDE_KO[m.declare]}(으)로 선언했어요. ` : ''}다른 사람을 기다리는 중이에요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{seat.inHand && v.handPhase === 'betting' && !seat.folded && !seat.allIn && <BetBar v={v} me={me} players={players} act={act} pending={pending} />}
|
||||
{v.handPhase === 'handEnd' && <ResultPanel v={v} players={players} />}
|
||||
{m.canReady && (
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={pending} onClick={() => act({ type: 'ready' })}>
|
||||
다음 판으로
|
||||
</button>
|
||||
)}
|
||||
{v.handPhase === 'handEnd' && seat.inHand && seat.ready && seat.status !== 'left' && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>다른 사람을 기다리는 중…</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MyCards({ v, seat }: { v: PokerView; seat: SeatView }) {
|
||||
const sd = v.showdown?.find((e) => e.id === seat.id);
|
||||
const best = new Set(sd?.best ?? []);
|
||||
return (
|
||||
<div className="pk-mine" aria-label="내 카드">
|
||||
{seat.forehead !== undefined && (
|
||||
<div className="stack" style={{ gap: 2, alignItems: 'center' }}>
|
||||
<PlayingCard card={seat.forehead} size="lg" title={seat.forehead ? undefined : '내 이마 카드(나만 못 봐요)'} />
|
||||
<span className="small muted">이마</span>
|
||||
</div>
|
||||
)}
|
||||
{seat.hole.map((c, i) => (
|
||||
<div key={`h${i}`} className="stack" style={{ gap: 2, alignItems: 'center' }}>
|
||||
<PlayingCard card={c} size="lg" highlight={!!c && best.has(c)} />
|
||||
{(isSevenMode(v.mode) || v.mode === 'indian') && <span className="small muted">{v.mode === 'indian' ? '손' : '나만 봄'}</span>}
|
||||
</div>
|
||||
))}
|
||||
{seat.up.map((c, i) => (
|
||||
<div key={`u${i}`} className="stack" style={{ gap: 2, alignItems: 'center' }}>
|
||||
<PlayingCard card={c} size="lg" highlight={!!c && best.has(c)} />
|
||||
<span className="small muted">공개</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 7포커 초이스: 1) 버릴 카드 2) 보여 줄 카드 → 확인 (§9). */
|
||||
function ChoicePicker({ cards, act, pending }: { cards: string[]; act: (a: unknown) => void; pending: boolean }) {
|
||||
const [discard, setDiscard] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const tap = (c: string) => {
|
||||
if (!discard) setDiscard(c);
|
||||
else if (c === discard) {
|
||||
setDiscard(null);
|
||||
setOpen(null);
|
||||
} else setOpen(open === c ? null : c);
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>{!discard ? '1단계: 버릴 카드를 누르세요' : !open ? '2단계: 모두에게 보여 줄 카드를 누르세요' : '확인을 누르면 정해져요'}</strong>
|
||||
<br />
|
||||
<span className="small muted">버린 카드는 아무에게도 보이지 않아요. 남는 2장은 나만 봐요.</span>
|
||||
</p>
|
||||
<div className="pk-mine">
|
||||
{cards.map((c) => (
|
||||
<span key={c} className={c === discard ? 'pk-x' : undefined}>
|
||||
<PlayingCard card={c} size="lg" selected={c === open} dimmed={c === discard} onClick={() => tap(c)} title={c === discard ? '버릴 카드(다시 누르면 취소)' : c === open ? '보여 줄 카드' : undefined} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!discard}
|
||||
onClick={() => {
|
||||
setDiscard(null);
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
다시 고르기
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!discard || !open || pending} onClick={() => act({ type: 'choose', discardId: discard, openId: open })}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 교환(바둑이 / 5카드 드로우): tap to lift the cards to change. */
|
||||
function DrawPicker({ v, hole, act, pending }: { v: PokerView; hole: string[]; act: (a: unknown) => void; pending: boolean }) {
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const { drawMax, drawAceBonus } = v.my;
|
||||
const keepsAce = hole.some((c) => isAce(c) && !sel.includes(c));
|
||||
const limit = drawAceBonus && keepsAce ? drawMax + 1 : drawMax;
|
||||
const tooMany = sel.length > limit;
|
||||
const round = v.seats.find((s) => s.id === v.me)?.drawCounts.length ?? 0;
|
||||
const label = v.mode === 'badugi' ? (['아침', '점심', '저녁'][round] ?? '교환') : '교환';
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>{label}: 바꿀 카드를 누르세요</strong>
|
||||
<br />
|
||||
<span className="small muted">
|
||||
{drawAceBonus ? '3장까지 바꿀 수 있어요(A를 남기면 4장).' : `${drawMax}장까지 바꿀 수 있어요.`} 모두 동시에 내고, 버린 카드는 공개되지 않아요.
|
||||
</span>
|
||||
</p>
|
||||
<div className="pk-mine">
|
||||
{hole.map((c) => (
|
||||
<PlayingCard key={c} card={c} size="lg" selected={sel.includes(c)} onClick={() => setSel((s) => (s.includes(c) ? s.filter((x) => x !== c) : [...s, c]))} />
|
||||
))}
|
||||
</div>
|
||||
{tooMany && <p className="field-error" style={{ margin: 0, textAlign: 'center' }}>{limit}장까지만 바꿀 수 있어요.</p>}
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={pending || tooMany} onClick={() => act({ type: 'draw', cardIds: sel })}>
|
||||
{sel.length === 0 ? '패스(안 바꾸기)' : `${sel.length}장 바꾸기`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeclarePicker({ v, act, pending }: { v: PokerView; act: (a: unknown) => void; pending: boolean }) {
|
||||
const sub: Record<Side, string> = {
|
||||
high: v.my.handName ?? '',
|
||||
low: v.my.lowName ?? '로우 없음',
|
||||
swing: '하이·로우 둘 다 이겨야 해요',
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>하이·로우·스윙 중 하나를 선언하세요.</strong> <span className="small muted">모두 고른 뒤 한꺼번에 공개돼요.</span>
|
||||
</p>
|
||||
<div className="pk-actions" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
{(['high', 'low', 'swing'] as Side[]).map((side) => (
|
||||
<button key={side} type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'declare', side })}>
|
||||
<span>{SIDE_KO[side]}</span>
|
||||
<small>{sub[side]}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BetBar({ v, me, players, act, pending }: AreaProps & { me: string }) {
|
||||
const myTurn = v.toAct === me;
|
||||
const legal = new Map<BetActionType, LegalBetView>(v.my.legal.map((l) => [l.type, l]));
|
||||
const [hover, setHover] = useState<BetActionType | null>(null);
|
||||
const [confirmFold, setConfirmFold] = useState<BetActionType | null>(null);
|
||||
const [raising, setRaising] = useState(false);
|
||||
const seat = v.seats.find((s) => s.id === me)!;
|
||||
useEffect(() => setRaising(false), [v.handNo, v.street, myTurn]);
|
||||
|
||||
const press = (t: BetActionType) => {
|
||||
if ((t === 'k.die' || t === 'nl.fold') && v.my.toCall === 0) return setConfirmFold(t);
|
||||
if (t === 'nl.raise') return setRaising(true);
|
||||
act({ type: t });
|
||||
};
|
||||
|
||||
const korean = v.betStyle === 'korean';
|
||||
const types: BetActionType[] = korean
|
||||
? KOREAN_ORDER.filter((t) => t !== 'k.full' || v.options.koreanMaxRaise === 'full')
|
||||
: ['nl.fold', legal.has('nl.call') ? 'nl.call' : 'nl.check', 'nl.raise', 'nl.allIn'];
|
||||
const hoverBet = hover ? legal.get(hover) : undefined;
|
||||
const raise = legal.get('nl.raise');
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{raising && raise ? (
|
||||
<RaisePanel v={v} seat={seat} raise={raise} act={act} pending={pending} onCancel={() => setRaising(false)} />
|
||||
) : (
|
||||
<div className="pk-actions" role="group" aria-label="베팅">
|
||||
{types.map((t) => {
|
||||
const l = legal.get(t);
|
||||
const enabled = myTurn && !!l && !pending;
|
||||
const cls = t === 'k.die' || t === 'nl.fold' ? 'btn-danger' : t.endsWith('check') || t.endsWith('call') ? 'btn-primary' : 'btn-soft';
|
||||
const amount = t === 'nl.raise' && l ? `${formatChips(l.minTo!)}부터` : l && l.amount > 0 ? `+${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : '';
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`btn ${cls}`}
|
||||
disabled={!enabled}
|
||||
title={BET_HELP[t]}
|
||||
onMouseEnter={() => setHover(t)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(t)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => press(t)}
|
||||
>
|
||||
<span>{BET_KO[t]}</span>
|
||||
<small>{amount || (t === 'k.die' || t === 'nl.fold' ? '포기' : l ? '0 칩' : '-')}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
|
||||
{myTurn
|
||||
? hoverBet && BET_HELP[hover!]
|
||||
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
|
||||
: v.my.toCall > 0
|
||||
? `내 차례예요. 맞추려면 ${formatChips(v.my.toCall)}이 필요해요.`
|
||||
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
|
||||
: v.toAct
|
||||
? `${nick(players, v.toAct)}님 차례예요.`
|
||||
: ''}
|
||||
</p>
|
||||
{confirmFold && (
|
||||
<ConfirmSheet
|
||||
title="체크할 수 있는데 포기할까요?"
|
||||
body="지금은 칩을 더 내지 않고 넘어갈 수 있어요."
|
||||
confirm="그래도 포기"
|
||||
danger
|
||||
onConfirm={() => {
|
||||
act({ type: confirmFold });
|
||||
setConfirmFold(null);
|
||||
}}
|
||||
onClose={() => setConfirmFold(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** NL/PL raise: slider + quick buttons (최소, 1/2팟, 팟, 최대) + ∓ 기본 단위, then 확인. Amounts are "총 몇 칩까지". */
|
||||
function RaisePanel({ v, seat, raise, act, pending, onCancel }: { v: PokerView; seat: SeatView; raise: LegalBetView; act: (a: unknown) => void; pending: boolean; onCancel: () => void }) {
|
||||
const min = raise.minTo!;
|
||||
const max = raise.maxTo!;
|
||||
const [to, setTo] = useState(min);
|
||||
const clamp = (x: number) => Math.max(min, Math.min(max, Math.round(x)));
|
||||
const potAfterCall = v.pot + v.my.toCall;
|
||||
const quick: [string, number][] = [
|
||||
['최소', min],
|
||||
['1/2 팟', v.currentBet + Math.floor(potAfterCall / 2)],
|
||||
['팟', v.currentBet + potAfterCall],
|
||||
[v.betStyle === 'potLimit' ? '최대' : '올인', max],
|
||||
];
|
||||
const add = to - seat.roundBet;
|
||||
const allIn = to >= seat.roundBet + seat.stack;
|
||||
return (
|
||||
<div className="card pk-raise" style={{ padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{v.currentBet === 0 ? '벳' : '레이즈'}: 이번 라운드 총 {formatChips(to)}
|
||||
</strong>
|
||||
<span className="small muted">
|
||||
지금 낼 칩 +{formatChips(add)}
|
||||
{allIn ? ' (올인)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-secondary" aria-label="기본 단위만큼 줄이기" onClick={() => setTo(clamp(to - v.baseUnit))}>
|
||||
−
|
||||
</button>
|
||||
<input type="range" min={min} max={max} step={1} value={to} onChange={(e) => setTo(clamp(Number(e.target.value)))} aria-label="올릴 칩" style={{ flex: 1 }} />
|
||||
<button type="button" className="btn btn-secondary" aria-label="기본 단위만큼 늘리기" onClick={() => setTo(clamp(to + v.baseUnit))}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
{quick.map(([label, x]) => (
|
||||
<button key={label} type="button" className="btn btn-soft" style={{ flex: 1 }} onClick={() => setTo(clamp(x))}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
||||
취소
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => act(allIn && v.betStyle === 'noLimit' ? { type: 'nl.allIn' } : { type: 'nl.raise', to })}>
|
||||
확인: {formatChips(to)}까지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
const RANKS_INTL = ['스트레이트 플러시(로열 포함)', '포카드', '풀하우스', '플러시', '스트레이트', '트리플', '투페어', '원페어', '하이카드'];
|
||||
const RANKS_KOREAN = ['로열 스트레이트 플러시', '백 스트레이트 플러시', '스트레이트 플러시', '포카드', '풀하우스', '플러시', '마운틴(A-K-Q-J-10)', '백 스트레이트(A-2-3-4-5)', '스트레이트', '트리플', '투페어', '원페어', '탑(노페어)'];
|
||||
|
||||
function HandRanks({ mode, o }: { mode: PokerMode; o: Pick<PokerOptions, 'indianCards' | 'indianDeck' | 'hiLoLow' | 'hiLoQualifier'> }) {
|
||||
if (mode === 'badugi') {
|
||||
return (
|
||||
<div className="small stack" style={{ gap: 4 }}>
|
||||
<span>낮을수록 좋은 로우 게임이에요. A가 가장 낮아요.</span>
|
||||
<span>
|
||||
4장 중 <strong>숫자도 무늬도 겹치지 않는 카드</strong>가 많을수록 셉니다: 메이드(4장) > 베이스(3장) > 투베이스(2장) > 노베이스.
|
||||
</span>
|
||||
<span>같은 등급이면 가장 높은 카드부터 비교해 낮은 쪽이 이겨요. 골프(A-2-3-4)가 최강, 세컨드(A-2-3-5), 써드(A-2-4-5).</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'indian') {
|
||||
return (
|
||||
<div className="small stack" style={{ gap: 4 }}>
|
||||
<span>내 이마 카드는 나만 못 보고, 다른 사람 카드는 다 보여요.</span>
|
||||
<span>{o.indianDeck === 'd20' ? '10이 가장 높고 A(1)가 가장 낮아요.' : 'A가 가장 높고 2가 가장 낮아요.'} 무늬는 보지 않아요.</span>
|
||||
{o.indianCards === 2 && <span>2장일 때: 페어 > 노페어. 노페어끼리는 높은 카드 → 낮은 카드 순으로 비교해요.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const korean = isSevenMode(mode);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<ol className="small" style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{(korean ? RANKS_KOREAN : RANKS_INTL).map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
{korean && <span className="small muted">같은 족보면 숫자, 그다음 무늬(♠ > ◆ > ♥ > ♣)로 정해요.</span>}
|
||||
{mode === 'sevenHiLo' && (
|
||||
<span className="small muted">
|
||||
로우: 서로 다른 숫자 5장, 낮을수록 좋음({o.hiLoLow === 'korean' ? '스트레이트·플러시 불인정, 최고 A-2-3-4-6' : '최고 A-2-3-4-5'}
|
||||
{o.hiLoQualifier === 'eight' ? ', 8 이하만 인정' : ''}).
|
||||
</span>
|
||||
)}
|
||||
{!korean && <span className="small muted">5장이 같은 서열이면 나눠 가져요(무늬는 보지 않아요).</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PokerSide({ view: v, me, players, act, pending, deadline }: BoardProps<PokerView>) {
|
||||
const [confirmQuit, setConfirmQuit] = useState(false);
|
||||
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
|
||||
const scoreBy = v.options.endCondition === 'hands' || v.handsPlayed >= 300;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<PlayChipNotice />
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{MODE_KO[v.mode]} · {v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
|
||||
</strong>
|
||||
{v.phase !== 'finished' && v.waiting.length > 0 && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">
|
||||
{v.betStyle === 'korean' ? `한국식 베팅(${v.options.koreanMaxRaise === 'full' ? '풀까지' : '하프까지'})` : v.betStyle === 'potLimit' ? '팟리밋' : '노리밋'} · 기본 {formatChips(v.baseUnit)}
|
||||
</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{ranked.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
|
||||
{s.status === 'eliminated' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
|
||||
</span>
|
||||
<Chips n={s.stack} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((g, i) => {
|
||||
const sc = v.result!.scores[g[0]!] ?? 0;
|
||||
return (
|
||||
<span key={i}>
|
||||
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
|
||||
{scoreBy ? ` (${sc < 0 ? '-' : ''}${Math.abs(sc).toLocaleString('ko-KR')} 칩)` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{scoreBy && <span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>}
|
||||
{v.stats.bestHand && (
|
||||
<span className="small">
|
||||
최고 족보: {nick(players, v.stats.bestHand.playerId)} · {v.stats.bestHand.handName} ({v.stats.bestHand.handNo}판)
|
||||
</span>
|
||||
)}
|
||||
{v.stats.biggestPot && <span className="small">가장 큰 판: {formatChips(v.stats.biggestPot.amount)} ({v.stats.biggestPot.handNo}판)</span>}
|
||||
</div>
|
||||
)}
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보</summary>
|
||||
<HandRanks mode={v.mode} o={v.options} />
|
||||
</details>
|
||||
{v.mode === 'indian' && v.indianUsed.length > 0 && (
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>이미 나온 카드 ({v.indianUsed.length}장)</summary>
|
||||
<div className="row" style={{ gap: 2, marginTop: 6 }}>
|
||||
{v.indianUsed.map((c, i) => (
|
||||
<PlayingCard key={`${c}${i}`} card={c} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 200, overflow: 'auto' }}>
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">{e.hand}판</span> {e.by ? `${nick(players, e.by)}: ` : ''}
|
||||
{e.msg}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{v.my.canRebuy && !confirmQuit && (
|
||||
<Sheet onClose={() => {}} label="칩 다시 받기">
|
||||
<h2>칩을 모두 잃었어요</h2>
|
||||
<p className="muted">
|
||||
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.my.rebuysLeft !== null ? ` (남은 횟수 ${v.my.rebuysLeft}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
|
||||
칩 다시 받기
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
|
||||
그만하기
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirmQuit && v.my.canRebuy && (
|
||||
<ConfirmSheet
|
||||
title="여기서 그만할까요?"
|
||||
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
|
||||
confirm="그만하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'rebuy', accept: false })}
|
||||
onClose={() => setConfirmQuit(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: PokerView, me: string | null): boolean {
|
||||
if (!me || v.phase === 'finished' || v.handPhase === 'handEnd') return false;
|
||||
return v.waiting.includes(me);
|
||||
}
|
||||
|
||||
function statusText(v: PokerView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'rebuy') return v.my.canRebuy ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
|
||||
const mine = isMyTurn(v, me);
|
||||
switch (v.handPhase) {
|
||||
case 'betting':
|
||||
if (v.toAct === me) return v.my.handName && v.options.hints ? `내 차례예요 · 지금: ${v.my.handName}` : '내 차례예요. 베팅해 주세요';
|
||||
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
|
||||
case 'choice':
|
||||
return mine ? '내 차례예요. 버릴 카드와 보여 줄 카드를 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
|
||||
case 'draw':
|
||||
return mine ? '내 차례예요. 바꿀 카드를 골라 주세요' : '다른 사람이 바꿀 카드를 고르는 중이에요';
|
||||
case 'declare':
|
||||
return mine ? '내 차례예요. 하이·로우·스윙을 선언해 주세요' : '다른 사람이 선언하는 중이에요';
|
||||
case 'handEnd':
|
||||
return resultLine(v, players) || '판이 끝났어요';
|
||||
default:
|
||||
return '잠시만 기다려 주세요';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<PokerOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<PokerOptions>) => onChange({ ...o, ...patch });
|
||||
const tooBig = o.baseUnit * 20 > o.startingChips;
|
||||
const max = o.mode === 'holdem' || o.mode === 'omaha' || (o.mode === 'indian' && o.indianCards === 1 && o.indianDeck === 'd52') ? 10 : 6;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="게임 모드">
|
||||
<Seg value={o.mode} options={(Object.keys(MODE_KO) as PokerMode[]).map((m) => [m, MODE_KO[m]])} onChange={(mode) => set({ mode })} />
|
||||
<span className="small muted">이 모드는 2~{max}명이 할 수 있어요.</span>
|
||||
</Field>
|
||||
<Field label="베팅 방식">
|
||||
<Seg
|
||||
value={o.betting}
|
||||
options={[
|
||||
['auto', '모드 기본'],
|
||||
['korean', '한국식(삥·따당·하프)'],
|
||||
['noLimit', '노리밋'],
|
||||
['potLimit', '팟리밋'],
|
||||
]}
|
||||
onChange={(betting) => set({ betting })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="시작 칩">
|
||||
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
|
||||
</Field>
|
||||
<Field label={o.mode === 'holdem' || o.mode === 'omaha' ? '빅 블라인드' : '기본 칩(앤티)'}>
|
||||
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
|
||||
{tooBig && <span className="field-error">기본 칩은 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
|
||||
</Field>
|
||||
<Field label="끝내는 방식">
|
||||
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
|
||||
{o.endCondition === 'hands' && <Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />}
|
||||
</Field>
|
||||
<Field label="행동 제한 시간">
|
||||
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
{o.mode === 'indian' && (
|
||||
<Field label="인디언 포커">
|
||||
<Seg value={o.indianCards} options={[[1, '1장(이마)'], [2, '2장(이마 + 손)']]} onChange={(indianCards) => set({ indianCards })} />
|
||||
<Seg value={o.indianDeck} options={[['d20', '20장(1~10 두 벌)'], ['d52', '52장']]} onChange={(indianDeck) => set({ indianDeck })} />
|
||||
</Field>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="한국식 최대 레이즈">
|
||||
<Seg value={o.koreanMaxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(koreanMaxRaise) => set({ koreanMaxRaise })} />
|
||||
</Field>
|
||||
<Field label="칩 다시 받기(리바이)">
|
||||
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
|
||||
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
|
||||
</Field>
|
||||
<Field label="기본 칩 2배로 올리기">
|
||||
<Seg value={o.blindUpEvery} options={[[0, '안 올림'], [5, '5판마다'], [10, '10판마다'], [15, '15판마다']]} onChange={(blindUpEvery) => set({ blindUpEvery })} />
|
||||
</Field>
|
||||
<Field label="다음 판 딜러">
|
||||
<Seg value={o.dealerRule} options={[['rotate', '시계 방향으로'], ['winner', '직전 판 승자']]} onChange={(dealerRule) => set({ dealerRule })} />
|
||||
</Field>
|
||||
<Field label="도움 표시">
|
||||
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '지금 족보 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
|
||||
</Field>
|
||||
{o.mode === 'sevenHiLo' && (
|
||||
<Field label="하이로우 로우 판정">
|
||||
<Seg value={o.hiLoLow} options={[['korean', '스트레이트·플러시 불인정'], ['aceToFive', 'A-2-3-4-5 최고']]} onChange={(hiLoLow) => set({ hiLoLow })} />
|
||||
<Seg value={o.hiLoQualifier} options={[['none', '제한 없음'], ['eight', '8 이하만 로우']]} onChange={(hiLoQualifier) => set({ hiLoQualifier })} />
|
||||
</Field>
|
||||
)}
|
||||
{o.mode === 'fiveDraw' && (
|
||||
<Field label="교환 장수">
|
||||
<Seg value={o.drawLimit} options={[['any', '0~5장'], ['three', '3장까지(A를 남기면 4장)']]} onChange={(drawLimit) => set({ drawLimit })} />
|
||||
</Field>
|
||||
)}
|
||||
{o.mode === 'indian' && (
|
||||
<>
|
||||
<Field label="가장 높은 카드를 단 채 다이하면 벌칙">
|
||||
<Seg value={o.indianFoldPenalty ? 'on' : 'off'} options={[['on', '벌칙 있음'], ['off', '없음']]} onChange={(k) => set({ indianFoldPenalty: k === 'on' })} />
|
||||
{o.indianFoldPenalty && <Seg value={o.indianPenaltyUnits} options={[[5, '기본 칩 5배'], [10, '10배'], [20, '20배']]} onChange={(indianPenaltyUnits) => set({ indianPenaltyUnits })} />}
|
||||
</Field>
|
||||
<Field label="동점일 때">
|
||||
<Seg value={o.indianTie} options={[['carry', '다음 판으로 넘김'], ['split', '나눠 가짐']]} onChange={(indianTie) => set({ indianTie })} />
|
||||
</Field>
|
||||
<Field label="덱 섞기">
|
||||
<Seg value={o.indianReshuffle} options={[['whenEmpty', '덱을 다 쓰면(카드 기억)'], ['everyHand', '판마다']]} onChange={(indianReshuffle) => set({ indianReshuffle })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="판 결과 보여 주는 시간">
|
||||
<Seg value={o.handEndSeconds} options={[[3, '3초'], [4, '4초'], [6, '6초'], [10, '10초']]} onChange={(handEndSeconds) => set({ handEndSeconds })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: PokerOptions): string {
|
||||
const bet = o.betting === 'auto' ? '' : o.betting === 'korean' ? '한국식' : o.betting === 'noLimit' ? '노리밋' : '팟리밋';
|
||||
return [MODE_KO[o.mode], bet, `시작 ${o.startingChips.toLocaleString('ko-KR')}칩`, `기본 ${o.baseUnit}칩`, o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인', `${o.turnSeconds}초`]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
const MODE_RULES: Record<PokerMode, string> = {
|
||||
holdem: '각자 2장을 받고, 가운데 공용 카드 5장(플랍 3 → 턴 1 → 리버 1)과 합쳐 가장 좋은 5장을 만들어요. 베팅은 프리플랍·플랍·턴·리버 네 번이에요.',
|
||||
omaha: '각자 4장을 받는데, 반드시 내 카드 2장 + 공용 카드 3장으로만 5장을 만들어요. 나머지는 홀덤과 같아요.',
|
||||
sevenPoker: '4장을 받아 1장은 버리고 1장은 모두에게 보여 줘요(초이스). 그다음 공개 카드 3장과 마지막 히든 카드 1장을 받으며 네 번 베팅해요. 7장 중 가장 좋은 5장으로 겨뤄요. 공개 카드가 가장 좋은 사람(보스)이 먼저 베팅해요.',
|
||||
sevenHiLo: '7포커와 같이 진행한 뒤, 마지막에 하이·로우·스윙 중 하나를 동시에 선언해요. 하이 1등과 로우 1등이 칩을 반씩 나눠 가져요. 스윙은 두 쪽 다 이겨야 모두 가져가고, 하나라도 지면 아무것도 못 받아요.',
|
||||
badugi: '4장을 받고 아침·점심·저녁 세 번 카드를 바꿀 수 있어요(교환 사이마다 베팅). 숫자와 무늬가 겹치지 않으면서 낮은 카드일수록 좋아요.',
|
||||
fiveDraw: '5장을 받고 한 번 베팅한 뒤, 원하는 만큼 카드를 바꾸고 다시 베팅해요. 가장 좋은 5장 족보가 이겨요.',
|
||||
indian: '카드를 이마에 붙이듯 나만 못 보고 다른 사람은 모두 봐요. 상대 카드와 베팅만 보고 내 카드를 짐작해야 해요.',
|
||||
};
|
||||
|
||||
function Rules({ options }: { options?: PokerOptions }) {
|
||||
const o = options;
|
||||
const mode = o?.mode ?? 'sevenPoker';
|
||||
const style = o ? (o.betting !== 'auto' ? o.betting : mode === 'holdem' || mode === 'indian' ? 'noLimit' : mode === 'omaha' ? 'potLimit' : 'korean') : 'korean';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
<strong>{MODE_KO[mode]}</strong>: {MODE_RULES[mode]} 칩은 이 방에서만 쓰는 놀이용 점수예요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>베팅</strong>:{' '}
|
||||
{style === 'korean'
|
||||
? '체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프·풀(모인 칩의 4분의 1·절반·전부만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요.'
|
||||
: `폴드(포기), 체크, 콜, 레이즈, 올인. 레이즈는 직전 레이즈만큼 이상 올려야 해요.${style === 'potLimit' ? ' 팟리밋은 모인 칩만큼까지만 올릴 수 있어요.' : ''}`}{' '}
|
||||
칩이 모자라면 가진 만큼 내고 올인하며, 그 이상 걸린 칩은 따로(사이드 팟) 겨뤄요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>족보</strong>
|
||||
</p>
|
||||
<HandRanks mode={mode} o={o ?? { indianCards: 1, indianDeck: 'd20', hiLoLow: 'korean', hiLoQualifier: 'none' }} />
|
||||
{mode === 'indian' && o?.indianFoldPenalty !== false && <p>가장 높은 카드를 이마에 단 채 다이하면 벌칙 칩을 내요. 벌칙 칩은 그 판 승자가 가져가요.</p>}
|
||||
<p>
|
||||
<strong>칩이 떨어지면</strong>:{' '}
|
||||
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
|
||||
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : '정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.'}
|
||||
</p>
|
||||
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 포기로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 짧아져요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<PokerView, PokerOptions> = {
|
||||
Board: PokerBoard,
|
||||
Side: PokerSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
936
apps/web/src/games/quoridor/index.tsx
Normal file
936
apps/web/src/games/quoridor/index.tsx
Normal file
@@ -0,0 +1,936 @@
|
||||
/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { toast } from '../../store/toast';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type O = 'h' | 'v';
|
||||
interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
const N = 9;
|
||||
const G = 0.2; // 홈(groove) 폭
|
||||
const PAD = 0.78;
|
||||
const SEAT_KO: Record<Seat, string> = { S: '남', W: '서', N: '북', E: '동' };
|
||||
const SEAT_COLOR: Record<Seat, string> = { S: '#dc2626', W: '#16a34a', N: '#2563eb', E: '#d97706' };
|
||||
const COLOR_KO: Record<Seat, string> = { S: '빨강', W: '초록', N: '파랑', E: '주황' };
|
||||
const SIDE_KO = { top: '맨 위', bottom: '맨 아래', left: '맨 왼쪽', right: '맨 오른쪽' } as const;
|
||||
type Side = keyof typeof SIDE_KO;
|
||||
const label = (p: Cell) => `${'abcdefghi'[p.c]}${p.r + 1}`;
|
||||
const wallKey = (w: Wall) => `${w.o}:${w.c},${w.r}`;
|
||||
/** 가로·세로는 보는 사람 화면 기준(서·동 자리는 판이 90° 돌아가 있음). */
|
||||
const wallText = (w: Wall, swap = false) => `${(w.o === 'h') !== swap ? '가로' : '세로'} 벽 ${label(w)}`;
|
||||
const swapFor = (seat: Seat | null) => seat === 'W' || seat === 'E';
|
||||
|
||||
const CSS = `
|
||||
.qd-pawn { transition: transform 220ms ease-out; }
|
||||
.qd-wall-new { animation: qd-drop 260ms ease-out both; transform-box: fill-box; transform-origin: center; }
|
||||
.qd-turn-ring { animation: qd-pulse 1.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
@keyframes qd-drop { 0% { transform: scale(1.35); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
||||
@keyframes qd-pulse { 0%, 100% { opacity: 0.95; } 50% { opacity: 0.35; } }
|
||||
.qd-board * { pointer-events: none; }
|
||||
.qd-board [data-cell], .qd-board [data-anchor] { pointer-events: auto; }
|
||||
.qd-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.qd-modes .btn { min-height: 52px; padding-inline: 8px; white-space: nowrap; font-size: 1rem; }
|
||||
.qd-modes .btn[aria-pressed='true'] { box-shadow: inset 0 0 0 3px var(--focus); }
|
||||
.qd-tray { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
|
||||
.qd-tray .btn { min-height: 48px; }
|
||||
.qd-hint { flex: 1 1 100%; min-width: 0; font-weight: 700; }
|
||||
.qd-tray .qd-confirm { flex: 2 1 0; }
|
||||
.qd-tray .qd-rotate { flex: 1 1 0; }
|
||||
.qd-hint.bad { color: var(--danger); }
|
||||
.qd-bars { display: inline-flex; gap: 2px; align-items: flex-end; }
|
||||
.qd-bars i { display: inline-block; width: 4px; height: 16px; border-radius: 2px; background: var(--warning); }
|
||||
.qd-bars i.used { background: var(--border); opacity: 0.6; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qd-pawn { transition: none; }
|
||||
.qd-wall-new, .qd-turn-ring { animation: none; }
|
||||
}`;
|
||||
|
||||
/** 판 돌리기: 모델 좌표(x 오른쪽, y 위쪽, 0~9) ↔ 화면 좌표(y 아래쪽). 네 가지 모두 자기 자신이 역변환. */
|
||||
function makeTf(seat: Seat | null) {
|
||||
const s = seat ?? 'S';
|
||||
const tf = (x: number, y: number): [number, number] =>
|
||||
s === 'S' ? [x, N - y] : s === 'N' ? [N - x, y] : s === 'W' ? [N - y, N - x] : [y, x];
|
||||
const swap = s === 'W' || s === 'E';
|
||||
return {
|
||||
swap,
|
||||
cellToScreen: (p: Cell): Pt => {
|
||||
const [x, y] = tf(p.c + 0.5, p.r + 0.5);
|
||||
return { x: Math.floor(x), y: Math.floor(y) };
|
||||
},
|
||||
screenToCell: (p: Pt): Cell => {
|
||||
const [x, y] = tf(p.x + 0.5, p.y + 0.5);
|
||||
return { c: Math.floor(x), r: Math.floor(y) };
|
||||
},
|
||||
/** 화면 앵커(0~7)와 화면 방향. */
|
||||
wallToScreen: (w: Wall): { a: Pt; o: O } => {
|
||||
const [x, y] = tf(w.c + 1, w.r + 1);
|
||||
return { a: { x: Math.round(x) - 1, y: Math.round(y) - 1 }, o: swap ? (w.o === 'h' ? 'v' : 'h') : w.o };
|
||||
},
|
||||
screenToWall: (a: Pt, o: O): Wall => {
|
||||
const [x, y] = tf(a.x + 1, a.y + 1);
|
||||
return { c: Math.round(x) - 1, r: Math.round(y) - 1, o: swap ? (o === 'h' ? 'v' : 'h') : o };
|
||||
},
|
||||
goalSide: (seat: Seat): Side => {
|
||||
const g: Record<Seat, [number, number]> = { S: [4.5, 9], N: [4.5, 0], W: [9, 4.5], E: [0, 4.5] };
|
||||
const [x, y] = tf(...g[seat]);
|
||||
return y < 0.5 ? 'top' : y > 8.5 ? 'bottom' : x < 0.5 ? 'left' : 'right';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function overlapsAny(walls: Wall[], w: Wall): boolean {
|
||||
const has = (o: O, c: number, r: number) => walls.some((x) => x.o === o && x.c === c && x.r === r);
|
||||
if (w.o === 'h') return has('h', w.c - 1, w.r) || has('h', w.c, w.r) || has('h', w.c + 1, w.r) || has('v', w.c, w.r);
|
||||
return has('v', w.c, w.r - 1) || has('v', w.c, w.r) || has('v', w.c, w.r + 1) || has('h', w.c, w.r);
|
||||
}
|
||||
|
||||
function wallRect(a: Pt, o: O) {
|
||||
return o === 'h' ? { x: a.x + 0.05, y: a.y + 1 - G / 2, width: 2 - 0.1, height: G } : { x: a.x + 1 - G / 2, y: a.y + 0.05, width: G, height: 2 - 0.1 };
|
||||
}
|
||||
|
||||
function moveText(m: QuoridorMove, swap = false): string {
|
||||
if (m.kind === 'move') return `${SEAT_KO[m.seat]} ${label(m.to)}${m.auto ? '(자동)' : ''}`;
|
||||
if (m.kind === 'wall') return `${SEAT_KO[m.seat]} ${wallText(m.wall, swap)}`;
|
||||
return `${SEAT_KO[m.seat]} 쉼`;
|
||||
}
|
||||
|
||||
function PawnMark({ seat, cx, cy, r = 0.34, ghost }: { seat: Seat; cx: number; cy: number; r?: number; ghost?: boolean }) {
|
||||
return (
|
||||
<g opacity={ghost ? 0.5 : 1}>
|
||||
{!ghost && <circle cx={cx + 0.03} cy={cy + 0.05} r={r} fill="#000" opacity={0.25} />}
|
||||
<circle cx={cx} cy={cy} r={r} fill={SEAT_COLOR[seat]} stroke="#fff" strokeWidth={0.05} />
|
||||
<text x={cx} y={cy + 0.01} fontSize={r * 0.95} fontWeight={800} fill="#fff" textAnchor="middle" dominantBaseline="central">
|
||||
{SEAT_KO[seat]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
type Mode = 'move' | 'wall';
|
||||
|
||||
function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProps<QuoridorView>) {
|
||||
const me = v.seats.find((x) => x.seat === v.mySeat) ?? null;
|
||||
const myTurn = v.phase === 'playing' && !!me && v.turn === me.seat && v.legalPawnMoves !== null;
|
||||
const t = useMemo(() => makeTf(v.mySeat), [v.mySeat]);
|
||||
const [mode, setMode] = useState<Mode>('move');
|
||||
const [orient, setOrient] = useState<O>('h');
|
||||
const [wallAt, setWallAt] = useState<Pt | null>(null); // 화면 앵커(고정된 미리보기)
|
||||
const [hoverAnchor, setHoverAnchor] = useState<Pt | null>(null);
|
||||
const [hoverCell, setHoverCell] = useState<Pt | null>(null);
|
||||
const [movePreview, setMovePreview] = useState<Pt | null>(null);
|
||||
const [cursor, setCursor] = useState<Pt>({ x: 4, y: 8 });
|
||||
const [kbd, setKbd] = useState(false);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const prevCount = useRef(v.moveCount);
|
||||
const prevWalls = useRef(v.walls.length);
|
||||
const [newWall, setNewWall] = useState<string | null>(null);
|
||||
|
||||
const legalCells = useMemo(() => {
|
||||
const m = new Set<string>();
|
||||
for (const c of v.legalPawnMoves ?? []) {
|
||||
const p = t.cellToScreen(c);
|
||||
m.add(`${p.x},${p.y}`);
|
||||
}
|
||||
return m;
|
||||
}, [v.legalPawnMoves, t]);
|
||||
const legalWalls = useMemo(() => {
|
||||
const m = new Map<string, QuoridorLegalWall>();
|
||||
for (const w of v.legalWalls ?? []) m.set(wallKey(w), w);
|
||||
return m;
|
||||
}, [v.legalWalls]);
|
||||
|
||||
// 새 수: 소리·진동·스크린리더 안내, 미리보기 지우기, 차례가 오면 커서를 내 말로.
|
||||
useLayoutEffect(() => {
|
||||
const prev = prevCount.current;
|
||||
prevCount.current = v.moveCount;
|
||||
setMovePreview(null);
|
||||
setWallAt(null);
|
||||
if (v.walls.length > prevWalls.current) setNewWall(wallKey(v.walls[v.walls.length - 1]!));
|
||||
else setNewWall(null);
|
||||
prevWalls.current = v.walls.length;
|
||||
if (v.moveCount <= prev) return;
|
||||
const added = v.moves.slice(prev);
|
||||
const texts = added.map((m) => (m.kind === 'pass' ? `${SEAT_KO[m.seat]}은 움직일 곳이 없어 차례를 넘겼어요.` : `${moveText(m, t.swap)}.`));
|
||||
for (const m of added) if (m.kind === 'pass' && m.seat === v.mySeat) toast('움직일 곳도 세울 벽도 없어서 차례를 넘겼어요');
|
||||
setAnnounce(texts.join(' '));
|
||||
if (added.some((m) => m.kind !== 'pass')) sounds.stone();
|
||||
if (v.phase === 'playing' && v.mySeat === v.turn) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (myTurn && me?.pos) setCursor(t.cellToScreen(me.pos));
|
||||
if (!myTurn) setMode('move');
|
||||
}, [myTurn]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const wallsLeft = me?.wallsLeft ?? 0;
|
||||
const canWall = myTurn && wallsLeft > 0;
|
||||
|
||||
/** 화면 앵커·방향의 벽에 대한 판정: 합법이면 정보, 아니면 이유. */
|
||||
const judge = (a: Pt, o: O): { wall: Wall; legal: QuoridorLegalWall | null; why: string | null } => {
|
||||
const wall = t.screenToWall(a, o);
|
||||
if (wallsLeft <= 0) return { wall, legal: null, why: '남은 벽이 없어요.' };
|
||||
const legal = legalWalls.get(wallKey(wall)) ?? null;
|
||||
if (legal) return { wall, legal, why: null };
|
||||
return { wall, legal: null, why: overlapsAny(v.walls, wall) ? '다른 벽과 겹쳐요.' : '누군가의 길을 완전히 막을 수 없어요.' };
|
||||
};
|
||||
|
||||
const doMove = (p: Pt) => {
|
||||
act({ type: 'move', to: t.screenToCell(p) });
|
||||
setMovePreview(null);
|
||||
setHoverCell(null);
|
||||
};
|
||||
const doWall = (a: Pt) => {
|
||||
const j = judge(a, orient);
|
||||
if (j.why) {
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce(j.why);
|
||||
toast(j.why);
|
||||
return;
|
||||
}
|
||||
act({ type: 'placeWall', ...j.wall });
|
||||
setWallAt(null);
|
||||
setHoverAnchor(null);
|
||||
};
|
||||
const refuseCell = (p: Pt) => {
|
||||
const c = t.screenToCell(p);
|
||||
const occupied = v.seats.some((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
|
||||
const msg = occupied ? '말이 있는 칸이에요.' : '그 칸으로는 갈 수 없어요. 밝게 표시된 칸으로 움직일 수 있어요.';
|
||||
haptic([30, 30, 30]);
|
||||
setAnnounce(msg);
|
||||
toast(msg);
|
||||
};
|
||||
|
||||
const tapCell = (p: Pt, mouse: boolean) => {
|
||||
setCursor(p);
|
||||
if (!myTurn) return;
|
||||
if (!legalCells.has(`${p.x},${p.y}`)) {
|
||||
refuseCell(p);
|
||||
return;
|
||||
}
|
||||
if (mouse || !tapConfirm || (movePreview && movePreview.x === p.x && movePreview.y === p.y)) {
|
||||
if (!pending) doMove(p);
|
||||
return;
|
||||
}
|
||||
setMovePreview(p);
|
||||
};
|
||||
const tapAnchor = (a: Pt) => {
|
||||
if (!canWall) return;
|
||||
if (wallAt && wallAt.x === a.x && wallAt.y === a.y) {
|
||||
setOrient((o) => (o === 'h' ? 'v' : 'h'));
|
||||
return;
|
||||
}
|
||||
setWallAt(a);
|
||||
setCursor(a);
|
||||
};
|
||||
|
||||
const pointer = (e: React.PointerEvent<SVGSVGElement>): { x: number; y: number } | null => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = e.clientX;
|
||||
pt.y = e.clientY;
|
||||
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
return { x: loc.x, y: loc.y };
|
||||
};
|
||||
const toCell = (p: { x: number; y: number }): Pt | null => {
|
||||
const x = Math.floor(p.x);
|
||||
const y = Math.floor(p.y);
|
||||
return x >= 0 && y >= 0 && x < N && y < N ? { x, y } : null;
|
||||
};
|
||||
/** 가장 가까운 교차점으로 스냅. */
|
||||
const toAnchor = (p: { x: number; y: number }): Pt | null => {
|
||||
if (p.x < -0.3 || p.y < -0.3 || p.x > N + 0.3 || p.y > N + 0.3) return null;
|
||||
return { x: Math.min(7, Math.max(0, Math.round(p.x) - 1)), y: Math.min(7, Math.max(0, Math.round(p.y) - 1)) };
|
||||
};
|
||||
|
||||
const setModeTo = (m: Mode) => {
|
||||
setMode(m);
|
||||
setMovePreview(null);
|
||||
setWallAt(null);
|
||||
if (m === 'wall' && me?.pos) {
|
||||
const p = t.cellToScreen(me.pos);
|
||||
// 내 말 바로 위쪽 교차점에서 시작(상대 쪽으로 세우기 쉽게).
|
||||
setCursor({ x: Math.min(7, Math.max(0, p.x - 1)), y: Math.min(7, Math.max(0, p.y - 2)) });
|
||||
} else if (m === 'move' && me?.pos) setCursor(t.cellToScreen(me.pos));
|
||||
setAnnounce(m === 'wall' ? '벽 세우기: 방향키로 자리를 고르고 R로 방향을 바꾼 뒤 Enter로 세워요.' : '말 움직이기: 방향키로 칸을 고르고 Enter로 움직여요.');
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
setKbd(true);
|
||||
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||
const max = mode === 'wall' ? 7 : 8;
|
||||
if (d[e.key]) {
|
||||
e.preventDefault();
|
||||
const next = { x: Math.min(max, Math.max(0, cursor.x + d[e.key]![0])), y: Math.min(max, Math.max(0, cursor.y + d[e.key]![1])) };
|
||||
setCursor(next);
|
||||
if (mode === 'wall') {
|
||||
setWallAt(next);
|
||||
const j = judge(next, orient);
|
||||
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
|
||||
} else {
|
||||
const c = t.screenToCell(next);
|
||||
const who = v.seats.find((x) => x.pos && x.pos.c === c.c && x.pos.r === c.r);
|
||||
setAnnounce(`${label(c)} ${who ? `${SEAT_KO[who.seat]} 말` : legalCells.has(`${next.x},${next.y}`) ? '갈 수 있음' : '빈칸'}`);
|
||||
}
|
||||
} else if ((e.key === 'r' || e.key === 'R') && mode === 'wall') {
|
||||
e.preventDefault();
|
||||
const o = orient === 'h' ? 'v' : 'h';
|
||||
setOrient(o);
|
||||
setWallAt(cursor);
|
||||
const j = judge(cursor, o);
|
||||
setAnnounce(`${wallText(j.wall, t.swap)} ${j.why ?? '세울 수 있음'}`);
|
||||
} else if (e.key === 'w' || e.key === 'W') {
|
||||
e.preventDefault();
|
||||
if (canWall) setModeTo(mode === 'wall' ? 'move' : 'wall');
|
||||
} else if (e.key === 'Escape') {
|
||||
setWallAt(null);
|
||||
setMovePreview(null);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (!myTurn || pending) return;
|
||||
if (mode === 'wall') doWall(cursor);
|
||||
else if (legalCells.has(`${cursor.x},${cursor.y}`)) doMove(cursor);
|
||||
else refuseCell(cursor);
|
||||
}
|
||||
};
|
||||
|
||||
// 미리보기 대상(고정 > 마우스 올림 > 키보드 커서).
|
||||
const previewAnchor = mode === 'wall' && canWall ? (wallAt ?? hoverAnchor ?? (kbd ? cursor : null)) : null;
|
||||
const preview = previewAnchor ? judge(previewAnchor, orient) : null;
|
||||
const ghostCell = mode === 'move' && myTurn ? (movePreview ?? (hoverCell && legalCells.has(`${hoverCell.x},${hoverCell.y}`) ? hoverCell : null)) : null;
|
||||
const path = myTurn && v.showPath && v.myShortestPath && me?.pos ? [me.pos, ...v.myShortestPath].map((c) => t.cellToScreen(c)) : null;
|
||||
const goalSides = v.seats.filter((x) => x.status === 'playing' || (x.status === 'finished' && v.phase === 'finished')).map((x) => ({ seat: x.seat, side: t.goalSide(x.seat) }));
|
||||
const nick = (id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const lastWall = v.lastMove?.kind === 'wall' ? wallKey(v.lastMove.wall) : null;
|
||||
const lastTo = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.to) : null;
|
||||
const lastFrom = v.lastMove?.kind === 'move' ? t.cellToScreen(v.lastMove.from) : null;
|
||||
const myGoal = me ? SIDE_KO[t.goalSide(me.seat)] : null;
|
||||
|
||||
const band = (side: Side, k: number) => {
|
||||
const w = 0.16;
|
||||
const off = -0.1 - w - k * (w + 0.04);
|
||||
if (side === 'top') return { x: 0, y: off, width: N, height: w };
|
||||
if (side === 'bottom') return { x: 0, y: N - off - w, width: N, height: w };
|
||||
if (side === 'left') return { x: off, y: 0, width: w, height: N };
|
||||
return { x: N - off - w, y: 0, width: w, height: N };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 10, maxWidth: 'min(100%, calc(100vh - 340px))', minWidth: 'min(100%, 300px)' }}>
|
||||
<style>{CSS}</style>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="qd-board"
|
||||
viewBox={`${-PAD} ${-PAD} ${N + 2 * PAD} ${N + 2 * PAD}`}
|
||||
style={{ width: '100%', display: 'block', background: 'var(--wood)', borderRadius: 14, touchAction: 'manipulation', cursor: myTurn ? 'pointer' : 'default', boxShadow: 'var(--shadow)' }}
|
||||
data-my-seat={v.mySeat ?? ''}
|
||||
data-mode={mode}
|
||||
data-path={path && v.myShortestPath ? v.myShortestPath.map(label).join(' ') : ''}
|
||||
data-preview={preview ? `${preview.wall.o}:${label(preview.wall)}${preview.why ? ':bad' : ''}` : ''}
|
||||
onPointerMove={(e) => {
|
||||
if (e.pointerType !== 'mouse' || !myTurn) return;
|
||||
const p = pointer(e);
|
||||
if (!p) return;
|
||||
if (mode === 'wall') {
|
||||
const a = toAnchor(p);
|
||||
if (a?.x !== hoverAnchor?.x || a?.y !== hoverAnchor?.y) setHoverAnchor(a);
|
||||
} else {
|
||||
const c = toCell(p);
|
||||
if (c?.x !== hoverCell?.x || c?.y !== hoverCell?.y) setHoverCell(c);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHoverAnchor(null);
|
||||
setHoverCell(null);
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
setKbd(false);
|
||||
const p = pointer(e);
|
||||
if (!p) return;
|
||||
if (mode === 'wall') {
|
||||
const a = toAnchor(p);
|
||||
if (a) tapAnchor(a);
|
||||
} else {
|
||||
const c = toCell(p);
|
||||
if (c) tapCell(c, e.pointerType === 'mouse');
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label={`벽 쌓기 길찾기 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
{goalSides.map((g, k) => {
|
||||
const sameSide = goalSides.filter((x, j) => j < k && x.side === g.side).length;
|
||||
return <rect key={`g${g.seat}`} {...band(g.side, sameSide)} rx={0.06} fill={SEAT_COLOR[g.seat]} opacity={0.9} />;
|
||||
})}
|
||||
{Array.from({ length: N }, (_, i) => {
|
||||
const bottom = t.screenToCell({ x: i, y: N - 1 });
|
||||
const left = t.screenToCell({ x: 0, y: i });
|
||||
return (
|
||||
<g key={`l${i}`} fill="var(--wood-line)" fontSize={0.28} fontWeight={700} textAnchor="middle" dominantBaseline="central" aria-hidden>
|
||||
<text x={i + 0.5} y={N + PAD - 0.2}>
|
||||
{t.swap ? bottom.r + 1 : 'abcdefghi'[bottom.c]}
|
||||
</text>
|
||||
<text x={-PAD + 0.2} y={i + 0.5}>
|
||||
{t.swap ? 'abcdefghi'[left.c] : left.r + 1}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: N * N }, (_, i) => {
|
||||
const x = i % N;
|
||||
const y = Math.floor(i / N);
|
||||
const legal = myTurn && mode === 'move' && legalCells.has(`${x},${y}`);
|
||||
return (
|
||||
<rect
|
||||
key={`c${i}`}
|
||||
x={x + G / 2}
|
||||
y={y + G / 2}
|
||||
width={1 - G}
|
||||
height={1 - G}
|
||||
rx={0.08}
|
||||
fill={legal ? 'rgba(255, 255, 255, 0.92)' : 'rgba(255, 255, 255, 0.32)'}
|
||||
stroke={legal ? 'var(--primary)' : 'none'}
|
||||
strokeWidth={legal ? 0.06 : 0}
|
||||
data-cell={label(t.screenToCell({ x, y }))}
|
||||
data-legal={legal ? '1' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{lastFrom && <rect x={lastFrom.x + G / 2 + 0.05} y={lastFrom.y + G / 2 + 0.05} width={1 - G - 0.1} height={1 - G - 0.1} rx={0.08} fill="none" stroke="var(--wood-line)" strokeWidth={0.04} strokeDasharray="0.1 0.08" />}
|
||||
{path && path.length > 1 && (
|
||||
<polyline
|
||||
points={path.map((p) => `${p.x + 0.5},${p.y + 0.5}`).join(' ')}
|
||||
fill="none"
|
||||
stroke={SEAT_COLOR[me!.seat]}
|
||||
strokeWidth={0.07}
|
||||
strokeDasharray="0.14 0.12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={0.75}
|
||||
/>
|
||||
)}
|
||||
{myTurn &&
|
||||
mode === 'move' &&
|
||||
[...legalCells].map((k) => {
|
||||
const [x, y] = k.split(',').map(Number) as [number, number];
|
||||
return <circle key={`d${k}`} cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" opacity={0.8} />;
|
||||
})}
|
||||
{v.walls.map((w) => {
|
||||
const s = t.wallToScreen(w);
|
||||
const k = wallKey(w);
|
||||
return (
|
||||
<rect
|
||||
key={k}
|
||||
className={k === newWall ? 'qd-wall-new' : undefined}
|
||||
{...wallRect(s.a, s.o)}
|
||||
rx={0.05}
|
||||
fill="#3b2412"
|
||||
stroke={k === lastWall ? 'var(--focus)' : '#f3e2c0'}
|
||||
strokeWidth={k === lastWall ? 0.05 : 0.025}
|
||||
data-wall={`${w.o}:${label(w)}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{v.seats.map((x) => {
|
||||
if (!x.pos) return null;
|
||||
const p = t.cellToScreen(x.pos);
|
||||
const turn = v.phase === 'playing' && v.turn === x.seat;
|
||||
return (
|
||||
<g key={x.seat} className="qd-pawn" style={{ transform: `translate(${p.x + 0.5}px, ${p.y + 0.5}px)` }} data-pawn={x.seat} data-at={label(x.pos)}>
|
||||
{turn && <circle className="qd-turn-ring" cx={0} cy={0} r={0.45} fill="none" stroke={SEAT_COLOR[x.seat]} strokeWidth={0.06} />}
|
||||
<PawnMark seat={x.seat} cx={0} cy={0} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{lastTo && <circle cx={lastTo.x + 0.5 + 0.27} cy={lastTo.y + 0.5 - 0.27} r={0.08} fill="var(--focus)" stroke="#fff" strokeWidth={0.02} />}
|
||||
{ghostCell && me && <PawnMark seat={me.seat} cx={ghostCell.x + 0.5} cy={ghostCell.y + 0.5} ghost />}
|
||||
{preview && previewAnchor && (
|
||||
<rect
|
||||
{...wallRect(previewAnchor, orient)}
|
||||
rx={0.05}
|
||||
fill={preview.why ? 'rgba(220, 38, 38, 0.6)' : SEAT_COLOR[me!.seat]}
|
||||
opacity={preview.why ? 1 : 0.6}
|
||||
stroke={preview.why ? '#7f1d1d' : '#fff'}
|
||||
strokeWidth={0.04}
|
||||
strokeDasharray={wallAt ? undefined : '0.12 0.08'}
|
||||
/>
|
||||
)}
|
||||
{myTurn && mode === 'wall' && (
|
||||
<g aria-hidden>
|
||||
{Array.from({ length: 64 }, (_, i) => {
|
||||
const a = { x: i % 8, y: Math.floor(i / 8) };
|
||||
const w = t.screenToWall(a, 'h');
|
||||
return <circle key={`a${i}`} cx={a.x + 1} cy={a.y + 1} r={0.09} fill="var(--wood-line)" opacity={0.35} data-anchor={label(w)} />;
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
{myTurn && kbd && mode === 'move' && (
|
||||
<rect x={cursor.x + 0.04} y={cursor.y + 0.04} width={0.92} height={0.92} rx={0.1} fill="none" stroke="var(--focus)" strokeWidth={0.07} />
|
||||
)}
|
||||
{myTurn && kbd && mode === 'wall' && <circle cx={cursor.x + 1} cy={cursor.y + 1} r={0.16} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||
</svg>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{me && v.phase === 'playing' && me.status === 'playing' && (
|
||||
<div className="qd-modes" role="group" aria-label="할 일 고르기">
|
||||
<button type="button" className={`btn ${mode === 'move' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'move'} disabled={!myTurn} onClick={() => setModeTo('move')}>
|
||||
🚶 말 움직이기
|
||||
</button>
|
||||
<button type="button" className={`btn ${mode === 'wall' ? 'btn-primary' : 'btn-secondary'}`} aria-pressed={mode === 'wall'} disabled={!canWall} onClick={() => setModeTo('wall')}>
|
||||
🧱 벽 세우기 ({wallsLeft}개)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{myTurn && mode === 'wall' && (
|
||||
<div className="qd-tray">
|
||||
<span className={`qd-hint small${preview?.why ? ' bad' : ''}`} role="status">
|
||||
{!preview
|
||||
? '판의 홈(칸 사이) 교차점을 눌러 벽 자리를 골라요.'
|
||||
: preview.why
|
||||
? `${wallText(preview.wall, t.swap)}: ${preview.why}`
|
||||
: `${wallText(preview.wall, t.swap)} · 상대 길 +${preview.legal!.opp}칸${preview.legal!.me ? ` · 내 길 +${preview.legal!.me}칸` : ''}`}
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary qd-rotate" onClick={() => {
|
||||
const o = orient === 'h' ? 'v' : 'h';
|
||||
setOrient(o);
|
||||
}} aria-label={`벽 방향 바꾸기, 지금 ${orient === 'h' ? '가로' : '세로'}`}>
|
||||
↻ {orient === 'h' ? '가로' : '세로'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary qd-confirm" disabled={!previewAnchor || !!preview?.why || pending} onClick={() => previewAnchor && doWall(previewAnchor)}>
|
||||
벽 세우기 확인
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{myTurn && mode === 'move' && movePreview && (
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(movePreview)}>
|
||||
{label(t.screenToCell(movePreview))}으로 이동
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setMovePreview(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{me && v.phase === 'playing' && !(myTurn && mode === 'wall') && (
|
||||
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{me.status === 'playing'
|
||||
? `내 말은 ${COLOR_KO[me.seat]}(${SEAT_KO[me.seat]})이에요. ${myGoal} 줄(${COLOR_KO[me.seat]} 띠)에 먼저 닿으면 이겨요.`
|
||||
: me.status === 'finished'
|
||||
? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.'
|
||||
: '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'}
|
||||
</p>
|
||||
)}
|
||||
{!me && (
|
||||
<p className="small muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{v.seats.map((x) => `${nick(x.playerId)}: ${COLOR_KO[x.seat]} → ${SIDE_KO[t.goalSide(x.seat)]} 줄`).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function WallBars({ left, total }: { left: number; total: number }) {
|
||||
return (
|
||||
<span className="qd-bars" aria-label={`남은 벽 ${left}개`} role="img">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<i key={i} className={i < left ? undefined : 'used'} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: QuoridorSeatView; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
|
||||
const u = players[x.playerId] ?? { id: x.playerId, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
|
||||
const active = v.phase === 'playing' && v.turn === x.seat;
|
||||
const now = useServerNow(500);
|
||||
const tc = v.timeControl;
|
||||
let clock: React.ReactNode = null;
|
||||
if (x.status === 'playing' && tc.kind === 'fischer') {
|
||||
const ms = Math.max(0, v.remainingMs[x.seat] - (active ? now - v.turnStartedAt : 0));
|
||||
const s = Math.ceil(ms / 1000);
|
||||
clock = (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: active && s <= 10 ? 'var(--danger)' : undefined }}>
|
||||
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
} else if (active) {
|
||||
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
|
||||
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
|
||||
}
|
||||
const state =
|
||||
x.status === 'finished'
|
||||
? `${x.finishOrder}등 도착`
|
||||
: x.status === 'resigned'
|
||||
? '기권'
|
||||
: x.status === 'timedOut'
|
||||
? '시간패'
|
||||
: `남은 길 ${x.distance}칸${x.autoMoveStreak > 0 ? ` · 자리 비움 ${x.autoMoveStreak}번` : ''}`;
|
||||
return (
|
||||
<div
|
||||
className="card row"
|
||||
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${active ? SEAT_COLOR[x.seat] : 'transparent'}`, opacity: x.status === 'resigned' || x.status === 'timedOut' ? 0.6 : 1 }}
|
||||
>
|
||||
<svg width={28} height={28} viewBox="-0.5 -0.5 1 1" aria-hidden>
|
||||
<PawnMark seat={x.seat} cx={0} cy={0} r={0.45} />
|
||||
</svg>
|
||||
<Avatar user={u} size={32} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{x.playerId === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="muted small" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{COLOR_KO[x.seat]} · {state}
|
||||
</div>
|
||||
</div>
|
||||
<WallBars left={x.wallsLeft} total={v.wallsPerPlayer} />
|
||||
<span className="small" style={{ fontWeight: 800, minWidth: '2ch', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }} aria-hidden>
|
||||
{x.wallsLeft}
|
||||
</span>
|
||||
{clock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoridorSide({ view: v, me, players, act, pending, deadline }: BoardProps<QuoridorView>) {
|
||||
const [confirm, setConfirm] = useState<null | 'resign' | 'draw'>(null);
|
||||
const mine = v.seats.find((x) => x.seat === v.mySeat);
|
||||
const live = v.phase === 'playing' && mine?.status === 'playing';
|
||||
const incoming =
|
||||
live && v.mySeat
|
||||
? v.undo.pending && v.undo.pending.by !== v.mySeat && !v.undo.pending.accepted.includes(v.mySeat)
|
||||
? 'undo'
|
||||
: v.draw.pendingBy && v.draw.pendingBy !== v.mySeat && !v.draw.accepted.includes(v.mySeat)
|
||||
? 'draw'
|
||||
: null
|
||||
: null;
|
||||
const waitingOthers = live && v.mySeat && ((v.undo.pending && (v.undo.pending.by === v.mySeat || v.undo.pending.accepted.includes(v.mySeat))) || (v.draw.pendingBy && (v.draw.pendingBy === v.mySeat || v.draw.accepted.includes(v.mySeat))));
|
||||
const outgoing = !!v.mySeat && (v.undo.pending?.by === v.mySeat || v.draw.pendingBy === v.mySeat);
|
||||
const undoLeft = v.mySeat ? v.undo.remaining[v.mySeat] : null;
|
||||
const requester = v.undo.pending?.by ?? v.draw.pendingBy;
|
||||
// 내 자리가 맨 아래, 나머지는 차례 순서대로 위에.
|
||||
const order = mine ? [...v.seats.filter((x) => x.seat !== mine.seat), mine] : v.seats;
|
||||
const four = v.seats.length === 4;
|
||||
return (
|
||||
<>
|
||||
{order.map((x) => (
|
||||
<PlayerRow key={x.seat} v={v} x={x} players={players} deadline={deadline} me={me} />
|
||||
))}
|
||||
{live && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: v.undo.allowed && v.draw.allowed ? '1fr 1fr 1fr' : '1fr 1fr', gap: 8 }}>
|
||||
{v.undo.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!requester || undoLeft === 0 || !v.moves.some((m) => m.kind !== 'pass' && m.seat === v.mySeat)} onClick={() => act({ type: 'requestUndo' })} title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}>
|
||||
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
|
||||
</button>
|
||||
)}
|
||||
{v.draw.allowed && (
|
||||
<button className="btn btn-secondary btn-sm" disabled={!!requester} onClick={() => setConfirm('draw')}>
|
||||
🤝 무승부
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
|
||||
🏳 기권
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{waitingOthers && (
|
||||
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
|
||||
<span className="small">다른 사람의 답을 기다리는 중…</span>
|
||||
{outgoing && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
|
||||
요청 취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<MoveList view={v} />
|
||||
{incoming && requester && (
|
||||
<Sheet onClose={() => {}} label="다른 사람의 요청">
|
||||
<h2>{incoming === 'undo' ? `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무르기를 요청했어요` : `${SEAT_KO[requester]}(${COLOR_KO[requester]})이 무승부를 제안했어요`}</h2>
|
||||
<p className="muted">
|
||||
{incoming === 'undo'
|
||||
? `수락하면 ${v.undo.pending!.plies}개의 행동이 되돌려져요.${four ? ' 다른 사람도 모두 수락해야 해요.' : ''}`
|
||||
: `수락하면 이 판은 무승부로 끝나요.${four ? ' 남은 사람이 모두 수락해야 해요.' : ''}`}
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
|
||||
수락
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirm === 'resign' && (
|
||||
<ConfirmSheet
|
||||
title="기권할까요?"
|
||||
body={four ? '기권하면 내 말이 판에서 빠지고, 남은 벽은 사라져요.' : '기권하면 이 판은 상대가 이겨요.'}
|
||||
confirm="기권하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'resign' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveList({ view: v }: { view: QuoridorView }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.moveCount]);
|
||||
if (v.moveCount === 0) return null;
|
||||
let n = 0;
|
||||
return (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록 ({v.moves.filter((m) => m.kind !== 'pass').length}수)</summary>
|
||||
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 132, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{v.moves.map((m, i) => (
|
||||
<li key={i} className={`small${m.kind === 'pass' ? ' muted' : ''}`}>
|
||||
{m.kind !== 'pass' && <span className="muted">{++n}. </span>}
|
||||
<span style={{ color: SEAT_COLOR[m.seat], fontWeight: 800 }} aria-hidden>
|
||||
●{' '}
|
||||
</span>
|
||||
{moveText(m, swapFor(v.mySeat))}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(v: QuoridorView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summaryKo ?? '게임이 끝났어요';
|
||||
const mine = v.seats.find((x) => x.seat === v.mySeat);
|
||||
if (mine && mine.status === 'playing') {
|
||||
if (v.undo.pending && v.undo.pending.by !== mine.seat && !v.undo.pending.accepted.includes(mine.seat)) return '무르기 요청이 왔어요';
|
||||
if (v.draw.pendingBy && v.draw.pendingBy !== mine.seat && !v.draw.accepted.includes(mine.seat)) return '무승부 제안이 왔어요';
|
||||
}
|
||||
if (mine && v.turn === mine.seat && mine.status === 'playing') {
|
||||
return mine.wallsLeft > 0 ? '내 차례예요. 말을 움직이거나 벽을 세워요' : '내 차례예요. 남은 벽이 없어서 말을 움직여요';
|
||||
}
|
||||
const turnSeat = v.seats.find((x) => x.seat === v.turn)!;
|
||||
const nick = players[turnSeat.playerId]?.nickname ?? SEAT_KO[v.turn];
|
||||
if (mine?.status === 'finished') return `도착했어요! ${nick}님 차례예요`;
|
||||
return mine ? `${nick}님(${COLOR_KO[v.turn]})이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange, label: aria }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={aria}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label: title, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{title}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: [string, string, Partial<QuoridorOptions['timeControl']>][] = [
|
||||
['pm15', '한 수 15초', { kind: 'perMove', perMoveSec: 15 }],
|
||||
['pm30', '한 수 30초', { kind: 'perMove', perMoveSec: 30 }],
|
||||
['pm60', '한 수 60초', { kind: 'perMove', perMoveSec: 60 }],
|
||||
['f3', '3분 + 2초', { kind: 'fischer', baseSec: 180, incSec: 2 }],
|
||||
['f5', '5분 + 3초', { kind: 'fischer', baseSec: 300, incSec: 3 }],
|
||||
['none', '시간 제한 없음', { kind: 'none' }],
|
||||
];
|
||||
|
||||
function timeKey(tc: QuoridorOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return 'none';
|
||||
if (tc.kind === 'perMove') return `pm${tc.perMoveSec}`;
|
||||
return `f${Math.round(tc.baseSec / 60)}`;
|
||||
}
|
||||
|
||||
function timeLabel(tc: QuoridorOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||
const m = Math.floor(tc.baseSec / 60);
|
||||
const sec = tc.baseSec % 60;
|
||||
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<QuoridorOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<QuoridorOptions>) => onChange({ ...o, ...patch });
|
||||
const onOff = (v: boolean) => (v ? 'on' : 'off');
|
||||
const known = TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl));
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="시간">
|
||||
<Seg
|
||||
label="시간"
|
||||
value={timeKey(o.timeControl)}
|
||||
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(known ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]])]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="2명일 때 1인당 벽 수">
|
||||
<Seg
|
||||
label="2명일 때 1인당 벽 수"
|
||||
value={o.wallsPerPlayer}
|
||||
options={[...([10, 8, 6] as const).map((n) => [n, n === 10 ? '10개(기본)' : `${n}개(빠른 게임)`] as [number, string]), ...([10, 8, 6].includes(o.wallsPerPlayer) ? [] : [[o.wallsPerPlayer, `${o.wallsPerPlayer}개`] as [number, string]])]}
|
||||
onChange={(wallsPerPlayer) => set({ wallsPerPlayer })}
|
||||
/>
|
||||
<span className="small muted">4명이면 1인당 5개로 정해져 있어요. 3명은 할 수 없어요.</span>
|
||||
</Field>
|
||||
<Field label="내 최단 길 힌트">
|
||||
<Seg label="내 최단 길 힌트" value={onOff(o.showPath)} options={[['on', '점선으로 보여 주기'], ['off', '안 보여 주기']]} onChange={(k) => set({ showPath: k === 'on' })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="대각선으로 넘어갈 수 있는 때">
|
||||
<Seg
|
||||
label="대각선으로 넘어갈 수 있는 때"
|
||||
value={o.diagonalJumpTrigger}
|
||||
options={[
|
||||
['wallEdgePawn', '말 뒤가 벽·판 끝·다른 말'],
|
||||
['wallOnly', '말 뒤가 벽일 때만'],
|
||||
]}
|
||||
onChange={(diagonalJumpTrigger) => set({ diagonalJumpTrigger })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="시작 자리">
|
||||
<Seg label="시작 자리" value={o.seatOrder} options={[['random', '무작위'], ['joinOrder', '들어온 순서']]} onChange={(seatOrder) => set({ seatOrder })} />
|
||||
</Field>
|
||||
<Field label="4명일 때 1등이 나오면">
|
||||
<Seg label="4명일 때 1등이 나오면" value={onOff(o.continueForRanking)} options={[['off', '바로 끝내기'], ['on', '계속해서 2·3등 가리기']]} onChange={(k) => set({ continueForRanking: k === 'on' })} />
|
||||
</Field>
|
||||
{o.timeControl.kind === 'perMove' && (
|
||||
<Field label="시간이 다 되면">
|
||||
<Seg label="시간이 다 되면" value={o.timeoutPolicy} options={[['autoMove', '최단 길로 한 칸 자동 이동'], ['lose', '시간패']]} onChange={(timeoutPolicy) => set({ timeoutPolicy })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="무르기">
|
||||
<Seg label="무르기" value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</Field>
|
||||
<Field label="무승부 제안">
|
||||
<Seg label="무승부 제안" value={onOff(o.drawOffer)} options={[['on', '허용'], ['off', '안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: QuoridorOptions): string {
|
||||
return [timeLabel(o.timeControl), `벽 ${o.wallsPerPlayer}개(2명)`, ...(o.showPath ? ['길 힌트'] : []), ...(o.diagonalJumpTrigger === 'wallOnly' ? ['대각은 벽 뒤만'] : []), ...(o.continueForRanking ? ['순위 끝까지'] : [])].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
/** 규칙 그림: 5×5 조각판. pawns: [열, 행(위=0), 자리], walls: [앵커 열, 앵커 행, 방향], marks: 갈 수 있는 칸. */
|
||||
function Diagram({ pawns, walls = [], marks = [], caption }: { pawns: [number, number, Seat][]; walls?: [number, number, O, boolean?][]; marks?: [number, number][]; caption: string }) {
|
||||
const n = 5;
|
||||
return (
|
||||
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||
<svg viewBox={`-0.1 -0.1 ${n + 0.2} ${n + 0.2}`} style={{ width: '100%', maxWidth: 170, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 8 }} aria-hidden>
|
||||
{Array.from({ length: n * n }, (_, i) => (
|
||||
<rect key={i} x={(i % n) + G / 2} y={Math.floor(i / n) + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.35)" />
|
||||
))}
|
||||
{marks.map(([x, y]) => (
|
||||
<g key={`m${x}${y}`}>
|
||||
<rect x={x + G / 2} y={y + G / 2} width={1 - G} height={1 - G} rx={0.08} fill="rgba(255,255,255,0.92)" stroke="var(--primary)" strokeWidth={0.06} />
|
||||
<circle cx={x + 0.5} cy={y + 0.5} r={0.11} fill="var(--primary)" />
|
||||
</g>
|
||||
))}
|
||||
{walls.map(([x, y, o, red], i) => (
|
||||
<rect key={`w${i}`} {...wallRect({ x, y }, o)} rx={0.05} fill={red ? 'rgba(220, 38, 38, 0.7)' : '#3b2412'} stroke={red ? '#7f1d1d' : '#f3e2c0'} strokeWidth={0.03} strokeDasharray={red ? '0.12 0.08' : undefined} />
|
||||
))}
|
||||
{pawns.map(([x, y, s]) => (
|
||||
<PawnMark key={`p${x}${y}`} seat={s} cx={x + 0.5} cy={y + 0.5} />
|
||||
))}
|
||||
</svg>
|
||||
<figcaption className="small muted" style={{ marginTop: 4 }}>
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: QuoridorOptions }) {
|
||||
const wallOnly = options?.diagonalJumpTrigger === 'wallOnly';
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
9×9 판에서 <strong>내 말을 반대편 끝줄까지 먼저 보내면</strong> 이겨요. 2명이면 마주 보고, 4명이면 네 방향에서 시작해요(3명은 할 수 없어요). 화면에서는 내 목표 줄이 항상 위쪽이고, 판 가장자리의 색 띠가 각자의 목표 줄이에요.
|
||||
</p>
|
||||
<p>
|
||||
내 차례에는 <strong>말을 한 칸 움직이거나</strong>, <strong>벽을 하나 세워요</strong>(둘 중 하나만). 벽은 2명이면 1인당 {options?.wallsPerPlayer ?? 10}개, 4명이면 5개예요. 한 번 세운 벽은 옮기거나 치울 수 없어요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>말 이동</strong>: 위·아래·왼쪽·오른쪽으로 한 칸. 벽이 있거나 다른 말이 있는 칸으로는 못 가요. 대각선 이동은 기본으로는 안 돼요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>뛰어넘기</strong>: 바로 앞에 다른 말이 있으면 그 말을 곧게 뛰어넘을 수 있어요. 넘을 말 뒤가 {wallOnly ? '벽으로 막혀 있으면' : '벽·판 끝·다른 말로 막혀 있으면'} 그 말의 왼쪽이나 오른쪽(대각선)으로 갈 수 있어요. 곧게 넘을 수 있을 때는 대각선으로 못 가고, 한 번에 말 하나만 넘을 수 있어요.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} marks={[[2, 1], [1, 3], [3, 3], [2, 4]]} caption="① 앞의 말을 곧게 뛰어넘기" />
|
||||
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h']]} marks={[[1, 2], [3, 2], [1, 3], [3, 3], [2, 4]]} caption="② 뒤가 벽이면 대각선으로" />
|
||||
<Diagram pawns={[[2, 3, 'S'], [2, 2, 'N']]} walls={[[2, 1, 'h'], [1, 1, 'v']]} marks={[[3, 2], [1, 3], [3, 3], [2, 4]]} caption="③ 한쪽도 벽이면 다른 쪽만" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>벽 세우기</strong>: 벽은 칸 2개 길이로, 칸 사이 홈에 세워요. 다른 벽과 겹치거나 같은 자리에서 십자로 엇갈릴 수 없어요. 그리고 <strong>누구의 길도 완전히 막으면 안 돼요</strong>. 돌아가더라도 모든 사람에게 목표 줄까지 가는 길이 하나는 남아 있어야 해요(말은 길을 막는 것으로 치지 않아요).
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h'], [3, 0, 'v', true]]} caption="④ 길을 완전히 막는 벽은 안 돼요" />
|
||||
<Diagram pawns={[[2, 0, 'N']]} walls={[[1, 0, 'v'], [2, 0, 'h']]} marks={[[3, 0]]} caption="⑤ 돌아갈 길이 남으면 괜찮아요" />
|
||||
</div>
|
||||
<p>
|
||||
<strong>세우는 방법</strong>: "벽 세우기"를 누르고 칸 사이 교차점을 누르면 벽이 반투명하게 미리 보여요. 같은 곳을 한 번 더 누르거나 "↻" 버튼으로 가로·세로를 바꾸고, "벽 세우기 확인"을 누르면 세워져요. 세울 수 없는 자리면 빨갛게 보이고 이유가 나와요. 키보드로는 방향키로 고르고 R로 방향을 바꾸고 Enter로 세워요(W로 모드 바꾸기).
|
||||
</p>
|
||||
<p>
|
||||
<strong>이기는 방법</strong>: 목표 줄의 아무 칸에나 닿는 순간 이겨요. 4명일 때 나머지 순위는 남은 길이가 짧은 순서예요{options?.continueForRanking ? '(이 방은 1등이 나와도 계속해서 2·3등을 가려요)' : ''}. 움직일 곳도 세울 벽도 없으면 자동으로 차례를 넘겨요.
|
||||
</p>
|
||||
<p className="muted">팁: 벽은 아끼는 것도 전략이에요. 상대가 목표에 가까워졌을 때 길을 크게 돌아가게 만드는 벽이 가장 효과가 커요. 미리보기에 "상대 길 +몇 칸"이 나와요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<QuoridorView, QuoridorOptions> = {
|
||||
Board: QuoridorBoard,
|
||||
Side: QuoridorSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase === 'playing' && !!v.mySeat && v.seats.some((x) => x.seat === v.turn && x.playerId === me && x.status === 'playing'),
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
47
apps/web/src/games/registry.ts
Normal file
47
apps/web/src/games/registry.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { GameUI } from './types';
|
||||
|
||||
/** Game screens are code-split: each is downloaded only when that game is opened (docs/09 §6). */
|
||||
export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
omok: () => import('./omok'),
|
||||
othello: () => import('./othello'),
|
||||
chess: () => import('./chess'),
|
||||
janggi: () => import('./janggi'),
|
||||
baduk: () => import('./baduk'),
|
||||
seotda: () => import('./seotda'),
|
||||
poker: () => import('./poker'),
|
||||
yacht: () => import('./yacht'),
|
||||
yut: () => import('./yut'),
|
||||
'one-card': () => import('./one-card'),
|
||||
rummikub: () => import('./rummikub'),
|
||||
'davinci-code': () => import('./davinci-code'),
|
||||
mahjong: () => import('./mahjong'),
|
||||
gostop: () => import('./gostop'),
|
||||
bang: () => import('./bang'),
|
||||
'halli-galli': () => import('./halli-galli'),
|
||||
marble: () => import('./marble'),
|
||||
dalmuti: () => import('./dalmuti'),
|
||||
splendor: () => import('./splendor'),
|
||||
quoridor: () => import('./quoridor'),
|
||||
'las-vegas': () => import('./las-vegas'),
|
||||
'cockroach-poker': () => import('./cockroach-poker'),
|
||||
'ninety-nine': () => import('./ninety-nine'),
|
||||
dobble: () => import('./dobble'),
|
||||
catchmind: () => import('./catchmind'),
|
||||
mafia: () => import('./mafia'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
export async function loadGameUI(id: string): Promise<GameUI | null> {
|
||||
const hit = cache.get(id);
|
||||
if (hit) return hit;
|
||||
const loader = GAME_UI[id];
|
||||
if (!loader) return null;
|
||||
const mod = (await loader()).default;
|
||||
cache.set(id, mod);
|
||||
return mod;
|
||||
}
|
||||
|
||||
export function cachedGameUI(id: string): GameUI | null {
|
||||
return cache.get(id) ?? null;
|
||||
}
|
||||
823
apps/web/src/games/rummikub/index.tsx
Normal file
823
apps/web/src/games/rummikub/index.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
/** 숫자 타일 화면 (docs/games/rummikub.md §9). Tap a tile to pick it up, then tap where it goes. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
// Pure tile/set helpers only (no zod): keeps this lazy chunk small.
|
||||
import { findMeld, normalizeSet, pickDisjoint, setProblem, sortTiles, tileLabel, tileOf, type DraftSet, type JokerAs, type TileColor } from '@bg/games/rummikub/rules';
|
||||
import type { LogEntry, RummikubOptions, RummikubView } from '@bg/games/rummikub';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
|
||||
/** Colour + shape mark so colours never have to be told apart by hue alone (§9). */
|
||||
const MARK: Record<TileColor, string> = { K: '●', R: '♥', B: '◆', O: '★' };
|
||||
|
||||
const CSS = `
|
||||
.rk-root { width: 100%; align-self: stretch; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rk-players { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rk-player { display: flex; align-items: center; gap: 6px; background: var(--surface); color: var(--text); border: 3px solid transparent;
|
||||
border-radius: 12px; padding: 4px 10px 4px 6px; box-shadow: var(--shadow); font-size: .9rem; min-height: 44px; }
|
||||
.rk-player.turn { border-color: var(--focus); }
|
||||
.rk-player.gone { opacity: .55; }
|
||||
.rk-player .rk-count { font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.rk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .75rem; line-height: 1.6; }
|
||||
.rk-table { flex: 1; min-height: 200px; overflow: auto; background: var(--felt); border-radius: var(--radius-lg); padding: 10px;
|
||||
display: flex; flex-wrap: wrap; align-content: flex-start; align-items: flex-start; gap: 10px 12px; box-shadow: inset 0 2px 10px rgba(0,0,0,.25); }
|
||||
.rk-table-head { flex-basis: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .92rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); }
|
||||
.rk-empty { color: rgba(255,255,255,.85); font-size: .95rem; padding: 6px 2px; }
|
||||
.rk-set { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; padding: 4px; border-radius: 10px; border: 3px solid transparent; background: rgba(0,0,0,.18); max-width: 100%; }
|
||||
.rk-set.ok { border-color: #86efac; }
|
||||
.rk-set.bad { border-color: #fca5a5; border-style: dashed; background: rgba(127,29,29,.35); }
|
||||
.rk-set.changed { animation: rk-flash 1s ease-out 2; }
|
||||
@keyframes rk-flash { 0% { box-shadow: 0 0 0 5px #fde047; } 100% { box-shadow: 0 0 0 0 rgba(253,224,71,0); } }
|
||||
.rk-problem { flex-basis: 100%; font-size: .8rem; font-weight: 700; color: #fff; background: #b91c1c; border-radius: 6px; padding: 1px 6px; }
|
||||
.rk-setinfo { flex-basis: 100%; display: flex; gap: 6px; align-items: center; justify-content: space-between; font-size: .78rem; color: rgba(255,255,255,.9); }
|
||||
.rk-setinfo button { background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.5); border-radius: 6px; min-height: 28px; padding: 0 6px; cursor: pointer; font-size: .78rem; }
|
||||
.rk-tile { --w: 44px; --h: 60px; --tc: #111827; position: relative; width: var(--w); height: var(--h); flex: none; padding: 0; border-radius: 8px;
|
||||
background: linear-gradient(#fffdf7, #f1e7cf); border: 1px solid #c9b98f; box-shadow: 0 2px 0 #b5a172, 0 3px 6px rgba(0,0,0,.25);
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; color: var(--tc); font-weight: 900; line-height: 1;
|
||||
font-family: inherit; transition: transform .2s ease, box-shadow .2s ease; user-select: none; }
|
||||
button.rk-tile { cursor: pointer; }
|
||||
button.rk-tile:disabled { cursor: default; }
|
||||
.rk-tile[data-c=K] { --tc: #111827; }
|
||||
.rk-tile[data-c=R] { --tc: #c62828; }
|
||||
.rk-tile[data-c=B] { --tc: #1d4ed8; }
|
||||
.rk-tile[data-c=O] { --tc: #a14a00; }
|
||||
.rk-n { font-size: calc(var(--h) * .44); font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
|
||||
.rk-m { font-size: calc(var(--h) * .2); margin-top: 3px; }
|
||||
.rk-jk { display: block; width: calc(var(--w) * .62); height: calc(var(--w) * .62); }
|
||||
.rk-jkas { position: absolute; right: 2px; top: 1px; font-size: .62rem; font-weight: 800; color: #374151; }
|
||||
.rk-tile[aria-pressed=true] { transform: translateY(-7px); box-shadow: 0 0 0 3px var(--focus), 0 6px 10px rgba(0,0,0,.3); }
|
||||
.rk-tile.hint { box-shadow: 0 0 0 3px #a855f7, 0 3px 6px rgba(0,0,0,.25); }
|
||||
.rk-tile.fresh::after { content: '새'; position: absolute; top: -7px; left: -5px; background: var(--primary); color: var(--primary-text); font-size: .62rem; font-weight: 800; border-radius: 999px; padding: 1px 4px; }
|
||||
.rk-tile.sm { --w: 38px; --h: 52px; }
|
||||
.rk-tile.xs { --w: 28px; --h: 38px; box-shadow: 0 1px 0 #b5a172; }
|
||||
.rk-tile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.rk-slot { min-width: 30px; height: 52px; border: 2px dashed rgba(255,255,255,.85); border-radius: 8px; background: rgba(255,255,255,.14); color: #fff;
|
||||
font-weight: 900; font-size: 1rem; cursor: pointer; padding: 0 4px; }
|
||||
.rk-slot:hover, .rk-newset:hover { background: rgba(255,255,255,.28); }
|
||||
.rk-newset { border: 2px dashed rgba(255,255,255,.9); color: #fff; background: rgba(255,255,255,.12); border-radius: 10px; min-height: 60px; padding: 0 16px; font-weight: 800; font-size: 1rem; cursor: pointer; }
|
||||
.rk-meld { flex-basis: 100%; display: flex; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .9rem; }
|
||||
.rk-meld progress { flex: 1; height: 12px; accent-color: #facc15; }
|
||||
.rk-bar { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
|
||||
.rk-bar .btn { min-height: 52px; padding: 0 6px; font-size: .98rem; }
|
||||
.rk-msg { margin: 0; min-height: 1.4em; text-align: center; font-size: .92rem; }
|
||||
.rk-rackwrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
.rk-rackhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.rk-rackhead .segmented button { min-height: 40px; }
|
||||
.rk-rack { background: var(--wood); border-radius: var(--radius-lg); padding: 10px 8px 14px; display: flex; flex-wrap: wrap; gap: 5px; min-height: 84px;
|
||||
box-shadow: inset 0 -7px 0 var(--wood-line); align-content: flex-start; }
|
||||
.rk-rack-empty { color: #3b2a0a; font-weight: 700; align-self: center; }
|
||||
.rk-result { display: grid; gap: 6px; }
|
||||
.rk-result-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rk-mini { display: inline-flex; gap: 2px; flex-wrap: wrap; }
|
||||
@media (max-width: 1099px) {
|
||||
.rk-table { overflow: visible; min-height: 160px; }
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.rk-bar { position: sticky; bottom: 0; z-index: 5; background: var(--bg); padding: 6px 0; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.rk-bar .btn { min-height: 48px; font-size: .95rem; }
|
||||
.rk-tile.sm { --w: 34px; --h: 47px; }
|
||||
.rk-slot { height: 47px; min-width: 28px; }
|
||||
.rk-table { padding: 8px; gap: 8px; }
|
||||
.rk-player { font-size: .85rem; padding: 3px 8px 3px 4px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rk-tile { transition: none; }
|
||||
.rk-set.changed { animation: none; box-shadow: 0 0 0 3px #fde047; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- tile
|
||||
|
||||
function JokerFace() {
|
||||
// Four coloured quarters + a ring: not a face, not a copy of any commercial joker design.
|
||||
return (
|
||||
<svg className="rk-jk" viewBox="0 0 40 40" aria-hidden>
|
||||
<path d="M20 20 L20 3 A17 17 0 0 1 37 20 Z" fill="#c62828" />
|
||||
<path d="M20 20 L37 20 A17 17 0 0 1 20 37 Z" fill="#1d4ed8" />
|
||||
<path d="M20 20 L20 37 A17 17 0 0 1 3 20 Z" fill="#a14a00" />
|
||||
<path d="M20 20 L3 20 A17 17 0 0 1 20 3 Z" fill="#111827" />
|
||||
<circle cx="20" cy="20" r="8" fill="#fffdf7" />
|
||||
<text x="20" y="24" textAnchor="middle" fontSize="11" fontWeight="900" fill="#111827">J</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
id: string;
|
||||
size?: 'md' | 'sm' | 'xs';
|
||||
as?: JokerAs;
|
||||
selected?: boolean;
|
||||
hint?: boolean;
|
||||
fresh?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function Tile({ id, size = 'md', as, selected, hint, fresh, onClick }: TileProps) {
|
||||
const t = tileOf(id);
|
||||
const cls = `rk-tile${size !== 'md' ? ` ${size}` : ''}${hint ? ' hint' : ''}${fresh ? ' fresh' : ''}`;
|
||||
const body =
|
||||
t.kind === 'joker' ? (
|
||||
<>
|
||||
<JokerFace />
|
||||
{as && <span className="rk-jkas">{as.n}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="rk-n">{t.n}</span>
|
||||
<span className="rk-m" aria-hidden>
|
||||
{MARK[t.color]}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const label = `${tileLabel(id, as)}${fresh ? ', 새로 가져온 타일' : ''}`;
|
||||
const dataC = t.kind === 'num' ? t.color : undefined;
|
||||
if (!onClick) {
|
||||
return (
|
||||
<span className={cls} data-c={dataC} role="img" aria-label={label}>
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cls} data-c={dataC} aria-pressed={!!selected} aria-label={label} onClick={onClick}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- draft editing
|
||||
|
||||
/** Keeps a set in a readable order: the server's interpretation when valid, otherwise as placed. */
|
||||
function arrange(d: DraftSet): DraftSet {
|
||||
const n = normalizeSet(d);
|
||||
const tileIds = n ? n.tileIds : d.tileIds;
|
||||
return d.as ? { tileIds, as: d.as } : { tileIds };
|
||||
}
|
||||
|
||||
type Target = { set: number; at: 'start' | 'end' } | 'new' | 'rack';
|
||||
|
||||
/** Moves `sel` to the target; tiles that were on the table at turn start never go back to the rack. */
|
||||
function moveTiles(sets: DraftSet[], sel: string[], target: Target, onTable0: Set<string>): DraftSet[] | null {
|
||||
const moving = target === 'rack' ? sel.filter((id) => !onTable0.has(id)) : sel;
|
||||
if (!moving.length) return null;
|
||||
const ordered = sortTiles(moving, 'number');
|
||||
const next: DraftSet[] = sets.map((d) => ({ ...d, tileIds: d.tileIds.filter((id) => !moving.includes(id)) }));
|
||||
let touched: DraftSet | null = null;
|
||||
if (target === 'new') {
|
||||
touched = { tileIds: ordered };
|
||||
next.push(touched);
|
||||
} else if (target !== 'rack') {
|
||||
touched = next[target.set]!;
|
||||
touched.tileIds = target.at === 'start' ? [...ordered, ...touched.tileIds] : [...touched.tileIds, ...ordered];
|
||||
}
|
||||
return next.filter((d) => d.tileIds.length > 0).map((d) => (d === touched ? arrange(d) : d));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function RummikubBoard({ view: v, me, players, act, pending, deadline }: BoardProps<RummikubView>) {
|
||||
const my = v.my;
|
||||
const mine = v.phase === 'playing' && !!me && v.current === me;
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [hintIds, setHintIds] = useState<string[]>([]);
|
||||
const [local, setLocal] = useState<DraftSet[] | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'color' | 'number'>(() => (localStorage.getItem('rk-sort') === 'number' ? 'number' : 'color'));
|
||||
const [msg, setMsg] = useState('');
|
||||
const [confirm, setConfirm] = useState<null | 'draw' | 'commit'>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const wasMine = useRef(false);
|
||||
const lastLog = useRef<string>('');
|
||||
|
||||
const serverSets: DraftSet[] = useMemo(
|
||||
() => (v.draft ? v.draft.map((d) => (d.as ? { tileIds: d.tileIds, as: d.as } : { tileIds: d.tileIds })) : v.table.map((t) => ({ tileIds: t.tileIds, as: t.kind }))),
|
||||
[v.draft, v.table],
|
||||
);
|
||||
// Optimistic draft until the server answers (cleared when nothing is pending any more).
|
||||
useEffect(() => {
|
||||
if (!pending) setLocal(null);
|
||||
}, [pending, v.turnNo, v.draftEdits]);
|
||||
const sets = mine && local ? local : serverSets;
|
||||
const onTable0 = useMemo(() => new Set(v.table.flatMap((t) => t.tileIds)), [v.table]);
|
||||
const inSets = useMemo(() => new Set(sets.flatMap((d) => d.tileIds)), [sets]);
|
||||
const rack = useMemo(() => sortTiles((my?.rack ?? []).filter((id) => !inSets.has(id)), sortBy), [my?.rack, inSets, sortBy]);
|
||||
const fresh = new Set(my?.lastDrawn ?? []);
|
||||
|
||||
// Turn change: clear selection and hints, announce.
|
||||
useEffect(() => {
|
||||
setSel([]);
|
||||
setHintIds([]);
|
||||
setMsg('');
|
||||
if (mine && !wasMine.current) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
}
|
||||
wasMine.current = mine;
|
||||
}, [v.turnNo, mine]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const e = v.log[v.log.length - 1];
|
||||
if (!e) return;
|
||||
const key = `${v.match.gameNo}-${e.turn}-${e.kind}`;
|
||||
if (key === lastLog.current) return;
|
||||
lastLog.current = key;
|
||||
if (e.by !== me) setAnnounce(logText(e, players, me));
|
||||
}, [v.log]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Drop selected tiles that disappeared (e.g. after undo on the server).
|
||||
useEffect(() => {
|
||||
setSel((s) => (s.every((id) => inSets.has(id) || rack.includes(id)) ? s : s.filter((id) => inSets.has(id) || rack.includes(id))));
|
||||
}, [inSets, rack]);
|
||||
|
||||
const send = (next: DraftSet[]) => {
|
||||
setLocal(next);
|
||||
act({ type: 'setDraft', sets: next, turn: v.turnNo });
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (!mine) return;
|
||||
setHintIds([]);
|
||||
setMsg('');
|
||||
setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
};
|
||||
|
||||
const move = (target: Target) => {
|
||||
if (!mine || !sel.length) return;
|
||||
if (!my?.melded && !v.options.initialMeldAllowExtend && sel.some((id) => onTable0.has(id))) {
|
||||
setMsg('첫 등록 전에는 바닥의 타일을 옮길 수 없어요. 내 받침대 타일만 골라 주세요.');
|
||||
return;
|
||||
}
|
||||
if (target === 'rack' && sel.some((id) => onTable0.has(id))) setMsg('바닥에 있던 타일은 받침대로 가져올 수 없어요.');
|
||||
else setMsg('');
|
||||
const next = moveTiles(sets, sel, target, onTable0);
|
||||
if (!next) return;
|
||||
setSel([]);
|
||||
send(next);
|
||||
};
|
||||
|
||||
const toggleKind = (i: number) => {
|
||||
const d = sets[i]!;
|
||||
const cur = v.draft?.[i]?.kind ?? 'run';
|
||||
send(sets.map((x, k) => (k === i ? { tileIds: d.tileIds, as: cur === 'run' ? 'group' : 'run' } : x)));
|
||||
};
|
||||
|
||||
const hint = () => {
|
||||
const free = rack;
|
||||
let found: string[][] = [];
|
||||
if (!my?.melded) found = (findMeld(free, v.options.initialMeldPoints) ?? []).map((m) => m.tileIds);
|
||||
else found = pickDisjoint(free).sets.slice(0, 1).map((m) => m.tileIds);
|
||||
if (!found.length) {
|
||||
setHintIds([]);
|
||||
setMsg(my?.melded ? '받침대 타일만으로 바로 만들 수 있는 묶음이 없어요. 바닥 묶음에 붙여 보거나 한 장 가져오세요.' : `받침대 타일만으로 ${v.options.initialMeldPoints}점을 만들기 어려워요. 한 장 가져오세요.`);
|
||||
return;
|
||||
}
|
||||
setHintIds(found.flat());
|
||||
setSel(found[0]!);
|
||||
setMsg(found.length > 1 ? `보라색 타일로 묶음 ${found.length}개를 만들 수 있어요. 첫 묶음을 골라 두었어요. "새 묶음"을 눌러 보세요.` : '보라색 타일로 묶음을 만들 수 있어요. 골라 두었으니 "새 묶음"을 눌러 보세요.');
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (my?.check && !my.check.ok && v.options.invalidCommitPenalty === 'penalty3' && my.changed && my.check.fromRack > 0) {
|
||||
setConfirm('commit');
|
||||
return;
|
||||
}
|
||||
act({ type: 'commit', turn: v.turnNo });
|
||||
};
|
||||
const draw = () => {
|
||||
if (my?.changed) setConfirm('draw');
|
||||
else act({ type: v.poolCount ? 'draw' : 'pass' });
|
||||
};
|
||||
|
||||
const check = my?.check ?? null;
|
||||
const viewDraft = v.draft;
|
||||
const showingDraft = !!viewDraft;
|
||||
const thinking = v.current && v.current !== me && v.draftEdits > 0;
|
||||
const curName = v.current ? nick(players, v.current) : '';
|
||||
const selFromSets = sel.some((id) => inSets.has(id) && !onTable0.has(id));
|
||||
|
||||
return (
|
||||
<div className="rk-root">
|
||||
<style>{CSS}</style>
|
||||
<Players v={v} me={me} players={players} deadline={deadline} />
|
||||
|
||||
<section className="rk-table" aria-label="바닥">
|
||||
<div className="rk-table-head">
|
||||
<span>
|
||||
{showingDraft && !mine ? `${curName}님이 고민 중인 배치` : mine && my?.changed ? '바닥(확정 전)' : '바닥'}
|
||||
{thinking && !showingDraft ? ` · ${curName}님이 생각 중…` : ''}
|
||||
</span>
|
||||
<span aria-label={`남은 타일 ${v.poolCount}장`}>남은 타일 {v.poolCount}장</span>
|
||||
</div>
|
||||
{mine && my && !my.melded && (
|
||||
<div className="rk-meld">
|
||||
<span>
|
||||
첫 등록 {check?.meldPoints ?? 0} / {v.options.initialMeldPoints}점
|
||||
</span>
|
||||
<progress max={v.options.initialMeldPoints || 1} value={Math.min(check?.meldPoints ?? 0, v.options.initialMeldPoints)} aria-label="첫 등록 점수" />
|
||||
</div>
|
||||
)}
|
||||
{sets.length === 0 && !(mine && sel.length) && <p className="rk-empty">아직 바닥에 놓인 타일이 없어요.</p>}
|
||||
{sets.map((d, i) => {
|
||||
const info = mine || showingDraft ? (local ? null : viewDraft?.[i]) : null;
|
||||
const n = info ? null : normalizeSet(d);
|
||||
const ok = info ? info.ok : !!n;
|
||||
const problem = info ? info.problem : n ? null : setProblem(d);
|
||||
const jokerAs = info ? info.jokerAs : (n?.jokerAs ?? {});
|
||||
const tableSet = !mine && !showingDraft ? v.table[i] : undefined;
|
||||
const changed = !!tableSet && v.lastChanged.includes(tableSet.id);
|
||||
const label = `묶음 ${i + 1}: ${d.tileIds.map((id) => tileLabel(id, jokerAs[id])).join(', ')}${mine || showingDraft ? (ok ? ' (올바름)' : ` (고쳐야 함: ${problem ?? ''})`) : ''}`;
|
||||
return (
|
||||
<div
|
||||
key={tableSet ? `${tableSet.id}-${changed ? v.turnNo : 0}` : `d${i}`}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={`rk-set${mine || showingDraft ? (ok ? ' ok' : ' bad') : ''}${changed ? ' changed' : ''}`}
|
||||
>
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 앞에 놓기`} onClick={() => move({ set: i, at: 'start' })}>
|
||||
◀
|
||||
</button>
|
||||
)}
|
||||
{d.tileIds.map((id) => (
|
||||
<Tile key={id} id={id} size="sm" as={jokerAs[id]} selected={sel.includes(id)} onClick={mine ? () => toggle(id) : undefined} />
|
||||
))}
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 뒤에 놓기`} onClick={() => move({ set: i, at: 'end' })}>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
{(mine || showingDraft) && !ok && problem && <span className="rk-problem">{problem}</span>}
|
||||
{mine && info?.ambiguous && (
|
||||
<span className="rk-setinfo">
|
||||
<span>{info.kind === 'group' ? '같은 숫자 묶음으로 봐요' : '이어진 숫자로 봐요'}</span>
|
||||
<button type="button" onClick={() => toggleKind(i)}>
|
||||
{info.kind === 'group' ? '이어진 숫자로' : '같은 숫자로'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-newset" onClick={() => move('new')}>
|
||||
+ 새 묶음
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{mine && (
|
||||
<>
|
||||
<p className="rk-msg" aria-live="polite" style={{ color: msg || (check && !check.ok && my?.changed) ? 'var(--danger)' : 'var(--text-sub)' }}>
|
||||
{msg ||
|
||||
(sel.length
|
||||
? `${sel.length}장 골랐어요. 놓을 곳(◀ ▶ 또는 새 묶음)을 누르세요.`
|
||||
: my?.changed
|
||||
? check?.ok
|
||||
? '좋아요! 확정을 누르면 차례가 끝나요.'
|
||||
: (check?.reason ?? '')
|
||||
: '타일을 눌러 고른 뒤 놓을 곳을 누르세요.')}
|
||||
</p>
|
||||
<div className="rk-bar" role="group" aria-label="내 차례 행동">
|
||||
<button type="button" className="btn btn-secondary" disabled={!my?.canUndo || pending} onClick={() => act({ type: 'undoDraft' })}>
|
||||
되돌리기
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={!my?.changed || pending} onClick={() => act({ type: 'resetDraft' })}>
|
||||
처음으로
|
||||
</button>
|
||||
{selFromSets ? (
|
||||
<button type="button" className="btn btn-soft" onClick={() => move('rack')}>
|
||||
받침대로
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-soft" disabled={!v.options.hints} onClick={hint} title={v.options.hints ? '받침대 타일로 바로 낼 수 있는 묶음을 찾아 줘요' : '이 방은 힌트를 쓰지 않아요'}>
|
||||
도와줘
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-soft" disabled={!sel.length} onClick={() => move('new')}>
|
||||
새 묶음
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={pending} onClick={draw}>
|
||||
{v.poolCount ? '한 장 가져오기' : '넘기기'}
|
||||
</button>
|
||||
<button type="button" className={`btn ${check?.ok ? 'btn-primary' : 'btn-secondary'}`} disabled={!my?.changed || pending} onClick={commit} aria-describedby="rk-commit-help">
|
||||
확정
|
||||
</button>
|
||||
</div>
|
||||
<span id="rk-commit-help" className="sr-only">
|
||||
{check?.ok ? '지금 확정할 수 있어요' : (check?.reason ?? '')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.phase !== 'playing' && v.round && <RoundPanel v={v} me={me} players={players} act={act} pending={pending} deadline={deadline} />}
|
||||
|
||||
{my && v.phase === 'playing' && (
|
||||
<div className="rk-rackwrap">
|
||||
<div className="rk-rackhead">
|
||||
<strong>
|
||||
내 받침대 <span className="muted small">({my.rack.length}장{rack.length !== my.rack.length ? `, ${my.rack.length - rack.length}장은 바닥에` : ''})</span>
|
||||
</strong>
|
||||
<div className="segmented" role="group" aria-label="받침대 정렬">
|
||||
{(
|
||||
[
|
||||
['color', '색깔순'],
|
||||
['number', '숫자순'],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
aria-pressed={sortBy === k}
|
||||
onClick={() => {
|
||||
setSortBy(k);
|
||||
localStorage.setItem('rk-sort', k);
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rk-rack" role="group" aria-label="내 받침대">
|
||||
{rack.length === 0 && <span className="rk-rack-empty">{my.rack.length ? '모두 바닥에 놓았어요.' : '타일이 없어요.'}</span>}
|
||||
{rack.map((id) => (
|
||||
<Tile key={id} id={id} selected={sel.includes(id)} hint={hintIds.includes(id)} fresh={fresh.has(id)} onClick={mine ? () => toggle(id) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{confirm === 'draw' && (
|
||||
<ConfirmSheet
|
||||
title="놓은 타일을 되돌리고 한 장 가져올까요?"
|
||||
body="이번 차례에 바꾼 배치는 모두 처음으로 돌아가요."
|
||||
confirm={v.poolCount ? '되돌리고 한 장 가져오기' : '되돌리고 넘기기'}
|
||||
onConfirm={() => act({ type: v.poolCount ? 'draw' : 'pass' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'commit' && (
|
||||
<ConfirmSheet
|
||||
title="틀린 묶음이 있어요. 그래도 확정할까요?"
|
||||
body={`${check?.reason ?? ''} 지금 확정하면 놓은 타일이 돌아오고 3장을 가져와요.`}
|
||||
confirm="그래도 확정"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'commit', turn: v.turnNo })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Players({ v, me, players, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
|
||||
return (
|
||||
<div className="rk-players" role="list" aria-label="참가자">
|
||||
{v.seats.map((s) => {
|
||||
const turn = v.current === s.id;
|
||||
const u = userOf(players, s.id);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
role="listitem"
|
||||
className={`rk-player${turn ? ' turn' : ''}${s.left || !s.inRound ? ' gone' : ''}`}
|
||||
aria-label={`${u.nickname}${s.id === me ? '(나)' : ''}, 타일 ${s.tiles}장, ${s.melded ? '첫 등록 함' : '첫 등록 전'}${turn ? ', 지금 차례' : ''}${s.left ? ', 나감' : s.away ? ', 자리 비움' : ''}`}
|
||||
>
|
||||
<Avatar user={u} size={26} />
|
||||
<span style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{u.nickname}
|
||||
{s.id === me && <span className="muted"> (나)</span>}
|
||||
</span>
|
||||
<span className="rk-count" title="받침대 타일 수">
|
||||
{s.tiles}장
|
||||
</span>
|
||||
{!s.melded && s.inRound && (
|
||||
<span className="rk-badge" style={{ background: 'var(--surface-2)', color: 'var(--text-sub)' }}>
|
||||
첫 등록 전
|
||||
</span>
|
||||
)}
|
||||
{s.left && (
|
||||
<span className="rk-badge" style={{ background: 'var(--danger)', color: '#fff' }}>
|
||||
나감
|
||||
</span>
|
||||
)}
|
||||
{!s.left && s.away && (
|
||||
<span className="rk-badge" style={{ background: 'var(--warning)', color: '#fff' }}>
|
||||
자리 비움
|
||||
</span>
|
||||
)}
|
||||
{v.match.total > 1 && <span className="small muted">{s.wins}승</span>}
|
||||
{turn && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine={s.id === me} size={30} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundPanel({ v, me, players, act, pending, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; deadline: number | null }) {
|
||||
const r = v.round!;
|
||||
const title =
|
||||
r.endReason === 'rackEmpty'
|
||||
? `${r.winners.map((w) => nick(players, w)).join(', ')}님이 타일을 모두 내려놓았어요! 다 냈다!`
|
||||
: r.endReason === 'stalemate'
|
||||
? '더 낼 수 있는 사람이 없어 끝났어요. 남은 타일 점수가 적은 사람이 이겨요.'
|
||||
: '판이 끝났어요.';
|
||||
const order = r.ranking.flat();
|
||||
return (
|
||||
<div className="card stack rk-result" style={{ padding: 12 }} aria-live="polite">
|
||||
<strong>
|
||||
{v.match.total > 1 ? `${r.gameNo}판 결과 · ` : ''}
|
||||
{title}
|
||||
</strong>
|
||||
{order.map((p) => (
|
||||
<div key={p} className="rk-result-row">
|
||||
<strong style={{ minWidth: 70 }}>
|
||||
{nick(players, p)}
|
||||
{r.winners.includes(p) ? ' 🏆' : ''}
|
||||
</strong>
|
||||
<span style={{ fontWeight: 800, color: r.scores[p]! >= 0 ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{r.scores[p]! > 0 ? '+' : ''}
|
||||
{r.scores[p]}점
|
||||
</span>
|
||||
<span className="small muted">남은 타일 {r.rackValues[p]}점</span>
|
||||
<span className="rk-mini" aria-label={`${nick(players, p)}님의 남은 타일`}>
|
||||
{(r.revealed[p] ?? []).map((id) => (
|
||||
<Tile key={id} id={id} size="xs" />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="small muted">점수는 이 방에서 순위를 정하는 데만 써요.</span>
|
||||
{v.phase === 'roundEnd' && (
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
{v.my?.needReady ? (
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'ready' })}>
|
||||
다음 판 시작
|
||||
</button>
|
||||
) : (
|
||||
<span className="small muted">{me && v.ready.includes(me) ? '다른 사람을 기다리는 중이에요.' : '다음 판을 기다리는 중이에요.'}</span>
|
||||
)}
|
||||
<TurnTimer deadline={deadline} totalMs={30_000} mine={!!v.my?.needReady} size={36} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const who = e.by === me ? '내가' : `${nick(players, e.by)}님이`;
|
||||
switch (e.kind) {
|
||||
case 'commit':
|
||||
return `${who} ${e.count}장을 냈어요.`;
|
||||
case 'timeoutCommit':
|
||||
return `시간이 다 되어 ${who} 놓아 둔 ${e.count}장이 확정됐어요.`;
|
||||
case 'draw':
|
||||
return `${who} 1장을 가져왔어요.`;
|
||||
case 'timeoutDraw':
|
||||
return `시간이 다 되어 ${who} ${e.count}장을 가져왔어요.`;
|
||||
case 'penalty':
|
||||
return `${who} 묶음을 다 맞추지 못해 ${e.count}장을 가져왔어요.`;
|
||||
case 'pass':
|
||||
return `${who} 넘겼어요.`;
|
||||
case 'left':
|
||||
return `${nick(players, e.by)}님이 나갔어요.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function RummikubSide({ view: v, me, players }: BoardProps<RummikubView>) {
|
||||
const order = [...v.seats].sort((a, b) => b.wins - a.wins || b.total - a.total);
|
||||
const lastRound = v.opening.length ? Math.max(...v.opening.map((o) => o.round)) : 0;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'}</strong>
|
||||
<span className="small muted">남은 타일 {v.poolCount}장</span>
|
||||
</div>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{order.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.left ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.left ? ' · 나감' : ''}
|
||||
</span>
|
||||
<span className="small">{s.tiles}장</span>
|
||||
{v.match.total > 1 && (
|
||||
<span className="small muted">
|
||||
{s.wins}승 · {s.total > 0 ? '+' : ''}
|
||||
{s.total}점
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{lastRound > 0 && v.match.gameNo >= 1 && (
|
||||
<span className="small muted">
|
||||
선 정하기:{' '}
|
||||
{v.opening
|
||||
.filter((o) => o.round === lastRound)
|
||||
.map((o) => `${nick(players, o.player)} ${o.n === null ? '조커(다시)' : o.n}`)
|
||||
.join(' · ')}
|
||||
{lastRound > 1 ? ` (${lastRound}번째 뽑기)` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.log.length > 0 && (
|
||||
<details className="card" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={`${e.turn}-${i}`} className="small">
|
||||
{logText(e, players, me)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
<details className="card wide-open" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>규칙 요약</summary>
|
||||
<Rules options={v.options} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: RummikubView, me: string | null): boolean {
|
||||
if (!me) return false;
|
||||
if (v.phase === 'roundEnd') return !!v.my?.needReady;
|
||||
return v.phase === 'playing' && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: RummikubView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'roundEnd') return v.my?.needReady ? '판이 끝났어요. 다음 판을 시작해 주세요' : '다음 판을 기다리는 중이에요';
|
||||
if (v.current === me && v.my) {
|
||||
if (!v.my.melded) return `내 차례예요 · 첫 등록 ${v.my.check?.meldPoints ?? 0}/${v.options.initialMeldPoints}점`;
|
||||
return '내 차례예요. 타일을 내거나 한 장 가져오세요';
|
||||
}
|
||||
if (!v.current) return '잠시만 기다려 주세요';
|
||||
return `${nick(players, v.current)}님 차례예요${v.draftEdits > 0 ? ' · 생각 중' : ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, help, children }: { label: string; help?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<RummikubOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<RummikubOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="첫 등록 최소 점수" help="처음 낼 때는 받침대 타일만으로 만든 묶음의 숫자 합이 이 점수 이상이어야 해요.">
|
||||
<Seg label="첫 등록 최소 점수" value={o.initialMeldPoints} options={[[0, '없음'], [20, '20점'], [30, '30점(공식)'], [50, '50점']]} onChange={(initialMeldPoints) => set({ initialMeldPoints })} />
|
||||
</Field>
|
||||
<Field label="한 차례 제한 시간" help="어르신이나 아이와 할 때는 넉넉하게 골라 주세요.">
|
||||
<Seg label="한 차례 제한 시간" value={o.turnSeconds} options={[[60, '1분'], [90, '1분 30초'], [120, '2분'], [180, '3분']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<Field label="판 수">
|
||||
<Seg label="판 수" value={o.gamesPerMatch} options={[['1', '1판'], ['players', '인원수만큼'], ['2', '2판'], ['3', '3판'], ['4', '4판']]} onChange={(gamesPerMatch) => set({ gamesPerMatch })} />
|
||||
</Field>
|
||||
<Field label='"도와줘" 힌트 버튼'>
|
||||
<Seg label="힌트 버튼" value={o.hints} options={[[true, '켜기'], [false, '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="시간이 다 됐을 때 올바르게 놓아 둔 타일">
|
||||
<Seg label="시간 초과 때 올바른 배치" value={o.timeoutValidDraft} options={[['commit', '자동으로 확정'], ['revert', '되돌리고 한 장 가져오기(공식)']]} onChange={(timeoutValidDraft) => set({ timeoutValidDraft })} />
|
||||
</Field>
|
||||
<Field label="시간이 다 됐는데 덜 맞췄을 때 가져올 장수">
|
||||
<Seg label="시간 초과 벌칙" value={o.invalidTimeoutPenalty} options={[[1, '1장'], [3, '3장(공식)']]} onChange={(invalidTimeoutPenalty) => set({ invalidTimeoutPenalty })} />
|
||||
</Field>
|
||||
<Field label="틀린 채로 확정을 누르면">
|
||||
<Seg label="틀린 확정" value={o.invalidCommitPenalty} options={[['reject', '알려 주고 계속 고치기'], ['penalty3', '되돌리고 3장 가져오기']]} onChange={(invalidCommitPenalty) => set({ invalidCommitPenalty })} />
|
||||
</Field>
|
||||
<Field label="받침대에 남은 조커 감점">
|
||||
<Seg label="조커 감점" value={o.jokerPenalty} options={[[30, '30점(공식)'], [50, '50점(대회)']]} onChange={(jokerPenalty) => set({ jokerPenalty })} />
|
||||
</Field>
|
||||
<Field label="첫 등록하는 차례에 바닥 묶음에도 붙이기" help="켜면 첫 등록 점수를 새 묶음으로 채운 뒤, 같은 차례에 바닥 묶음을 고칠 수 있어요.">
|
||||
<Seg label="첫 등록 차례 재배치" value={o.initialMeldAllowExtend} options={[[false, '안 됨(공식)'], [true, '허용']]} onChange={(initialMeldAllowExtend) => set({ initialMeldAllowExtend })} />
|
||||
</Field>
|
||||
<Field label="고민 중인 배치를 다른 사람에게 보이기" help="켜면 바닥에 올려 본 타일이 바로 보여요. 다시 되돌려도 이미 본 타일은 기억에 남아요.">
|
||||
<Seg label="고민 중인 배치 공개" value={o.showDraftToOthers} options={[[false, '안 보이기'], [true, '보이기']]} onChange={(showDraftToOthers) => set({ showDraftToOthers })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: RummikubOptions): string {
|
||||
const games = o.gamesPerMatch === 'players' ? '인원수만큼' : `${o.gamesPerMatch}판`;
|
||||
const sec = o.turnSeconds % 60 ? `${Math.floor(o.turnSeconds / 60)}분 ${o.turnSeconds % 60}초` : `${o.turnSeconds / 60}분`;
|
||||
return [o.initialMeldPoints ? `첫 등록 ${o.initialMeldPoints}점` : '첫 등록 점수 없음', `한 차례 ${sec}`, games, o.jokerPenalty === 50 ? '조커 50점' : '', o.showDraftToOthers ? '배치 공개' : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Example({ ids }: { ids: string[] }) {
|
||||
const n = normalizeSet({ tileIds: ids });
|
||||
return (
|
||||
<span className="rk-mini" style={{ verticalAlign: 'middle', margin: '2px 0' }}>
|
||||
{ids.map((id) => (
|
||||
<Tile key={id} id={id} size="xs" as={n?.jokerAs[id]} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: RummikubOptions }) {
|
||||
const o = options;
|
||||
const meld = o?.initialMeldPoints ?? 30;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
1부터 13까지 숫자가 적힌 4가지 색 타일(색마다 모양 표시 ● ♥ ◆ ★)이 두 벌씩 104장, 조커 2장이 있어요. 각자 14장을 받고, 내 받침대의 타일을 가장 먼저 모두 내려놓으면 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>묶음 두 가지</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>
|
||||
같은 숫자, 다른 색 3~4장 <Example ids={['K7a', 'R7a', 'B7a']} />
|
||||
</li>
|
||||
<li>
|
||||
같은 색, 이어지는 숫자 3장 이상 <Example ids={['B3a', 'B4a', 'B5a', 'B6a']} /> (13 다음에 1은 올 수 없어요)
|
||||
</li>
|
||||
<li>
|
||||
조커는 아무 타일이나 대신해요 <Example ids={['O9a', 'O10a', 'JK1']} />
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>내 차례</strong>: 타일을 1장 이상 내고 "확정"을 누르거나, "한 장 가져오기"를 눌러요. 타일을 누른 뒤 놓을 곳(◀ 앞, ▶ 뒤, 새 묶음)을 누르면 옮겨져요. 확정할 때 바닥의 모든 묶음이 올바라야 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>첫 등록</strong>:{' '}
|
||||
{meld ? `처음 낼 때는 받침대 타일만으로 만든 새 묶음의 숫자 합이 ${meld}점 이상이어야 해요. 조커는 대신하는 숫자로 세요.` : '이 방은 첫 등록 점수가 없어요.'}{' '}
|
||||
{o?.initialMeldAllowExtend ? '첫 등록 차례에도 점수를 채운 뒤에는 바닥 묶음을 고칠 수 있어요.' : '첫 등록 전에는 바닥 묶음을 건드릴 수 없어요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>바닥 고치기</strong>: 첫 등록 뒤에는 바닥의 묶음을 쪼개고 합치고 옮겨서 내 타일을 끼워 넣을 수 있어요. 예를 들어 빨강 4-5-6-7-8 사이에 내 빨강 6을 넣어 4-5-6과 6-7-8로 나눌 수 있어요. 바닥 타일은 받침대로 가져올 수 없고, 조커를 빼 왔다면 같은 차례에 다시 바닥에 써야 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>시간</strong>: 한 차례는 {o ? `${o.turnSeconds}초` : '1분'}예요. 시간이 다 되면{' '}
|
||||
{o?.timeoutValidDraft === 'revert' ? '놓아 둔 타일을 되돌리고 1장을 가져와요' : '올바르게 놓아 둔 타일은 자동으로 확정돼요'}. 묶음이 틀린 채로 끝나면 처음으로 되돌리고 {o?.invalidTimeoutPenalty ?? 3}장을 가져와요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>점수</strong>: 누가 다 내려놓으면 나머지는 받침대 숫자 합만큼 빼고(조커 {o?.jokerPenalty ?? 30}점), 이긴 사람이 그만큼 더해요. 남은 타일이 없는데 아무도 못 내면 받침대 합이 가장 적은 사람이 이겨요. 점수는 이 방의 순위에만 써요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<RummikubView, RummikubOptions> = {
|
||||
Board: RummikubBoard,
|
||||
Side: RummikubSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
831
apps/web/src/games/seotda/index.tsx
Normal file
831
apps/web/src/games/seotda/index.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
/** 섯다 화면 (docs/games/seotda.md §9). Chips are room-only play points. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
BetType,
|
||||
CardSlot,
|
||||
PotOutcome,
|
||||
SeotdaCardView,
|
||||
SeotdaOptions,
|
||||
SeotdaSeatView,
|
||||
SeotdaView,
|
||||
SpecialKind,
|
||||
} from '@bg/games/seotda';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
|
||||
import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const BET_ORDER: BetType[] = ['die', 'check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full'];
|
||||
const BET_KO: Record<BetType, string> = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' };
|
||||
const BET_HELP: Record<BetType, string> = {
|
||||
check: '칩을 걸지 않고 넘겨요',
|
||||
ping: '기본 칩만큼 걸어요',
|
||||
call: '앞사람만큼 맞춰요',
|
||||
ddadang: '앞사람이 건 만큼의 두 배',
|
||||
quarter: '모인 칩의 4분의 1만큼 더',
|
||||
half: '모인 칩의 절반만큼 더',
|
||||
full: '모인 칩만큼 더',
|
||||
die: '이번 판을 포기해요',
|
||||
};
|
||||
const STRENGTH_COLOR = { high: 'var(--success)', mid: 'var(--warning)', low: 'var(--text-sub)' } as const;
|
||||
const STRENGTH_KO = { high: '센 패', mid: '중간 패', low: '약한 패' } as const;
|
||||
const REMATCH_KO = { gusa: '구사', mungGusa: '멍텅구리구사', tie: '동점' } as const;
|
||||
const REASON_KO: Record<PotOutcome['reason'], string> = {
|
||||
normal: '',
|
||||
ttaengjabi: '땡잡이',
|
||||
amhaeng: '암행어사',
|
||||
gusa: '구사 재경기',
|
||||
mungGusa: '멍텅구리구사 재경기',
|
||||
tie: '동점',
|
||||
limit: '재경기 횟수 초과로 나눔',
|
||||
fold: '모두 다이',
|
||||
};
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
|
||||
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const isCard = (c: CardSlot): c is SeotdaCardView => !('hidden' in c);
|
||||
|
||||
/** Illustration card for the rules / 족보표 (no rule code needed on the client). */
|
||||
function illus(month: number, special: boolean): HwatuInfo {
|
||||
const gwang = [1, 3, 8].includes(month);
|
||||
const kind: HwatuInfo['kind'] = special ? (gwang ? 'gwang' : 'yeol') : month === 8 ? 'pi' : 'tti';
|
||||
return {
|
||||
id: (month - 1) * 4 + (special ? 0 : 1),
|
||||
month,
|
||||
kind,
|
||||
...(kind === 'tti' ? { tti: 'plain' as const } : {}),
|
||||
name: `${month}월 ${special ? (gwang ? '광' : '열끗') : month === 8 ? '기러기' : '띠'}`,
|
||||
};
|
||||
}
|
||||
|
||||
function totalMsOf(v: SeotdaView): number {
|
||||
const o = v.options;
|
||||
if (v.phase === 'rebuy' || v.handPhase === 'rejoin') return o.decisionSeconds * 1000;
|
||||
if (v.handPhase === 'choose') return (o.turnSeconds + 10) * 1000;
|
||||
if (v.handPhase === 'handEnd') return 4000;
|
||||
const turn = v.seats.find((s) => s.id === v.toAct);
|
||||
return turn?.away ? 5000 : o.turnSeconds * 1000;
|
||||
}
|
||||
|
||||
function specialFlash(v: SeotdaView): string | null {
|
||||
const pots = v.showdown?.pots ?? [];
|
||||
if (pots.some((p) => p.reason === 'ttaengjabi')) return '땡잡이!';
|
||||
if (pots.some((p) => p.reason === 'amhaeng')) return '암행어사 출두!';
|
||||
if (pots.some((p) => p.type === 'rematch' && p.reason === 'gusa')) return '구사 재경기';
|
||||
if (pots.some((p) => p.type === 'rematch' && p.reason === 'mungGusa')) return '멍텅구리구사 재경기';
|
||||
if (pots.some((p) => p.type === 'rematch')) return '동점 재경기';
|
||||
return null;
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.sd-table { position: relative; width: 100%; aspect-ratio: 16 / 11; border-radius: 180px; border: 10px solid var(--wood);
|
||||
background: radial-gradient(ellipse at center, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 60%), var(--felt); box-shadow: var(--shadow); }
|
||||
.sd-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--seat-w, 168px); z-index: 1;
|
||||
background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
border: 3px solid transparent; display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; transition: border-color .2s; }
|
||||
.sd-seat.turn { border-color: var(--focus); }
|
||||
.sd-seat.me { border-color: var(--primary); }
|
||||
.sd-seat.me.turn { border-color: var(--focus); }
|
||||
.sd-seat.out { opacity: 0.6; }
|
||||
.sd-center { position: absolute; left: 50%; top: 47%; transform: translate(-50%, -50%); text-align: center; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.5); max-width: 46%; }
|
||||
.sd-flash { position: absolute; left: 50%; top: 30%; transform: translate(-50%, -50%); z-index: 3; font-size: 2.6rem; font-weight: 900; color: #fde047;
|
||||
text-shadow: 0 3px 8px rgba(0,0,0,.6); animation: sd-pop .35s ease-out; pointer-events: none; white-space: nowrap; }
|
||||
@keyframes sd-pop { from { transform: translate(-50%, -50%) scale(.5); opacity: 0 } to { transform: translate(-50%, -50%) scale(1); opacity: 1 } }
|
||||
.sd-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.sd-actions .btn { min-height: 64px; flex-direction: column; gap: 0; padding: 0 8px; line-height: 1.15; }
|
||||
.sd-actions .btn small { font-weight: 600; font-size: .85rem; opacity: .9; }
|
||||
.sd-mine { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; justify-content: center; }
|
||||
.sd-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; }
|
||||
@media (max-width: 719px) {
|
||||
.sd-table { aspect-ratio: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 10px; border-radius: 20px; border-width: 6px; }
|
||||
.sd-seat { position: static; transform: none; width: auto; }
|
||||
.sd-center { position: static; transform: none; grid-column: 1 / -1; order: -1; max-width: none; padding: 8px 0; }
|
||||
.sd-flash { position: fixed; top: 40%; }
|
||||
.sd-actions { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function SeatBox({ seat, view: v, players, me, deadline, x, y }: { seat: SeotdaSeatView; view: SeotdaView; players: Record<string, PublicUser>; me: string | null; deadline: number | null; x: number; y: number }) {
|
||||
const u = userOf(players, seat.id);
|
||||
const out = seat.status === 'eliminated' || seat.folded || (!seat.inHand && v.handPhase !== null);
|
||||
const badges: [string, string][] = [];
|
||||
if (seat.left) badges.push(['나감', 'var(--danger)']);
|
||||
else if (seat.away) badges.push(['자리 비움', 'var(--warning)']);
|
||||
if (seat.status === 'eliminated') badges.push(['탈락', 'var(--text-sub)']);
|
||||
if (seat.status === 'busted') badges.push(['리바이 대기', 'var(--warning)']);
|
||||
if (seat.folded) badges.push(['다이', 'var(--danger)']);
|
||||
if (seat.allIn) badges.push(['올인', 'var(--primary)']);
|
||||
if (seat.chosen === true) badges.push(['고름', 'var(--success)']);
|
||||
if (seat.chosen === false) badges.push(['고르는 중', 'var(--text-sub)']);
|
||||
const timed = seat.isTurn || seat.chosen === false;
|
||||
return (
|
||||
<div
|
||||
className={`sd-seat${seat.isTurn ? ' turn' : ''}${seat.id === me ? ' me' : ''}${out ? ' out' : ''}`}
|
||||
style={{ '--x': `${x}%`, '--y': `${y}%` } as React.CSSProperties}
|
||||
aria-label={`${u.nickname} ${formatChips(seat.stack)}${seat.isDealer ? ', 선' : ''}${badges.length ? `, ${badges.map((b) => b[0]).join(', ')}` : ''}`}
|
||||
>
|
||||
<div className="row" style={{ gap: 5, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={u} size={22} />
|
||||
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{u.nickname}
|
||||
{seat.id === me && <span className="muted"> (나)</span>}
|
||||
</strong>
|
||||
{seat.isDealer && (
|
||||
<span className="sd-badge" style={{ background: '#facc15', color: '#422006' }} title="선(먼저 하는 사람)">
|
||||
선
|
||||
</span>
|
||||
)}
|
||||
{timed && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={seat.id === me} size={28} />}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 6, flexWrap: 'nowrap', alignItems: 'flex-start' }}>
|
||||
<div className="stack" style={{ gap: 2, flex: 1, minWidth: 0 }}>
|
||||
<Chips n={seat.stack} strong />
|
||||
{((seat.lastAction && !seat.folded) || seat.roundBet > 0) && (
|
||||
<span className="small" style={{ whiteSpace: 'nowrap' }} title="마지막 행동 · 이번 라운드에 건 칩">
|
||||
{seat.lastAction && !seat.folded && <span className="muted">{BET_KO[seat.lastAction.type]} </span>}
|
||||
{seat.roundBet > 0 && `+${seat.roundBet.toLocaleString('ko-KR')}`}
|
||||
</span>
|
||||
)}
|
||||
<div className="row" style={{ gap: 3 }}>
|
||||
{badges.map(([t, c]) => (
|
||||
<span key={t} className="sd-badge" style={{ background: c, color: '#fff' }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{seat.cards.length > 0 && (
|
||||
<div style={{ display: 'flex', flex: 'none' }}>
|
||||
{seat.cards.map((c, i) => (
|
||||
<HwatuCard key={isCard(c) ? c.key : `h${i}`} card={isCard(c) ? c : null} size="sm" style={i > 0 ? { marginLeft: -14 } : undefined} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seat.handName && <strong style={{ color: 'var(--primary)', textAlign: 'center' }}>{seat.handName}</strong>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterInfo({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
|
||||
const limit = v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : '';
|
||||
// On the result screen the paid-out pots are no longer in v.pot (only a carried rematch pot is).
|
||||
const paid = v.handPhase === 'handEnd' ? (v.foldWin?.amount ?? (v.showdown?.pots ?? []).filter((p) => p.type !== 'rematch').reduce((a, p) => a + p.amount, 0)) : 0;
|
||||
return (
|
||||
<div className="sd-center">
|
||||
{v.rematchNo > 0 && v.rematchReason && (
|
||||
<div className="sd-badge" style={{ background: '#fde047', color: '#422006', fontSize: '1rem', padding: '2px 12px', marginBottom: 6 }}>
|
||||
재경기 {v.rematchNo}회차 · {REMATCH_KO[v.rematchReason]}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.95rem', opacity: 0.9 }}>모인 칩</div>
|
||||
<div style={{ fontSize: '2.2rem', fontWeight: 900, fontVariantNumeric: 'tabular-nums' }} aria-live="polite">
|
||||
{formatChips(v.pot + paid)}
|
||||
</div>
|
||||
{v.pots.length > 1 && (
|
||||
<div className="small" style={{ opacity: 0.9 }}>
|
||||
{v.pots.map((p, i) => (
|
||||
<div key={i}>
|
||||
{i === v.pots.length - 1 ? '메인' : `사이드 ${i + 1}`}: {formatChips(p.amount)} ({p.eligible.map((id) => nick(players, id)).join('·')})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="small" style={{ opacity: 0.85, marginTop: 4 }}>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}${limit}판`} · 학교 {formatChips(v.baseUnit)}
|
||||
{v.currentBet > 0 && v.handPhase === 'betting' ? ` · 이번 라운드 ${formatChips(v.currentBet)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeotdaBoard({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
|
||||
const n = v.seats.length;
|
||||
const myIdx = Math.max(0, v.seats.findIndex((s) => s.id === me));
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevToAct = useRef(v.toAct);
|
||||
const showKey = `${v.handNo}-${v.rematchNo}-${v.handPhase}`;
|
||||
|
||||
useEffect(() => {
|
||||
const f = v.handPhase === 'handEnd' ? specialFlash(v) : null;
|
||||
if (f) {
|
||||
setFlash(f);
|
||||
const t = setTimeout(() => setFlash(null), 1400);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (v.toAct && v.toAct !== prevToAct.current) {
|
||||
if (v.toAct === me) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
} else setAnnounce(`${nick(players, v.toAct)}님 차례예요.`);
|
||||
}
|
||||
prevToAct.current = v.toAct;
|
||||
}, [v.toAct]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (v.handPhase !== 'handEnd') return;
|
||||
if (v.foldWin) setAnnounce(`${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요.`);
|
||||
else if (v.showdown) setAnnounce(resultLine(v, players));
|
||||
const won = v.foldWin?.winner === me || v.showdown?.pots.some((p) => p.type !== 'rematch' && p.winners.includes(me ?? ''));
|
||||
if (me && won) sounds.win();
|
||||
}, [showKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="board-wrap stack" style={{ gap: 12 }}>
|
||||
<style>{CSS}</style>
|
||||
<div className="sd-table" role="region" aria-label="섯다 테이블" style={{ '--seat-w': v.options.cardMode === 'three' ? '196px' : n > 6 ? '150px' : '172px' } as React.CSSProperties}>
|
||||
{v.seats.map((s, i) => {
|
||||
const rel = (i - myIdx + n) % n;
|
||||
const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n;
|
||||
return <SeatBox key={s.id} seat={s} view={v} players={players} me={me} deadline={deadline} x={50 + 40 * Math.cos(ang)} y={50 + 38 * Math.sin(ang)} />;
|
||||
})}
|
||||
<CenterInfo view={v} players={players} />
|
||||
{flash && <div className="sd-flash">{flash}</div>}
|
||||
</div>
|
||||
<MyArea view={v} me={me} players={players} act={act} pending={pending} />
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resultLine(v: SeotdaView, players: Record<string, PublicUser>): string {
|
||||
if (v.foldWin) return `${nick(players, v.foldWin.winner)}님이 ${formatChips(v.foldWin.amount)}을 가져갔어요(모두 다이).`;
|
||||
if (!v.showdown) return '';
|
||||
return v.showdown.pots
|
||||
.map((p) => {
|
||||
const who = p.winners.map((id) => nick(players, id)).join('·');
|
||||
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name ?? '';
|
||||
if (p.type === 'rematch') return `${REASON_KO[p.reason]}: ${who}님이 다시 겨뤄요(${formatChips(p.amount)})`;
|
||||
if (p.type === 'split') return `${who}님이 ${formatChips(p.amount)}을 나눠 가졌어요${p.reason === 'limit' ? '(재경기 횟수 초과)' : ''}`;
|
||||
return `${who}님이 ${name}${p.reason === 'normal' ? '' : ' 발동'}(으)로 ${formatChips(p.amount)}을 가져갔어요`;
|
||||
})
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
/** My cards, hand name, 3장 choice and the action buttons. */
|
||||
function MyArea({ view: v, me, players, act, pending }: { view: SeotdaView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
|
||||
const seat = v.seats.find((s) => s.id === me);
|
||||
const [pick, setPick] = useState<number[]>([]);
|
||||
const [hover, setHover] = useState<BetType | null>(null);
|
||||
useEffect(() => setPick([]), [v.handNo, v.rematchNo, v.handPhase]);
|
||||
if (!me || !seat) return v.handPhase === 'handEnd' ? <ResultPanel view={v} players={players} /> : null;
|
||||
const myCards = seat.cards.filter(isCard);
|
||||
const choosing = v.handPhase === 'choose' && seat.chosen === false;
|
||||
const preview = choosing && pick.length === 2 ? v.myCombos?.find((c) => c.cardIds.includes(pick[0]!) && c.cardIds.includes(pick[1]!)) : null;
|
||||
const myTurn = v.toAct === me && v.handPhase === 'betting';
|
||||
const legal = new Map(v.legal.map((l) => [l.type, l]));
|
||||
const hoverBet = hover ? legal.get(hover) : undefined;
|
||||
const toggle = (id: number) => setPick((p) => (p.includes(id) ? p.filter((x) => x !== id) : p.length >= 2 ? [p[1]!, id] : [...p, id]));
|
||||
const showHand = myCards.length > 0 && seat.inHand;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 10 }}>
|
||||
{showHand && (
|
||||
<div className="card stack" style={{ gap: 10, padding: 12 }}>
|
||||
<div className="sd-mine">
|
||||
{myCards.map((c) => {
|
||||
const chosen = v.myChosen?.includes(c.id);
|
||||
return (
|
||||
<HwatuCard
|
||||
key={c.key}
|
||||
card={c}
|
||||
size="lg"
|
||||
selected={choosing && pick.includes(c.id)}
|
||||
highlight={!!chosen}
|
||||
dimmed={!!v.myChosen && !chosen}
|
||||
onClick={choosing ? () => toggle(c.id) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{v.myHand && (
|
||||
<div className="stack" style={{ gap: 2, minWidth: 160, maxWidth: 320 }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: 900, color: v.myHand.strength ? STRENGTH_COLOR[v.myHand.strength] : undefined }}>
|
||||
지금: {v.myHand.name}
|
||||
{v.myHand.strength && <span className="small" style={{ marginLeft: 6 }}>({STRENGTH_KO[v.myHand.strength]})</span>}
|
||||
</span>
|
||||
<span className="small muted">{v.myHand.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seat.folded && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>이번 판은 다이했어요. 내 패는 나만 볼 수 있어요.</p>}
|
||||
{choosing && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<p style={{ margin: 0, textAlign: 'center' }}>
|
||||
<strong>쓸 카드 2장을 골라 주세요.</strong> 남는 1장은 아무에게도 보이지 않아요.
|
||||
{preview && (
|
||||
<>
|
||||
{' '}
|
||||
고른 조합: <strong>{preview.name}</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
{v.options.hints && v.myCombos && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setPick([...v.myCombos!.find((c) => c.best)!.cardIds])}>
|
||||
추천 조합
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={pick.length !== 2 || pending}
|
||||
onClick={() => act({ type: 'choose', cardIds: [pick[0], pick[1]] })}
|
||||
>
|
||||
{preview ? `${preview.name}(으)로 정하기` : '두 장을 고르세요'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{v.handPhase === 'choose' && seat.chosen === true && <p className="small muted" style={{ margin: 0, textAlign: 'center' }}>골랐어요. 다른 사람을 기다리는 중이에요.</p>}
|
||||
</div>
|
||||
)}
|
||||
{seat.inHand && v.handPhase === 'betting' && !seat.folded && (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="sd-actions" role="group" aria-label="베팅">
|
||||
{BET_ORDER.filter((t) => t !== 'full' || v.options.maxRaise === 'full').map((t) => {
|
||||
const l = legal.get(t);
|
||||
const enabled = myTurn && !!l && !pending;
|
||||
const cls = t === 'die' ? 'btn-danger' : t === 'check' || t === 'call' ? 'btn-primary' : 'btn-soft';
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`btn ${cls}`}
|
||||
disabled={!enabled}
|
||||
title={BET_HELP[t]}
|
||||
onMouseEnter={() => setHover(t)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onFocus={() => setHover(t)}
|
||||
onBlur={() => setHover(null)}
|
||||
onClick={() => act({ type: t })}
|
||||
>
|
||||
<span>{BET_KO[t]}</span>
|
||||
<small>{l && l.amount > 0 ? `${formatChips(l.amount)}${l.allIn ? ' 올인' : ''}` : t === 'die' ? '포기' : l ? '0 칩' : '-'}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="small muted" style={{ margin: 0, minHeight: '1.4em', textAlign: 'center' }}>
|
||||
{myTurn
|
||||
? hoverBet
|
||||
? `${BET_KO[hover!]}: ${BET_HELP[hover!]}. 내고 나면 내 칩 ${formatChips(seat.stack - hoverBet.amount)}, 모인 칩 ${formatChips(v.pot + hoverBet.amount)}`
|
||||
: '내 차례예요. 버튼에 적힌 칩이 바로 나가요.'
|
||||
: v.toAct
|
||||
? `${nick(players, v.toAct)}님 차례예요.`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{v.handPhase === 'handEnd' && <ResultPanel view={v} players={players} />}
|
||||
{v.handPhase === 'handEnd' && seat.status === 'playing' && !seat.left && (
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={seat.ready || pending} onClick={() => act({ type: 'ready' })}>
|
||||
{seat.ready ? '다른 사람을 기다리는 중…' : v.next === 'rematch' ? '재경기 시작' : '다음 판으로'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPanel({ view: v, players }: { view: SeotdaView; players: Record<string, PublicUser> }) {
|
||||
return (
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }} aria-live="polite">
|
||||
<strong>{v.next === 'rematch' ? '재경기가 결정됐어요' : '이번 판 결과'}</strong>
|
||||
{v.foldWin && (
|
||||
<span>
|
||||
{nick(players, v.foldWin.winner)}님이 {formatChips(v.foldWin.amount)}을 가져갔어요. 모두 다이해서 패는 공개하지 않아요.
|
||||
</span>
|
||||
)}
|
||||
{v.showdown?.pots.map((p, i) => {
|
||||
const who = p.winners.map((id) => nick(players, id)).join(' · ');
|
||||
const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name;
|
||||
return (
|
||||
<span key={i}>
|
||||
{v.showdown!.pots.length > 1 ? `${i === v.showdown!.pots.length - 1 ? '메인' : '사이드'} ${formatChips(p.amount)}: ` : `${formatChips(p.amount)}: `}
|
||||
{p.type === 'win' && (
|
||||
<>
|
||||
<strong>{who}</strong> 승리 ({name}
|
||||
{p.reason === 'ttaengjabi' || p.reason === 'amhaeng' ? ' 발동' : ''})
|
||||
</>
|
||||
)}
|
||||
{p.type === 'split' && (
|
||||
<>
|
||||
<strong>{who}</strong> 나눠 가짐{p.reason === 'limit' ? ' (재경기 횟수 초과)' : ' (같은 족보)'}
|
||||
</>
|
||||
)}
|
||||
{p.type === 'rematch' && (
|
||||
<>
|
||||
<strong>{REASON_KO[p.reason]}</strong> — {who}님이 다시 겨뤄요. 칩은 그대로 남아요.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
interface JokboRow {
|
||||
name: string;
|
||||
cards: [number, boolean][];
|
||||
note: string;
|
||||
match: (value: number | null, special: SpecialKind | null) => boolean;
|
||||
}
|
||||
|
||||
function jokboRows(o: SeotdaOptions): JokboRow[] {
|
||||
const v = (lo: number, hi = lo) => (x: number | null, sp: SpecialKind | null) => !sp && x !== null && x >= lo && x <= hi;
|
||||
const rows: JokboRow[] = [
|
||||
{ name: '38광땡', cards: [[3, true], [8, true]], note: '가장 센 패', match: v(1000) },
|
||||
...(o.gwangTtaeng === 'all'
|
||||
? [
|
||||
{ name: '18광땡', cards: [[1, true], [8, true]], note: '', match: v(990) } as JokboRow,
|
||||
{ name: '13광땡', cards: [[1, true], [3, true]], note: '', match: v(980) } as JokboRow,
|
||||
]
|
||||
: []),
|
||||
{ name: '장땡(10땡)', cards: [[10, true], [10, false]], note: '같은 월 두 장 중 최고', match: v(910) },
|
||||
{ name: '9땡 ~ 삥땡(1땡)', cards: [[9, true], [9, false]], note: '숫자가 클수록 셈', match: v(901, 909) },
|
||||
{ name: '알리', cards: [[1, true], [2, false]], note: '1 + 2', match: v(806) },
|
||||
{ name: '독사', cards: [[1, true], [4, false]], note: '1 + 4', match: v(805) },
|
||||
{ name: '구삥', cards: [[1, true], [9, false]], note: '1 + 9', match: v(804) },
|
||||
{ name: '장삥', cards: [[1, true], [10, false]], note: '1 + 10', match: v(803) },
|
||||
{ name: '장사', cards: [[4, false], [10, false]], note: '4 + 10', match: v(802) },
|
||||
{ name: '세륙', cards: [[4, false], [6, false]], note: '4 + 6', match: v(801) },
|
||||
{ name: '갑오(9끗)', cards: [[2, false], [7, false]], note: '두 월을 더한 일의 자리 9', match: v(9) },
|
||||
{ name: '8끗 ~ 1끗', cards: [[3, false], [5, false]], note: '일의 자리가 클수록 셈', match: v(1, 8) },
|
||||
{ name: '망통(0끗)', cards: [[2, false], [8, false]], note: '가장 약한 패', match: v(0) },
|
||||
];
|
||||
const sp = (k: SpecialKind) => (_x: number | null, s: SpecialKind | null) => s === k;
|
||||
if (o.ttaengjabi !== 'off')
|
||||
rows.push({
|
||||
name: '땡잡이',
|
||||
cards: [[3, true], [7, true]],
|
||||
note: `${o.ttaengjabi === 'strict' ? '3월 광 + 7월 열끗' : '3월 + 7월 아무 패'}. 상대 최고가 1~9땡이면 이김, 아니면 망통`,
|
||||
match: sp('ttaengjabi'),
|
||||
});
|
||||
if (o.amhaeng) rows.push({ name: '암행어사', cards: [[4, true], [7, true]], note: '4·7월 열끗. 13·18광땡을 잡음, 아니면 1끗', match: sp('amhaeng') });
|
||||
if (o.gusa) {
|
||||
rows.push({ name: '구사', cards: [[4, false], [9, true]], note: '4 + 9. 상대가 알리 이하면 재경기, 아니면 3끗', match: sp('gusa') });
|
||||
if (o.mungGusa !== 'off') {
|
||||
const upTo = o.mungGusa === 'upTo9' ? '9땡' : o.mungGusa === 'upToJang' ? '장땡' : '8땡';
|
||||
rows.push({ name: '멍텅구리구사', cards: [[4, true], [9, true]], note: `4·9월 열끗. 상대가 ${upTo} 이하면 재경기`, match: sp('mungGusa') });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function JokboTable({ options, mine }: { options: SeotdaOptions; mine?: SeotdaView['myHand'] }) {
|
||||
const rows = jokboRows(options);
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const hit = !!mine && options.hints && r.match(mine.value, mine.special);
|
||||
return (
|
||||
<tr key={r.name} style={{ background: hit ? 'var(--warning-soft)' : undefined, borderBottom: '1px solid var(--border)' }} aria-current={hit ? 'true' : undefined}>
|
||||
<td style={{ padding: '4px 2px', whiteSpace: 'nowrap' }}>
|
||||
{r.cards.map(([m, sp], i) => (
|
||||
<HwatuCard key={i} card={illus(m, sp)} size="sm" style={{ marginRight: 2 }} />
|
||||
))}
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<strong>{r.name}</strong>
|
||||
{hit && <span className="sd-badge" style={{ background: 'var(--warning)', color: '#fff', marginLeft: 4 }}>내 패</span>}
|
||||
{r.note && <div className="small muted">{r.note}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function SeotdaSide({ view: v, me, players, act, pending, deadline }: BoardProps<SeotdaView>) {
|
||||
const [confirmQuit, setConfirmQuit] = useState(false);
|
||||
const ranked = [...v.seats].sort((a, b) => b.stack - a.stack);
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<PlayChipNotice />
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${Math.max(1, v.handNo)}판${v.options.endCondition === 'hands' ? ` / ${v.options.handLimit}판` : ''}`}
|
||||
{v.rematchNo > 0 ? ` · 재경기 ${v.rematchNo}회차` : ''}
|
||||
</strong>
|
||||
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">
|
||||
학교 {formatChips(v.baseUnit)} · {v.options.cardMode === 'three' ? '3장 섯다' : '2장 섯다'} · {v.options.maxRaise === 'full' ? '풀까지' : '하프까지'}
|
||||
</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{ranked.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'eliminated' ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.isDealer ? ' · 선' : ''}
|
||||
{s.rebuys > 0 ? ` · 리바이 ${s.rebuys}` : ''}
|
||||
{s.status === 'eliminated' ? ' · 탈락' : s.left ? ' · 나감' : s.away ? ' · 자리 비움' : ''}
|
||||
</span>
|
||||
<Chips n={s.stack} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{v.result && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{v.result.ranking.map((g, i) => (
|
||||
<span key={i}>
|
||||
{v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')}
|
||||
{v.options.endCondition === 'hands' || v.handsPlayed >= 300
|
||||
? ` (${(v.result!.scores[g[0]!] ?? 0) >= 0 ? '+' : ''}${(v.result!.scores[g[0]!] ?? 0).toLocaleString('ko-KR')} 칩)`
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
<span className="small muted">점수 = 마지막 칩 − 시작 칩 × 리바이 횟수</span>
|
||||
</div>
|
||||
)}
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>족보표</summary>
|
||||
<JokboTable options={v.options} mine={v.myHand} />
|
||||
</details>
|
||||
{v.history.length > 0 && (
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>판 기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
|
||||
{[...v.history].reverse().map((r, i) => (
|
||||
<li key={i} className="small">
|
||||
<span className="muted">
|
||||
{r.handNo}판{r.rematchNo ? `(재${r.rematchNo})` : ''}
|
||||
</span>{' '}
|
||||
{r.how === 'rematch'
|
||||
? `재경기로 넘어감 ${formatChips(r.amount)}`
|
||||
: `${r.winners.map((id) => nick(players, id)).join('·')} ${formatChips(r.amount)}${r.name ? ` · ${r.name}` : ''}${r.how !== 'normal' ? ` · ${REASON_KO[r.how]}` : ''}`}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{v.rejoin?.mine && (
|
||||
<Sheet onClose={() => {}} label="재경기 참가">
|
||||
<h2>구사 재경기에 다시 들어갈까요?</h2>
|
||||
<p className="muted">
|
||||
{formatChips(v.rejoin.cost)}을 모인 칩에 더 내면 재경기에 참가할 수 있어요. 시간이 지나면 참가하지 않아요.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending || !v.rejoin.canAfford} onClick={() => act({ type: 'rejoin', accept: true })}>
|
||||
{v.rejoin.canAfford ? `${formatChips(v.rejoin.cost)} 내고 참가` : '칩이 모자라요'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rejoin', accept: false })}>
|
||||
참가 안 함
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{v.rebuy?.mine && !confirmQuit && (
|
||||
<Sheet onClose={() => {}} label="칩 다시 받기">
|
||||
<h2>칩을 모두 잃었어요</h2>
|
||||
<p className="muted">
|
||||
놀이용 칩 {formatChips(v.options.startingChips)}을 다시 받고 계속할 수 있어요{v.rebuy.left !== null ? ` (남은 횟수 ${v.rebuy.left}번)` : ''}. 점수에서는 다시 받은 만큼 빠져요.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'rebuy', accept: true })}>
|
||||
칩 다시 받기
|
||||
</button>
|
||||
<button className="btn btn-danger" style={{ flex: 1 }} disabled={pending} onClick={() => setConfirmQuit(true)}>
|
||||
그만하기
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
)}
|
||||
{confirmQuit && v.rebuy?.mine && (
|
||||
<ConfirmSheet
|
||||
title="여기서 그만할까요?"
|
||||
body="그만하면 이번 게임에서 탈락하고 구경만 할 수 있어요."
|
||||
confirm="그만하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'rebuy', accept: false })}
|
||||
onClose={() => setConfirmQuit(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: SeotdaView, me: string | null): boolean {
|
||||
if (!me || v.phase === 'finished') return false;
|
||||
if (v.phase === 'rebuy') return !!v.rebuy?.mine;
|
||||
if (v.handPhase === 'betting') return v.toAct === me;
|
||||
if (v.handPhase === 'choose') return v.seats.find((s) => s.id === me)?.chosen === false;
|
||||
if (v.handPhase === 'rejoin') return !!v.rejoin?.mine;
|
||||
return false;
|
||||
}
|
||||
|
||||
function statusText(v: SeotdaView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'rebuy') return v.rebuy?.mine ? '칩을 다시 받을지 골라 주세요' : '칩을 다시 받을지 기다리는 중이에요';
|
||||
switch (v.handPhase) {
|
||||
case 'betting':
|
||||
if (v.toAct === me) return v.myHand && v.options.hints ? `내 차례예요 · 지금: ${v.myHand.name}` : '내 차례예요. 베팅해 주세요';
|
||||
return v.toAct ? `${nick(players, v.toAct)}님 차례예요` : '카드를 나눠 주는 중이에요';
|
||||
case 'choose':
|
||||
return isMyTurn(v, me) ? '쓸 카드 2장을 골라 주세요' : '다른 사람이 카드를 고르는 중이에요';
|
||||
case 'rejoin':
|
||||
return v.rejoin?.mine ? '구사 재경기에 참가할지 골라 주세요' : '다이한 사람이 재경기 참가를 고르는 중이에요';
|
||||
case 'handEnd':
|
||||
return resultLine(v, players) || '판이 끝났어요';
|
||||
default:
|
||||
return '잠시만 기다려 주세요';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<SeotdaOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<SeotdaOptions>) => onChange({ ...o, ...patch });
|
||||
const tooBig = o.baseUnit * 20 > o.startingChips;
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="카드 수">
|
||||
<Seg value={o.cardMode} options={[['two', '2장 섯다'], ['three', '3장 섯다(최대 6명)']]} onChange={(cardMode) => set({ cardMode })} />
|
||||
</Field>
|
||||
{o.cardMode === 'two' && (
|
||||
<Field label="카드 나눠 주기">
|
||||
<Seg value={o.dealMode} options={[['twoRounds', '1장씩 두 번(베팅 2번)'], ['oneRound', '2장 한 번에(베팅 1번)']]} onChange={(dealMode) => set({ dealMode })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="시작 칩">
|
||||
<Seg value={o.startingChips} options={[[500, '500'], [1000, '1,000'], [2000, '2,000'], [5000, '5,000']]} onChange={(startingChips) => set({ startingChips })} />
|
||||
</Field>
|
||||
<Field label="학교(기본 칩) = 삥">
|
||||
<Seg value={o.baseUnit} options={[[2, '2칩'], [10, '10칩'], [20, '20칩'], [50, '50칩']]} onChange={(baseUnit) => set({ baseUnit })} />
|
||||
{tooBig && <span className="field-error">학교는 시작 칩의 20분의 1 이하로 골라 주세요.</span>}
|
||||
</Field>
|
||||
<Field label="끝내는 방식">
|
||||
<Seg value={o.endCondition} options={[['hands', '정해진 판 수'], ['lastStanding', '최후의 1인']]} onChange={(endCondition) => set({ endCondition })} />
|
||||
{o.endCondition === 'hands' && (
|
||||
<Seg value={o.handLimit} options={[[10, '10판'], [20, '20판'], [30, '30판'], [50, '50판']]} onChange={(handLimit) => set({ handLimit })} />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="행동 제한 시간">
|
||||
<Seg value={o.turnSeconds} options={[[15, '15초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="최대 레이즈">
|
||||
<Seg value={o.maxRaise} options={[['half', '하프까지'], ['full', '풀까지']]} onChange={(maxRaise) => set({ maxRaise })} />
|
||||
</Field>
|
||||
<Field label="칩 다시 받기(리바이)">
|
||||
<Seg value={o.rebuy} options={[['off', '안 함(탈락)'], ['limited', '횟수 제한'], ['unlimited', '무제한']]} onChange={(rebuy) => set({ rebuy })} />
|
||||
{o.rebuy === 'limited' && <Seg value={o.rebuyLimit} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(rebuyLimit) => set({ rebuyLimit })} />}
|
||||
</Field>
|
||||
<Field label="학교 올리기">
|
||||
<Seg value={o.schoolUpEvery} options={[[0, '안 올림'], [5, '5판마다 2배'], [10, '10판마다 2배'], [15, '15판마다 2배']]} onChange={(schoolUpEvery) => set({ schoolUpEvery })} />
|
||||
</Field>
|
||||
<Field label="다음 판의 선">
|
||||
<Seg value={o.dealerRule} options={[['winner', '이긴 사람'], ['rotate', '돌아가며']]} onChange={(dealerRule) => set({ dealerRule })} />
|
||||
</Field>
|
||||
<Field label="광땡 종류">
|
||||
<Seg value={o.gwangTtaeng} options={[['all', '38·18·13광땡'], ['only38', '38광땡만']]} onChange={(gwangTtaeng) => set({ gwangTtaeng })} />
|
||||
</Field>
|
||||
<Field label="땡잡이(3·7)">
|
||||
<Seg value={o.ttaengjabi} options={[['strict', '3월 광 + 7월 열끗'], ['loose', '3월 + 7월 아무 패'], ['off', '없음']]} onChange={(ttaengjabi) => set({ ttaengjabi })} />
|
||||
</Field>
|
||||
<Field label="특수패">
|
||||
<Seg value={o.amhaeng ? 'on' : 'off'} options={[['on', '암행어사 켜기'], ['off', '암행어사 끄기']]} onChange={(k) => set({ amhaeng: k === 'on' })} />
|
||||
<Seg value={o.gusa ? 'on' : 'off'} options={[['on', '구사 재경기 켜기'], ['off', '구사 재경기 끄기']]} onChange={(k) => set({ gusa: k === 'on' })} />
|
||||
</Field>
|
||||
{o.gusa && (
|
||||
<>
|
||||
<Field label="멍텅구리구사 재경기 범위">
|
||||
<Seg value={o.mungGusa} options={[['upTo8', '8땡까지'], ['upTo9', '9땡까지'], ['upToJang', '장땡까지'], ['off', '일반 구사로']]} onChange={(mungGusa) => set({ mungGusa })} />
|
||||
</Field>
|
||||
<Field label="구사 재경기 때 다이한 사람">
|
||||
<Seg value={o.rejoinCost} options={[['none', '참가 불가'], ['half', '모인 칩 절반 내고 참가'], ['full', '모인 칩만큼 내고 참가']]} onChange={(rejoinCost) => set({ rejoinCost })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="같은 족보일 때">
|
||||
<Seg value={o.tieRule} options={[['rematch', '재경기'], ['split', '나눠 가짐']]} onChange={(tieRule) => set({ tieRule })} />
|
||||
</Field>
|
||||
<Field label="재경기 최대 횟수(넘으면 나눠 가짐)">
|
||||
<Seg value={o.maxRematches} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(maxRematches) => set({ maxRematches })} />
|
||||
</Field>
|
||||
<Field label="재참가·리바이 답 시간">
|
||||
<Seg value={o.decisionSeconds} options={[[10, '10초'], [15, '15초'], [30, '30초']]} onChange={(decisionSeconds) => set({ decisionSeconds })} />
|
||||
</Field>
|
||||
<Field label="도움 표시">
|
||||
<Seg value={o.hints ? 'on' : 'off'} options={[['on', '내 족보 이름 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ hints: k === 'on' })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: SeotdaOptions): string {
|
||||
return [
|
||||
o.cardMode === 'three' ? '3장 섯다' : o.dealMode === 'twoRounds' ? '2장 섯다(1장씩 두 번)' : '2장 섯다(한 번에)',
|
||||
`시작 ${o.startingChips.toLocaleString('ko-KR')}칩`,
|
||||
`학교 ${o.baseUnit}칩`,
|
||||
o.endCondition === 'hands' ? `${o.handLimit}판` : '최후의 1인',
|
||||
`${o.turnSeconds}초`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: SeotdaOptions }) {
|
||||
const o = options;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
화투 20장(1~10월 각 2장)으로 하는 족보 대결이에요. 각자 받은 <strong>2장의 조합(족보)</strong>이 가장 센 사람이 모인 칩을 가져가요. 칩은 이 방에서만 쓰는 놀이용 점수예요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>진행</strong>: 판마다 모두 <strong>학교(기본 칩)</strong>를 내요. 선부터 카드를 받고 차례로 베팅해요.
|
||||
{o?.cardMode === 'three'
|
||||
? ' 3장 섯다는 2장을 받고 베팅 → 1장을 더 받아 3장 중 쓸 2장을 동시에 몰래 고르고 → 마지막 베팅 후 공개해요. 버린 1장은 끝까지 아무도 못 봐요.'
|
||||
: o?.dealMode === 'oneRound'
|
||||
? ' 2장을 한 번에 받고 한 번 베팅한 뒤 공개해요.'
|
||||
: ' 1장 받고 베팅, 1장 더 받고 베팅한 뒤 남은 사람끼리 패를 공개해요.'}{' '}
|
||||
모두 다이하고 한 명만 남으면 그 사람이 패를 보여 주지 않고 가져가요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>베팅</strong>: 체크(안 걸고 넘김), 삥(기본 칩만큼), 콜(앞사람만큼), 따당(앞사람이 건 만큼의 두 배), 쿼터·하프{o?.maxRaise === 'full' ? '·풀' : ''}(모인 칩의 4분의 1·절반
|
||||
{o?.maxRaise === 'full' ? '·전부' : ''}만큼 더), 다이(포기). 한 라운드에 올리기는 한 번뿐이고, 체크나 콜을 한 뒤에는 같은 라운드에서 올릴 수 없어요. 칩이 모자라면 가진 만큼 내고 올인해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>끗</strong>: 두 장의 월을 더한 값의 일의 자리예요. 7 + 8 = 15 → 5끗, 9 + 10 = 19 → 9끗(갑오), 2 + 8 = 10 → 0끗(망통).
|
||||
</p>
|
||||
<JokboTable options={o ?? ({ gwangTtaeng: 'all', ttaengjabi: 'strict', amhaeng: true, gusa: true, mungGusa: 'upTo8', hints: false } as SeotdaOptions)} />
|
||||
<p>
|
||||
<strong>재경기</strong>: 구사나 같은 족보{o?.tieRule === 'split' ? '(이 방은 동점이면 나눠 가져요)' : ''}로 재경기가 되면 모인 칩은 그대로 두고 남은 사람끼리 새로 카드를 받아요. 학교는 다시 내지 않아요.
|
||||
{o && o.rejoinCost !== 'none' ? ` 구사 재경기 때 다이한 사람은 모인 칩의 ${o.rejoinCost === 'half' ? '절반' : '전부'}만큼 내고 다시 들어올 수 있어요.` : ''} 한 판에서 재경기가{' '}
|
||||
{o?.maxRematches ?? 3}번을 넘으면 나눠 가져요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>선</strong>: 선은 카드를 먼저 받고 먼저 베팅해요. {o?.dealerRule === 'rotate' ? '판마다 다음 사람에게 넘어가요.' : '이긴 사람이 다음 판의 선이 돼요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>칩이 떨어지면</strong>:{' '}
|
||||
{o?.rebuy === 'off' ? '탈락하고 구경해요.' : '칩을 다시 받을지 고를 수 있어요. 다시 받은 만큼 최종 점수에서 빠져요.'}{' '}
|
||||
{o?.endCondition === 'lastStanding' ? '칩을 가진 사람이 한 명 남으면 끝나요.' : `정해진 판 수가 끝나면 (마지막 칩 − 시작 칩 × 다시 받은 횟수)로 순위를 정해요.`}
|
||||
</p>
|
||||
<p>시간 안에 고르지 않으면 걸린 칩이 없을 때는 체크, 있을 때는 다이로 처리돼요. 두 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<SeotdaView, SeotdaOptions> = {
|
||||
Board: SeotdaBoard,
|
||||
Side: SeotdaSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
1238
apps/web/src/games/splendor/index.tsx
Normal file
1238
apps/web/src/games/splendor/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
32
apps/web/src/games/types.ts
Normal file
32
apps/web/src/games/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
export interface BoardProps<V = unknown> {
|
||||
view: V;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
deadline: number | null;
|
||||
active: string[];
|
||||
tapConfirm: boolean;
|
||||
}
|
||||
|
||||
export interface OptionsProps<O = unknown> {
|
||||
value: O;
|
||||
onChange: (next: O) => void;
|
||||
}
|
||||
|
||||
export interface GameUI<V = any, O = any> {
|
||||
/** The board / table only (center of the game screen). */
|
||||
Board: ComponentType<BoardProps<V>>;
|
||||
/** Players, clocks, action buttons, move history (right panel on PC). */
|
||||
Side: ComponentType<BoardProps<V>>;
|
||||
statusText(view: V, me: string | null, players: Record<string, PublicUser>): string;
|
||||
isMyTurn(view: V, me: string | null): boolean;
|
||||
Options: ComponentType<OptionsProps<O>>;
|
||||
optionsSummary(options: O): string;
|
||||
Rules: ComponentType<{ options?: O }>;
|
||||
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
|
||||
ownChat?: boolean;
|
||||
}
|
||||
667
apps/web/src/games/yacht/index.tsx
Normal file
667
apps/web/src/games/yacht/index.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
/** 요트 다이스 화면 (docs/games/yacht.md §9). */
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { Category, ScoreEntry, YachtOptions, YachtSheetView, YachtView } from '@bg/games/yacht';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
// The client draws from the view only; names and hints live here so the rules module isn't bundled.
|
||||
const NAME: Record<Category, string> = {
|
||||
ones: '에이스',
|
||||
twos: '듀스',
|
||||
threes: '트레이',
|
||||
fours: '포',
|
||||
fives: '파이브',
|
||||
sixes: '식스',
|
||||
choice: '초이스',
|
||||
threeKind: '쓰리 카인드',
|
||||
fourKind: '포 카인드',
|
||||
fullHouse: '풀 하우스',
|
||||
smallStraight: '스몰 스트레이트',
|
||||
largeStraight: '라지 스트레이트',
|
||||
yacht: '요트',
|
||||
};
|
||||
const UPPER: Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
|
||||
const catName = (c: Category, scoring: YachtOptions['scoring']) => (c === 'choice' && scoring === 'yahtzee' ? '찬스' : NAME[c]);
|
||||
const ROLL_MS = 600;
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const CSS = `
|
||||
.yt-wrap { width: 100%; max-width: 1080px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.yt-tray { display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
|
||||
.yt-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; }
|
||||
.yt-dice { display: flex; gap: 8px; justify-content: center; padding: 6px 0 18px; }
|
||||
.yt-die { --s: 64px; width: var(--s); height: var(--s); padding: 0; border-radius: 14px; border: 3px solid #c9c4b8; background: #fffdf8;
|
||||
box-shadow: 0 3px 0 #b7b1a3, 0 4px 10px rgba(0,0,0,.15); cursor: pointer; position: relative; transition: transform .15s ease, border-color .15s; flex: none; }
|
||||
.yt-die svg { display: block; width: 100%; height: 100%; }
|
||||
.yt-die:disabled { cursor: default; }
|
||||
.yt-die.held { transform: translateY(14px); border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft), 0 3px 8px rgba(0,0,0,.15); }
|
||||
.yt-die.blank { background: var(--surface-2); border-style: dashed; box-shadow: none; }
|
||||
.yt-die:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
|
||||
.yt-lock { position: absolute; left: 50%; bottom: -12px; transform: translateX(-50%); background: var(--primary); color: var(--primary-text);
|
||||
font-size: .72rem; font-weight: 800; border-radius: 999px; padding: 1px 7px; white-space: nowrap; line-height: 1.4; }
|
||||
@keyframes yt-tumble { 0% { transform: translateY(-18px) rotate(-30deg) scale(.9); } 40% { transform: translateY(4px) rotate(200deg); }
|
||||
70% { transform: translateY(-6px) rotate(330deg); } 100% { transform: translateY(0) rotate(360deg); } }
|
||||
.yt-die.rolling { animation: yt-tumble ${ROLL_MS}ms ease-out both; }
|
||||
@keyframes yt-pop { 0% { transform: translate(-50%, -50%) scale(.4); opacity: 0; } 60% { transform: translate(-50%, -50%) scale(1.15); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } }
|
||||
.yt-flash { position: fixed; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; font-size: 4rem; font-weight: 900;
|
||||
color: #fde047; text-shadow: 0 4px 12px rgba(0,0,0,.55), 0 0 2px #854d0e; animation: yt-pop .5s ease-out both; white-space: nowrap; }
|
||||
.yt-roll { min-height: 64px; font-size: 1.2rem; }
|
||||
.yt-sheet { padding: 0; overflow: auto; }
|
||||
.yt-table { border-collapse: separate; border-spacing: 0; width: 100%; font-variant-numeric: tabular-nums; }
|
||||
.yt-table th, .yt-table td { height: 38px; padding: 0 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
|
||||
.yt-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 2; height: 46px; font-size: .9rem; }
|
||||
.yt-table th.yt-cat { position: sticky; left: 0; z-index: 1; background: var(--surface); text-align: left; font-weight: 700; padding-left: 12px; min-width: 112px; }
|
||||
.yt-table thead th.yt-cat { z-index: 3; }
|
||||
.yt-table td { min-width: 56px; }
|
||||
.yt-table td.mine { background: var(--primary-soft); min-width: 72px; font-size: 1.08rem; }
|
||||
.yt-table th.turn { box-shadow: inset 0 -4px 0 var(--focus); }
|
||||
.yt-table tr.yt-sum th, .yt-table tr.yt-sum td { background: var(--surface-2); font-weight: 700; font-size: .92rem; }
|
||||
.yt-table tr.yt-sum td.mine { background: var(--primary-soft); }
|
||||
.yt-table tr.yt-total th, .yt-table tr.yt-total td { font-weight: 900; font-size: 1.1rem; border-bottom: 0; height: 44px; }
|
||||
.yt-filled { font-weight: 800; }
|
||||
.yt-ghost { color: var(--text-sub); opacity: .75; font-weight: 600; }
|
||||
.yt-cell { width: 100%; height: 34px; border-radius: 8px; border: 2px dashed var(--border); background: var(--surface); color: var(--text-sub);
|
||||
font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.yt-cell.good { border-style: solid; border-color: var(--success); color: var(--success); background: var(--success-soft); }
|
||||
.yt-cell.zero { opacity: .6; }
|
||||
.yt-cell:hover:not(:disabled) { border-color: var(--primary); color: var(--text); }
|
||||
.yt-cell:disabled { cursor: not-allowed; opacity: .35; }
|
||||
.yt-cell:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; }
|
||||
@keyframes yt-new { from { background: #fde68a; } to { background: transparent; } }
|
||||
.yt-new { animation: yt-new 1.6s ease-out; border-radius: 6px; }
|
||||
.yt-bar { height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
|
||||
.yt-bar > span { display: block; height: 100%; background: var(--success); transition: width .3s; }
|
||||
.yt-pname { display: inline-block; max-width: 72px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
|
||||
@media (min-width: 1100px) {
|
||||
.yt-wrap { display: grid; grid-template-columns: 380px minmax(0, 1fr); align-items: start; max-height: 100%; }
|
||||
.yt-die { --s: 64px; }
|
||||
.yt-sheet { max-height: calc(100vh - 64px - 52px - 40px); }
|
||||
.yt-table th, .yt-table td { height: 34px; }
|
||||
.yt-cell { height: 30px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.yt-die, .yt-die.held { transition: none; }
|
||||
.yt-die.rolling, .yt-flash, .yt-new { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- dice
|
||||
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[50, 50]],
|
||||
2: [[28, 28], [72, 72]],
|
||||
3: [[26, 26], [50, 50], [74, 74]],
|
||||
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
|
||||
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
|
||||
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
|
||||
};
|
||||
|
||||
function DieFace({ value }: { value: number | null }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" aria-hidden>
|
||||
{value !== null &&
|
||||
PIPS[value]!.map(([x, y], i) => <circle key={i} cx={x} cy={y} r={value === 1 ? 13 : 10} fill={value === 1 ? '#c81e1e' : '#1c1e21'} />)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small static die for the rules / last-score line. */
|
||||
function MiniDie({ value, size = 26 }: { value: number; size?: number }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-block', width: size, height: size, borderRadius: size / 4.5, border: '2px solid #c9c4b8', background: '#fffdf8', verticalAlign: 'middle' }}>
|
||||
<DieFace value={value} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DiceRow({ dice, size }: { dice: number[]; size?: number }) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', gap: 3 }} aria-label={`주사위 ${dice.join(', ')}`} role="img">
|
||||
{dice.map((d, i) => (
|
||||
<MiniDie key={i} value={d} size={size} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
interface RollAnim {
|
||||
key: number;
|
||||
mask: boolean[];
|
||||
}
|
||||
|
||||
function YachtBoard({ view: v, me, players, act, pending, tapConfirm }: BoardProps<YachtView>) {
|
||||
const myTurn = v.phase === 'playing' && !!me && v.current === me;
|
||||
const [held, setHeld] = useState<boolean[]>(v.held);
|
||||
const [anim, setAnim] = useState<RollAnim | null>(null);
|
||||
const [flick, setFlick] = useState<number[] | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [confirm, setConfirm] = useState<{ category: Category; points: number } | null>(null);
|
||||
const prevRoll = useRef(v.rollSeq);
|
||||
const prevTurn = useRef(v.turnSeq);
|
||||
const pointer = useRef<string>('');
|
||||
|
||||
// Server holds win once our own pending action is answered (optimistic taps in between).
|
||||
useEffect(() => {
|
||||
if (!pending) setHeld(v.held);
|
||||
}, [v.held, v.turnSeq, v.rollSeq, pending]);
|
||||
|
||||
// Roll animation, yacht flash and screen-reader text when a new roll arrives.
|
||||
useLayoutEffect(() => {
|
||||
if (v.rollSeq <= prevRoll.current || !v.dice) {
|
||||
prevRoll.current = v.rollSeq;
|
||||
return;
|
||||
}
|
||||
prevRoll.current = v.rollSeq;
|
||||
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
|
||||
setAnnounce(`${who} 굴렸어요: ${v.dice.join(', ')}`);
|
||||
if (v.dice.every((d) => d === v.dice![0])) {
|
||||
setFlash('요트!');
|
||||
haptic([80, 40, 80]);
|
||||
}
|
||||
if (reducedMotion()) {
|
||||
sounds.stone();
|
||||
return;
|
||||
}
|
||||
const mask = v.rollsUsed === 1 ? [true, true, true, true, true] : v.held.map((h) => !h);
|
||||
setAnim({ key: v.rollSeq, mask });
|
||||
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!anim) return;
|
||||
const t = setInterval(() => setFlick([0, 1, 2, 3, 4].map(() => 1 + Math.floor(Math.random() * 6))), 70);
|
||||
const end = setTimeout(() => {
|
||||
setAnim(null);
|
||||
setFlick(null);
|
||||
sounds.stone();
|
||||
}, ROLL_MS);
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
clearTimeout(end);
|
||||
};
|
||||
}, [anim]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) return;
|
||||
const t = setTimeout(() => setFlash(null), 1500);
|
||||
return () => clearTimeout(t);
|
||||
}, [flash]);
|
||||
|
||||
// Score announcement + my-turn cue on turn changes.
|
||||
useEffect(() => {
|
||||
if (v.turnSeq === prevTurn.current) return;
|
||||
prevTurn.current = v.turnSeq;
|
||||
setConfirm(null);
|
||||
const ls = v.lastScore;
|
||||
if (ls) {
|
||||
const who = ls.player === me ? '내가' : `${nick(players, ls.player)}님이`;
|
||||
setAnnounce(`${who} ${catName(ls.category, v.options.scoring)}에 ${ls.points}점을 기록했어요${ls.bonus ? ` (요트 보너스 +${ls.bonus})` : ''}${ls.auto ? ' (시간 초과로 자동 기록)' : ''}.`);
|
||||
}
|
||||
if (v.phase === 'playing' && v.current === me) {
|
||||
haptic(40);
|
||||
if (document.visibilityState === 'hidden') sounds.myTurn();
|
||||
}
|
||||
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canRoll = myTurn && !pending && v.rollsUsed < 3 && !(v.rollsUsed > 0 && held.every(Boolean));
|
||||
const canHold = myTurn && v.rollsUsed > 0 && v.rollsUsed < 3;
|
||||
|
||||
const toggle = (i: number) => {
|
||||
if (!canHold) return;
|
||||
const next = held.map((h, j) => (j === i ? !h : h));
|
||||
setHeld(next);
|
||||
act({ type: 'hold', held: next });
|
||||
};
|
||||
|
||||
const roll = () => {
|
||||
if (!canRoll) return;
|
||||
act({ type: 'roll' });
|
||||
};
|
||||
|
||||
const score = (category: Category, points: number, viaMouse: boolean) => {
|
||||
if (!myTurn || pending) return;
|
||||
if (points === 0 || (tapConfirm && !viaMouse)) setConfirm({ category, points });
|
||||
else act({ type: 'score', category });
|
||||
};
|
||||
|
||||
const shownDice = v.dice ?? null;
|
||||
const rollLabel =
|
||||
v.rollsUsed >= 3 ? '점수 칸을 골라 주세요' : v.rollsUsed > 0 && held.every(Boolean) ? '모두 고정했어요' : `🎲 굴리기 (남은 ${v.rollsLeft}번)`;
|
||||
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
|
||||
|
||||
return (
|
||||
<div className="yt-wrap">
|
||||
<style>{CSS}</style>
|
||||
<section className="card yt-tray" aria-label="주사위">
|
||||
<div className="yt-head">
|
||||
<strong>
|
||||
{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.totalRounds} 라운드`}
|
||||
</strong>
|
||||
{v.current && (
|
||||
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
<Avatar user={userOf(players, v.current)} size={24} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
|
||||
<span className="badge" aria-label={`굴린 횟수 ${v.rollsUsed}번`}>
|
||||
{'●'.repeat(v.rollsUsed)}
|
||||
{'○'.repeat(3 - v.rollsUsed)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="yt-dice" role="group" aria-label={canHold ? '주사위. 누르면 고정하거나 풀어요' : '주사위'}>
|
||||
{[0, 1, 2, 3, 4].map((i) => {
|
||||
const value = shownDice ? (anim?.mask[i] && flick ? flick[i]! : shownDice[i]!) : null;
|
||||
const isHeld = !!shownDice && v.rollsUsed > 0 && held[i]!;
|
||||
const rolling = !!anim && anim.mask[i];
|
||||
return (
|
||||
<button
|
||||
key={`${i}-${rolling ? anim!.key : 's'}`}
|
||||
type="button"
|
||||
className={`yt-die${isHeld ? ' held' : ''}${shownDice ? '' : ' blank'}${rolling ? ' rolling' : ''}`}
|
||||
aria-pressed={canHold ? isHeld : undefined}
|
||||
aria-label={shownDice ? `주사위 ${i + 1}: ${shownDice[i]}${isHeld ? ', 고정됨' : ''}` : `주사위 ${i + 1}: 아직 안 굴림`}
|
||||
disabled={!canHold}
|
||||
onClick={() => toggle(i)}
|
||||
>
|
||||
<DieFace value={value} />
|
||||
{isHeld && <span className="yt-lock">🔒 고정</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{myTurn ? (
|
||||
<button className="btn btn-primary btn-block yt-roll" disabled={!canRoll} onClick={roll}>
|
||||
{rollLabel}
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
|
||||
{v.phase === 'finished' ? '게임이 끝났어요' : v.current ? `${nick(players, v.current)}님이 굴리는 중이에요` : ''}
|
||||
</p>
|
||||
)}
|
||||
{myTurn && v.rollsUsed > 0 && v.rollsUsed < 3 && <p className="muted small" style={{ textAlign: 'center' }}>남길 주사위를 누르면 고정돼요. 점수표의 칸을 누르면 기록해요.</p>}
|
||||
{v.lastScore && <LastScore entry={v.lastScore} view={v} me={me} players={players} />}
|
||||
</section>
|
||||
<ScoreSheet view={v} me={me} players={players} myTurn={myTurn} pending={pending} onScore={score} pointer={pointer} />
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{flash && (
|
||||
<div className="yt-flash" aria-hidden>
|
||||
{flash}
|
||||
</div>
|
||||
)}
|
||||
{confirm && (
|
||||
<ConfirmSheet
|
||||
title={`${catName(confirm.category, v.options.scoring)}에 ${confirm.points}점 기록할까요?`}
|
||||
body={confirm.points === 0 ? '조건에 맞지 않아서 0점이 돼요. 한 번 기록한 칸은 바꿀 수 없어요.' : '한 번 기록한 칸은 바꿀 수 없어요.'}
|
||||
confirm="기록하기"
|
||||
onConfirm={() => act({ type: 'score', category: confirm.category })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LastScore({ entry: e, view: v, me, players }: { entry: ScoreEntry; view: YachtView; me: string | null; players: Record<string, PublicUser> }) {
|
||||
return (
|
||||
<p className="small row" style={{ justifyContent: 'center', gap: 6 }}>
|
||||
<span className="muted">방금:</span>
|
||||
<strong>{e.player === me ? '나' : nick(players, e.player)}</strong>
|
||||
<DiceRow dice={e.dice} size={20} />
|
||||
<span>
|
||||
→ {catName(e.category, v.options.scoring)} {e.points}점{e.bonus ? ` +${e.bonus}` : ''}
|
||||
{e.auto ? ' (자동)' : ''}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- score sheet
|
||||
|
||||
function ScoreSheet({
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
myTurn,
|
||||
pending,
|
||||
onScore,
|
||||
pointer,
|
||||
}: {
|
||||
view: YachtView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
myTurn: boolean;
|
||||
pending: boolean;
|
||||
onScore: (c: Category, points: number, viaMouse: boolean) => void;
|
||||
pointer: React.MutableRefObject<string>;
|
||||
}) {
|
||||
// My column first (bigger), then everyone else in turn order.
|
||||
const cols = me && v.order.includes(me) ? [me, ...v.order.filter((p) => p !== me)] : v.order;
|
||||
const upper = v.categories.filter((c) => UPPER.includes(c));
|
||||
const lower = v.categories.filter((c) => !UPPER.includes(c));
|
||||
const ls = v.lastScore;
|
||||
const yz = v.options.scoring === 'yahtzee';
|
||||
|
||||
const cell = (p: string, c: Category) => {
|
||||
const sh = v.sheets[p]!;
|
||||
const val = sh.scores[c];
|
||||
if (val !== undefined) {
|
||||
const fresh = ls && ls.player === p && ls.category === c;
|
||||
return (
|
||||
<span key={fresh ? `n${v.turnSeq}` : 'f'} className={`yt-filled${fresh ? ' yt-new' : ''}`}>
|
||||
{val}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const prev = v.current === p ? v.preview?.[c] : undefined;
|
||||
if (p === me && myTurn && v.rollsUsed > 0 && prev !== undefined) {
|
||||
const allowed = v.allowed.includes(c);
|
||||
const show = v.options.showPreview;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`yt-cell${show && prev > 0 ? ' good' : ''}${show && prev === 0 ? ' zero' : ''}`}
|
||||
disabled={!allowed || pending}
|
||||
aria-label={`${catName(c, v.options.scoring)}에 ${show ? `${prev}점 ` : ''}기록하기${allowed ? '' : ' (지금은 넣을 수 없어요)'}`}
|
||||
onPointerDown={(e) => (pointer.current = e.pointerType)}
|
||||
onClick={(e) => {
|
||||
const viaMouse = e.detail === 0 || pointer.current === 'mouse';
|
||||
pointer.current = '';
|
||||
onScore(c, prev, viaMouse);
|
||||
}}
|
||||
>
|
||||
{show ? prev : '기록'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (prev !== undefined && v.options.showPreview) return <span className="yt-ghost">{prev}</span>;
|
||||
return null;
|
||||
};
|
||||
|
||||
const header = (
|
||||
<tr>
|
||||
<th className="yt-cat" scope="col">
|
||||
칸
|
||||
</th>
|
||||
{cols.map((p) => {
|
||||
const sh = v.sheets[p]!;
|
||||
return (
|
||||
<th key={p} scope="col" className={v.current === p ? 'turn' : undefined} title={nick(players, p)}>
|
||||
<span className="yt-pname">{p === me ? '나' : nick(players, p)}</span>
|
||||
{sh.left && <div className="small muted">기권</div>}
|
||||
{!sh.left && sh.away && <div className="small muted">자리 비움</div>}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
|
||||
const row = (c: Category) => (
|
||||
<tr key={c}>
|
||||
<th className="yt-cat" scope="row">
|
||||
{catName(c, v.options.scoring)}
|
||||
</th>
|
||||
{cols.map((p) => (
|
||||
<td key={p} className={p === me ? 'mine' : undefined}>
|
||||
{cell(p, c)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
const sumRow = (label: string, f: (s: YachtSheetView) => React.ReactNode, cls = 'yt-sum') => (
|
||||
<tr className={cls}>
|
||||
<th className="yt-cat" scope="row">
|
||||
{label}
|
||||
</th>
|
||||
{cols.map((p) => (
|
||||
<td key={p} className={p === me ? 'mine' : undefined}>
|
||||
{f(v.sheets[p]!)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="card yt-sheet" aria-label="점수표">
|
||||
<table className="yt-table">
|
||||
<thead>{header}</thead>
|
||||
<tbody>
|
||||
{upper.map(row)}
|
||||
{sumRow('소계', (s) => `${s.upper}/${v.upperBonusThreshold}`)}
|
||||
{sumRow('보너스 +35', (s) => (s.upperBonus ? `+${s.upperBonus}` : <span className="muted small">{`${Math.max(0, v.upperBonusThreshold - s.upper)} 남음`}</span>))}
|
||||
{lower.map(row)}
|
||||
{yz && sumRow('요트 보너스', (s) => (s.yahtzeeBonus ? `+${s.yahtzeeBonus}` : '-'))}
|
||||
{sumRow('총점', (s) => s.total, 'yt-total')}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function YachtSide({ view: v, me, players, act, deadline }: BoardProps<YachtView>) {
|
||||
const [ask, setAsk] = useState(false);
|
||||
const mine = me ? v.sheets[me] : undefined;
|
||||
const total = v.options.turnSeconds > 0 ? v.options.turnSeconds * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 60_000;
|
||||
return (
|
||||
<>
|
||||
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
|
||||
{v.order.map((p, i) => {
|
||||
const sh = v.sheets[p]!;
|
||||
const turn = v.current === p;
|
||||
return (
|
||||
<div key={p} className="row" style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}>
|
||||
<span className="muted small" style={{ width: 16, textAlign: 'right' }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<Avatar user={userOf(players, p)} size={30} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{nick(players, p)}
|
||||
{p === me && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="small muted">
|
||||
{sh.left ? '기권' : sh.away ? '자리 비움' : turn ? '굴리는 중' : `${sh.filled}/${v.totalRounds}칸`}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontWeight: 800, fontSize: '1.3rem', fontVariantNumeric: 'tabular-nums' }} aria-label={`${nick(players, p)} 총점 ${sh.total}점`}>
|
||||
{sh.total}
|
||||
</span>
|
||||
{turn && v.phase === 'playing' && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
{mine && !mine.left && (
|
||||
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="상단 보너스">
|
||||
<div className="row small" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>상단 보너스</strong>
|
||||
<span>{mine.upperBonus ? '35점 확보!' : `${v.upperBonusThreshold}까지 ${Math.max(0, v.upperBonusThreshold - mine.upper)}점 남음`}</span>
|
||||
</div>
|
||||
<div className="yt-bar" role="progressbar" aria-valuemin={0} aria-valuemax={v.upperBonusThreshold} aria-valuenow={Math.min(mine.upper, v.upperBonusThreshold)} aria-label="상단 합계">
|
||||
<span style={{ width: `${Math.min(100, (mine.upper / v.upperBonusThreshold) * 100)}%` }} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{v.phase === 'playing' && mine && !mine.left && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
|
||||
🏳 기권
|
||||
</button>
|
||||
)}
|
||||
{ask && (
|
||||
<ConfirmSheet
|
||||
title="기권할까요?"
|
||||
body="남은 칸은 모두 0점이 되고, 이후 차례에서 빠져요."
|
||||
confirm="기권하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'concede' })}
|
||||
onClose={() => setAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: YachtView, me: string | null): boolean {
|
||||
return v.phase === 'playing' && !!me && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: YachtView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (me && v.sheets[me]?.left) return '기권했어요. 구경하는 중이에요';
|
||||
if (isMyTurn(v, me)) {
|
||||
if (v.rollsUsed === 0) return '내 차례예요. 주사위를 굴려 주세요';
|
||||
if (v.rollsUsed < 3) return `내 차례예요. 다시 굴리거나(${v.rollsLeft}번 남음) 칸을 골라 주세요`;
|
||||
return '내 차례예요. 점수를 넣을 칸을 골라 주세요';
|
||||
}
|
||||
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.totalRounds}라운드` : '잠시만 기다려 주세요';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<YachtOptions>) {
|
||||
const set = (patch: Partial<YachtOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="점수 규칙">
|
||||
<Seg value={o.scoring} options={[['yacht51', '요트(12칸)'], ['yahtzee', '13칸 변형(미국식)']]} onChange={(scoring) => set({ scoring })} />
|
||||
</Field>
|
||||
{o.scoring === 'yacht51' ? (
|
||||
<Field label="5개 모두 같은 눈을 풀 하우스로">
|
||||
<Seg value={o.fullHouseAllowsYacht ? 'on' : 'off'} options={[['on', '인정'], ['off', '인정 안 함(전통 규칙)']]} onChange={(k) => set({ fullHouseAllowsYacht: k === 'on' })} />
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="요트가 또 나왔을 때">
|
||||
<Seg value={o.yahtzeeJoker} options={[['forced', '정해진 순서대로(공식)'], ['free', '아무 칸이나']]} onChange={(yahtzeeJoker) => set({ yahtzeeJoker })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="한 차례 제한 시간">
|
||||
<Seg value={o.turnSeconds} options={[[30, '30초'], [60, '60초'], [90, '90초'], [0, '제한 없음']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<Field label="점수 미리 보기">
|
||||
<Seg value={o.showPreview ? 'on' : 'off'} options={[['on', '빈 칸에 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showPreview: k === 'on' })} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: YachtOptions): string {
|
||||
return [
|
||||
o.scoring === 'yacht51' ? '요트 12칸' : '13칸 변형',
|
||||
...(o.scoring === 'yacht51' && !o.fullHouseAllowsYacht ? ['풀 하우스에 요트 불인정'] : []),
|
||||
...(o.scoring === 'yahtzee' ? [o.yahtzeeJoker === 'forced' ? '추가 요트는 정해진 순서' : '추가 요트는 아무 칸'] : []),
|
||||
o.turnSeconds > 0 ? `한 차례 ${o.turnSeconds}초` : '시간 제한 없음',
|
||||
...(o.showPreview ? [] : ['미리 보기 끔']),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Rules({ options }: { options?: YachtOptions }) {
|
||||
const yz = options?.scoring === 'yahtzee';
|
||||
const fhYacht = options?.fullHouseAllowsYacht ?? true;
|
||||
const rows: [string, string, number[]][] = yz
|
||||
? [
|
||||
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
|
||||
['쓰리 카인드', '같은 눈 3개 이상이면 5개 합', [4, 4, 4, 2, 5]],
|
||||
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
|
||||
['풀 하우스', '같은 눈 3개 + 다른 같은 눈 2개면 25점', [2, 2, 5, 5, 5]],
|
||||
['스몰 스트레이트', '이어지는 눈 4개면 30점', [1, 2, 3, 4, 6]],
|
||||
['라지 스트레이트', '이어지는 눈 5개면 40점', [2, 3, 4, 5, 6]],
|
||||
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
|
||||
['찬스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
|
||||
]
|
||||
: [
|
||||
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
|
||||
['초이스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
|
||||
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
|
||||
['풀 하우스', `같은 눈 3개 + 다른 같은 눈 2개면 5개 합${fhYacht ? '(5개 모두 같아도 인정)' : ''}`, [2, 2, 5, 5, 5]],
|
||||
['스몰 스트레이트', '이어지는 눈 4개면 15점', [1, 2, 3, 4, 6]],
|
||||
['라지 스트레이트', '이어지는 눈 5개면 30점', [2, 3, 4, 5, 6]],
|
||||
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
|
||||
];
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
주사위 5개를 한 차례에 <strong>최대 3번</strong> 굴려요. 마음에 드는 주사위는 눌러서 <strong>고정</strong>하고 나머지만 다시 굴릴 수 있어요. 고정은 굴릴 때마다 바꿀 수 있어요.
|
||||
</p>
|
||||
<p>
|
||||
굴린 뒤에는 점수표의 <strong>빈 칸 하나</strong>를 골라 지금 주사위로 점수를 적어요. 조건에 맞지 않는 칸에 넣으면 0점이에요. 한 번 적은 칸은 바꿀 수 없고, 3번 굴린 뒤에는 꼭 적어야 해요.
|
||||
</p>
|
||||
<p>
|
||||
{yz ? '13칸' : '12칸'}을 모두 채우면 끝나고, <strong>총점이 가장 높은 사람</strong>이 이겨요. 혼자서 최고 점수에 도전할 수도 있어요.
|
||||
</p>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
{rows.map(([name, rule, dice]) => (
|
||||
<div key={name} className="row" style={{ gap: 10, flexWrap: 'nowrap' }}>
|
||||
<DiceRow dice={dice} size={22} />
|
||||
<span className="small">
|
||||
<strong>{name}</strong> · {rule}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<strong>상단 보너스</strong>: 에이스~식스 6칸의 합이 63점 이상이면 35점을 더 받아요(눈마다 3개씩 모으면 딱 63점이에요).
|
||||
</p>
|
||||
{yz && (
|
||||
<p>
|
||||
<strong>추가 요트</strong>: 요트 칸에 50점을 적은 뒤 요트가 또 나오면 100점을 더 받아요.{' '}
|
||||
{options?.yahtzeeJoker === 'free'
|
||||
? '이때는 아무 빈 칸에나 적을 수 있어요.'
|
||||
: '이때는 그 눈의 윗칸이 비어 있으면 거기에 먼저 적어야 하고, 윗칸이 차 있으면 아래쪽 칸에 적어요.'}{' '}
|
||||
그 눈의 윗칸이 이미 차 있으면 풀 하우스 25점, 스트레이트 30·40점을 조건과 상관없이 인정해요.
|
||||
</p>
|
||||
)}
|
||||
<p className="muted">
|
||||
시간 안에 적지 않으면 지금 주사위로 가장 높은 점수가 나오는 칸에 자동으로 적혀요(아직 안 굴렸으면 먼저 굴려요). 기권하면 남은 칸은 모두 0점이 돼요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<YachtView, YachtOptions> = {
|
||||
Board: YachtBoard,
|
||||
Side: YachtSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
866
apps/web/src/games/yut/index.tsx
Normal file
866
apps/web/src/games/yut/index.tsx
Normal file
@@ -0,0 +1,866 @@
|
||||
/** 윷놀이 화면 (docs/games/yut.md §9). Board, sticks and pieces are drawn here (SVG/CSS only). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Throw, YutLegalMove, YutOptions, YutSideView, YutView } from '@bg/games/yut';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
type From = number | 'home';
|
||||
type GameEvent = YutView['log'][number];
|
||||
|
||||
// The rules module is imported for types only (no zod in this chunk); these small tables mirror packages/games/src/yut/board.ts.
|
||||
const NODE_NAMES = [
|
||||
'참먹이', '도', '개', '걸', '윷', '모', '뒷도', '뒷개', '뒷걸', '뒷윷',
|
||||
'뒷모', '찌도', '찌개', '찌걸', '찌윷', '찌모', '날도', '날개', '날걸', '날윷',
|
||||
'앞모도', '앞모개', '방', '속윷', '속모', '뒷모도', '뒷모개', '사려', '안찌',
|
||||
];
|
||||
const THROW_KO: Record<Throw, string> = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' };
|
||||
const STEPS: Record<Throw, number> = { do: 1, gae: 2, geol: 3, yut: 4, mo: 5, backdo: -1 };
|
||||
const stepText = (t: Throw) => (t === 'backdo' ? '뒤로 1칸' : `${STEPS[t]}칸`);
|
||||
const BIG = new Set([0, 5, 10, 15, 22]);
|
||||
const BIG_LABELS: [number, string][] = [[0, '출발'], [5, '모'], [10, '뒷모'], [15, '찌모'], [22, '방']];
|
||||
|
||||
// Board geometry (viewBox 0..100): 참먹이 bottom-right, 모 top-right, 뒷모 top-left, 찌모 bottom-left; pieces go anticlockwise.
|
||||
const LO = 8;
|
||||
const HI = 92;
|
||||
const GAP = (HI - LO) / 5;
|
||||
const POS: [number, number][] = (() => {
|
||||
const p: [number, number][] = [];
|
||||
p[0] = [HI, HI];
|
||||
for (let i = 1; i <= 4; i++) p[i] = [HI, HI - GAP * i];
|
||||
p[5] = [HI, LO];
|
||||
for (let i = 6; i <= 9; i++) p[i] = [HI - GAP * (i - 5), LO];
|
||||
p[10] = [LO, LO];
|
||||
for (let i = 11; i <= 14; i++) p[i] = [LO, LO + GAP * (i - 10)];
|
||||
p[15] = [LO, HI];
|
||||
for (let i = 16; i <= 19; i++) p[i] = [LO + GAP * (i - 15), HI];
|
||||
const lerp = (a: [number, number], b: [number, number], t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
[20, 21, 22, 23, 24].forEach((n, k) => (p[n] = lerp(p[5]!, p[15]!, (k + 1) / 6)));
|
||||
[25, 26].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 1) / 6)));
|
||||
[27, 28].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 4) / 6)));
|
||||
return p;
|
||||
})();
|
||||
/** Where pieces come from / go to (just outside 참먹이). */
|
||||
const HOME_PT: [number, number] = [HI + 4, HI + 7];
|
||||
const LINES: [number, number][] = [
|
||||
...Array.from({ length: 20 }, (_, i) => [i, (i + 1) % 20] as [number, number]),
|
||||
[5, 20], [20, 21], [21, 22], [22, 23], [23, 24], [24, 15],
|
||||
[10, 25], [25, 26], [26, 22], [22, 27], [27, 28], [28, 0],
|
||||
];
|
||||
const R_NODE = 5.1;
|
||||
const R_BIG = 6.9;
|
||||
|
||||
// Side look: colour + shape (colour-blind safe: every side also has its own shape).
|
||||
const SOLO_COLORS = ['#dc2626', '#2563eb', '#16a34a', '#eab308'];
|
||||
const TEAM_COLORS = ['#2563eb', '#dc2626'];
|
||||
const SHAPES = ['circle', 'triangle', 'square', 'star'] as const;
|
||||
const SHAPE_KO = { circle: '동그라미', triangle: '세모', square: '네모', star: '별' } as const;
|
||||
const sideColor = (team: boolean, side: number) => (team ? TEAM_COLORS : SOLO_COLORS)[side] ?? '#888';
|
||||
const sideShape = (side: number) => SHAPES[side % SHAPES.length]!;
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const nodeName = (n: number | 'home' | 'goal') => (n === 'home' ? '대기' : n === 'goal' ? '남' : NODE_NAMES[n]!);
|
||||
|
||||
const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const CSS = `
|
||||
.yut-wrap { width: 100%; display: grid; gap: 14px; align-items: start; }
|
||||
.yut-board-box { width: 100%; max-width: min(100%, 640px); margin: 0 auto; }
|
||||
.yut-board { width: 100%; display: block; border-radius: 18px; box-shadow: var(--shadow); touch-action: manipulation; }
|
||||
.yut-ctrl { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
@media (min-width: 1100px) {
|
||||
.yut-wrap { grid-template-columns: minmax(0, 1fr) 300px; align-items: center; }
|
||||
.yut-board-box { max-width: min(100%, calc(100vh - 64px - 52px - 40px)); }
|
||||
}
|
||||
.yut-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.yut-chip { min-width: 64px; min-height: 64px; border-radius: 14px; border: 3px solid var(--border); background: var(--surface); color: var(--text);
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1.1; cursor: pointer; padding: 4px 8px; font-weight: 800; }
|
||||
.yut-chip b { font-size: 1.5rem; }
|
||||
.yut-chip small { font-size: .8rem; font-weight: 700; color: var(--text-sub); }
|
||||
.yut-chip[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.yut-chip.back b { color: var(--danger); }
|
||||
.yut-chip:disabled { cursor: default; opacity: .85; }
|
||||
.yut-throw { min-height: 72px; font-size: 1.35rem; }
|
||||
.yut-tray { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.yut-tray-btn { min-height: 56px; border-radius: 14px; border: 3px dashed var(--border); background: var(--surface); color: var(--text); padding: 4px 12px;
|
||||
display: inline-flex; align-items: center; gap: 8px; font-weight: 700; cursor: pointer; }
|
||||
.yut-tray-btn.can { border-style: solid; border-color: var(--focus); animation: yut-glow 1.1s ease-in-out infinite; }
|
||||
.yut-tray-btn.sel { border-style: solid; border-color: var(--primary); background: var(--primary-soft); animation: none; }
|
||||
.yut-tray-btn:disabled { cursor: default; }
|
||||
.yut-sticks-box { display: flex; align-items: center; gap: 12px; min-height: 84px; }
|
||||
.yut-sticks { width: 120px; height: 80px; flex: none; overflow: visible; }
|
||||
.yut-result { font-size: 2rem; font-weight: 900; min-width: 3ch; }
|
||||
.yut-toss .yut-stick { transform-box: fill-box; transform-origin: center; animation: yut-toss .8s cubic-bezier(.25,.7,.35,1) both; animation-delay: var(--d, 0ms); }
|
||||
@keyframes yut-toss { 0% { transform: translateY(0) rotate(0deg); } 45% { transform: translateY(-34px) rotate(260deg); } 75% { transform: translateY(2px) rotate(350deg); } 100% { transform: translateY(0) rotate(360deg); } }
|
||||
@keyframes yut-glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, .0); } 50% { box-shadow: 0 0 0 5px rgba(245, 158, 11, .35); } }
|
||||
@keyframes yut-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } }
|
||||
@keyframes yut-hop { 0% { transform: translateY(0); } 50% { transform: translateY(-5px); } 100% { transform: translateY(0); } }
|
||||
@keyframes yut-pop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
.yut-can { animation: yut-pulse 1s ease-in-out infinite; }
|
||||
.yut-hop { transform-box: fill-box; transform-origin: center bottom; animation: yut-hop .17s ease-out; }
|
||||
.yut-pop { transform-box: fill-box; transform-origin: center; animation: yut-pop .25s ease-out both; }
|
||||
.yut-hit { cursor: pointer; outline: none; }
|
||||
.yut-hit .yut-focus { opacity: 0; }
|
||||
.yut-hit:focus-visible .yut-focus { opacity: 1; }
|
||||
.yut-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow: auto; font-size: .92rem; }
|
||||
.yut-side-card { padding: 10px 12px; border: 3px solid transparent; transition: border-color .2s; }
|
||||
.yut-side-card.turn { border-color: var(--primary); }
|
||||
.yut-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .78rem; color: #fff; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.yut-toss .yut-stick, .yut-can, .yut-hop, .yut-pop, .yut-tray-btn.can { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- pieces & sticks
|
||||
|
||||
function Token({ side, team, x, y, r, count = 1 }: { side: number; team: boolean; x: number; y: number; r: number; count?: number }) {
|
||||
const color = sideColor(team, side);
|
||||
const shape = sideShape(side);
|
||||
const common = { fill: color, stroke: '#fff', strokeWidth: r * 0.16, strokeLinejoin: 'round' as const };
|
||||
let body: React.ReactNode;
|
||||
if (shape === 'circle') body = <circle cx={0} cy={0} r={r} {...common} />;
|
||||
else if (shape === 'square') body = <rect x={-r * 0.86} y={-r * 0.86} width={r * 1.72} height={r * 1.72} rx={r * 0.2} {...common} />;
|
||||
else if (shape === 'triangle') body = <polygon points={`0,${-r * 1.05} ${r * 1.02},${r * 0.75} ${-r * 1.02},${r * 0.75}`} {...common} />;
|
||||
else {
|
||||
const pts = Array.from({ length: 10 }, (_, i) => {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / 5;
|
||||
const rr = i % 2 === 0 ? r * 1.1 : r * 0.5;
|
||||
return `${(Math.cos(a) * rr).toFixed(2)},${(Math.sin(a) * rr).toFixed(2)}`;
|
||||
});
|
||||
body = <polygon points={pts.join(' ')} {...common} />;
|
||||
}
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<g transform={`translate(${r * 0.08} ${r * 0.14})`} opacity={0.3}>
|
||||
<circle r={r * 0.95} fill="#000" />
|
||||
</g>
|
||||
{body}
|
||||
{count > 1 && (
|
||||
<g transform={`translate(${r * 0.75} ${-r * 0.75})`}>
|
||||
<circle r={r * 0.62} fill="#111" stroke="#fff" strokeWidth={r * 0.1} />
|
||||
<text textAnchor="middle" dominantBaseline="central" fontSize={r * 0.82} fontWeight={900} fill="#fff">
|
||||
{count}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenIcon({ side, team, size = 22 }: { side: number; team: boolean; size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="-6 -6 12 12" aria-hidden style={{ flex: 'none' }}>
|
||||
<Token side={side} team={team} x={0} y={0} r={4.4} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** One 윷가락: 배(flat side, light) or 등(round side, dark with marks). Stick 0 carries the 빽도 mark on its flat side. */
|
||||
function Stick({ x, flat, marked, delay }: { x: number; flat: boolean; marked: boolean; delay: number }) {
|
||||
return (
|
||||
<g className="yut-stick" style={{ ['--d' as string]: `${delay}ms` }}>
|
||||
<rect x={x} y={8} width={18} height={64} rx={9} fill={flat ? '#f6e4b8' : '#a86b2d'} stroke={flat ? '#9a7432' : '#5c3713'} strokeWidth={2} />
|
||||
{flat ? (
|
||||
<>
|
||||
<line x1={x + 9} y1={16} x2={x + 9} y2={64} stroke="#d9bd83" strokeWidth={1.5} />
|
||||
{marked && (
|
||||
<text x={x + 9} y={41} textAnchor="middle" dominantBaseline="central" fontSize={12} fontWeight={900} fill="#c81e1e">
|
||||
빽
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
[20, 32, 44, 56].map((y) => <line key={y} x1={x + 5} y1={y} x2={x + 13} y2={y} stroke="#f6e4b8" strokeWidth={2} strokeLinecap="round" opacity={0.7} />)
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Sticks({ view: v, players }: { view: YutView; players: Record<string, PublicUser> }) {
|
||||
const lt = v.lastThrow;
|
||||
const [tossing, setTossing] = useState(false);
|
||||
const seen = useRef(lt?.no ?? 0);
|
||||
useEffect(() => {
|
||||
if (!lt || lt.no === seen.current) return;
|
||||
seen.current = lt.no;
|
||||
if (reducedMotion()) return;
|
||||
setTossing(true);
|
||||
const t = setTimeout(() => setTossing(false), 820);
|
||||
return () => clearTimeout(t);
|
||||
}, [lt?.no]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const sticks = lt?.sticks ?? [false, true, false, true];
|
||||
const label = !lt ? '' : tossing ? '…' : lt.result === 'nak' ? '낙!' : `${THROW_KO[lt.result]}!`;
|
||||
return (
|
||||
<div className="yut-sticks-box">
|
||||
<svg className={`yut-sticks${tossing ? ' yut-toss' : ''}`} viewBox="0 0 120 80" role="img" aria-label={lt ? `${nick(players, lt.player)}님이 던진 윷: ${lt.result === 'nak' ? '낙' : THROW_KO[lt.result]}` : '윷가락 네 개'}>
|
||||
{lt?.result === 'nak' ? (
|
||||
<g opacity={0.45} transform="rotate(-18 60 40)">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Stick key={i} x={8 + i * 27} flat={i % 2 === 0} marked={false} delay={i * 40} />
|
||||
))}
|
||||
</g>
|
||||
) : (
|
||||
sticks.map((f, i) => <Stick key={i} x={8 + i * 27} flat={f} marked={i === 0 && v.options.backdo} delay={i * 40} />)
|
||||
)}
|
||||
</svg>
|
||||
<div>
|
||||
<div className="yut-result" aria-hidden>
|
||||
{label}
|
||||
</div>
|
||||
{lt && !tossing && <div className="small muted">{nick(players, lt.player)}님</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
interface Anim {
|
||||
no: number;
|
||||
pts: [number, number][];
|
||||
i: number;
|
||||
side: number;
|
||||
count: number;
|
||||
to: number | 'goal';
|
||||
caught: boolean;
|
||||
}
|
||||
|
||||
function YutBoard({ view: v, me, players, act, pending, deadline }: BoardProps<YutView>) {
|
||||
const myTurn = v.phase !== 'finished' && v.current === me;
|
||||
const moves = useMemo(() => (myTurn && v.phase === 'move' ? (v.legalMoves ?? []) : []), [myTurn, v.phase, v.legalMoves]);
|
||||
const [selIdx, setSelIdx] = useState<number | null>(null);
|
||||
const [selFrom, setSelFrom] = useState<From | null>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const [anim, setAnim] = useState<Anim | null>(null);
|
||||
const [flash, setFlash] = useState<{ text: string; at: [number, number]; key: number } | null>(null);
|
||||
const lastMoveNo = useRef(v.lastMove?.no ?? 0);
|
||||
const lastThrowNo = useRef(v.lastThrow?.no ?? 0);
|
||||
const prevCurrent = useRef(v.current);
|
||||
|
||||
const kinds = [...new Set(v.pending)];
|
||||
// Reset the selection whenever the state moves on; pick the result automatically when there is only one kind.
|
||||
useEffect(() => {
|
||||
setSelFrom(null);
|
||||
if (myTurn && v.phase === 'move' && kinds.length === 1) setSelIdx(v.pending.indexOf(kinds[0]!));
|
||||
else setSelIdx(null);
|
||||
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const selThrow = selIdx !== null ? (v.pending[selIdx] ?? null) : null;
|
||||
const forThrow = selThrow ? moves.filter((m) => m.throw === selThrow) : [];
|
||||
const movable = new Set<From>(forThrow.map((m) => m.from));
|
||||
const chosen: YutLegalMove | null = selFrom !== null ? (forThrow.find((m) => m.from === selFrom) ?? null) : null;
|
||||
|
||||
// Throw / move feedback: sounds, screen-reader text, hop animation along the path.
|
||||
useEffect(() => {
|
||||
const lt = v.lastThrow;
|
||||
if (lt && lt.no !== lastThrowNo.current) {
|
||||
lastThrowNo.current = lt.no;
|
||||
const who = lt.player === me ? '내가' : `${nick(players, lt.player)}님이`;
|
||||
setAnnounce(lt.result === 'nak' ? `${who} 던진 윷이 판 밖으로 떨어졌어요(낙).` : `${who} ${THROW_KO[lt.result]}를 던졌어요.`);
|
||||
sounds.stone();
|
||||
}
|
||||
}, [v.lastThrow?.no]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const m = v.lastMove;
|
||||
if (!m || m.no === lastMoveNo.current) return;
|
||||
lastMoveNo.current = m.no;
|
||||
const caught = m.captured.reduce((a, c) => a + c.count, 0);
|
||||
const end = m.to === 'goal' ? HOME_PT : POS[m.to]!;
|
||||
const parts = [`${nick(players, m.player)}님이 ${THROW_KO[m.throw]}로 ${nodeName(m.from)}에서 ${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리로'} 움직였어요${m.count > 1 ? `(${m.count}개 함께)` : ''}.`];
|
||||
if (caught) parts.push(`말 ${caught}개를 잡았어요!`);
|
||||
if (m.stacked) parts.push('업었어요.');
|
||||
if (m.to === 'goal') parts.push(`말 ${m.count}개가 났어요.`);
|
||||
setAnnounce(parts.join(' '));
|
||||
const text = caught ? '잡았다!' : m.to === 'goal' ? '났다!' : m.stacked ? '업었다!' : null;
|
||||
if (text) setFlash({ text, at: end, key: m.no });
|
||||
if (caught) haptic([40, 30, 40]);
|
||||
if (reducedMotion()) return;
|
||||
const pts: [number, number][] = [m.from === 'home' ? HOME_PT : POS[m.from]!, ...m.path.map((n) => POS[n]!)];
|
||||
if (m.to === 'goal') pts.push(HOME_PT);
|
||||
setAnim({ no: m.no, pts, i: 0, side: m.side, count: m.count, to: m.to, caught: caught > 0 });
|
||||
}, [v.lastMove?.no]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!anim) return;
|
||||
if (anim.i >= anim.pts.length - 1) {
|
||||
const t = setTimeout(() => setAnim(null), 120);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
const t = setTimeout(() => setAnim((a) => (a && a.no === anim.no ? { ...a, i: a.i + 1 } : a)), 170);
|
||||
return () => clearTimeout(t);
|
||||
}, [anim]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) return;
|
||||
const t = setTimeout(() => setFlash(null), 1100);
|
||||
return () => clearTimeout(t);
|
||||
}, [flash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (v.current !== prevCurrent.current && v.current === me && v.phase !== 'finished') {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
}
|
||||
prevCurrent.current = v.current;
|
||||
}, [v.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const doMove = (m: YutLegalMove) => {
|
||||
act({ type: 'MOVE', throwIndex: m.throwIndex, from: m.from });
|
||||
setSelFrom(null);
|
||||
};
|
||||
|
||||
/** First tap shows where it lands, second tap (or the destination / 이동하기) confirms. */
|
||||
const tapSource = (from: From) => {
|
||||
if (!myTurn || pending) return;
|
||||
if (!selThrow) {
|
||||
setAnnounce('먼저 아래에서 쓸 윷 결과를 골라 주세요.');
|
||||
return;
|
||||
}
|
||||
if (!movable.has(from)) {
|
||||
setAnnounce(selThrow === 'backdo' && from === 'home' ? '빽도로는 새 말을 올릴 수 없어요.' : '그 말은 지금 움직일 수 없어요.');
|
||||
return;
|
||||
}
|
||||
if (selFrom === from && chosen) doMove(chosen);
|
||||
else {
|
||||
setSelFrom(from);
|
||||
const m = forThrow.find((x) => x.from === from)!;
|
||||
setAnnounce(`${nodeName(m.to)}${m.to === 'goal' ? '' : ' 자리'}로 가요. ${badge(m) ?? ''} 한 번 더 누르면 움직여요.`);
|
||||
}
|
||||
};
|
||||
|
||||
const keyAct = (fn: () => void) => (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // keep the chat's global "Enter focuses chat" shortcut from stealing focus
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
// Stacks to draw (the arriving stack is drawn by the animation until it lands).
|
||||
const stacks = v.stacks.filter((st) => !(anim && anim.to === st.node && st.side === anim.side));
|
||||
const previewPath = chosen ? [chosen.from === 'home' ? HOME_PT : POS[chosen.from]!, ...chosen.path.map((n) => POS[n]!)] : null;
|
||||
const destPt = chosen ? (chosen.to === 'goal' ? HOME_PT : POS[chosen.to]!) : null;
|
||||
const mySideView = v.mySide !== null ? v.sides[v.mySide] : null;
|
||||
const lone = moves.length === 1 ? moves[0]! : null;
|
||||
const totalMs = v.away.includes(v.current) ? 5000 : (v.options.turnSeconds ?? 0) * 1000;
|
||||
|
||||
return (
|
||||
<div className="yut-wrap">
|
||||
<style>{CSS}</style>
|
||||
<div className="yut-board-box">
|
||||
<svg className="yut-board" viewBox="0 0 100 100" role="group" aria-label={`윷판. 말은 오른쪽 아래 참먹이에서 출발해 시계 반대 방향으로 돌아요.${chosen ? ` 고른 말은 ${nodeName(chosen.to)}로 가요.` : ''}`} style={{ background: 'var(--wood)' }}>
|
||||
<rect x={1.5} y={1.5} width={97} height={97} rx={6} fill="none" stroke="var(--wood-line)" strokeWidth={0.6} opacity={0.6} />
|
||||
{LINES.map(([a, b]) => (
|
||||
<line key={`${a}-${b}`} x1={POS[a]![0]} y1={POS[a]![1]} x2={POS[b]![0]} y2={POS[b]![1]} stroke="var(--wood-line)" strokeWidth={0.9} strokeLinecap="round" opacity={0.75} />
|
||||
))}
|
||||
{previewPath && (
|
||||
<polyline points={previewPath.map((p) => p.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={1.6} strokeDasharray="2.2 1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
)}
|
||||
{POS.map(([x, y], n) => (
|
||||
<g key={n}>
|
||||
<circle cx={x} cy={y} r={BIG.has(n) ? R_BIG : R_NODE} fill="var(--surface)" stroke="var(--wood-line)" strokeWidth={BIG.has(n) ? 1 : 0.7} />
|
||||
{BIG.has(n) && <circle cx={x} cy={y} r={(BIG.has(n) ? R_BIG : R_NODE) - 1.6} fill="none" stroke="var(--wood-line)" strokeWidth={0.4} opacity={0.6} />}
|
||||
</g>
|
||||
))}
|
||||
{BIG_LABELS.map(([n, t]) => (
|
||||
<text key={`l${n}`} x={POS[n]![0]} y={POS[n]![1]} textAnchor="middle" dominantBaseline="central" fontSize={n === 0 ? 2.9 : 3.2} fontWeight={800} fill="var(--wood-line)" opacity={0.9}>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{/* direction hint at the start */}
|
||||
<path d={`M ${HI - 4.5} ${HI - 9} L ${HI - 4.5} ${HI - 14} M ${HI - 6.3} ${HI - 12} L ${HI - 4.5} ${HI - 14.2} L ${HI - 2.7} ${HI - 12}`} stroke="var(--wood-line)" strokeWidth={0.7} fill="none" strokeLinecap="round" opacity={0.8} />
|
||||
{stacks.map((st) => {
|
||||
const [x, y] = POS[st.node]!;
|
||||
const mine = v.mySide === st.side;
|
||||
const can = myTurn && mine && movable.has(st.node);
|
||||
const sel = mine && selFrom === st.node;
|
||||
const sideName = v.sides[st.side]!.name;
|
||||
return (
|
||||
<g key={`s${st.node}`}>
|
||||
{(can || sel) && <circle cx={x} cy={y} r={R_BIG + 0.6} fill="none" stroke={sel ? 'var(--primary)' : 'var(--focus)'} strokeWidth={1.3} className={sel ? undefined : 'yut-can'} />}
|
||||
<Token side={st.side} team={v.team} x={x} y={y} r={BIG.has(st.node) ? 4.6 : 4} count={st.count} />
|
||||
{mine && myTurn && v.phase === 'move' && (
|
||||
<g
|
||||
className="yut-hit"
|
||||
role="button"
|
||||
tabIndex={can ? 0 : -1}
|
||||
aria-disabled={!can}
|
||||
aria-pressed={sel}
|
||||
aria-label={`${sideName} 말 ${st.count}개 (${NODE_NAMES[st.node]} 자리) ${sel ? '— 한 번 더 누르면 움직여요' : '움직이기'}`}
|
||||
onClick={() => tapSource(st.node)}
|
||||
onKeyDown={keyAct(() => tapSource(st.node))}
|
||||
>
|
||||
<circle cx={x} cy={y} r={R_BIG + 1} fill="transparent" />
|
||||
<circle className="yut-focus" cx={x} cy={y} r={R_BIG + 1.6} fill="none" stroke="var(--focus)" strokeWidth={1} />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{chosen && destPt && (
|
||||
<g
|
||||
className="yut-hit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${nodeName(chosen.to)}${chosen.to === 'goal' ? '' : ' 자리'}로 이동하기`}
|
||||
onClick={() => !pending && doMove(chosen)}
|
||||
onKeyDown={keyAct(() => !pending && doMove(chosen))}
|
||||
>
|
||||
<circle cx={destPt[0]} cy={destPt[1]} r={R_BIG + 0.8} fill="rgba(37, 99, 235, 0.18)" stroke={chosen.captures ? 'var(--danger)' : 'var(--primary)'} strokeWidth={1.4} strokeDasharray={chosen.captures ? undefined : '2 1.4'} />
|
||||
<circle className="yut-focus" cx={destPt[0]} cy={destPt[1]} r={R_BIG + 2} fill="none" stroke="var(--focus)" strokeWidth={1} />
|
||||
{badge(chosen) && (
|
||||
<g transform={`translate(${Math.min(88, Math.max(12, destPt[0]))} ${Math.max(6, destPt[1] - R_BIG - 4.5)})`}>
|
||||
<rect x={-8} y={-3.2} width={16} height={6.4} rx={3.2} fill={chosen.captures ? 'var(--danger)' : chosen.finishes ? 'var(--success)' : 'var(--primary)'} />
|
||||
<text textAnchor="middle" dominantBaseline="central" fontSize={3.6} fontWeight={900} fill="#fff">
|
||||
{badge(chosen)}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
{anim && (
|
||||
<g key={`${anim.no}-${anim.i}`} className="yut-hop">
|
||||
<Token side={anim.side} team={v.team} x={anim.pts[anim.i]![0]} y={anim.pts[anim.i]![1]} r={4.4} count={anim.count} />
|
||||
</g>
|
||||
)}
|
||||
{flash && !anim && (
|
||||
<g key={flash.key} className="yut-pop">
|
||||
<text x={Math.min(84, Math.max(16, flash.at[0]))} y={Math.max(8, flash.at[1] - 9)} textAnchor="middle" fontSize={6} fontWeight={900} fill="#fde047" stroke="#7c2d12" strokeWidth={0.8} paintOrder="stroke">
|
||||
{flash.text}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="yut-ctrl">
|
||||
<div className="row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
|
||||
<Sticks view={v} players={players} />
|
||||
{v.phase !== 'finished' && <TurnTimer deadline={deadline} totalMs={Math.max(1000, totalMs)} mine={myTurn} size={44} />}
|
||||
</div>
|
||||
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong className="small">모아 둔 결과{v.current !== me && v.phase !== 'finished' ? ` · ${nick(players, v.current)}님` : ''}</strong>
|
||||
{v.pending.length === 0 ? (
|
||||
<span className="muted small">{v.phase === 'throw' ? '윷을 던지면 여기에 모여요.' : '없음'}</span>
|
||||
) : (
|
||||
<div className="yut-chips" role="group" aria-label="모아 둔 윷 결과">
|
||||
{v.pending.map((t, i) => {
|
||||
const usable = moves.some((m) => m.throw === t);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`yut-chip${t === 'backdo' ? ' back' : ''}`}
|
||||
aria-pressed={selIdx === i}
|
||||
aria-label={`${THROW_KO[t]}, ${t === 'backdo' ? '뒤로 1칸' : `앞으로 ${STEPS[t]}칸`}`}
|
||||
disabled={!myTurn || v.phase !== 'move' || !usable || pending}
|
||||
onClick={() => {
|
||||
setSelIdx(i);
|
||||
setSelFrom(null);
|
||||
const n = moves.filter((m) => m.throw === t).length;
|
||||
setAnnounce(`${THROW_KO[t]}를 골랐어요. 움직일 수 있는 말이 ${n}가지예요.`);
|
||||
}}
|
||||
>
|
||||
<b>{THROW_KO[t]}</b>
|
||||
<small>{stepText(t)}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{myTurn && v.phase === 'throw' && (
|
||||
<button className="btn btn-primary btn-block yut-throw" disabled={pending} onClick={() => act({ type: 'THROW' })}>
|
||||
{v.pending.length || v.lastThrow?.player === me ? '🥢 한 번 더 던지기' : '🥢 윷 던지기'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{myTurn && v.phase === 'move' && (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
{chosen ? (
|
||||
<div className="row" style={{ flexWrap: 'nowrap' }}>
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={pending} onClick={() => doMove(chosen)}>
|
||||
이동하기 · {nodeName(chosen.from)} → {nodeName(chosen.to)}
|
||||
{badge(chosen) ? ` (${badge(chosen)})` : ''}
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setSelFrom(null)}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="small muted" style={{ margin: 0 }}>
|
||||
{selThrow ? '반짝이는 말을 누르면 갈 자리가 보여요. 한 번 더 누르면 움직여요.' : '쓸 결과를 먼저 골라 주세요.'}
|
||||
</p>
|
||||
)}
|
||||
{lone && !chosen && (
|
||||
<button className="btn btn-success btn-block" disabled={pending} onClick={() => doMove(lone)}>
|
||||
자동 이동 · {THROW_KO[lone.throw]}로 {nodeName(lone.from)} → {nodeName(lone.to)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mySideView && (
|
||||
<div className="yut-tray" aria-label="내 편 말">
|
||||
<button
|
||||
type="button"
|
||||
className={`yut-tray-btn${myTurn && movable.has('home') ? (selFrom === 'home' ? ' sel' : ' can') : ''}`}
|
||||
disabled={!myTurn || v.phase !== 'move' || mySideView.home === 0 || pending}
|
||||
aria-pressed={selFrom === 'home'}
|
||||
aria-label={`대기 말 ${mySideView.home}개${myTurn && movable.has('home') ? (selFrom === 'home' ? ' — 한 번 더 누르면 올려요' : ' — 새 말 올리기') : ''}`}
|
||||
onClick={() => tapSource('home')}
|
||||
>
|
||||
<TokenIcon side={mySideView.id} team={v.team} />
|
||||
대기 {mySideView.home}개{myTurn && movable.has('home') ? ' · 새 말 올리기' : ''}
|
||||
</button>
|
||||
<span className="small" aria-label={`난 말 ${mySideView.done}개`}>
|
||||
🏁 난 말 <strong>{mySideView.done}</strong>/{v.options.pieces}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function badge(m: YutLegalMove): string | null {
|
||||
if (m.captures) return '잡기!';
|
||||
if (m.finishes) return '날!';
|
||||
if (m.stacks) return '업기';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side panel
|
||||
|
||||
function SideCard({ side, view: v, players, me, deadline }: { side: YutSideView; view: YutView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
|
||||
const turn = v.phase !== 'finished' && v.turnSide === side.id;
|
||||
const shape = SHAPE_KO[sideShape(side.id)];
|
||||
const status = side.finishedRank !== null ? `${side.finishedRank}등` : side.out ? '기권' : null;
|
||||
return (
|
||||
<div className={`card yut-side-card${turn ? ' turn' : ''}`} aria-label={`${side.name}(${shape}) 대기 ${side.home}개, 판 위 ${side.onBoard}개, 남 ${side.done}개${status ? `, ${status}` : ''}${turn ? ', 차례' : ''}`}>
|
||||
<div className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
|
||||
<TokenIcon side={side.id} team={v.team} size={26} />
|
||||
<strong style={{ flex: 1, minWidth: 0 }}>
|
||||
{side.name}
|
||||
{v.mySide === side.id && <span className="muted small"> (우리 편)</span>}
|
||||
</strong>
|
||||
{status && (
|
||||
<span className="yut-badge" style={{ background: side.finishedRank !== null ? 'var(--success)' : 'var(--text-sub)' }}>
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
<span className="small" style={{ fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }} aria-hidden>
|
||||
대기 {side.home} · 판 {side.onBoard} · 🏁 {side.done}
|
||||
</span>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 4, marginTop: 6 }}>
|
||||
{side.members.map((id) => {
|
||||
const u = userOf(players, id);
|
||||
const cur = turn && v.current === id;
|
||||
return (
|
||||
<div key={id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: side.conceded.includes(id) ? 0.55 : 1 }}>
|
||||
<Avatar user={u} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: cur ? 800 : 500 }}>
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted small"> (나)</span>}
|
||||
{cur && <span className="small" style={{ color: 'var(--primary)' }}> · {v.phase === 'throw' ? '던지는 중' : '옮기는 중'}</span>}
|
||||
</span>
|
||||
{side.conceded.includes(id) && <span className="yut-badge" style={{ background: 'var(--text-sub)' }}>기권</span>}
|
||||
{v.away.includes(id) && !side.conceded.includes(id) && <span className="yut-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
|
||||
{cur && <TurnTimer deadline={deadline} totalMs={Math.max(1000, v.away.includes(id) ? 5000 : (v.options.turnSeconds ?? 0) * 1000)} mine={id === me} size={30} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function logLine(e: GameEvent, v: YutView, players: Record<string, PublicUser>): string | null {
|
||||
const who = typeof e.player === 'string' ? nick(players, e.player) : '';
|
||||
const sn = (s: unknown) => v.sides[s as number]?.name ?? '';
|
||||
switch (e.type) {
|
||||
case 'thrown':
|
||||
return `${who}: ${THROW_KO[e.result as Throw]}`;
|
||||
case 'nak':
|
||||
return `${who}: 낙! (무효)`;
|
||||
case 'moved':
|
||||
return `${who}: ${THROW_KO[e.throw as Throw]}로 ${nodeName(e.from as From)} → ${nodeName(e.to as number | 'goal')}${(e.count as number) > 1 ? ` (${e.count}개)` : ''}`;
|
||||
case 'captured':
|
||||
return `${sn(e.side)}이 ${sn(e.victimSide)} 말 ${e.count}개를 잡았어요`;
|
||||
case 'stacked':
|
||||
return `${sn(e.side)} 말 ${e.count}개가 업혔어요`;
|
||||
case 'finishedPieces':
|
||||
return `${sn(e.side)} 말 ${e.count}개가 났어요`;
|
||||
case 'discarded':
|
||||
return `쓸 곳이 없는 ${(e.throws as Throw[]).map((t) => THROW_KO[t]).join('·')}는 버렸어요`;
|
||||
case 'bonusThrow':
|
||||
return `${who}: 잡아서 한 번 더 던져요`;
|
||||
case 'sideFinished':
|
||||
return `${sn(e.side)} ${e.rank}등!`;
|
||||
case 'conceded':
|
||||
return `${who}님이 기권했어요`;
|
||||
case 'timeout':
|
||||
return `${who}님 시간 초과 · 자동으로 했어요`;
|
||||
case 'away':
|
||||
return `${who}님 자리 비움`;
|
||||
case 'gameOver':
|
||||
return String(e.summary ?? '게임이 끝났어요');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function YutSide({ view: v, me, players, act, deadline }: BoardProps<YutView>) {
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
const lines = v.log.map((e) => logLine(e, v, players)).filter((x): x is string => !!x);
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo({ top: ref.current.scrollHeight });
|
||||
}, [v.turnSeq]);
|
||||
const mySide = v.mySide !== null ? v.sides[v.mySide] : null;
|
||||
const canConcede = v.phase !== 'finished' && !!me && !!mySide && !mySide.conceded.includes(me) && mySide.finishedRank === null;
|
||||
return (
|
||||
<>
|
||||
{v.sides.map((x) => (
|
||||
<SideCard key={x.id} side={x} view={v} players={players} me={me} deadline={deadline} />
|
||||
))}
|
||||
{v.log.length > 0 && (
|
||||
<details className="card wide-open" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>진행 기록</summary>
|
||||
<ol ref={ref} className="yut-log" style={{ marginTop: 8 }}>
|
||||
{lines.slice(-24).map((l, i) => (
|
||||
<li key={`${v.turnSeq}-${i}`}>{l}</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{canConcede && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setConfirm(true)}>
|
||||
🏳 기권
|
||||
</button>
|
||||
)}
|
||||
{confirm && (
|
||||
<ConfirmSheet
|
||||
title="기권할까요?"
|
||||
body={v.team ? '팀원이 모두 기권하면 우리 팀이 져요. 남은 팀원이 이어서 해요.' : '기권하면 내 말이 판에서 빠지고 꼴찌가 돼요.'}
|
||||
confirm="기권하기"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'CONCEDE' })}
|
||||
onClose={() => setConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function statusText(v: YutView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
const cur = nick(players, v.current);
|
||||
if (v.current === me) {
|
||||
if (v.phase === 'throw') return v.pending.length || v.lastThrow?.player === me ? '내 차례예요. 한 번 더 던져요' : '내 차례예요. 윷을 던져 주세요';
|
||||
return '내 차례예요. 결과를 골라 말을 움직여 주세요';
|
||||
}
|
||||
if (v.team && v.mySide === v.turnSide) return `${cur}님(우리 팀) 차례예요`;
|
||||
return `${cur}님 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented">
|
||||
{options.map(([k, l]) => (
|
||||
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<YutOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<YutOptions>) => onChange({ ...o, ...patch });
|
||||
const onOff = (b: boolean) => (b ? 'on' : 'off');
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="경기 방식">
|
||||
<Seg value={o.mode} options={[['individual', '개인전(2~4명)'], ['team', '팀전(4·6·8명)']]} onChange={(mode) => set({ mode })} />
|
||||
{o.mode === 'team' && <span className="small muted">자리 순서대로 청팀·홍팀이 번갈아 앉아요(1·3·5·7번째 청팀). 자리를 바꿔 팀을 맞춰 주세요.</span>}
|
||||
</Field>
|
||||
<Field label="한 편의 말 수">
|
||||
<Seg value={o.pieces} options={[[2, '2개'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(pieces) => set({ pieces })} />
|
||||
</Field>
|
||||
<Field label="행동 제한 시간">
|
||||
<Seg
|
||||
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
|
||||
options={[['15', '15초'], ['30', '30초'], ['60', '60초'], ['none', '무제한']]}
|
||||
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })}
|
||||
/>
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 우리 집 규칙 닫기' : '▼ 우리 집 규칙(자세한 설정)'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="빽도">
|
||||
<Seg value={onOff(o.backdo)} options={[['on', '빽도 쓰기'], ['off', '안 씀(그냥 도)']]} onChange={(k) => set({ backdo: k === 'on' })} />
|
||||
</Field>
|
||||
{o.backdo && (
|
||||
<>
|
||||
<Field label="도 자리 말이 빽도를 받으면">
|
||||
<Seg value={o.backdoFromDo} options={[['chammeogi', '참먹이로(다음에 남)'], ['finish', '바로 남']]} onChange={(backdoFromDo) => set({ backdoFromDo })} />
|
||||
</Field>
|
||||
<Field label="빽도로 잡았을 때">
|
||||
<Seg value={onOff(o.backdoCaptureBonus)} options={[['on', '한 번 더 던지기'], ['off', '더 안 던짐']]} onChange={(k) => set({ backdoCaptureBonus: k === 'on' })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="날(완주) 판정">
|
||||
<Seg value={o.finishRule} options={[['pass', '참먹이를 지나야 남'], ['reach', '참먹이에 도착하면 남']]} onChange={(finishRule) => set({ finishRule })} />
|
||||
</Field>
|
||||
<Field label="윷가락 확률">
|
||||
<Seg value={o.stickModel} options={[['realistic', '실제 윷처럼'], ['fair', '앞뒤 반반']]} onChange={(stickModel) => set({ stickModel })} />
|
||||
</Field>
|
||||
<Field label="낙(윷이 판 밖으로)">
|
||||
<Seg value={o.nakPercent} options={[[0, '없음'], [3, '3%'], [5, '5%'], [10, '10%']]} onChange={(nakPercent) => set({ nakPercent })} />
|
||||
</Field>
|
||||
{o.mode === 'individual' && (
|
||||
<Field label="3~4명일 때 1등이 나오면">
|
||||
<Seg value={o.ranking} options={[['first', '바로 끝'], ['all', '꼴찌까지 계속']]} onChange={(ranking) => set({ ranking })} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: YutOptions): string {
|
||||
return [
|
||||
o.mode === 'team' ? '팀전' : '개인전',
|
||||
`말 ${o.pieces}개`,
|
||||
o.backdo ? '빽도 있음' : '빽도 없음',
|
||||
o.finishRule === 'pass' ? '참먹이 지나야 남' : '참먹이 도착하면 남',
|
||||
...(o.nakPercent ? [`낙 ${o.nakPercent}%`] : []),
|
||||
o.turnSeconds === null ? '시간 무제한' : `${o.turnSeconds}초`,
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function MiniBoard() {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" style={{ width: '100%', maxWidth: 240, display: 'block', margin: '0 auto', background: 'var(--wood)', borderRadius: 12 }} role="img" aria-label="윷판 그림: 모서리와 방에 멈추면 안쪽 지름길로 꺾여요">
|
||||
{LINES.map(([a, b]) => (
|
||||
<line key={`${a}-${b}`} x1={POS[a]![0]} y1={POS[a]![1]} x2={POS[b]![0]} y2={POS[b]![1]} stroke="var(--wood-line)" strokeWidth={0.9} opacity={0.7} />
|
||||
))}
|
||||
<polyline points={[5, 20, 21, 22, 27, 28, 0].map((n) => POS[n]!.join(',')).join(' ')} fill="none" stroke="var(--primary)" strokeWidth={2} strokeDasharray="3 2" />
|
||||
{POS.map(([x, y], n) => (
|
||||
<circle key={n} cx={x} cy={y} r={BIG.has(n) ? 6 : 4.2} fill="var(--surface)" stroke="var(--wood-line)" strokeWidth={0.7} />
|
||||
))}
|
||||
{[
|
||||
[0, '출발'],
|
||||
[5, '모'],
|
||||
[10, '뒷모'],
|
||||
[15, '찌모'],
|
||||
[22, '방'],
|
||||
].map(([n, t]) => (
|
||||
<text key={n} x={POS[n as number]![0]} y={POS[n as number]![1]} textAnchor="middle" dominantBaseline="central" fontSize={3.4} fontWeight={800} fill="var(--text)">
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StickRow({ flats, label }: { flats: boolean[]; label: string }) {
|
||||
return (
|
||||
<figure style={{ margin: 0, textAlign: 'center' }}>
|
||||
<svg viewBox="0 0 120 80" style={{ width: 84, height: 56 }} aria-hidden>
|
||||
{flats.map((f, i) => (
|
||||
<Stick key={i} x={8 + i * 27} flat={f} marked={i === 0} delay={0} />
|
||||
))}
|
||||
</svg>
|
||||
<figcaption className="small">
|
||||
<strong>{label}</strong>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: YutOptions }) {
|
||||
const o = options;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
윷가락 네 개를 던져 나온 만큼 말을 움직여, <strong>내 말을 모두 먼저 판 밖으로 내보내면(날)</strong> 이겨요. 말은 오른쪽 아래 <strong>참먹이</strong>에서 출발해 시계 반대 방향으로 돌아요.
|
||||
{o?.mode === 'team' ? ' 팀전은 청팀·홍팀이 번갈아 던지고, 팀 안에서는 자리 순서대로 돌아가요.' : ''}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(96px, 1fr))', gap: 8 }}>
|
||||
<StickRow flats={[false, true, false, false]} label="도 · 1칸" />
|
||||
<StickRow flats={[true, true, false, false]} label="개 · 2칸" />
|
||||
<StickRow flats={[true, true, true, false]} label="걸 · 3칸" />
|
||||
<StickRow flats={[true, true, true, true]} label="윷 · 4칸 + 한 번 더" />
|
||||
<StickRow flats={[false, false, false, false]} label="모 · 5칸 + 한 번 더" />
|
||||
{o?.backdo !== false && <StickRow flats={[true, false, false, false]} label="빽도 · 뒤로 1칸" />}
|
||||
</div>
|
||||
<p className="small muted">밝은 면이 배(평평한 면), 어두운 면이 등이에요. 배가 위인 가락 수로 결과가 정해져요. '빽' 표시가 있는 가락 하나만 배가 위면 빽도예요.</p>
|
||||
<p>
|
||||
<strong>윷·모</strong>가 나오면 한 번 더 던져요. 모아 둔 결과는 원하는 순서로, 원하는 말에 하나씩 써요. 결과 칩을 누르고 → 반짝이는 말을 누르면 갈 자리가 보이고 → 한 번 더 누르면 움직여요.
|
||||
</p>
|
||||
<MiniBoard />
|
||||
<p>
|
||||
<strong>지름길</strong>: 모서리(모·뒷모)나 가운데 방에 <strong>멈췄다가</strong> 다음에 출발하면 안쪽 대각선으로 꺾어요. 지나가기만 하면 그대로 직진해요. 방에서 출발하면 언제나 참먹이 쪽으로 가요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>업기</strong>: 내 말이 있는 자리에 멈추면 함께 업혀서 같이 움직여요. <strong>잡기</strong>: 상대 말이 있는 자리에 멈추면 그 말들을 처음으로 돌려보내고 한 번 더 던져요
|
||||
{o && !o.backdoCaptureBonus ? '(빽도로 잡을 때는 빼고)' : ''}. 지나가기만 해서는 잡지 않아요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>날</strong>: {o?.finishRule === 'reach' ? '한 바퀴 돈 말이 참먹이에 도착하면 바로 나요.' : '한 바퀴 돈 말이 참먹이를 지나가야 나요. 참먹이에 딱 멈추면 아직 판 위라서 잡힐 수 있어요.'}
|
||||
</p>
|
||||
{o?.backdo !== false && (
|
||||
<p>
|
||||
<strong>빽도</strong>: 말이 왔던 길로 한 칸 돌아가요. 판 위에 말이 없으면 빽도는 버려져요.{' '}
|
||||
{o?.backdoFromDo === 'finish' ? '도 자리에 있는 말이 빽도를 받으면 바로 나요.' : '도 자리에 있는 말이 빽도를 받으면 참먹이로 가고, 다음 전진에서 바로 나요.'}
|
||||
</p>
|
||||
)}
|
||||
{!!o?.nakPercent && <p>낙: {o.nakPercent}% 확률로 윷가락이 판 밖으로 떨어져 그 던지기는 무효가 돼요(그 전에 모아 둔 결과는 남아요).</p>}
|
||||
<p className="muted small">시간 안에 하지 않으면 자동으로 던지거나, 잡기 → 내기 → 업기 → 뒤처진 말 순서로 골라 움직여요. 세 번 연속 시간을 넘기면 '자리 비움'이 되고 제한 시간이 5초로 줄어요.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<YutView, YutOptions> = {
|
||||
Board: YutBoard,
|
||||
Side: YutSide,
|
||||
statusText,
|
||||
isMyTurn: (v, me) => v.phase !== 'finished' && !!me && v.current === me,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
31
apps/web/src/lib/catalog-ui.ts
Normal file
31
apps/web/src/lib/catalog-ui.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const GAME_ICON: Record<string, string> = {
|
||||
omok: '⚫',
|
||||
baduk: '⚪',
|
||||
chess: '♞',
|
||||
janggi: '將',
|
||||
seotda: '🎴',
|
||||
poker: '🃏',
|
||||
mahjong: '🀇',
|
||||
yut: '🪵',
|
||||
othello: '◐',
|
||||
yacht: '🎲',
|
||||
'one-card': '♣',
|
||||
rummikub: '🔢',
|
||||
'davinci-code': '🔐',
|
||||
gostop: '🌸',
|
||||
marble: '🏙️',
|
||||
bang: '🤠',
|
||||
'halli-galli': '🔔',
|
||||
dalmuti: '👑',
|
||||
splendor: '💎',
|
||||
quoridor: '🧱',
|
||||
'las-vegas': '⚅',
|
||||
'cockroach-poker': '🐞',
|
||||
'ninety-nine': '💯',
|
||||
dobble: '👀',
|
||||
catchmind: '🖍️',
|
||||
mafia: '🌙',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING: string[] = [];
|
||||
55
apps/web/src/lib/feedback.ts
Normal file
55
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Tiny synthesized sounds + vibration (no audio files; docs/07 §6). */
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function audio(): AudioContext | null {
|
||||
if (!useSession.getState().settings.sound) return null;
|
||||
try {
|
||||
ctx ??= new AudioContext();
|
||||
if (ctx.state === 'suspended') void ctx.resume();
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tone(freq: number, ms: number, type: OscillatorType = 'sine', gain = 0.15, delay = 0): void {
|
||||
const a = audio();
|
||||
if (!a) return;
|
||||
const t = a.currentTime + delay / 1000;
|
||||
const o = a.createOscillator();
|
||||
const g = a.createGain();
|
||||
o.type = type;
|
||||
o.frequency.value = freq;
|
||||
g.gain.setValueAtTime(gain, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000);
|
||||
o.connect(g).connect(a.destination);
|
||||
o.start(t);
|
||||
o.stop(t + ms / 1000 + 0.02);
|
||||
}
|
||||
|
||||
export const sounds = {
|
||||
stone: () => tone(900, 70, 'triangle', 0.25),
|
||||
myTurn: () => {
|
||||
tone(660, 120);
|
||||
tone(880, 160, 'sine', 0.15, 120);
|
||||
},
|
||||
warn: () => tone(520, 90, 'square', 0.06),
|
||||
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)),
|
||||
lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)),
|
||||
/** Bell "땡!" (할리갈리 correct bell). */
|
||||
ding: () => {
|
||||
tone(1568, 700, 'sine', 0.22);
|
||||
tone(2350, 450, 'sine', 0.08);
|
||||
},
|
||||
/** Buzzer "삐!" (wrong answer). */
|
||||
buzz: () => tone(180, 260, 'square', 0.08),
|
||||
};
|
||||
|
||||
export function haptic(pattern: number | number[]): void {
|
||||
if (!useSession.getState().settings.vibrate) return;
|
||||
try {
|
||||
navigator.vibrate?.(pattern);
|
||||
} catch {}
|
||||
}
|
||||
17
apps/web/src/lib/media.ts
Normal file
17
apps/web/src/lib/media.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
/** Subscribes to a CSS media query. */
|
||||
export function useMedia(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
const m = matchMedia(query);
|
||||
m.addEventListener('change', cb);
|
||||
return () => m.removeEventListener('change', cb);
|
||||
},
|
||||
() => matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export const useWide = () => useMedia('(min-width: 1100px)');
|
||||
export const usePhone = () => useMedia('(max-width: 719px)');
|
||||
84
apps/web/src/main.tsx
Normal file
84
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { StrictMode, Suspense, lazy, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter, Route, Routes, useLocation, useNavigate } from 'react-router';
|
||||
import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css';
|
||||
import './styles/global.css';
|
||||
import { ConnectionBanner, Toasts } from './components/ui';
|
||||
import { useSession } from './store/session';
|
||||
import { toast } from './store/toast';
|
||||
import { Home } from './pages/Home';
|
||||
import { SiteHeader } from './components/SiteHeader';
|
||||
|
||||
const JoinPage = lazy(() => import('./pages/JoinPage'));
|
||||
const RoomPage = lazy(() => import('./pages/RoomPage'));
|
||||
const GamePage = lazy(() => import('./pages/GamePage'));
|
||||
const MePage = lazy(() => import('./pages/MePage'));
|
||||
const RulesPage = lazy(() => import('./pages/RulesPage'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
|
||||
const GamesPage = lazy(() => import('./pages/GamesPage'));
|
||||
|
||||
function Announcement() {
|
||||
const text = useSession((s) => s.announcement);
|
||||
const maintenance = useSession((s) => s.maintenance);
|
||||
if (!text && !maintenance) return null;
|
||||
return (
|
||||
<div role="note" style={{ background: maintenance ? 'var(--warning-soft)' : 'var(--primary-soft)', padding: '8px 16px', textAlign: 'center', fontWeight: 600 }}>
|
||||
{maintenance && '🛠 점검 중 · '}
|
||||
{text || '새 방을 잠시 만들 수 없어요.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const loaded = useSession((s) => s.loaded);
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
useEffect(() => {
|
||||
void useSession.getState().refresh();
|
||||
const onUnauthorized = () => void useSession.getState().refresh();
|
||||
window.addEventListener('bg:unauthorized', onUnauthorized);
|
||||
return () => window.removeEventListener('bg:unauthorized', onUnauthorized);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(loc.search);
|
||||
if (q.get('linked') === '1') toast('디스코드 계정과 연결했어요.');
|
||||
if (q.get('login') === 'failed') toast('디스코드 로그인에 실패했어요. 다시 시도해 주세요.', 'error');
|
||||
if (q.get('login') === 'banned') toast('이용이 제한된 계정이에요.', 'error', 6000);
|
||||
if (q.has('linked') || q.has('login')) {
|
||||
q.delete('linked');
|
||||
q.delete('login');
|
||||
nav({ pathname: loc.pathname, search: q.toString() }, { replace: true });
|
||||
}
|
||||
}, [loc.search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!loaded) return <div className="page muted">불러오는 중…</div>;
|
||||
return (
|
||||
<>
|
||||
<ConnectionBanner />
|
||||
<SiteHeader />
|
||||
<Announcement />
|
||||
<Toasts />
|
||||
<Suspense fallback={<div className="page muted">불러오는 중…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/j" element={<JoinPage />} />
|
||||
<Route path="/games" element={<GamesPage />} />
|
||||
<Route path="/r/:code" element={<RoomPage />} />
|
||||
<Route path="/g/:gameId" element={<GamePage />} />
|
||||
<Route path="/rules/:gameId" element={<RulesPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<Shell />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
79
apps/web/src/net/api.ts
Normal file
79
apps/web/src/net/api.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError('인터넷 연결을 확인해 주세요.', 0);
|
||||
}
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
|
||||
if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
fontScale?: 'normal' | 'large' | 'xlarge';
|
||||
theme?: 'auto' | 'light' | 'dark';
|
||||
sound?: boolean;
|
||||
vibrate?: boolean;
|
||||
colorBlind?: boolean;
|
||||
tapConfirm?: boolean;
|
||||
}
|
||||
|
||||
export type Role = 'user' | 'admin' | 'superadmin';
|
||||
|
||||
export interface MeResponse {
|
||||
me: PublicUser | null;
|
||||
role?: Role;
|
||||
settings?: Settings;
|
||||
useAvatar?: boolean;
|
||||
hasDiscordAvatar?: boolean;
|
||||
activeRoom?: string | null;
|
||||
}
|
||||
|
||||
export interface RoomPreview {
|
||||
code: string;
|
||||
codeLabel: string;
|
||||
gameId: string;
|
||||
host: string;
|
||||
seated: number;
|
||||
maxPlayers: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: () =>
|
||||
call<{ discord: boolean; catalog: (CatalogEntry & { notice?: string })[]; siteName: string; announcement: string; maintenance: boolean }>('GET', '/api/config'),
|
||||
me: () => call<MeResponse>('GET', '/api/me'),
|
||||
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
|
||||
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
|
||||
logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}),
|
||||
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
||||
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
||||
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
||||
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
||||
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
||||
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
||||
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),
|
||||
publicRooms: (gameId: string) =>
|
||||
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
|
||||
};
|
||||
|
||||
export { call as apiCall };
|
||||
|
||||
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;
|
||||
344
apps/web/src/net/socket.ts
Normal file
344
apps/web/src/net/socket.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* WebSocket client with auto-reconnect, seq ordering, ping/clock sync (docs/03 §4–§8).
|
||||
* Room/game state lives in the zustand store below; components only read it.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { PROTOCOL, type GameResultView, type PublicUser, type RoomView, type ServerMessage } from '@bg/shared/lite';
|
||||
import { toast } from '../store/toast';
|
||||
|
||||
export type ConnStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'replaced' | 'offline';
|
||||
|
||||
export interface ChatLine {
|
||||
id: number;
|
||||
from: PublicUser | null;
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
seq: number;
|
||||
view: unknown;
|
||||
active: string[];
|
||||
deadline: number | null;
|
||||
events: unknown[];
|
||||
}
|
||||
|
||||
interface RoomStore {
|
||||
status: ConnStatus;
|
||||
me: PublicUser | null;
|
||||
code: string | null;
|
||||
room: RoomView | null;
|
||||
roomSeq: number;
|
||||
game: GameState | null;
|
||||
result: GameResultView | null;
|
||||
chat: ChatLine[];
|
||||
emotes: { id: number; from: string; emote: string }[];
|
||||
pending: number | null;
|
||||
slow: boolean;
|
||||
rttMs: number | null;
|
||||
clockOffset: number;
|
||||
joinError: string | null;
|
||||
kicked: boolean;
|
||||
needsRefresh: boolean;
|
||||
}
|
||||
|
||||
export const useRoom = create<RoomStore>(() => ({
|
||||
status: 'idle',
|
||||
me: null,
|
||||
code: null,
|
||||
room: null,
|
||||
roomSeq: -1,
|
||||
game: null,
|
||||
result: null,
|
||||
chat: [],
|
||||
emotes: [],
|
||||
pending: null,
|
||||
slow: false,
|
||||
rttMs: null,
|
||||
clockOffset: 0,
|
||||
joinError: null,
|
||||
kicked: false,
|
||||
needsRefresh: false,
|
||||
}));
|
||||
|
||||
const BACKOFF = [500, 1000, 2000, 4000, 8000];
|
||||
const MAX_BACKOFF = 10_000;
|
||||
const PING_EVERY = 10_000;
|
||||
const PONG_TIMEOUT = 5_000;
|
||||
|
||||
class GameSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private attempt = 0;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private offsets: number[] = [];
|
||||
private wanted = false;
|
||||
private cs = Date.now();
|
||||
private chatId = 1;
|
||||
private onMessageHooks = new Set<(m: ServerMessage) => void>();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') this.kick();
|
||||
});
|
||||
window.addEventListener('online', () => this.kick());
|
||||
window.addEventListener('offline', () => useRoom.setState({ status: 'offline' }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (or keep) a connection. Safe to call repeatedly. */
|
||||
start(): void {
|
||||
this.wanted = true;
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
this.open();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.wanted = false;
|
||||
this.clearTimers();
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
useRoom.setState({ status: 'idle' });
|
||||
}
|
||||
|
||||
/** Reconnect immediately if we should be connected but are not. */
|
||||
private kick(): void {
|
||||
if (!this.wanted) return;
|
||||
if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
this.open();
|
||||
} else if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ping();
|
||||
}
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
this.ws = ws;
|
||||
useRoom.setState({ status: this.attempt === 0 ? 'connecting' : 'reconnecting' });
|
||||
ws.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.pingTimer = setInterval(() => this.ping(), PING_EVERY);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
let m: ServerMessage;
|
||||
try {
|
||||
m = JSON.parse(String(e.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handle(m);
|
||||
for (const h of this.onMessageHooks) h(m);
|
||||
};
|
||||
ws.onclose = (e) => {
|
||||
if (this.ws !== ws) return;
|
||||
this.clearTimers();
|
||||
this.ws = null;
|
||||
const st = useRoom.getState().status;
|
||||
if (!this.wanted || st === 'replaced' || useRoom.getState().kicked) return;
|
||||
if (e.code === 4401 || e.code === 1008) {
|
||||
// Not logged in (session expired): stop and let the page ask for a nickname.
|
||||
useRoom.setState({ status: 'idle' });
|
||||
this.wanted = false;
|
||||
window.dispatchEvent(new Event('bg:unauthorized'));
|
||||
return;
|
||||
}
|
||||
if (this.attempt >= 1) {
|
||||
// Repeated failures may mean the session is gone (the upgrade got a 401).
|
||||
fetch('/api/me', { credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((d: { me: unknown }) => {
|
||||
if (d.me === null) {
|
||||
this.wanted = false;
|
||||
useRoom.setState({ status: 'idle' });
|
||||
window.dispatchEvent(new Event('bg:unauthorized'));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(extraDelay = 0): void {
|
||||
const base = BACKOFF[this.attempt] ?? MAX_BACKOFF;
|
||||
const jitter = base * (0.7 + Math.random() * 0.6);
|
||||
this.attempt++;
|
||||
useRoom.setState({ status: navigator.onLine === false ? 'offline' : 'reconnecting' });
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
if (this.wanted) this.open();
|
||||
}, jitter + extraDelay);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
if (this.pongTimer) clearTimeout(this.pongTimer);
|
||||
this.pingTimer = null;
|
||||
this.pongTimer = null;
|
||||
}
|
||||
|
||||
private ping(): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN || this.pongTimer) return;
|
||||
this.send({ t: 'ping', ts: Date.now() });
|
||||
this.pongTimer = setTimeout(() => {
|
||||
this.pongTimer = null;
|
||||
// Dead connection: force a reconnect.
|
||||
this.ws?.close();
|
||||
}, PONG_TIMEOUT);
|
||||
}
|
||||
|
||||
send(m: unknown): boolean {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return false;
|
||||
this.ws.send(JSON.stringify(m));
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(fn: (m: ServerMessage) => void): () => void {
|
||||
this.onMessageHooks.add(fn);
|
||||
return () => this.onMessageHooks.delete(fn);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- room commands
|
||||
|
||||
join(code: string, as: 'player' | 'spectator' = 'player'): void {
|
||||
const cur = useRoom.getState();
|
||||
if (cur.code !== code) {
|
||||
useRoom.setState({ code, room: null, roomSeq: -1, game: null, result: null, chat: [], joinError: null, kicked: false, pending: null });
|
||||
}
|
||||
this.start();
|
||||
this.send({ t: 'join', code, as });
|
||||
}
|
||||
|
||||
leave(): void {
|
||||
this.send({ t: 'leave' });
|
||||
useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null });
|
||||
}
|
||||
|
||||
/** Sends a game action; returns false when not connected. */
|
||||
act(a: unknown): boolean {
|
||||
const cs = ++this.cs;
|
||||
if (!this.send({ t: 'act', cs, a })) {
|
||||
toast('연결이 끊겨 있어요. 다시 연결되면 해 주세요.', 'error');
|
||||
return false;
|
||||
}
|
||||
useRoom.setState({ pending: cs, slow: false });
|
||||
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
||||
this.pendingTimer = setTimeout(() => {
|
||||
if (useRoom.getState().pending === cs) {
|
||||
useRoom.setState({ slow: true });
|
||||
this.ping();
|
||||
}
|
||||
}, 2000);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Server time estimate. */
|
||||
now(): number {
|
||||
return Date.now() + useRoom.getState().clockOffset;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- inbound
|
||||
|
||||
private handle(m: ServerMessage): void {
|
||||
const st = useRoom.getState();
|
||||
switch (m.t) {
|
||||
case 'welcome': {
|
||||
if (m.protocol !== PROTOCOL) useRoom.setState({ needsRefresh: true });
|
||||
useRoom.setState({ status: 'open', me: m.me });
|
||||
this.offsets = [m.serverTime - Date.now()];
|
||||
useRoom.setState({ clockOffset: this.offsets[0]! });
|
||||
// Re-join the room we were in (reconnect path).
|
||||
if (st.code && !st.kicked) this.send({ t: 'join', code: st.code, as: 'player' });
|
||||
break;
|
||||
}
|
||||
case 'room':
|
||||
if (m.seq >= st.roomSeq || m.room.code !== st.room?.code) {
|
||||
useRoom.setState({ room: m.room, roomSeq: m.seq, joinError: null });
|
||||
if (m.room.status === 'playing' && st.result) useRoom.setState({ result: null });
|
||||
}
|
||||
break;
|
||||
case 'state': {
|
||||
if (st.game && m.seq < st.game.seq) break; // stale
|
||||
const patch: Partial<RoomStore> = {
|
||||
game: { seq: m.seq, view: m.view, active: m.active, deadline: m.deadline, events: m.events },
|
||||
};
|
||||
if (m.cs !== undefined && m.cs === st.pending) {
|
||||
patch.pending = null;
|
||||
patch.slow = false;
|
||||
}
|
||||
useRoom.setState(patch);
|
||||
break;
|
||||
}
|
||||
case 'reject':
|
||||
if (m.cs === st.pending) useRoom.setState({ pending: null, slow: false });
|
||||
toast(m.reason, 'error');
|
||||
break;
|
||||
case 'result':
|
||||
useRoom.setState({ result: m.result });
|
||||
break;
|
||||
case 'chat':
|
||||
useRoom.setState({ chat: [...st.chat, { id: this.chatId++, from: m.from, text: m.text, at: m.at }].slice(-100) });
|
||||
break;
|
||||
case 'emote': {
|
||||
const id = this.chatId++;
|
||||
useRoom.setState({ emotes: [...st.emotes, { id, from: m.from, emote: m.id }].slice(-10) });
|
||||
setTimeout(() => useRoom.setState((s) => ({ emotes: s.emotes.filter((e) => e.id !== id) })), 2500);
|
||||
break;
|
||||
}
|
||||
case 'notice':
|
||||
toast(m.message);
|
||||
break;
|
||||
case 'error':
|
||||
if (m.code === 'room-not-found' || m.code === 'join-failed') useRoom.setState({ joinError: m.message });
|
||||
else toast(m.message, 'error');
|
||||
break;
|
||||
case 'pong': {
|
||||
if (this.pongTimer) clearTimeout(this.pongTimer);
|
||||
this.pongTimer = null;
|
||||
const now = Date.now();
|
||||
const rtt = now - m.ts;
|
||||
const offset = m.serverTime + rtt / 2 - now;
|
||||
this.offsets = [...this.offsets, offset].slice(-5);
|
||||
const sorted = [...this.offsets].sort((a, b) => a - b);
|
||||
useRoom.setState({ rttMs: rtt, clockOffset: sorted[Math.floor(sorted.length / 2)]! });
|
||||
if (st.slow && st.pending) {
|
||||
// Connection is alive but our action got no answer: resync instead of resending.
|
||||
this.send({ t: 'sync' });
|
||||
useRoom.setState({ pending: null, slow: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bye':
|
||||
if (m.reason === 'restart') {
|
||||
toast('서버를 업데이트하고 있어요. 잠시 후 자동으로 다시 연결돼요.');
|
||||
this.attempt = 0;
|
||||
this.ws?.close();
|
||||
// onclose schedules a reconnect; add 1–3s so clients don't all return at once.
|
||||
this.ws = null;
|
||||
this.clearTimers();
|
||||
this.scheduleReconnect(1000 + Math.random() * 2000);
|
||||
} else if (m.reason === 'replaced') {
|
||||
useRoom.setState({ status: 'replaced' });
|
||||
} else if (m.reason === 'kicked') {
|
||||
useRoom.setState({ kicked: true, joinError: '방장이 방에서 내보냈어요.' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** "Use here" after another tab took over. */
|
||||
reclaim(): void {
|
||||
useRoom.setState({ status: 'connecting' });
|
||||
this.wanted = true;
|
||||
this.attempt = 0;
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new GameSocket();
|
||||
39
apps/web/src/no-money-wording.test.ts
Normal file
39
apps/web/src/no-money-wording.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Guard (docs/04 §6, reviewer request): betting-game UI text must never look like real money.
|
||||
* Scans the betting games' screens and shared chip/card components for currency-like wording.
|
||||
*/
|
||||
import { expect, test } from 'bun:test';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'games/las-vegas', 'components/cards'].map((p) => join(import.meta.dir, p));
|
||||
const FORBIDDEN: [RegExp, string][] = [
|
||||
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
|
||||
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],
|
||||
[/판돈|돈을|돈이|돈은|현금|머니|골드/, '돈을 떠올리는 낱말'],
|
||||
[/충전|환전|입금|출금|송금|결제|구매/, '거래 낱말'],
|
||||
];
|
||||
|
||||
function files(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).flatMap((f) => {
|
||||
const p = join(dir, f);
|
||||
return statSync(p).isDirectory() ? files(p) : /\.(tsx?|css)$/.test(f) ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Only string literals / JSX text matter; comments are ignored. */
|
||||
function visibleText(src: string): string {
|
||||
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
test('no currency-like wording in betting game UI', () => {
|
||||
const problems: string[] = [];
|
||||
for (const f of ROOTS.flatMap(files)) {
|
||||
const text = visibleText(readFileSync(f, 'utf8'));
|
||||
text.split('\n').forEach((line, i) => {
|
||||
for (const [re, why] of FORBIDDEN) if (re.test(line)) problems.push(`${f.split('/src/')[1]}:${i + 1} ${why}: ${line.trim().slice(0, 80)}`);
|
||||
});
|
||||
}
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
104
apps/web/src/pages/GamePage.tsx
Normal file
104
apps/web/src/pages/GamePage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { catalogById, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
import { GAME_ICON } from '../lib/catalog-ui';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId = '' } = useParams();
|
||||
const catalog = useSession((s) => s.catalog);
|
||||
const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId);
|
||||
const nav = useNavigate();
|
||||
const me = useSession((s) => s.me);
|
||||
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
|
||||
const [needName, setNeedName] = useState<null | (() => void)>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!g) return;
|
||||
const load = () => api.publicRooms(gameId).then((r) => setRooms(r.rooms)).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [gameId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!g) return <main className="page">없는 게임이에요.</main>;
|
||||
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
|
||||
const run = (p: () => Promise<{ code: string }>) =>
|
||||
withName(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
nav(`/r/${(await p()).code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title={g.nameKo} onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<div className="row" style={{ gap: 16 }}>
|
||||
<span style={{ fontSize: '3.5rem' }} aria-hidden>
|
||||
{GAME_ICON[g.id] ?? '🎲'}
|
||||
</span>
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<h1>{g.nameKo}</h1>
|
||||
<p className="muted">{g.blurb}</p>
|
||||
<p className="small">
|
||||
{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`} · 약 {g.minutes}분 · {g.level}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack">
|
||||
<button className="btn btn-primary btn-block" style={{ minHeight: 64 }} disabled={busy} onClick={() => run(() => api.createRoom(g.id))}>
|
||||
+ 친구랑 하기 (방 만들기)
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
|
||||
⚡ 아무나와 빠른 시작
|
||||
</button>
|
||||
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
|
||||
📖 규칙 보기
|
||||
</Link>
|
||||
</div>
|
||||
<section className="stack">
|
||||
<h2>기다리는 방</h2>
|
||||
{rooms.length === 0 && <p className="muted">지금 열린 공개 방이 없어요. [빠른 시작]을 누르면 새 방이 열려요.</p>}
|
||||
{rooms.map((r) => (
|
||||
<div key={r.code} className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{r.host}님의 방</strong>
|
||||
<div className="muted small">
|
||||
{r.seated}/{r.max}명 · 코드 {formatRoomCode(r.code)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
|
||||
참여
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{needName && (
|
||||
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
|
||||
<h2>먼저 이름을 정해 주세요</h2>
|
||||
<NicknameForm
|
||||
next={`/g/${g.id}`}
|
||||
onDone={() => {
|
||||
const fn = needName;
|
||||
setNeedName(null);
|
||||
fn();
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/pages/GamesPage.tsx
Normal file
20
apps/web/src/pages/GamesPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CATALOG } from '@bg/shared/lite';
|
||||
import { GameGrid } from '../components/GameGrid';
|
||||
import { UPCOMING } from '../lib/catalog-ui';
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
export default function GamesPage() {
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>게임 목록</h1>
|
||||
<GameGrid games={catalog.filter((g) => g.available)} />
|
||||
{UPCOMING.length > 0 && (
|
||||
<section className="stack">
|
||||
<h2>곧 나와요</h2>
|
||||
<p className="muted">{UPCOMING.join(' · ')}</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
138
apps/web/src/pages/Home.tsx
Normal file
138
apps/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { CATALOG, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import { GameGrid } from '../components/GameGrid';
|
||||
import { gameName } from '../components/GameNotice';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
|
||||
|
||||
export function Home() {
|
||||
const { me, activeRoom } = useSession();
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
const nav = useNavigate();
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [needName, setNeedName] = useState<null | (() => void)>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
|
||||
const [codeKey, setCodeKey] = useState(0);
|
||||
const available = catalog.filter((g) => g.available);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
|
||||
const create = (gameId: string) =>
|
||||
withName(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { code } = await api.createRoom(gameId);
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
const joinCode = async (code: string) => {
|
||||
try {
|
||||
await api.room(code);
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
setCodeKey((k) => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="page" style={{ gap: 28 }}>
|
||||
<h1 className="sr-only">{useSession.getState().siteName}</h1>
|
||||
{activeRoom && (
|
||||
<Link to={`/r/${activeRoom}`} className="btn btn-success btn-block" style={{ minHeight: 60, fontSize: '1.15rem' }}>
|
||||
▶ 하던 게임으로 돌아가기 (방 {formatRoomCode(activeRoom)})
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 20 }}>
|
||||
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
|
||||
<h2>친구랑 같이 하기</h2>
|
||||
<p className="muted">방을 만들면 코드와 링크가 바로 나와요. 친구에게 보내 주세요.</p>
|
||||
<button
|
||||
className="btn btn-primary btn-block"
|
||||
style={{ minHeight: 72, fontSize: '1.3rem' }}
|
||||
disabled={busy}
|
||||
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}
|
||||
>
|
||||
+ 방 만들기
|
||||
</button>
|
||||
</div>
|
||||
<div className="card stack" style={{ padding: 24 }}>
|
||||
<h2>코드로 참여하기</h2>
|
||||
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
|
||||
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="two-col" style={{ gridTemplateColumns: undefined }}>
|
||||
<section className="stack" style={{ gap: 16 }}>
|
||||
<h2>게임 고르기</h2>
|
||||
<GameGrid games={available} />
|
||||
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
|
||||
</section>
|
||||
<aside className="stack" style={{ gap: 12 }}>
|
||||
<h2>지금 열린 방</h2>
|
||||
{rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>}
|
||||
{rooms.map((r) => (
|
||||
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
|
||||
<span style={{ fontSize: '1.8rem' }} aria-hidden>
|
||||
{GAME_ICON[r.gameId] ?? '🎲'}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span>
|
||||
<div className="muted small">
|
||||
{r.seated}/{r.max}명 · {formatRoomCode(r.code)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
|
||||
참여
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{picker && (
|
||||
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
|
||||
<h2>어떤 게임을 할까요?</h2>
|
||||
{available.map((g) => (
|
||||
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}>
|
||||
{GAME_ICON[g.id]} {g.nameKo}
|
||||
</button>
|
||||
))}
|
||||
</Sheet>
|
||||
)}
|
||||
{needName && (
|
||||
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
|
||||
<h2>먼저 이름을 정해 주세요</h2>
|
||||
<NicknameForm
|
||||
next="/"
|
||||
onDone={() => {
|
||||
const fn = needName;
|
||||
setNeedName(null);
|
||||
fn();
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/pages/JoinPage.tsx
Normal file
36
apps/web/src/pages/JoinPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { api } from '../net/api';
|
||||
|
||||
export default function JoinPage() {
|
||||
const nav = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [key, setKey] = useState(0);
|
||||
const go = async (code: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.room(code);
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setKey((k) => k + 1); // reset the boxes
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<TopBar title="코드로 참여하기" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<h1>방 코드 6자리를 눌러 주세요</h1>
|
||||
<p className="muted">방을 만든 사람에게 받은 숫자예요. 다 누르면 바로 들어가요.</p>
|
||||
<CodeInput key={key} onComplete={go} busy={busy} />
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
212
apps/web/src/pages/MePage.tsx
Normal file
212
apps/web/src/pages/MePage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/** 계정 페이지 (docs/05 §7). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api, discordLoginUrl } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
|
||||
function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
<div className="segmented" role="group" aria-label={label}>
|
||||
{options.map(([k, l]) => (
|
||||
<button key={k} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MePage() {
|
||||
const nav = useNavigate();
|
||||
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings, role } = useSession();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [nick, setNick] = useState(me?.nickname ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<{ gameId: string; played: number; wins: number; draws: number }[]>([]);
|
||||
const [confirm, setConfirm] = useState<null | 'logout' | 'logoutAll' | 'delete'>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (me) api.stats().then((r) => setStats(r.stats)).catch(() => {});
|
||||
}, [me]);
|
||||
|
||||
const saveNick = async () => {
|
||||
const n = normalizeNickname(nick);
|
||||
const e = nicknameError(n);
|
||||
if (e) return setError(e);
|
||||
try {
|
||||
const r = await api.updateMe({ nickname: n });
|
||||
useSession.getState().setMe(r.me);
|
||||
setEditing(false);
|
||||
toast('닉네임을 바꿨어요.');
|
||||
} catch (ex) {
|
||||
setError((ex as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const display = (
|
||||
<section className="card stack">
|
||||
<h2>화면 설정</h2>
|
||||
<Seg label="글자 크기" value={settings.fontScale} options={[['normal', '보통'], ['large', '크게'], ['xlarge', '아주 크게']]} onChange={(fontScale) => updateSettings({ fontScale })} />
|
||||
<Seg label="화면 색" value={settings.theme} options={[['auto', '자동'], ['light', '밝게'], ['dark', '어둡게']]} onChange={(theme) => updateSettings({ theme })} />
|
||||
<Seg label="소리" value={settings.sound ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ sound: v === 'on' })} />
|
||||
<Seg label="진동" value={settings.vibrate ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ vibrate: v === 'on' })} />
|
||||
<Seg
|
||||
label="돌·카드 놓기"
|
||||
value={settings.tapConfirm ? 'on' : 'off'}
|
||||
options={[['on', '두 번 눌러 확정(실수 방지)'], ['off', '한 번에 놓기']]}
|
||||
onChange={(v) => updateSettings({ tapConfirm: v === 'on' })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="내 정보" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h2>시작하기</h2>
|
||||
<NicknameForm next="/me" />
|
||||
</section>
|
||||
{display}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="내 정보" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
|
||||
<Avatar user={me} size={88} />
|
||||
{editing ? (
|
||||
<div className="stack" style={{ width: '100%' }}>
|
||||
<input className="input" value={nick} onChange={(e) => (setNick(e.target.value), setError(null))} maxLength={20} autoFocus aria-label="새 닉네임" />
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 1 }} onClick={saveNick}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => (setEditing(false), setNick(me.nickname), setError(null))}>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted small">게임 중에 바꾸면 그 게임이 끝난 뒤에 보여요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1>{me.nickname}</h1>
|
||||
<button className="btn btn-secondary" onClick={() => (setNick(me.nickname), setEditing(true))}>
|
||||
✏️ 닉네임 바꾸기
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
|
||||
{me.kind === 'guest' && discordEnabled && (
|
||||
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
|
||||
디스코드 연결하기 (기록을 안전하게 보관)
|
||||
</a>
|
||||
)}
|
||||
{me.kind === 'member' && hasDiscordAvatar && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={async () => {
|
||||
await api.updateMe({ useAvatar: !useAvatar });
|
||||
await useSession.getState().refresh();
|
||||
}}
|
||||
>
|
||||
{useAvatar ? '디스코드 프로필 사진 숨기기' : '디스코드 프로필 사진 쓰기'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{role !== 'user' && (
|
||||
<button className="btn btn-soft btn-block" onClick={() => nav('/admin')}>
|
||||
🛠 관리자 페이지 ({role === 'superadmin' ? '슈퍼어드민' : '어드민'})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{display}
|
||||
|
||||
<section className="card stack">
|
||||
<h2>내 기록</h2>
|
||||
{stats.length === 0 ? (
|
||||
<p className="muted">아직 끝까지 한 게임이 없어요.</p>
|
||||
) : (
|
||||
stats.map((s) => (
|
||||
<div key={s.gameId} className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{catalogById(s.gameId)?.nameKo ?? s.gameId}</strong>
|
||||
<span>
|
||||
{s.played}판 {s.wins}승{s.draws ? ` ${s.draws}무` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="stack">
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
|
||||
로그아웃
|
||||
</button>
|
||||
{me.kind === 'member' && (
|
||||
<button className="btn btn-ghost btn-block" onClick={() => setConfirm('logoutAll')}>
|
||||
모든 기기에서 로그아웃
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-block small muted" onClick={() => setConfirm('delete')}>
|
||||
계정 삭제
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{confirm === 'logout' && (
|
||||
<ConfirmSheet
|
||||
title="로그아웃할까요?"
|
||||
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
|
||||
confirm="로그아웃"
|
||||
danger={me.kind === 'guest'}
|
||||
onConfirm={async () => {
|
||||
await api.logout();
|
||||
await useSession.getState().refresh();
|
||||
nav('/');
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'logoutAll' && (
|
||||
<ConfirmSheet
|
||||
title="모든 기기에서 로그아웃할까요?"
|
||||
confirm="모두 로그아웃"
|
||||
onConfirm={async () => {
|
||||
await api.logoutAll();
|
||||
await useSession.getState().refresh();
|
||||
nav('/');
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'delete' && (
|
||||
<ConfirmSheet
|
||||
title="계정을 삭제할까요?"
|
||||
body="닉네임과 연결 정보가 지워지고 되돌릴 수 없어요."
|
||||
confirm="삭제하기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await api.deleteMe();
|
||||
await useSession.getState().refresh();
|
||||
nav('/');
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
apps/web/src/pages/NotFound.tsx
Normal file
12
apps/web/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>페이지를 찾을 수 없어요</h1>
|
||||
<Link className="btn btn-primary" to="/">
|
||||
홈으로
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
666
apps/web/src/pages/RoomPage.tsx
Normal file
666
apps/web/src/pages/RoomPage.tsx
Normal file
@@ -0,0 +1,666 @@
|
||||
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
import { QrModal } from '../components/QrModal';
|
||||
import { api, type RoomPreview } from '../net/api';
|
||||
import { socket, useRoom } from '../net/socket';
|
||||
import { useSession } from '../store/session';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { sounds } from '../lib/feedback';
|
||||
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
|
||||
import { useWide } from '../lib/media';
|
||||
|
||||
export default function RoomPage() {
|
||||
const { code = '' } = useParams();
|
||||
const me = useSession((s) => s.me);
|
||||
const nav = useNavigate();
|
||||
const [preview, setPreview] = useState<RoomPreview | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
setPreviewError('방 코드가 올바르지 않아요.');
|
||||
return;
|
||||
}
|
||||
if (!me) api.room(code).then(setPreview, (e) => setPreviewError((e as Error).message));
|
||||
}, [code, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && /^\d{6}$/.test(code)) socket.join(code);
|
||||
}, [code, me]);
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="방에 들어가기" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
{previewError ? (
|
||||
<RoomGone message={previewError} />
|
||||
) : (
|
||||
<section className="card stack">
|
||||
{preview ? (
|
||||
<>
|
||||
<h1>
|
||||
{gameName(preview.gameId)} 방에 초대받았어요
|
||||
</h1>
|
||||
<p className="muted">
|
||||
방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">방 정보를 불러오는 중…</p>
|
||||
)}
|
||||
<NicknameForm next={`/r/${code}`} submitLabel="이 이름으로 입장하기" />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Room code={code} me={me} />;
|
||||
}
|
||||
|
||||
function RoomGone({ message }: { message: string }) {
|
||||
const nav = useNavigate();
|
||||
return (
|
||||
<section className="card stack">
|
||||
<h1>{message}</h1>
|
||||
<button className="btn btn-primary btn-block" onClick={() => nav('/j')}>
|
||||
다른 코드 입력하기
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav('/')}>
|
||||
홈으로
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
const nav = useNavigate();
|
||||
const { room, game, result, status, joinError, pending, slow, needsRefresh } = useRoom();
|
||||
const [ui, setUi] = useState<GameUI | null>(null);
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [leaveAsk, setLeaveAsk] = useState(false);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const gameId = room?.gameId;
|
||||
|
||||
useEffect(() => {
|
||||
if (gameId) loadGameUI(gameId).then(setUi);
|
||||
}, [gameId]);
|
||||
|
||||
const players = useMemo(() => {
|
||||
const map: Record<string, PublicUser> = {};
|
||||
for (const s of room?.seats ?? []) if (s) map[s.user.id] = s.user;
|
||||
for (const u of room?.spectators ?? []) map[u.id] = u;
|
||||
return map;
|
||||
}, [room]);
|
||||
|
||||
useTurnTitle(ui, game?.view, me.id);
|
||||
|
||||
if (status === 'replaced') {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h1>다른 창에서 이 방을 열었어요</h1>
|
||||
<p className="muted">한 번에 한 창에서만 할 수 있어요.</p>
|
||||
<button className="btn btn-primary btn-block" onClick={() => socket.reclaim()}>
|
||||
여기서 계속하기
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (joinError) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="방" onBack={() => nav('/')} />
|
||||
<main className="page">
|
||||
<RoomGone message={joinError} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!room || room.code !== code || !ui) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`방 ${formatRoomCode(code)}`} onBack={() => nav('/')} />
|
||||
<main className="page muted">방에 들어가는 중…</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const g = { nameKo: gameName(room.gameId) };
|
||||
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
|
||||
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
|
||||
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
|
||||
const isHost = room.hostId === me.id;
|
||||
const doLeave = () => {
|
||||
socket.leave();
|
||||
nav('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="room-bar" aria-label="방 메뉴">
|
||||
<h1 className="sr-only">
|
||||
{g.nameKo} 방 {formatRoomCode(code)}
|
||||
</h1>
|
||||
<div className="room-bar-inner">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로">
|
||||
← 홈
|
||||
</button>
|
||||
<strong className="rb-game" style={{ fontSize: '1.1rem' }}>
|
||||
{g.nameKo}
|
||||
</strong>
|
||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||
<span style={{ flex: 1 }} />
|
||||
{!roomChatClosed && (
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
</span>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||
📖 규칙
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMenu(true)} aria-label="메뉴">
|
||||
⋯ 메뉴
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
{needsRefresh && (
|
||||
<div className="card row" style={{ margin: 12, justifyContent: 'space-between' }}>
|
||||
<span>새 버전이 있어요.</span>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => location.reload()}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inGame && game ? (
|
||||
<GameView room={room} ui={ui} me={me} players={players} pending={pending !== null} slow={slow} />
|
||||
) : (
|
||||
<Lobby room={room} ui={ui} me={me} />
|
||||
)}
|
||||
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
|
||||
<EmoteBubbles room={room} />
|
||||
{chatOpen && !roomChatClosed && (
|
||||
<Sheet onClose={() => setChatOpen(false)} label="채팅">
|
||||
<ChatPanel room={room} me={me} />
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
|
||||
닫기
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
{menu && (
|
||||
<Sheet onClose={() => setMenu(false)} label="메뉴">
|
||||
<h2>메뉴</h2>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||
📖 규칙 보기
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => nav('/me')}>
|
||||
⚙️ 글자 크기 · 소리 설정
|
||||
</button>
|
||||
{isHost && room.status === 'paused' && (
|
||||
<button className="btn btn-danger btn-block" onClick={() => (socket.send({ t: 'void' }), setMenu(false))}>
|
||||
게임 끝내기(무효)
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger btn-block" onClick={() => (setMenu(false), setLeaveAsk(true))}>
|
||||
🚪 방 나가기
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
{leaveAsk && (
|
||||
<ConfirmSheet
|
||||
title="방에서 나갈까요?"
|
||||
body={room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined}
|
||||
confirm="나가기"
|
||||
danger
|
||||
onConfirm={doLeave}
|
||||
onClose={() => setLeaveAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Blink the tab title when it's my turn and the tab is hidden (docs/07 §6). */
|
||||
function useTurnTitle(ui: GameUI | null, view: unknown, me: string) {
|
||||
const myTurn = !!ui && !!view && ui.isMyTurn(view, me);
|
||||
const prev = useRef(false);
|
||||
useEffect(() => {
|
||||
if (myTurn && !prev.current && document.visibilityState === 'hidden') sounds.myTurn();
|
||||
prev.current = myTurn;
|
||||
if (!myTurn) return;
|
||||
const base = document.title;
|
||||
let on = false;
|
||||
const t = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
document.title = base;
|
||||
return;
|
||||
}
|
||||
on = !on;
|
||||
document.title = on ? '● 내 차례예요' : base;
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
document.title = base;
|
||||
};
|
||||
}, [myTurn]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- lobby
|
||||
|
||||
function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {
|
||||
const [qr, setQr] = useState(false);
|
||||
const [settings, setSettings] = useState(false);
|
||||
const isHost = room.hostId === me.id;
|
||||
const mySeat = room.seats.findIndex((s) => s?.user.id === me.id);
|
||||
const seated = room.seats.filter(Boolean).length;
|
||||
const need = Math.max(0, room.minPlayers - seated);
|
||||
const url = `${location.origin}/r/${room.code}`;
|
||||
const gName = gameName(room.gameId);
|
||||
const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname;
|
||||
const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
|
||||
const [askStart, setAskStart] = useState(false);
|
||||
|
||||
const wide = useWide();
|
||||
const invite = (
|
||||
<section className="card stack" style={{ textAlign: 'center' }} aria-label="초대하기">
|
||||
<p className="muted">친구에게 이 코드를 알려 주세요</p>
|
||||
<p style={{ fontSize: '3.2rem', fontWeight: 800, lineHeight: 1.1 }}>
|
||||
<CodeLabel code={room.code} />
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
|
||||
<CopyButton text={room.code} label="🔢 코드 복사" />
|
||||
<CopyButton text={url} label="🔗 링크 복사" />
|
||||
<ShareButton title={`${gName} 같이 해요`} text={shareText} url={url} />
|
||||
<button className="btn btn-secondary" onClick={() => setQr(true)}>
|
||||
📷 QR 보기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const seats = (
|
||||
<section className="stack" aria-label="자리">
|
||||
<h2>
|
||||
자리 ({seated}/{room.maxPlayers})
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
|
||||
{room.seats.map((s, i) =>
|
||||
s ? (
|
||||
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={s.user} size={44} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
|
||||
{s.user.nickname}
|
||||
{s.user.id === me.id && <span className="muted small"> (나)</span>}
|
||||
</div>
|
||||
<div className="row small" style={{ gap: 6 }}>
|
||||
<span className="dot" style={{ background: s.connected ? 'var(--success)' : s.away ? 'var(--text-sub)' : 'var(--warning)' }} />
|
||||
{s.connected ? '접속 중' : s.away ? '자리 비움' : '연결 끊김'}
|
||||
{room.hostId !== s.user.id && (s.ready ? <span className="badge success">준비 완료</span> : <span className="badge">준비 중</span>)}
|
||||
</div>
|
||||
</div>
|
||||
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
|
||||
</div>
|
||||
) : (
|
||||
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
|
||||
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
|
||||
{mySeat >= 0 && room.allowSpectators && (
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => socket.send({ t: 'unseat' })}>
|
||||
관전으로 바꾸기
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
const settingsCard = (
|
||||
<section className="card stack">
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2>게임 설정</h2>
|
||||
<p className="muted">{ui.optionsSummary(room.options)}</p>
|
||||
</div>
|
||||
{isHost && !wide && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setSettings(true)}>
|
||||
설정 바꾸기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||
<GameNotice gameId={room.gameId} />
|
||||
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
|
||||
</section>
|
||||
);
|
||||
const action = (
|
||||
<div className="bottom-bar inline-wide">
|
||||
<div className="bottom-bar-inner">
|
||||
{isHost ? (
|
||||
<button className="btn btn-primary" style={{ minHeight: 64, fontSize: '1.2rem' }} disabled={need > 0} onClick={() => (notReady > 0 ? setAskStart(true) : socket.send({ t: 'start' }))}>
|
||||
{need > 0 ? `${need}명 더 필요해요` : '▶ 게임 시작'}
|
||||
</button>
|
||||
) : mySeat >= 0 ? (
|
||||
room.seats[mySeat]!.ready ? (
|
||||
<button className="btn btn-secondary" style={{ minHeight: 64 }} onClick={() => socket.send({ t: 'ready', ready: false })}>
|
||||
준비 취소
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-success" style={{ minHeight: 64, fontSize: '1.2rem' }} onClick={() => socket.send({ t: 'ready', ready: true })}>
|
||||
✓ 준비하기
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button className="btn btn-primary" style={{ minHeight: 64 }} disabled={!room.seats.some((s) => s === null)} onClick={() => socket.send({ t: 'seat' })}>
|
||||
{room.seats.some((s) => s === null) ? '자리에 앉기' : '자리가 다 찼어요 (관전 중)'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="two-col">
|
||||
<div className="stack" style={{ gap: 20 }}>
|
||||
{invite}
|
||||
{wide ? settingsCard : seats}
|
||||
{wide && (
|
||||
<section className="card stack">
|
||||
<h2>규칙 요약</h2>
|
||||
<ui.Rules options={room.options} />
|
||||
</section>
|
||||
)}
|
||||
{!wide && settingsCard}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 16, position: wide ? 'sticky' : undefined, top: 140 }}>
|
||||
{wide && seats}
|
||||
{action}
|
||||
{wide && <ChatPanel room={room} me={me} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{qr && <QrModal url={url} code={room.code} onClose={() => setQr(false)} />}
|
||||
{settings && <SettingsSheet room={room} ui={ui} onClose={() => setSettings(false)} />}
|
||||
{askStart && (
|
||||
<ConfirmSheet title="준비 안 된 사람이 있어요" body="그래도 시작할까요?" confirm="시작하기" onConfirm={() => socket.send({ t: 'start' })} onClose={() => setAskStart(false)} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HostMenu({ room, target }: { room: RoomView; target: PublicUser }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kick, setKick] = useState(false);
|
||||
void room;
|
||||
return (
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(true)} aria-label={`${target.nickname} 관리`}>
|
||||
⋯
|
||||
</button>
|
||||
{open && (
|
||||
<Sheet onClose={() => setOpen(false)} label={`${target.nickname} 관리`}>
|
||||
<h2>{target.nickname}</h2>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => (socket.send({ t: 'host', userId: target.id }), setOpen(false))}>
|
||||
👑 방장 넘기기
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={() => (setOpen(false), setKick(true))}>
|
||||
내보내기
|
||||
</button>
|
||||
</Sheet>
|
||||
)}
|
||||
{kick && (
|
||||
<ConfirmSheet
|
||||
title={`${target.nickname}님을 내보낼까요?`}
|
||||
body="내보낸 사람은 이 방에 다시 들어올 수 없어요."
|
||||
confirm="내보내기"
|
||||
danger
|
||||
onConfirm={() => socket.send({ t: 'kick', userId: target.id })}
|
||||
onClose={() => setKick(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onClose: () => void }) {
|
||||
return (
|
||||
<Sheet onClose={onClose} label="게임 설정">
|
||||
<h2>게임 설정</h2>
|
||||
<SettingsForm room={room} ui={ui} onDone={onClose} />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** Host settings: game options + room options. Inline on PC, in a sheet on narrow screens. */
|
||||
function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) {
|
||||
const [options, setOptions] = useState(room.options);
|
||||
const [visibility, setVisibility] = useState(room.visibility);
|
||||
const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators);
|
||||
const [chatEnabled, setChatEnabled] = useState(room.chatEnabled);
|
||||
// Follow server-side changes (e.g. admin locks) when not editing.
|
||||
useEffect(() => setOptions(room.options), [room.options]);
|
||||
const dirty =
|
||||
JSON.stringify(options) !== JSON.stringify(room.options) ||
|
||||
visibility !== room.visibility ||
|
||||
allowSpectators !== room.allowSpectators ||
|
||||
chatEnabled !== room.chatEnabled;
|
||||
const save = () => {
|
||||
socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled });
|
||||
onDone?.();
|
||||
};
|
||||
const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={value} onClick={() => set(true)}>
|
||||
{on}
|
||||
</button>
|
||||
<button type="button" aria-pressed={!value} onClick={() => set(false)}>
|
||||
{off}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="stack" style={{ gap: 14 }}>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
|
||||
<ui.Options value={options} onChange={setOptions} />
|
||||
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>방 공개</strong>
|
||||
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>관전</strong>
|
||||
{seg(allowSpectators, setAllowSpectators, '허용', '안 됨')}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>채팅</strong>
|
||||
{seg(chatEnabled, setChatEnabled, '켜기', '끄기')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" style={{ flex: 2 }} disabled={inline && !dirty} onClick={save}>
|
||||
{inline ? (dirty ? '설정 저장' : '저장됨') : '저장'}
|
||||
</button>
|
||||
{!inline && (
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onDone}>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- game
|
||||
|
||||
function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui: GameUI; me: PublicUser; players: Record<string, PublicUser>; pending: boolean; slow: boolean }) {
|
||||
const game = useRoom((s) => s.game)!;
|
||||
const tapConfirm = useSession((s) => s.settings.tapConfirm);
|
||||
const wide = useWide();
|
||||
const myTurn = ui.isMyTurn(game.view, me.id);
|
||||
const isPlayer = room.seats.some((s) => s?.user.id === me.id) && game.active !== undefined;
|
||||
const props = {
|
||||
view: game.view,
|
||||
me: me.id,
|
||||
players,
|
||||
act: (a: unknown) => socket.act(a),
|
||||
pending,
|
||||
deadline: game.deadline,
|
||||
active: game.active,
|
||||
tapConfirm,
|
||||
};
|
||||
const status = (
|
||||
<div
|
||||
className="card turn-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
fontWeight: 700,
|
||||
fontSize: '1.15rem',
|
||||
background: myTurn ? 'var(--primary)' : 'var(--surface)',
|
||||
color: myTurn ? 'var(--primary-text)' : 'var(--text)',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{room.status === 'paused' ? '오류로 게임이 멈췄어요. 방장이 끝낼 수 있어요.' : ui.statusText(game.view, me.id, players)}
|
||||
{slow && <span className="small"> · 연결 확인 중…</span>}
|
||||
{!isPlayer && (
|
||||
<span className="badge" style={{ marginLeft: 8 }}>
|
||||
관전 중
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="game-layout">
|
||||
<div className="game-main">
|
||||
{!wide && status}
|
||||
<ui.Board {...props} />
|
||||
</div>
|
||||
<aside className="game-side" aria-label="게임 정보">
|
||||
{wide && status}
|
||||
<ui.Side {...props} />
|
||||
{wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: PublicUser; players: Record<string, PublicUser>; onLeave: () => void }) {
|
||||
const result = useRoom((s) => s.result)!;
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const played = useRef(false);
|
||||
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
|
||||
const amSeated = seated.includes(me.id);
|
||||
const voted = room.rematchVotes.includes(me.id);
|
||||
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
|
||||
const solo = result.ranking.flat().length === 1;
|
||||
const allFirst = !solo && result.ranking.length === 1;
|
||||
const won = !solo && !allFirst && result.ranking[0]?.includes(me.id);
|
||||
const lost = !solo && !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
|
||||
useEffect(() => {
|
||||
if (played.current) return;
|
||||
played.current = true;
|
||||
if (won) sounds.win();
|
||||
else if (lost) sounds.lose();
|
||||
}, [won, lost]);
|
||||
if (hidden) {
|
||||
return (
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={() => setHidden(false)}>
|
||||
결과 다시 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Sheet onClose={() => setHidden(true)} label="게임 결과">
|
||||
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
|
||||
<p style={{ textAlign: 'center' }}>{result.summary}</p>
|
||||
<div className="stack">
|
||||
{result.ranking.map((group, i) =>
|
||||
group.map((u) => (
|
||||
<div key={u} className="row">
|
||||
<strong style={{ width: 40 }}>{i + 1}등</strong>
|
||||
{players[u] && <Avatar user={players[u]!} size={28} />}
|
||||
<span>{players[u]?.nickname ?? '나간 사람'}</span>
|
||||
<span className="muted small" style={{ marginLeft: 'auto' }}>
|
||||
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
|
||||
</span>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
{amSeated && (
|
||||
<button className="btn btn-primary btn-block" disabled={voted} onClick={() => socket.send({ t: 'rematch' })}>
|
||||
{voted ? `다른 사람을 기다리는 중… (${room.rematchVotes.length}/${seated.length})` : `🔁 한 판 더 (${room.rematchVotes.length}/${seated.length})`}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setHidden(true)}>
|
||||
판 다시 보기
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-block" onClick={onLeave}>
|
||||
방 나가기
|
||||
</button>
|
||||
{room.lastSeed && (
|
||||
<FairnessCheck seedHash={room.seedHash} seed={room.lastSeed} />
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* docs/04 §5: the seed's SHA-256 was published when the game started; the seed is revealed at the end.
|
||||
* The browser recomputes the hash itself so players don't have to trust the server's word.
|
||||
*/
|
||||
function FairnessCheck({ seedHash, seed }: { seedHash: string | null; seed: string }) {
|
||||
const [verdict, setVerdict] = useState<null | boolean>(null);
|
||||
const check = async () => {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(seed));
|
||||
const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
setVerdict(hex === seedHash);
|
||||
};
|
||||
return (
|
||||
<details className="small muted">
|
||||
<summary>공정성 확인</summary>
|
||||
<p>시작할 때 공개한 값(SHA-256): {seedHash ?? '-'}</p>
|
||||
<p>끝나고 공개한 난수 씨앗: {seed}</p>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={check}>
|
||||
내 브라우저에서 직접 확인하기
|
||||
</button>
|
||||
{verdict !== null && (
|
||||
<p style={{ fontWeight: 700, color: verdict ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{verdict ? '✓ 일치해요. 게임 도중 카드 순서나 난수가 바뀌지 않았어요.' : '✗ 일치하지 않아요. 관리자에게 알려 주세요.'}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- chat
|
||||
|
||||
function ChatButton({ onOpen }: { onOpen: () => void }) {
|
||||
const total = useRoom((s) => s.chat.length);
|
||||
const seen = useChatSeen((s) => s.seen);
|
||||
const unread = Math.max(0, total - seen);
|
||||
return (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onOpen} aria-label={`채팅${unread ? `, 새 메시지 ${unread}개` : ''}`}>
|
||||
💬 채팅{unread > 0 && <span className="badge warning">{unread}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/pages/RulesPage.tsx
Normal file
25
apps/web/src/pages/RulesPage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
|
||||
export default function RulesPage() {
|
||||
const { gameId = '' } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [ui, setUi] = useState<GameUI | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
loadGameUI(gameId).then(setUi);
|
||||
}, [gameId]);
|
||||
const g = { nameKo: gameName(gameId) };
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
||||
<main className="page">
|
||||
<GameNotice gameId={gameId} />
|
||||
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
671
apps/web/src/pages/admin/AdminApp.tsx
Normal file
671
apps/web/src/pages/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { apiCall, discordLoginUrl, type Role } from '../../net/api';
|
||||
import { useSession } from '../../store/session';
|
||||
import { toast } from '../../store/toast';
|
||||
import { SchemaForm, type JsonSchema } from './SchemaForm';
|
||||
|
||||
const a = {
|
||||
get: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
|
||||
put: <T,>(p: string, b: unknown) => apiCall<T>('PUT', `/api/admin${p}`, b),
|
||||
post: <T,>(p: string, b: unknown = {}) => apiCall<T>('POST', `/api/admin${p}`, b),
|
||||
patch: <T,>(p: string, b: unknown) => apiCall<T>('PATCH', `/api/admin${p}`, b),
|
||||
del: <T,>(p: string) => apiCall<T>('DELETE', `/api/admin${p}`),
|
||||
};
|
||||
|
||||
function useLoad<T>(path: string): [T | null, () => void, string | null] {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const load = useCallback(() => {
|
||||
a.get<T>(path).then(
|
||||
(d) => (setData(d), setErr(null)),
|
||||
(e) => setErr((e as Error).message),
|
||||
);
|
||||
}, [path]);
|
||||
useEffect(load, [load]);
|
||||
return [data, load, err];
|
||||
}
|
||||
|
||||
const time = (ms: number) => (ms ? new Date(ms).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }) : '-');
|
||||
|
||||
export default function AdminApp() {
|
||||
const nav = useNavigate();
|
||||
const [role, setRole] = useState<Role | null | 'denied'>(null);
|
||||
useEffect(() => {
|
||||
a.get<{ role: Role }>('/me').then(
|
||||
(r) => setRole(r.role),
|
||||
() => setRole('denied'),
|
||||
);
|
||||
}, []);
|
||||
if (role === null) return <main className="page muted">확인 중…</main>;
|
||||
if (role === 'denied' || role === 'user') return <Denied />;
|
||||
const sup = role === 'superadmin';
|
||||
const tabs: [string, string, boolean][] = [
|
||||
['', '대시보드', true],
|
||||
['games', '게임', true],
|
||||
['rooms', '방', true],
|
||||
['site', '사이트', true],
|
||||
['users', '사용자', sup],
|
||||
['admins', '관리자', sup],
|
||||
['audit', '기록', true],
|
||||
];
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<nav className="admin-side" aria-label="관리자 메뉴">
|
||||
<div className="muted small" style={{ padding: '0 16px 8px', fontWeight: 700 }}>
|
||||
관리자 · {sup ? '슈퍼어드민' : '어드민'}
|
||||
</div>
|
||||
{tabs
|
||||
.filter((t) => t[2])
|
||||
.map(([to, label]) => (
|
||||
<NavLink key={to} to={to ? `/admin/${to}` : '/admin'} end={to === ''}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<button className="btn btn-ghost btn-sm" style={{ justifyContent: 'flex-start', marginTop: 8 }} onClick={() => nav('/')}>
|
||||
← 사이트로
|
||||
</button>
|
||||
</nav>
|
||||
<main className="stack" style={{ gap: 20, minWidth: 0, paddingBottom: 96 }}>
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
<Route path="games/:id" element={<GameEditor />} />
|
||||
<Route path="rooms" element={<Rooms />} />
|
||||
<Route path="site" element={<Site />} />
|
||||
{sup && <Route path="users" element={<Users />} />}
|
||||
{sup && <Route path="admins" element={<Admins />} />}
|
||||
<Route path="audit" element={<Audit />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Denied() {
|
||||
const me = useSession((s) => s.me);
|
||||
const discord = useSession((s) => s.discordEnabled);
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h1>권한이 없어요</h1>
|
||||
<p className="muted">관리자 페이지는 관리자로 등록된 디스코드 계정만 볼 수 있어요.</p>
|
||||
{discord && me?.kind !== 'member' && (
|
||||
<a className="btn btn-primary btn-block" href={discordLoginUrl('/admin')}>
|
||||
디스코드로 로그인
|
||||
</a>
|
||||
)}
|
||||
<a className="btn btn-secondary btn-block" href="/">
|
||||
홈으로
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div className="muted small">{label}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dashboard
|
||||
|
||||
function Dashboard() {
|
||||
const [d] = useLoad<any>('/overview');
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
|
||||
<Stat label="지금 접속" value={d.connections} />
|
||||
<Stat label="열린 방" value={d.rooms.total} />
|
||||
<Stat label="게임 중인 방" value={d.rooms.playing} />
|
||||
<Stat label="오늘 시작한 판" value={d.games?.today ?? 0} />
|
||||
<Stat label="전체 판" value={d.games?.total ?? 0} />
|
||||
<Stat label="사용자" value={d.users.total} />
|
||||
<Stat label="게스트 / 디스코드" value={`${d.users.guests ?? 0} / ${d.users.members ?? 0}`} />
|
||||
<Stat label="이용 제한" value={d.users.banned ?? 0} />
|
||||
<Stat label="가동 시간" value={`${Math.floor(d.uptimeSec / 3600)}시간 ${Math.floor((d.uptimeSec % 3600) / 60)}분`} />
|
||||
<Stat label="메모리" value={`${d.memoryMb}MB`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ games
|
||||
|
||||
interface AdminGame {
|
||||
id: string;
|
||||
codeDefaults: { nameKo: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown };
|
||||
raw: Record<string, unknown>;
|
||||
effective: { enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; lockedOptions: string[] };
|
||||
schema: JsonSchema;
|
||||
}
|
||||
|
||||
function Games() {
|
||||
const [d] = useLoad<{ games: AdminGame[] }>('/games');
|
||||
const nav = useNavigate();
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p className="muted">설정을 바꾸면 새로 만드는 방과 새로 시작하는 판부터 적용돼요. 진행 중인 판은 그대로예요.</p>
|
||||
{d.games.map((g) => (
|
||||
<button key={g.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => nav(`/admin/games/${g.id}`)}>
|
||||
<div>
|
||||
<strong>{g.effective.nameKo}</strong> <span className="muted small">({g.id})</span>
|
||||
<div className="muted small">
|
||||
{g.effective.minPlayers}~{g.effective.maxPlayers}명 · 잠긴 옵션 {g.effective.lockedOptions.length}개
|
||||
</div>
|
||||
</div>
|
||||
{g.effective.enabled ? <span className="badge success">사용 중</span> : <span className="badge warning">꺼짐</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameEditor() {
|
||||
const { id = '' } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [d, reload] = useLoad<{ games: AdminGame[] }>('/games');
|
||||
const g = d?.games.find((x) => x.id === id);
|
||||
const [form, setForm] = useState<{ enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; locked: Set<string> } | null>(null);
|
||||
const [ask, setAsk] = useState(false);
|
||||
useEffect(() => {
|
||||
if (g) setForm({ ...g.effective, locked: new Set(g.effective.lockedOptions) });
|
||||
}, [g?.id, d]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
if (!g || !form) return <p>없는 게임이에요.</p>;
|
||||
const cd = g.codeDefaults;
|
||||
const save = async () => {
|
||||
try {
|
||||
await a.put(`/games/${id}`, {
|
||||
enabled: form.enabled,
|
||||
nameKo: form.nameKo,
|
||||
blurb: form.blurb,
|
||||
notice: form.notice,
|
||||
minPlayers: form.minPlayers,
|
||||
maxPlayers: form.maxPlayers,
|
||||
defaultOptions: form.defaultOptions,
|
||||
lockedOptions: [...form.locked],
|
||||
});
|
||||
toast('저장했어요.');
|
||||
reload();
|
||||
void useSession.getState().refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error', 6000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => nav('/admin/games')}>
|
||||
← 게임 목록
|
||||
</button>
|
||||
<h1>{g.effective.nameKo} 설정</h1>
|
||||
<section className="card stack">
|
||||
<h2>기본 정보</h2>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>사용</strong>
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={form.enabled} onClick={() => setForm({ ...form, enabled: true })}>
|
||||
사용
|
||||
</button>
|
||||
<button type="button" aria-pressed={!form.enabled} onClick={() => setForm({ ...form, enabled: false })}>
|
||||
끄기(새 방 막기)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">
|
||||
표시 이름 <span className="muted small">(원래: {cd.nameKo})</span>
|
||||
<input className="input" value={form.nameKo} maxLength={30} onChange={(e) => setForm({ ...form, nameKo: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
한 줄 설명
|
||||
<input className="input" value={form.blurb} maxLength={80} onChange={(e) => setForm({ ...form, blurb: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
안내문(규칙 화면 위에 표시)
|
||||
<textarea className="input" style={{ minHeight: 90, padding: 12 }} value={form.notice} maxLength={500} onChange={(e) => setForm({ ...form, notice: e.target.value })} />
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
최소 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
||||
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.minPlayers} onChange={(e) => setForm({ ...form, minPlayers: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
최대 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
||||
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.maxPlayers} onChange={(e) => setForm({ ...form, maxPlayers: Number(e.target.value) })} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="card stack">
|
||||
<h2>기본 규칙</h2>
|
||||
<p className="muted small">방을 만들 때 처음 들어가는 값이에요. 🔒을 켜면 방장이 바꿀 수 없어요.</p>
|
||||
<SchemaForm
|
||||
schema={g.schema}
|
||||
value={form.defaultOptions}
|
||||
onChange={(v) => setForm({ ...form, defaultOptions: v })}
|
||||
locked={form.locked}
|
||||
onToggleLock={(p) => {
|
||||
const next = new Set(form.locked);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
setForm({ ...form, locked: next });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<div className="bottom-bar sticky-save">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setAsk(true)}>
|
||||
처음 값으로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ask && (
|
||||
<ConfirmSheet
|
||||
title="코드 기본값으로 되돌릴까요?"
|
||||
body="이 게임의 관리자 설정이 모두 지워져요."
|
||||
confirm="되돌리기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.post(`/games/${id}/reset`);
|
||||
toast('처음 값으로 되돌렸어요.');
|
||||
reload();
|
||||
void useSession.getState().refresh();
|
||||
}}
|
||||
onClose={() => setAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rooms
|
||||
|
||||
function Rooms() {
|
||||
const [d, reload] = useLoad<{ rooms: any[] }>('/rooms');
|
||||
const [closing, setClosing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const t = setInterval(reload, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [reload]);
|
||||
const catalog = useSession((s) => s.catalog);
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
const STATUS: Record<string, string> = { lobby: '대기 중', playing: '게임 중', finished: '끝남', paused: '멈춤', broken: '복구 실패' };
|
||||
return (
|
||||
<div className="stack">
|
||||
{d.rooms.length === 0 && <p className="muted">열린 방이 없어요.</p>}
|
||||
{d.rooms.map((r) => (
|
||||
<div key={r.code} className="card stack" style={{ gap: 6 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{catalog.find((c) => c.id === r.gameId)?.nameKo ?? r.gameId} · {formatRoomCode(r.code)}
|
||||
</strong>
|
||||
<span className="badge">{STATUS[r.status] ?? r.status}</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
{r.seats.map((s: any, i: number) => (s ? `${s.nickname}${s.connected ? '' : '(끊김)'}` : `빈자리${i + 1}`)).join(', ')}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
방장 {r.host ?? '-'} · 관전 {r.spectators} · 접속 {r.connections} · {r.visibility === 'public' ? '공개' : '비공개'} · {r.gameNo}판 · 마지막 활동 {time(r.lastActiveAt)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<a className="btn btn-secondary btn-sm" href={`/r/${r.code}`}>
|
||||
들어가 보기
|
||||
</a>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setClosing(r.code)}>
|
||||
방 닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{closing && (
|
||||
<ConfirmSheet
|
||||
title={`방 ${formatRoomCode(closing)}을 닫을까요?`}
|
||||
body="진행 중인 판은 무효가 되고 모두 방에서 나가요."
|
||||
confirm="닫기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.post(`/rooms/${closing}/close`).catch((e) => toast((e as Error).message, 'error'));
|
||||
reload();
|
||||
}}
|
||||
onClose={() => setClosing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ site
|
||||
|
||||
function Site() {
|
||||
const [d] = useLoad<{ site: any }>('/site');
|
||||
const [s, setS] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
if (d) setS({ ...d.site, bannedText: d.site.bannedWords.join('\n') });
|
||||
}, [d]);
|
||||
if (!s) return <p className="muted">불러오는 중…</p>;
|
||||
const num = (k: string, label: string, min: number, max: number) => (
|
||||
<label className="field">
|
||||
{label} <span className="muted small">({min}~{max})</span>
|
||||
<input className="input" type="number" min={min} max={max} value={s[k]} onChange={(e) => setS({ ...s, [k]: Number(e.target.value) })} />
|
||||
</label>
|
||||
);
|
||||
const bool = (k: string, label: string, on = '켜기', off = '끄기') => (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={s[k]} onClick={() => setS({ ...s, [k]: true })}>
|
||||
{on}
|
||||
</button>
|
||||
<button type="button" aria-pressed={!s[k]} onClick={() => setS({ ...s, [k]: false })}>
|
||||
{off}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const save = async () => {
|
||||
const { bannedText, ...rest } = s;
|
||||
try {
|
||||
await a.put('/site', { ...rest, bannedWords: String(bannedText).split('\n').map((w: string) => w.trim()).filter(Boolean) });
|
||||
toast('저장했어요.');
|
||||
void useSession.getState().refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error', 6000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<section className="card stack">
|
||||
<label className="field">
|
||||
사이트 이름
|
||||
<input className="input" value={s.siteName} maxLength={40} onChange={(e) => setS({ ...s, siteName: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
공지(모든 화면 위에 표시, 비우면 숨김)
|
||||
<textarea className="input" style={{ minHeight: 80, padding: 12 }} maxLength={300} value={s.announcement} onChange={(e) => setS({ ...s, announcement: e.target.value })} />
|
||||
</label>
|
||||
{bool('maintenance', '점검 모드(새 방 만들기 막기)', '점검 중', '정상')}
|
||||
</section>
|
||||
<section className="card stack">
|
||||
{num('maxRoomsPerUser', '사용자당 동시에 만들 수 있는 방', 1, 50)}
|
||||
{num('maxRooms', '서버 전체 방 수 제한', 10, 20000)}
|
||||
{num('graceSec', '연결 끊김 유예(초, 새 방 기본값)', 10, 600)}
|
||||
{bool('chatEnabledDefault', '새 방 채팅 기본값', '켜기', '끄기')}
|
||||
{bool('chatFilterDefault', '새 방 욕설 필터 기본값', '켜기', '끄기')}
|
||||
</section>
|
||||
<section className="card stack">
|
||||
<label className="field">
|
||||
금지어(한 줄에 하나, 채팅에서 가리고 닉네임에 못 씀)
|
||||
<textarea className="input" style={{ minHeight: 140, padding: 12 }} value={s.bannedText} onChange={(e) => setS({ ...s, bannedText: e.target.value })} />
|
||||
</label>
|
||||
</section>
|
||||
<div className="bottom-bar sticky-save">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ users (superadmin)
|
||||
|
||||
function Users() {
|
||||
const [q, setQ] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [d, reload] = useLoad<{ total: number; users: any[] }>(`/users?q=${encodeURIComponent(query)}`);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="stack">
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setQuery(q.trim());
|
||||
}}
|
||||
>
|
||||
<input className="input" style={{ flex: 1 }} placeholder="닉네임, 사용자 ID, 디스코드 ID" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn btn-primary">검색</button>
|
||||
</form>
|
||||
{d && <p className="muted small">전체 {d.total}명</p>}
|
||||
{d && (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>닉네임</th>
|
||||
<th className="wide-only">로그인</th>
|
||||
<th>권한</th>
|
||||
<th className="wide-only">마지막 접속</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.users.map((u) => (
|
||||
<tr key={u.id} className="clickable" onClick={() => setOpen(u.id)} tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setOpen(u.id)}>
|
||||
<td>
|
||||
<strong>{u.nickname}</strong>
|
||||
</td>
|
||||
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
|
||||
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
|
||||
<td className="wide-only small">{time(u.lastSeenAt)}</td>
|
||||
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{open && <UserSheet id={open} onClose={() => (setOpen(null), reload())} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const [d, reload] = useLoad<{ user: any; stats: any[] }>(`/users/${id}`);
|
||||
const [nick, setNick] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
|
||||
useEffect(() => {
|
||||
if (d) setNick(d.user.nickname);
|
||||
}, [d]);
|
||||
const run = async (p: Promise<unknown>, msg: string) => {
|
||||
try {
|
||||
await p;
|
||||
toast(msg);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Sheet onClose={onClose} label="사용자 관리">
|
||||
{!d ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : (
|
||||
<>
|
||||
<h2>{d.user.nickname}</h2>
|
||||
<p className="muted small">
|
||||
ID {d.user.id}
|
||||
<br />
|
||||
{d.user.discordId ? `디스코드 ID ${d.user.discordId}` : '게스트'} · 가입 {time(d.user.createdAt)} · 마지막 접속 {time(d.user.lastSeenAt)}
|
||||
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
|
||||
</p>
|
||||
<p className="small">{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}</p>
|
||||
<div className="row">
|
||||
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
|
||||
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
|
||||
닉네임 변경
|
||||
</button>
|
||||
</div>
|
||||
{d.user.banned ? (
|
||||
<div className="card stack" style={{ background: 'var(--warning-soft)' }}>
|
||||
<p>
|
||||
이용 제한 중: {d.user.banned.reason} ({time(d.user.banned.at)})
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={() => run(a.patch(`/users/${id}`, { ban: null }), '제한을 풀었어요.')}>
|
||||
제한 해제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
<input className="input" style={{ flex: 1 }} placeholder="제한 사유" value={reason} onChange={(e) => setReason(e.target.value)} maxLength={200} />
|
||||
<button className="btn btn-danger" disabled={!reason.trim()} onClick={() => run(a.patch(`/users/${id}`, { ban: { reason } }), '이용을 제한했어요.')}>
|
||||
이용 제한
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setAsk('logout')}>
|
||||
모든 기기에서 로그아웃시키기
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={() => setAsk('delete')}>
|
||||
계정 삭제
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-block" onClick={onClose}>
|
||||
닫기
|
||||
</button>
|
||||
{ask === 'logout' && <ConfirmSheet title="로그아웃시킬까요?" confirm="로그아웃" onConfirm={() => run(a.post(`/users/${id}/logout`), '로그아웃시켰어요.')} onClose={() => setAsk(null)} />}
|
||||
{ask === 'delete' && (
|
||||
<ConfirmSheet
|
||||
title="계정을 삭제할까요?"
|
||||
body="되돌릴 수 없어요."
|
||||
confirm="삭제"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await run(a.del(`/users/${id}`), '삭제했어요.');
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setAsk(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ admins (superadmin)
|
||||
|
||||
function Admins() {
|
||||
const [d, reload] = useLoad<{ admins: any[] }>('/admins');
|
||||
const [discordId, setDiscordId] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await a.post('/admins', { discordId: discordId.trim(), note: note.trim() || undefined });
|
||||
toast('어드민을 추가했어요.');
|
||||
setDiscordId('');
|
||||
setNote('');
|
||||
reload();
|
||||
} catch (ex) {
|
||||
toast((ex as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack">
|
||||
<form className="card stack" onSubmit={add}>
|
||||
<h2>어드민 추가</h2>
|
||||
<label className="field">
|
||||
디스코드 ID(숫자)
|
||||
<input className="input" inputMode="numeric" value={discordId} onChange={(e) => setDiscordId(e.target.value)} placeholder="예: 293719842274541579" />
|
||||
</label>
|
||||
<label className="field">
|
||||
메모(선택)
|
||||
<input className="input" value={note} maxLength={100} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
<p className="muted small">디스코드 설정 → 고급 → 개발자 모드를 켠 뒤, 프로필을 우클릭해 "사용자 ID 복사"로 얻을 수 있어요. 아직 사이트에 들어온 적 없는 사람도 미리 추가할 수 있어요.</p>
|
||||
<button className="btn btn-primary btn-block" disabled={!/^\d{17,20}$/.test(discordId.trim())}>
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
{d?.admins.map((x) => (
|
||||
<div key={x.discordId} className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{x.nickname ?? '(아직 로그인 안 함)'}</strong> <span className="badge">{x.fixed ? '슈퍼어드민' : '어드민'}</span>
|
||||
<div className="muted small">
|
||||
{x.discordId}
|
||||
{x.note ? ` · ${x.note}` : ''}
|
||||
{x.addedAt ? ` · ${time(x.addedAt)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{!x.fixed && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setRemoving(x.discordId)}>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{removing && (
|
||||
<ConfirmSheet
|
||||
title="어드민에서 뺄까요?"
|
||||
body={`디스코드 ID ${removing}`}
|
||||
confirm="빼기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.del(`/admins/${removing}`).catch((e) => toast((e as Error).message, 'error'));
|
||||
reload();
|
||||
}}
|
||||
onClose={() => setRemoving(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audit
|
||||
|
||||
const ACTION_KO: Record<string, string> = {
|
||||
'game.update': '게임 설정 변경',
|
||||
'game.reset': '게임 설정 초기화',
|
||||
'site.update': '사이트 설정 변경',
|
||||
'room.close': '방 닫기',
|
||||
'user.rename': '닉네임 변경',
|
||||
'user.ban': '이용 제한',
|
||||
'user.unban': '제한 해제',
|
||||
'user.logout': '강제 로그아웃',
|
||||
'user.delete': '계정 삭제',
|
||||
'admin.add': '어드민 추가',
|
||||
'admin.remove': '어드민 삭제',
|
||||
};
|
||||
|
||||
function Audit() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [d] = useLoad<{ entries: any[] }>(`/audit?page=${page}`);
|
||||
return (
|
||||
<div className="stack">
|
||||
{d?.entries.length === 0 && <p className="muted">기록이 없어요.</p>}
|
||||
{d?.entries.map((e) => (
|
||||
<details key={e.id} className="card">
|
||||
<summary style={{ cursor: 'pointer' }}>
|
||||
<strong>{ACTION_KO[e.action] ?? e.action}</strong> {e.target && <span className="muted">· {e.target}</span>}
|
||||
<div className="muted small">
|
||||
{time(e.at)} · {e.actor ?? e.actorId}
|
||||
</div>
|
||||
</summary>
|
||||
<pre className="small" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
전: {JSON.stringify(e.before, null, 1)}
|
||||
{'\n'}후: {JSON.stringify(e.after, null, 1)}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
<div className="row">
|
||||
<button className="btn btn-secondary btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
← 최근
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={(d?.entries.length ?? 0) < 50} onClick={() => setPage(page + 1)}>
|
||||
이전 기록 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/pages/admin/SchemaForm.tsx
Normal file
139
apps/web/src/pages/admin/SchemaForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Renders a form from a zod-generated JSON Schema (docs/14-admin.md §4).
|
||||
* Every option of a game appears automatically; titles/labels come from `.meta()`.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface JsonSchema {
|
||||
type?: string | string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
labels?: Record<string, string>;
|
||||
enum?: (string | number)[];
|
||||
const?: string | number | boolean;
|
||||
anyOf?: JsonSchema[];
|
||||
properties?: Record<string, JsonSchema>;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
locked?: Set<string>;
|
||||
onToggleLock?: (path: string) => void;
|
||||
}
|
||||
|
||||
function choices(s: JsonSchema): (string | number | boolean)[] | null {
|
||||
if (s.enum) return s.enum;
|
||||
if (s.anyOf && s.anyOf.every((a) => a.const !== undefined)) return s.anyOf.map((a) => a.const!);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SchemaForm({ schema, value, onChange, locked, onToggleLock }: Props) {
|
||||
return <Field schema={schema} value={value} onChange={onChange} path="" locked={locked} onToggleLock={onToggleLock} root />;
|
||||
}
|
||||
|
||||
function Field({ schema, value, onChange, path, locked, onToggleLock, root }: Props & { path: string; root?: boolean }) {
|
||||
const title = schema.title ?? path.split('.').pop() ?? '';
|
||||
const lock = onToggleLock && path ? (
|
||||
<label className="row small" style={{ gap: 4, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||
<input type="checkbox" checked={locked?.has(path) ?? false} onChange={() => onToggleLock(path)} style={{ width: 20, height: 20 }} />
|
||||
🔒 방장 변경 금지
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
const obj = (value ?? {}) as Record<string, unknown>;
|
||||
const inner = Object.entries(schema.properties).map(([k, sub]) => (
|
||||
<Field
|
||||
key={k}
|
||||
schema={sub}
|
||||
value={obj[k]}
|
||||
onChange={(v) => onChange({ ...obj, [k]: v })}
|
||||
path={path ? `${path}.${k}` : k}
|
||||
locked={locked}
|
||||
onToggleLock={onToggleLock}
|
||||
/>
|
||||
));
|
||||
if (root) return <div className="schema-grid">{inner}</div>;
|
||||
return (
|
||||
<fieldset className="stack schema-group" style={{ border: '2px solid var(--border)', borderRadius: 12, padding: 12, gap: 12 }}>
|
||||
<legend style={{ fontWeight: 700, padding: '0 6px' }}>{title}</legend>
|
||||
{lock && <div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end' }}>{lock}</div>}
|
||||
{inner}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
let control: ReactNode;
|
||||
const opts = choices(schema);
|
||||
if (opts) {
|
||||
const cur = value ?? schema.default;
|
||||
control = (
|
||||
<select
|
||||
className="input"
|
||||
value={String(cur)}
|
||||
onChange={(e) => {
|
||||
const picked = opts.find((o) => String(o) === e.target.value);
|
||||
onChange(picked);
|
||||
}}
|
||||
>
|
||||
{opts.map((o) => (
|
||||
<option key={String(o)} value={String(o)}>
|
||||
{schema.labels?.[String(o)] ?? String(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
} else if (schema.type === 'boolean') {
|
||||
const cur = (value ?? schema.default) === true;
|
||||
control = (
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={cur} onClick={() => onChange(true)}>
|
||||
켜기
|
||||
</button>
|
||||
<button type="button" aria-pressed={!cur} onClick={() => onChange(false)}>
|
||||
끄기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
const cur = (value ?? schema.default ?? '') as number | '';
|
||||
control = (
|
||||
<div className="row">
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 180 }}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={cur}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={schema.type === 'integer' ? 1 : 'any'}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
/>
|
||||
{(schema.minimum !== undefined || schema.maximum !== undefined) && (
|
||||
<span className="muted small">
|
||||
{schema.minimum ?? ''} ~ {schema.maximum ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = <input className="input" value={String(value ?? schema.default ?? '')} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<strong>{title}</strong>
|
||||
{lock}
|
||||
</div>
|
||||
{schema.description && <p className="muted small">{schema.description}</p>}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/store/session.ts
Normal file
110
apps/web/src/store/session.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/** Who am I + display settings (docs/05 §7, docs/07 §2). */
|
||||
import { create } from 'zustand';
|
||||
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
|
||||
import { api, type Settings } from '../net/api';
|
||||
|
||||
const LS_KEY = 'bg.settings';
|
||||
|
||||
export const DEFAULT_SETTINGS: Required<Settings> = {
|
||||
fontScale: 'normal',
|
||||
theme: 'auto',
|
||||
sound: true,
|
||||
vibrate: true,
|
||||
colorBlind: false,
|
||||
// Two-step placing is the default only on touch devices; on PC one click places (docs/07 §3.5).
|
||||
tapConfirm: typeof matchMedia !== 'undefined' ? matchMedia('(pointer: coarse)').matches : true,
|
||||
};
|
||||
|
||||
/** Settings that belong to this device and are never synced to the account. */
|
||||
const DEVICE_ONLY = ['tapConfirm'] as const;
|
||||
const withoutDeviceOnly = (s: Settings): Settings => {
|
||||
const out: Record<string, unknown> = { ...s };
|
||||
for (const k of DEVICE_ONLY) delete out[k];
|
||||
return out as Settings;
|
||||
};
|
||||
|
||||
function loadLocal(): Required<Settings> {
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(LS_KEY) ?? '{}') };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySettings(s: Required<Settings>): void {
|
||||
const html = document.documentElement;
|
||||
html.dataset.fontScale = s.fontScale;
|
||||
html.dataset.theme = s.theme;
|
||||
html.dataset.colorBlind = String(s.colorBlind);
|
||||
const dark = s.theme === 'dark' || (s.theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#16181d' : '#F7F5F0');
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
loaded: boolean;
|
||||
me: PublicUser | null;
|
||||
activeRoom: string | null;
|
||||
discordEnabled: boolean;
|
||||
catalog: (CatalogEntry & { notice?: string })[];
|
||||
role: 'user' | 'admin' | 'superadmin';
|
||||
siteName: string;
|
||||
announcement: string;
|
||||
maintenance: boolean;
|
||||
settings: Required<Settings>;
|
||||
useAvatar: boolean;
|
||||
hasDiscordAvatar: boolean;
|
||||
refresh(): Promise<void>;
|
||||
setMe(me: PublicUser | null): void;
|
||||
updateSettings(patch: Settings): void;
|
||||
}
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
loaded: false,
|
||||
me: null,
|
||||
activeRoom: null,
|
||||
discordEnabled: false,
|
||||
catalog: [],
|
||||
role: 'user',
|
||||
siteName: '같이 놀자 보드게임',
|
||||
announcement: '',
|
||||
maintenance: false,
|
||||
settings: loadLocal(),
|
||||
useAvatar: true,
|
||||
hasDiscordAvatar: false,
|
||||
async refresh() {
|
||||
const [cfg, me] = await Promise.all([
|
||||
api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[], siteName: '같이 놀자 보드게임', announcement: '', maintenance: false })),
|
||||
api.me().catch(() => ({ me: null }) as Awaited<ReturnType<typeof api.me>>),
|
||||
]);
|
||||
const settings = { ...get().settings, ...withoutDeviceOnly(me.settings ?? {}) };
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
document.title = cfg.siteName;
|
||||
set({
|
||||
loaded: true,
|
||||
me: me.me,
|
||||
activeRoom: me.activeRoom ?? null,
|
||||
discordEnabled: cfg.discord,
|
||||
catalog: cfg.catalog,
|
||||
role: me.role ?? 'user',
|
||||
siteName: cfg.siteName,
|
||||
announcement: cfg.announcement,
|
||||
maintenance: cfg.maintenance,
|
||||
settings,
|
||||
useAvatar: me.useAvatar ?? true,
|
||||
hasDiscordAvatar: me.hasDiscordAvatar ?? false,
|
||||
});
|
||||
},
|
||||
setMe(me) {
|
||||
set({ me });
|
||||
},
|
||||
updateSettings(patch) {
|
||||
const settings = { ...get().settings, ...patch };
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
set({ settings });
|
||||
if (get().me) api.updateMe({ settings: withoutDeviceOnly(settings) }).catch(() => {});
|
||||
},
|
||||
}));
|
||||
|
||||
applySettings(loadLocal());
|
||||
17
apps/web/src/store/toast.ts
Normal file
17
apps/web/src/store/toast.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
kind: 'info' | 'error';
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export const useToasts = create<{ toasts: Toast[] }>(() => ({ toasts: [] }));
|
||||
|
||||
export function toast(text: string, kind: Toast['kind'] = 'info', ms = 3000): void {
|
||||
const id = nextId++;
|
||||
useToasts.setState((s) => ({ toasts: [...s.toasts.filter((t) => t.text !== text), { id, text, kind }].slice(-3) }));
|
||||
setTimeout(() => useToasts.setState((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), ms);
|
||||
}
|
||||
959
apps/web/src/styles/global.css
Normal file
959
apps/web/src/styles/global.css
Normal file
@@ -0,0 +1,959 @@
|
||||
/* Design tokens: docs/07-ui-ux.md §2 */
|
||||
:root {
|
||||
--bg: #f7f5f0;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #efece4;
|
||||
--text: #1c1e21;
|
||||
--text-sub: #4a4f57;
|
||||
--border: #d9d5cb;
|
||||
--primary: #2563eb;
|
||||
--primary-press: #1d4fd0;
|
||||
--primary-text: #ffffff;
|
||||
--primary-soft: #dbe7ff;
|
||||
--success: #15803d;
|
||||
--success-soft: #dcfce7;
|
||||
--warning: #b45309;
|
||||
--warning-soft: #fef3c7;
|
||||
--danger: #b91c1c;
|
||||
--danger-soft: #fee2e2;
|
||||
--felt: #1f6b4a;
|
||||
--wood: #e3b76b;
|
||||
--wood-line: #7a5a22;
|
||||
--focus: #f59e0b;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--tap: 48px;
|
||||
--btn-h: 56px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #16181d;
|
||||
--surface: #20232a;
|
||||
--surface-2: #2a2e37;
|
||||
--text: #f2f3f5;
|
||||
--text-sub: #b8bcc4;
|
||||
--border: #3a3f4a;
|
||||
--primary: #5b8def;
|
||||
--primary-press: #4a7de0;
|
||||
--primary-text: #0b1020;
|
||||
--primary-soft: #1e2a44;
|
||||
--success: #4ade80;
|
||||
--success-soft: #133222;
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #3a2c0c;
|
||||
--danger: #f87171;
|
||||
--danger-soft: #3d1717;
|
||||
--felt: #17513a;
|
||||
--wood: #b98c45;
|
||||
--wood-line: #4a3612;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-theme='auto'] {
|
||||
--bg: #16181d;
|
||||
--surface: #20232a;
|
||||
--surface-2: #2a2e37;
|
||||
--text: #f2f3f5;
|
||||
--text-sub: #b8bcc4;
|
||||
--border: #3a3f4a;
|
||||
--primary: #5b8def;
|
||||
--primary-press: #4a7de0;
|
||||
--primary-text: #0b1020;
|
||||
--primary-soft: #1e2a44;
|
||||
--success: #4ade80;
|
||||
--success-soft: #133222;
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: #3a2c0c;
|
||||
--danger: #f87171;
|
||||
--danger-soft: #3d1717;
|
||||
--felt: #17513a;
|
||||
--wood: #b98c45;
|
||||
--wood-line: #4a3612;
|
||||
--shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Font scale setting: 보통 / 크게 / 아주 크게. Everything else is in rem. */
|
||||
html {
|
||||
font-size: 106.25%; /* 17px */
|
||||
}
|
||||
html[data-font-scale='large'] {
|
||||
font-size: 127.5%;
|
||||
}
|
||||
html[data-font-scale='xlarge'] {
|
||||
font-size: 148.75%;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px calc(120px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
min-height: var(--btn-h);
|
||||
min-width: var(--tap);
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid transparent;
|
||||
font-size: 1.06rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: transform 0.08s ease, background 0.15s ease;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-press);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-soft {
|
||||
background: var(--primary-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--surface);
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: var(--tap);
|
||||
padding: 0 14px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
min-height: var(--tap);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* ---------- inputs ---------- */
|
||||
.input {
|
||||
min-height: var(--btn-h);
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 0 16px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
.field-error {
|
||||
color: var(--danger);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
label.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.segmented button {
|
||||
min-height: var(--tap);
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.segmented button[aria-pressed='true'] {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 56px;
|
||||
padding: 4px 8px;
|
||||
padding-top: calc(4px + env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title {
|
||||
flex: 1;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- bottom action bar ---------- */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 15;
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.bottom-bar-inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.bottom-bar-inner > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- banners & toasts ---------- */
|
||||
.conn-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: var(--warning);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 8px 12px;
|
||||
padding-top: calc(8px + env(safe-area-inset-top));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: calc(64px + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(92vw, 420px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
animation: toast-in 0.2s ease;
|
||||
}
|
||||
.toast.error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- sheets / modals ---------- */
|
||||
.sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
animation: fade 0.15s ease;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.sheet-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.sheet {
|
||||
background: var(--surface);
|
||||
width: min(100%, 520px);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
animation: sheet-up 0.2s ease;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.sheet {
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(24px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- avatar ---------- */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.badge.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Icon + small label stacked (keeps text with icons but saves width on phones). */
|
||||
.icon-btn {
|
||||
min-width: var(--tap);
|
||||
min-height: var(--tap);
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.1;
|
||||
position: relative;
|
||||
}
|
||||
.icon-btn .lbl {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.icon-btn .badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.sheet:focus {
|
||||
outline: none;
|
||||
}
|
||||
.board-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: min(100%, calc(100vh - 300px));
|
||||
min-width: min(100%, 320px);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
PC-first layout (docs/07-ui-ux.md §3). Breakpoints: ≥1100 PC, 720–1099 tablet, <720 phone.
|
||||
===================================================================== */
|
||||
.page {
|
||||
max-width: 1280px;
|
||||
padding: 24px 24px 48px;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.page {
|
||||
padding: 16px 16px calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: 64px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.site-header-inner {
|
||||
max-width: 1280px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.site-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.site-nav a {
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-sub);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.site-nav a:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.site-nav a.active {
|
||||
color: var(--primary);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
.header-join {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.header-join input {
|
||||
width: 150px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--bg);
|
||||
padding: 0 12px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.header-join input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.header-join .btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
.header-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.header-user:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.site-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.site-header {
|
||||
height: 56px;
|
||||
}
|
||||
.site-header-inner {
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-join {
|
||||
display: none;
|
||||
}
|
||||
.site-logo .full {
|
||||
display: none;
|
||||
}
|
||||
.site-logo {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Room bar under the site header */
|
||||
.room-bar {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 25;
|
||||
height: 52px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.room-bar-inner {
|
||||
max-width: 1600px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.room-bar {
|
||||
top: 56px;
|
||||
}
|
||||
.room-bar-inner {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two-column page (lobby, home) */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 400px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.two-col {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Game screen: board + right panel, fits the viewport height on PC */
|
||||
.game-layout {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 64px - 52px);
|
||||
min-height: 560px;
|
||||
}
|
||||
.game-main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.game-side {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
.game-side .chat-panel {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.game-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
max-width: min(100%, calc(100vh - 64px - 52px - 120px));
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 1099px) {
|
||||
.board-wrap {
|
||||
max-width: min(100%, 720px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat panel (inline on PC) */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 12px;
|
||||
}
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.game-side .chat-list {
|
||||
max-height: none;
|
||||
}
|
||||
.chat-line {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.chat-line strong {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.emote-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.emote-row button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.emote-row button:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* Bottom bar only floats on phones; on wider screens it renders inline. */
|
||||
@media (min-width: 720px) {
|
||||
.bottom-bar.inline-wide {
|
||||
position: static;
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.bottom-bar.inline-wide .bottom-bar-inner {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover affordances (mouse) */
|
||||
@media (hover: hover) {
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.game-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
.game-card {
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.phone-only {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.phone-only {
|
||||
display: initial;
|
||||
}
|
||||
.wide-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin: sidebar layout */
|
||||
.admin-layout {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
.admin-side {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-side a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
.admin-side a:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.admin-side a.active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.admin-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 12px;
|
||||
}
|
||||
.admin-side {
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.admin-side a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table th {
|
||||
background: var(--surface-2);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.data-table tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.data-table tr.clickable:hover td {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* Page sub-header (TopBar) sits under the site header and scrolls with the page. */
|
||||
.topbar {
|
||||
position: static;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
background: transparent;
|
||||
border-bottom: 0;
|
||||
padding: 12px 16px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.bottom-bar.sticky-save {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: auto;
|
||||
right: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
}
|
||||
.bottom-bar.sticky-save .bottom-bar-inner {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
.room-bar-inner > *,
|
||||
.room-bar-inner .btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.room-bar-inner {
|
||||
gap: 4px;
|
||||
}
|
||||
.room-bar .rb-game {
|
||||
display: none;
|
||||
}
|
||||
.room-bar .btn-sm {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.schema-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.schema-grid > .schema-group {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.schema-group > .schema-grid,
|
||||
.schema-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
.schema-group > legend {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
20
apps/web/tsconfig.json
Normal file
20
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user