주루마블 기본 알고리즘 생성중.
- 시작위치 생성 - 각 위치별 추가 이벤트시 사용될 이벤트 알고리즘 - Rapier, Three.js를 통한 주사위.
This commit is contained in:
88
app/game/cell-event.ts
Normal file
88
app/game/cell-event.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Chessman } from './chessman';
|
||||
|
||||
export type PlaneCell = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** UI에 보이는 이름 */
|
||||
type: string;
|
||||
/** 효과가 있을 때만 설정. 없으면 착지 시 아무 일도 없음 */
|
||||
eventId?: string | null;
|
||||
};
|
||||
|
||||
export type TurnContext = {
|
||||
chessman: Chessman;
|
||||
board: PlaneCell[];
|
||||
roll: number;
|
||||
landIndex: number;
|
||||
};
|
||||
|
||||
export type TurnResult = {
|
||||
message: string;
|
||||
/** 비행기 등: 즉시 이동할 칸 */
|
||||
teleportTo?: number;
|
||||
};
|
||||
|
||||
export interface CellEvent {
|
||||
readonly id: string;
|
||||
apply(ctx: TurnContext): TurnResult;
|
||||
}
|
||||
|
||||
/** 무인도: 최대 3턴 대기, 주사위 6이면 즉시 탈출 가능 */
|
||||
export class IslandEvent implements CellEvent {
|
||||
readonly id = 'island';
|
||||
|
||||
apply(ctx: TurnContext): TurnResult {
|
||||
ctx.chessman.enterIsland(3, 6);
|
||||
return { message: '무인도! 3턴 대기 (6이면 탈출)' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 역방향: 진행 방향을 반대로 */
|
||||
export class ReverseEvent implements CellEvent {
|
||||
readonly id = 'reverse';
|
||||
|
||||
apply(ctx: TurnContext): TurnResult {
|
||||
const dir = ctx.chessman.reverseDirection();
|
||||
return {
|
||||
message: dir === 1 ? '역방향! 다시 시계방향' : '역방향! 반시계로 진행',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 비행기: 지정한 칸으로 즉시 이동 */
|
||||
export class AirplaneEvent implements CellEvent {
|
||||
readonly id = 'airplane';
|
||||
|
||||
constructor(private readonly targetIndex: number) {}
|
||||
|
||||
apply(ctx: TurnContext): TurnResult {
|
||||
const target =
|
||||
((this.targetIndex % ctx.board.length) + ctx.board.length) % ctx.board.length;
|
||||
ctx.chessman.teleport(target);
|
||||
const dest = ctx.board[target];
|
||||
return {
|
||||
message: `비행기! → ${dest?.type ?? target}`,
|
||||
teleportTo: target,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CELL_EVENTS: Record<string, CellEvent> = {
|
||||
island: new IslandEvent(),
|
||||
reverse: new ReverseEvent(),
|
||||
// 기본 도착지는 시작(0). 판 생성 시 덮어쓸 수 있다.
|
||||
airplane: new AirplaneEvent(0),
|
||||
};
|
||||
|
||||
export function getCellEvent(eventId: string | null | undefined): CellEvent | null {
|
||||
if (!eventId) return null;
|
||||
return CELL_EVENTS[eventId] ?? null;
|
||||
}
|
||||
|
||||
export function applyLandEvent(ctx: TurnContext): TurnResult | null {
|
||||
const cell = ctx.board[ctx.landIndex];
|
||||
const event = getCellEvent(cell?.eventId);
|
||||
if (!event) return null;
|
||||
return event.apply(ctx);
|
||||
}
|
||||
51
app/game/chessman-piece.tsx
Normal file
51
app/game/chessman-piece.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
const DEFAULT_CHESSMAN_SRC = '/chessman/default.png';
|
||||
|
||||
/** 주사위판 그리드 위에 올리는 말 표시. public/chessman/*.png 로 커스텀 가능. */
|
||||
export default function ChessmanPiece({
|
||||
x,
|
||||
y,
|
||||
hopKey,
|
||||
src = DEFAULT_CHESSMAN_SRC,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
/** 칸이 바뀔 때마다 증가시켜 hop 애니메이션을 다시 재생한다. */
|
||||
hopKey: number;
|
||||
/** public/chessman 하위 이미지 경로. 기본값: /chessman/default.png */
|
||||
src?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label="chessman"
|
||||
style={{
|
||||
gridColumn: x + 1,
|
||||
gridRow: y + 1,
|
||||
zIndex: 5,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
key={hopKey}
|
||||
src={src}
|
||||
alt="chessman"
|
||||
draggable={false}
|
||||
className="drinkmarble-chessman-token"
|
||||
style={{
|
||||
width: '58%',
|
||||
height: '58%',
|
||||
minWidth: 22,
|
||||
minHeight: 22,
|
||||
objectFit: 'contain',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.35))',
|
||||
animation: hopKey > 0 ? 'drinkmarble-chessman-hop 220ms ease-out' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
app/game/chessman.ts
Normal file
93
app/game/chessman.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** 주사위판 위를 이동하는 말. */
|
||||
export class Chessman {
|
||||
private index: number;
|
||||
private readonly boardLength: number;
|
||||
/** 1: 시계방향, -1: 반시계방향 */
|
||||
private direction: 1 | -1 = 1;
|
||||
/** 무인도: 남은 대기 턴, 탈출 가능 눈 */
|
||||
private island: { turnsLeft: number; escapeOn: number } | null = null;
|
||||
|
||||
constructor(boardLength: number, startIndex = 0) {
|
||||
if (boardLength <= 0) {
|
||||
throw new Error('Chessman boardLength must be positive');
|
||||
}
|
||||
this.boardLength = boardLength;
|
||||
this.index = ((startIndex % boardLength) + boardLength) % boardLength;
|
||||
}
|
||||
|
||||
get position(): number {
|
||||
return this.index;
|
||||
}
|
||||
|
||||
get facing(): 1 | -1 {
|
||||
return this.direction;
|
||||
}
|
||||
|
||||
get isOnIsland(): boolean {
|
||||
return this.island != null;
|
||||
}
|
||||
|
||||
get islandTurnsLeft(): number {
|
||||
return this.island?.turnsLeft ?? 0;
|
||||
}
|
||||
|
||||
reverseDirection(): 1 | -1 {
|
||||
this.direction = this.direction === 1 ? -1 : 1;
|
||||
return this.direction;
|
||||
}
|
||||
|
||||
enterIsland(turns: number, escapeOn = 6) {
|
||||
this.island = { turnsLeft: Math.max(1, Math.trunc(turns)), escapeOn };
|
||||
}
|
||||
|
||||
clearIsland() {
|
||||
this.island = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무인도 상태에서의 주사위 처리.
|
||||
* - escape: 6(또는 escapeOn)으로 탈출 → 이후 정상 이동
|
||||
* - wait: 턴 소모 후 대기 (turnsLeft 0이면 자동 해제, 이번 턴은 이동 없음)
|
||||
* - free: 이미 무인도 아님
|
||||
*/
|
||||
resolveIslandRoll(roll: number): 'escape' | 'wait' | 'free' {
|
||||
if (!this.island) return 'free';
|
||||
|
||||
if (roll === this.island.escapeOn) {
|
||||
this.island = null;
|
||||
return 'escape';
|
||||
}
|
||||
|
||||
this.island.turnsLeft -= 1;
|
||||
if (this.island.turnsLeft <= 0) {
|
||||
this.island = null;
|
||||
}
|
||||
return 'wait';
|
||||
}
|
||||
|
||||
teleport(index: number): number {
|
||||
this.index = ((index % this.boardLength) + this.boardLength) % this.boardLength;
|
||||
return this.index;
|
||||
}
|
||||
|
||||
moveOne(): number {
|
||||
return this.move(1);
|
||||
}
|
||||
|
||||
/** steps칸만큼 현재 방향으로 이동 */
|
||||
move(steps: number): number {
|
||||
const amount = Math.trunc(steps);
|
||||
if (amount === 0) return this.index;
|
||||
const delta = amount * this.direction;
|
||||
this.index = (this.index + delta) % this.boardLength;
|
||||
if (this.index < 0) this.index += this.boardLength;
|
||||
return this.index;
|
||||
}
|
||||
|
||||
reset(startIndex = 0): number {
|
||||
this.index = ((startIndex % this.boardLength) + this.boardLength) % this.boardLength;
|
||||
this.direction = 1;
|
||||
this.island = null;
|
||||
return this.index;
|
||||
}
|
||||
}
|
||||
280
app/game/dice.tsx
Normal file
280
app/game/dice.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber';
|
||||
import {
|
||||
Physics,
|
||||
RigidBody,
|
||||
CuboidCollider,
|
||||
type RapierRigidBody,
|
||||
} from '@react-three/rapier';
|
||||
import {
|
||||
Quaternion,
|
||||
SRGBColorSpace,
|
||||
TextureLoader,
|
||||
Vector3,
|
||||
type OrthographicCamera,
|
||||
} from 'three';
|
||||
|
||||
const FLOOR_SIZE = 10;
|
||||
const FLOOR_HALF = FLOOR_SIZE / 2;
|
||||
const WALL_THICKNESS = 0.2;
|
||||
const WALL_HEIGHT = 10;
|
||||
const WALL_HALF_T = WALL_THICKNESS / 2;
|
||||
const WALL_HALF_H = WALL_HEIGHT / 2;
|
||||
|
||||
const STILL_SPEED_SQ = 0.05;
|
||||
const STILL_FRAMES = 18;
|
||||
|
||||
// boxGeometry material 순서: +X, -X, +Y, -Y, +Z, -Z
|
||||
const DICE_FACE_TEXTURES = [
|
||||
'/dice/2.png', // +X
|
||||
'/dice/5.png', // -X
|
||||
'/dice/1.png', // +Y
|
||||
'/dice/6.png', // -Y
|
||||
'/dice/3.png', // +Z
|
||||
'/dice/4.png', // -Z
|
||||
] as const;
|
||||
|
||||
// 결과 판정: 로컬 법선이 월드 +Y에 가장 가까운 면
|
||||
const DICE_FACES = [
|
||||
{ value: 1, localNormal: new Vector3(0, 1, 0) },
|
||||
{ value: 6, localNormal: new Vector3(0, -1, 0) },
|
||||
{ value: 2, localNormal: new Vector3(1, 0, 0) },
|
||||
{ value: 5, localNormal: new Vector3(-1, 0, 0) },
|
||||
{ value: 3, localNormal: new Vector3(0, 0, 1) },
|
||||
{ value: 4, localNormal: new Vector3(0, 0, -1) },
|
||||
];
|
||||
|
||||
const _quat = new Quaternion();
|
||||
const _normal = new Vector3();
|
||||
const WORLD_UP = new Vector3(0, 1, 0);
|
||||
|
||||
function readTopFace(body: RapierRigidBody): number {
|
||||
const r = body.rotation();
|
||||
_quat.set(r.x, r.y, r.z, r.w);
|
||||
|
||||
let bestValue = 1;
|
||||
let bestDot = -Infinity;
|
||||
for (const face of DICE_FACES) {
|
||||
_normal.copy(face.localNormal).applyQuaternion(_quat);
|
||||
const dot = _normal.dot(WORLD_UP);
|
||||
if (dot > bestDot) {
|
||||
bestDot = dot;
|
||||
bestValue = face.value;
|
||||
}
|
||||
}
|
||||
return bestValue;
|
||||
}
|
||||
|
||||
function FitSquareFloor() {
|
||||
const { camera, size } = useThree();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const cam = camera as OrthographicCamera;
|
||||
cam.left = -FLOOR_HALF;
|
||||
cam.right = FLOOR_HALF;
|
||||
cam.top = FLOOR_HALF;
|
||||
cam.bottom = -FLOOR_HALF;
|
||||
cam.near = 0.1;
|
||||
cam.far = 50;
|
||||
cam.updateProjectionMatrix();
|
||||
}, [camera, size.width, size.height]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function InvisibleWalls() {
|
||||
const t = WALL_HALF_T;
|
||||
const h = WALL_HALF_H;
|
||||
const f = FLOOR_HALF;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CuboidCollider args={[t, h, f]} position={[f + t, h, 0]} />
|
||||
<CuboidCollider args={[t, h, f]} position={[-(f + t), h, 0]} />
|
||||
<CuboidCollider args={[f, h, t]} position={[0, h, f + t]} />
|
||||
<CuboidCollider args={[f, h, t]} position={[0, h, -(f + t)]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiceMesh() {
|
||||
const textures = useLoader(TextureLoader, [...DICE_FACE_TEXTURES]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
for (const texture of textures) {
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.anisotropy = 8;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
}, [textures]);
|
||||
|
||||
return (
|
||||
<mesh castShadow>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
{textures.map((texture, index) => (
|
||||
<meshStandardMaterial
|
||||
key={texture.uuid}
|
||||
attach={`material-${index}`}
|
||||
map={texture}
|
||||
roughness={0.55}
|
||||
metalness={0.05}
|
||||
/>
|
||||
))}
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function DiceSettleWatcher({
|
||||
diceRef,
|
||||
rolling,
|
||||
onSettled,
|
||||
}: {
|
||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||
rolling: boolean;
|
||||
onSettled: (value: number) => void;
|
||||
}) {
|
||||
const stillFrames = useRef(0);
|
||||
const reported = useRef(false);
|
||||
|
||||
useFrame(() => {
|
||||
const body = diceRef.current;
|
||||
if (!body || !rolling) {
|
||||
stillFrames.current = 0;
|
||||
reported.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lv = body.linvel();
|
||||
const av = body.angvel();
|
||||
const speedSq =
|
||||
lv.x * lv.x + lv.y * lv.y + lv.z * lv.z + av.x * av.x + av.y * av.y + av.z * av.z;
|
||||
|
||||
if (speedSq < STILL_SPEED_SQ || body.isSleeping()) {
|
||||
stillFrames.current += 1;
|
||||
if (stillFrames.current >= STILL_FRAMES && !reported.current) {
|
||||
reported.current = true;
|
||||
onSettled(readTopFace(body));
|
||||
}
|
||||
} else {
|
||||
stillFrames.current = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function RapierDiceExample({
|
||||
diceRef,
|
||||
rolling,
|
||||
onSettled,
|
||||
}: {
|
||||
diceRef: React.RefObject<RapierRigidBody | null>;
|
||||
rolling: boolean;
|
||||
onSettled: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Canvas
|
||||
orthographic
|
||||
camera={{ position: [0, 20, 0.01], zoom: 1 }}
|
||||
className="h-full w-full"
|
||||
gl={{ alpha: true }}
|
||||
>
|
||||
<FitSquareFloor />
|
||||
<ambientLight intensity={0.85} />
|
||||
<directionalLight position={[4, 10, 2]} intensity={0.9} />
|
||||
<Physics>
|
||||
<RigidBody type="fixed" colliders={false} position={[0, 0, 0]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
<meshBasicMaterial color="blue" />
|
||||
</mesh>
|
||||
<CuboidCollider args={[FLOOR_HALF, 0.05, FLOOR_HALF]} position={[0, -0.05, 0]} />
|
||||
<InvisibleWalls />
|
||||
</RigidBody>
|
||||
|
||||
<RigidBody
|
||||
ref={diceRef}
|
||||
type="dynamic"
|
||||
position={[0, 4, 0]}
|
||||
colliders="cuboid"
|
||||
restitution={0.2}
|
||||
friction={0.6}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<DiceMesh />
|
||||
</Suspense>
|
||||
</RigidBody>
|
||||
|
||||
<DiceSettleWatcher diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
|
||||
</Physics>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
function rollDice(body: RapierRigidBody | null) {
|
||||
if (!body) return;
|
||||
|
||||
body.wakeUp();
|
||||
body.applyImpulse(
|
||||
{ x: (Math.random() - 0.5) * 8, y: 4 + Math.random() * 4, z: (Math.random() - 0.5) * 8 },
|
||||
true,
|
||||
);
|
||||
body.applyTorqueImpulse(
|
||||
{ x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 6, z: (Math.random() - 0.5) * 6 },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dice({
|
||||
onRolled,
|
||||
disabled = false,
|
||||
}: {
|
||||
onRolled?: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const diceRef = useRef<RapierRigidBody>(null);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
|
||||
const onSettled = useCallback(
|
||||
(value: number) => {
|
||||
setResult(value);
|
||||
setRolling(false);
|
||||
onRolled?.(value);
|
||||
},
|
||||
[onRolled],
|
||||
);
|
||||
|
||||
const busy = rolling || disabled;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" style={{ containerType: 'size' }}>
|
||||
<div className="absolute top-0 left-0 aspect-square w-[min(100%,100cqh)]">
|
||||
<RapierDiceExample diceRef={diceRef} rolling={rolling} onSettled={onSettled} />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-2 left-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setRolling(true);
|
||||
rollDice(diceRef.current);
|
||||
}}
|
||||
>
|
||||
Roll
|
||||
</button>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 rounded bg-black/70 px-3 py-1 text-sm text-white">
|
||||
{rolling
|
||||
? '굴리는 중…'
|
||||
: disabled
|
||||
? '이동 중…'
|
||||
: result != null
|
||||
? `결과: ${result}`
|
||||
: '결과: —'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,307 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type PlaneCell = { id: number; x: number; y: number; type: string };
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Dice from './dice';
|
||||
import { Chessman } from './chessman';
|
||||
import ChessmanPiece from './chessman-piece';
|
||||
import {
|
||||
applyLandEvent,
|
||||
type PlaneCell,
|
||||
} from './cell-event';
|
||||
|
||||
const PLANE_SIZE = { width: 9, height: 7 } as const;
|
||||
const STEP_MS = 280;
|
||||
|
||||
const CELL_TYPES = ['한잔', '안주', '모두한잔', '두잔', '벌칙1', '벌칙2'] as const;
|
||||
|
||||
/** 특수 칸 배치 (28칸 기준 인덱스) */
|
||||
const SPECIAL_CELLS: Record<number, { type: string; eventId: string }> = {
|
||||
7: { type: '무인도', eventId: 'island' },
|
||||
14: { type: '역방향', eventId: 'reverse' },
|
||||
21: { type: '비행기', eventId: 'airplane' }, // → 시작(0)
|
||||
};
|
||||
|
||||
function randomType() {
|
||||
return CELL_TYPES[Math.floor(Math.random() * CELL_TYPES.length)];
|
||||
}
|
||||
|
||||
function cellLabel(cell: PlaneCell | undefined, index: number) {
|
||||
if (!cell) return '';
|
||||
return index === 0 ? '시작' : cell.type;
|
||||
}
|
||||
|
||||
function cellColor(cell: PlaneCell, index: number) {
|
||||
if (index === 0) return '#059669';
|
||||
switch (cell.eventId) {
|
||||
case 'island':
|
||||
return '#0f766e';
|
||||
case 'reverse':
|
||||
return '#7c3aed';
|
||||
case 'airplane':
|
||||
return '#0284c7';
|
||||
default:
|
||||
return '#e11d48';
|
||||
}
|
||||
}
|
||||
|
||||
/** 시계방향 둘레 칸. 모서리는 한 번만 넣어서 9x7 기준 28칸이 된다. */
|
||||
function buildPerimeter(width: number, height: number): PlaneCell[] {
|
||||
function buildPerimeter(width: number, height: number, assignType: () => string): PlaneCell[] {
|
||||
const cells: PlaneCell[] = [];
|
||||
let id = 0;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
cells.push({ id: id++, x, y: 0, type: randomType() });
|
||||
cells.push({ id: id++, x, y: 0, type: assignType() });
|
||||
}
|
||||
for (let y = 1; y < height; y++) {
|
||||
cells.push({ id: id++, x: width - 1, y, type: randomType() });
|
||||
cells.push({ id: id++, x: width - 1, y, type: assignType() });
|
||||
}
|
||||
for (let x = width - 2; x >= 0; x--) {
|
||||
cells.push({ id: id++, x, y: height - 1, type: randomType() });
|
||||
cells.push({ id: id++, x, y: height - 1, type: assignType() });
|
||||
}
|
||||
for (let y = height - 2; y >= 1; y--) {
|
||||
cells.push({ id: id++, x: 0, y, type: randomType() });
|
||||
cells.push({ id: id++, x: 0, y, type: assignType() });
|
||||
}
|
||||
|
||||
if (cells[0]) {
|
||||
cells[0].type = '시작';
|
||||
cells[0].eventId = null;
|
||||
}
|
||||
|
||||
for (const [indexText, special] of Object.entries(SPECIAL_CELLS)) {
|
||||
const index = Number(indexText);
|
||||
if (cells[index]) {
|
||||
cells[index].type = special.type;
|
||||
cells[index].eventId = special.eventId;
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
export default function DicePlane() {
|
||||
const [dicePlaneArray] = useState(() =>
|
||||
buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height),
|
||||
const [dicePlaneArray, setDicePlaneArray] = useState(() =>
|
||||
buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, () => 'none'),
|
||||
);
|
||||
const chessmanRef = useRef(new Chessman(28));
|
||||
const boardRef = useRef(dicePlaneArray);
|
||||
const [chessmanIndex, setChessmanIndex] = useState(0);
|
||||
const [hopKey, setHopKey] = useState(0);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [landedLabel, setLandedLabel] = useState<string | null>(null);
|
||||
const [landedKey, setLandedKey] = useState(0);
|
||||
const [statusText, setStatusText] = useState('시계방향');
|
||||
const [islandTurnsLeft, setIslandTurnsLeft] = useState(0);
|
||||
const moveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const cells = buildPerimeter(PLANE_SIZE.width, PLANE_SIZE.height, randomType);
|
||||
chessmanRef.current = new Chessman(cells.length, 0);
|
||||
boardRef.current = cells;
|
||||
setChessmanIndex(0);
|
||||
setHopKey(0);
|
||||
setLandedLabel(null);
|
||||
setStatusText('시계방향');
|
||||
setIslandTurnsLeft(0);
|
||||
setDicePlaneArray(cells);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
boardRef.current = dicePlaneArray;
|
||||
}, [dicePlaneArray]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (moveTimerRef.current) clearTimeout(moveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showBanner = useCallback((message: string) => {
|
||||
setLandedLabel(message);
|
||||
setLandedKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const applyLanding = useCallback(
|
||||
(landIndex: number, roll: number) => {
|
||||
const man = chessmanRef.current;
|
||||
const result = applyLandEvent({
|
||||
chessman: man,
|
||||
board: boardRef.current,
|
||||
roll,
|
||||
landIndex,
|
||||
});
|
||||
|
||||
setStatusText(man.facing === 1 ? '시계방향' : '반시계방향');
|
||||
setIslandTurnsLeft(man.islandTurnsLeft);
|
||||
|
||||
if (result?.teleportTo != null) {
|
||||
setChessmanIndex(result.teleportTo);
|
||||
setHopKey((k) => k + 1);
|
||||
showBanner(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
showBanner(result?.message ?? cellLabel(boardRef.current[landIndex], landIndex));
|
||||
},
|
||||
[showBanner],
|
||||
);
|
||||
|
||||
const animateMove = useCallback(
|
||||
(steps: number, roll: number) => {
|
||||
const amount = Math.max(0, Math.trunc(steps));
|
||||
if (amount === 0) {
|
||||
setMoving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setMoving(true);
|
||||
let remaining = amount;
|
||||
|
||||
const stepOnce = () => {
|
||||
const next = chessmanRef.current.moveOne();
|
||||
setChessmanIndex(next);
|
||||
setHopKey((k) => k + 1);
|
||||
remaining -= 1;
|
||||
|
||||
if (remaining > 0) {
|
||||
moveTimerRef.current = setTimeout(stepOnce, STEP_MS);
|
||||
} else {
|
||||
moveTimerRef.current = null;
|
||||
setMoving(false);
|
||||
applyLanding(next, roll);
|
||||
}
|
||||
};
|
||||
|
||||
stepOnce();
|
||||
},
|
||||
[applyLanding],
|
||||
);
|
||||
|
||||
const onDiceRolled = useCallback(
|
||||
(value: number) => {
|
||||
const roll = Math.max(0, Math.trunc(value));
|
||||
if (roll === 0) return;
|
||||
|
||||
setLandedLabel(null);
|
||||
const man = chessmanRef.current;
|
||||
|
||||
if (man.isOnIsland) {
|
||||
const outcome = man.resolveIslandRoll(roll);
|
||||
setIslandTurnsLeft(man.islandTurnsLeft);
|
||||
if (outcome === 'wait') {
|
||||
showBanner(
|
||||
man.isOnIsland
|
||||
? `무인도 대기 (남은 ${man.islandTurnsLeft}턴)`
|
||||
: '무인도 3턴 종료!',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// escape with 6
|
||||
showBanner('무인도 탈출!');
|
||||
}
|
||||
|
||||
animateMove(roll, roll);
|
||||
},
|
||||
[animateMove, showBanner],
|
||||
);
|
||||
|
||||
const chessmanCell = dicePlaneArray[chessmanIndex] ?? dicePlaneArray[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${PLANE_SIZE.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${PLANE_SIZE.height}, minmax(0, 1fr))`,
|
||||
gap: 'clamp(6px, 1vw, 12px)',
|
||||
padding: 'clamp(8px, 1.2vw, 16px)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{dicePlaneArray.map((plane) => (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${PLANE_SIZE.width}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${PLANE_SIZE.height}, minmax(0, 1fr))`,
|
||||
gap: 'clamp(6px, 1vw, 12px)',
|
||||
padding: 'clamp(8px, 1.2vw, 16px)',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{dicePlaneArray.map((plane, index) => {
|
||||
const isStart = index === 0;
|
||||
const isLanded = !moving && index === chessmanIndex && landedLabel != null;
|
||||
return (
|
||||
<div
|
||||
key={plane.id}
|
||||
style={{
|
||||
gridColumn: plane.x + 1,
|
||||
gridRow: plane.y + 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: cellColor(plane, index),
|
||||
color: 'white',
|
||||
borderRadius: 8,
|
||||
fontSize: 'clamp(11px, 1.6vw, 16px)',
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
padding: 4,
|
||||
boxShadow: isLanded
|
||||
? '0 0 0 3px #fde68a, 0 0 18px rgba(253, 224, 71, 0.75)'
|
||||
: isStart
|
||||
? 'inset 0 0 0 2px #a7f3d0'
|
||||
: undefined,
|
||||
transform: isLanded ? 'scale(1.04)' : undefined,
|
||||
transition: 'box-shadow 180ms ease, transform 180ms ease',
|
||||
zIndex: isLanded ? 4 : undefined,
|
||||
}}
|
||||
>
|
||||
{isStart ? '시작' : plane.type}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{chessmanCell && (
|
||||
<ChessmanPiece x={chessmanCell.x} y={chessmanCell.y} hopKey={hopKey} />
|
||||
)}
|
||||
|
||||
<div
|
||||
key={plane.id}
|
||||
style={{
|
||||
gridColumn: plane.x + 1,
|
||||
gridRow: plane.y + 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#e11d48',
|
||||
color: 'white',
|
||||
borderRadius: 8,
|
||||
fontSize: 'clamp(11px, 1.6vw, 16px)',
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
padding: 4,
|
||||
gridColumn: '2 / 4',
|
||||
gridRow: '2 / 4',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
zIndex: 6,
|
||||
}}
|
||||
>
|
||||
{plane.type}
|
||||
<Dice onRolled={onDiceRolled} disabled={moving} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute top-3 right-3 z-20 rounded bg-black/65 px-3 py-1 text-sm text-white">
|
||||
{statusText}
|
||||
{islandTurnsLeft > 0 ? ` · 무인도 ${islandTurnsLeft}턴` : ''}
|
||||
</div>
|
||||
|
||||
{landedLabel && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
key={landedKey}
|
||||
style={{
|
||||
padding: 'clamp(12px, 2vw, 28px) clamp(20px, 4vw, 48px)',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(15, 23, 42, 0.78)',
|
||||
color: '#fff7ed',
|
||||
fontSize: 'clamp(1.6rem, 6vw, 4rem)',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.04em',
|
||||
textShadow: '0 4px 24px rgba(0,0,0,0.45)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.35)',
|
||||
animation: 'drinkmarble-landed-pop 420ms ease-out',
|
||||
maxWidth: '80%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{landedLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import DicePlane from './diceplane';
|
||||
import Dice from './dice';
|
||||
|
||||
export default function GamePage() {
|
||||
return (
|
||||
<div className="relative h-screen w-screen overflow-hidden">
|
||||
<DicePlane />
|
||||
<h1 className="pointer-events-none absolute inset-0 flex items-center justify-center text-4xl font-bold">
|
||||
Game
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user