diff --git a/src/common/ClientOnly.jsx b/src/common/ClientOnly.jsx new file mode 100644 index 00000000..48f0ae32 --- /dev/null +++ b/src/common/ClientOnly.jsx @@ -0,0 +1,36 @@ +import { useSyncExternalStore, useEffect } from "react"; + +const mountedStore = { + mounted: false, + listeners: new Set(), + mount() { + mountedStore.mounted = true; + mountedStore.listeners.forEach((listener) => listener()); + }, + subscribe(listener) { + mountedStore.listeners.add(listener); + return () => mountedStore.listeners.delete(listener); + }, + getSnapshot() { + return mountedStore.mounted; + }, +}; + +/** + * react 클라이언트 only 래퍼 입니다. + * 이 래퍼 컴포넌트는 클라이언트에서만 필요한 동작이 필요할 때, SSR시와 하이드레이션시 반환한 함수가 + * 동일한 폴백 엘리먼트를 렌더링하도록 보장합니다. + */ +export default function ClientOnly({ children, fallback }) { + const mounted = useSyncExternalStore( + mountedStore.subscribe, + mountedStore.getSnapshot, + () => false, + ); + useEffect(() => { + mountedStore.mount(); + }, []); + + if (!mounted) return fallback; + return children; +} diff --git a/src/common/Suspense.jsx b/src/common/Suspense.jsx index d3d7bb3d..d2263cea 100644 --- a/src/common/Suspense.jsx +++ b/src/common/Suspense.jsx @@ -1,4 +1,5 @@ -import { Suspense as ReactSuspense, useState, useEffect } from "react"; +import { Suspense as ReactSuspense } from "react"; +import ClientOnly from "./ClientOnly.jsx"; /** * react 의 래퍼 컴포넌트입니다. @@ -8,11 +9,9 @@ import { Suspense as ReactSuspense, useState, useEffect } from "react"; * 출처 : https://toss.tech/article/faster-initial-rendering */ export default function Suspense({ children, fallback }) { - const [init, setInit] = useState(false); - useEffect(() => { - setInit(true); - }, []); - - if (!init) return fallback; - return {children}; + return ( + + {children} + + ); } diff --git a/src/interactions/fastCharge/batteryStyle.module.css b/src/interactions/fastCharge/batteryStyle.module.css index 2417d3e9..da40b385 100644 --- a/src/interactions/fastCharge/batteryStyle.module.css +++ b/src/interactions/fastCharge/batteryStyle.module.css @@ -1,15 +1,3 @@ -.hull { - --bar-scale: var(--progress, 1); -} - -/* -768px 미만 : 48px ~ 256px -768px 이상 : 66px ~ 352px (명세에 나온 80px ~ 410px과 실제 산출된 디자인의 width가 다름) - -8px ~ 216px - -*/ - .left { width: 1.5rem; transition: background-color 0.3s; diff --git a/src/interactions/univasalIsland/Phone.jsx b/src/interactions/univasalIsland/Phone.jsx index 39e13572..996820dc 100644 --- a/src/interactions/univasalIsland/Phone.jsx +++ b/src/interactions/univasalIsland/Phone.jsx @@ -1,10 +1,7 @@ +import style from "./style.module.css"; + function Phone({ dynamicStyle, onPointerDown, isSnapped }) { - const staticStyle = `absolute flex justify-center items-center - left-[541px] top-[293px] w-[54px] h-[97px] - lg:left-[528px] lg:top-[185px] lg:w-[66px] lg:h-[118px] - xl:left-[516px] xl:top-[75px] xl:w-[77px] xl:h-[140px] - touch-none - `; + const staticStyle = `absolute flex justify-center items-center ${style.phone} cursor-pointer touch-none`; const phoneScreenFill = isSnapped ? "fill-green-700" : "fill-neutral-900"; const lightningOpacity = isSnapped ? "opacity-100" : "opacity-0"; diff --git a/src/interactions/univasalIsland/index.jsx b/src/interactions/univasalIsland/index.jsx index 03ceaa77..b637c0ed 100644 --- a/src/interactions/univasalIsland/index.jsx +++ b/src/interactions/univasalIsland/index.jsx @@ -2,6 +2,7 @@ import { useImperativeHandle } from "react"; import InteractionDescription from "../InteractionDescription.jsx"; import Phone from "./Phone.jsx"; import useIslandDrag from "./useIslandDrag.js"; +import style from "./style.module.css"; import seat from "./assets/seat.png"; import univasalIsland1x from "./assets/univasalIsland@1x.png"; @@ -21,26 +22,9 @@ function UnivasalIslandInteraction({ interactCallback, $ref }) { useImperativeHandle($ref, () => ({ reset }), [reset]); - const seatHullStyle = `absolute w-[1200px] h-[800px] - bottom-[min(calc(100%-800px),-140px)] - lg:bottom-[min(calc(100%-900px),-170px)] - xl:bottom-[min(calc(100%-1000px),-200px)] - flex justify-center items-end select-none`; - - const seatStyle = `w-[317.44px] h-[501.88px] - lg:w-[385.46px] lg:h-[610.64px] - xl:w-[453.48px] xl:h-[718.4px]`; - - const univasalIslandStaticStyle = `w-[158.2px] h-[546px] - lg:w-[192.1px] lg:h-[663px] - xl:w-[226px] xl:h-[780px] - flex flex-col gap-2 cursor-pointer touch-none`; - - const snapAreaStyle = `absolute scale-50 - left-[21px] top-[40px] w-[54px] h-[97px] - lg:left-[25px] lg:top-[49px] lg:w-[66px] lg:h-[118px] - xl:left-[30px] xl:top-[56px] xl:w-[77px] xl:h-[140px] - `; + const seatHullStyle = `absolute w-[1200px] h-[800px] ${style.hull} flex justify-center items-end select-none`; + const univasalIslandStaticStyle = `${style.island} flex flex-col gap-2 cursor-pointer touch-none`; + const snapAreaStyle = `absolute scale-50 ${style.snap}`; return (
@@ -52,7 +36,7 @@ function UnivasalIslandInteraction({ interactCallback, $ref }) { />
left seat
right seat ({ reset }), []); + + const isCorrect = checkPuzzle(piece, answer); + + return ( +
+
+ start position +
+ + + +
+
+ {piece.map((shape, i) => { + const onClick = () => { + setPiece((board) => { + const newBoard = [...board]; + newBoard[i] = board[i].rotated(); + return newBoard; + }); + interactCallback?.(); + }; + const fixRotate = () => { + setPiece((board) => { + const newBoard = [...board]; + newBoard[i] = board[i].fixedRotated(); + return newBoard; + }); + }; + + return ( + + ); + })} +
+
+
+ + + + start position + +
+
+
+ ); +} + +export default Puzzle; diff --git a/src/interactions/v2l/PuzzlePiece.jsx b/src/interactions/v2l/PuzzlePiece.jsx new file mode 100644 index 00000000..d39656fb --- /dev/null +++ b/src/interactions/v2l/PuzzlePiece.jsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { LINEAR } from "./constants.js"; + +function PuzzlePiece({ shape, onClick, fixRotate }) { + const [fixing, setFixing] = useState(false); + const style = { + transform: `rotate( ${shape.rotate * 90}deg)`, + }; + + return ( +
{ + onClick(); + setFixing(false); + }} + onTransitionEnd={() => { + if (shape.rotate < 4) return; + setFixing(true); + fixRotate(); + }} + > + + + +
+ ); +} + +export default PuzzlePiece; diff --git a/src/interactions/v2l/assets/car@1x.png b/src/interactions/v2l/assets/car@1x.png new file mode 100644 index 00000000..a77bd9d6 Binary files /dev/null and b/src/interactions/v2l/assets/car@1x.png differ diff --git a/src/interactions/v2l/assets/car@2x.png b/src/interactions/v2l/assets/car@2x.png new file mode 100644 index 00000000..4c5e1cbe Binary files /dev/null and b/src/interactions/v2l/assets/car@2x.png differ diff --git a/src/interactions/v2l/assets/pan.svg b/src/interactions/v2l/assets/pan.svg new file mode 100644 index 00000000..11e59e12 --- /dev/null +++ b/src/interactions/v2l/assets/pan.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interactions/v2l/assets/panContainer@1x.png b/src/interactions/v2l/assets/panContainer@1x.png new file mode 100644 index 00000000..03161d31 Binary files /dev/null and b/src/interactions/v2l/assets/panContainer@1x.png differ diff --git a/src/interactions/v2l/assets/panContainer@2x.png b/src/interactions/v2l/assets/panContainer@2x.png new file mode 100644 index 00000000..37c0d434 Binary files /dev/null and b/src/interactions/v2l/assets/panContainer@2x.png differ diff --git a/src/interactions/v2l/constants.js b/src/interactions/v2l/constants.js new file mode 100644 index 00000000..cd3e19d6 --- /dev/null +++ b/src/interactions/v2l/constants.js @@ -0,0 +1,3 @@ +export const LINEAR = Symbol("linear"); +export const CURVED = Symbol("curved"); +export const ANY = Symbol("any"); diff --git a/src/interactions/v2l/generateRandom.js b/src/interactions/v2l/generateRandom.js new file mode 100644 index 00000000..ee81fa60 --- /dev/null +++ b/src/interactions/v2l/generateRandom.js @@ -0,0 +1,124 @@ +import { generatePiece, generateAnswer } from "./utils.js"; + +const dir = [ + [1, 0], + [0, 1], + [-1, 0], + [0, -1], +]; + +function randInt(min, max) { + return Math.floor(Math.random() * (max - min)) + min; +} + +function generateRandomPath(width, height) { + let traced = Array.from({ length: width }, () => + new Array(height).fill(false), + ); + const stack = []; + let cursor = [0, 0]; + + function getNextCursorHubo([x, y]) { + return dir + .map(([dx, dy]) => [x + dx, y + dy]) + .filter(([tx, ty]) => { + if (tx < 0 || ty < 0) return false; + if (tx >= width || ty >= height) return false; + if (traced[tx][ty]) return false; + return true; + }); + } + + while (cursor[0] !== width - 1 || cursor[1] !== height - 1) { + traced[cursor[0]][cursor[1]] = true; + let hubo = getNextCursorHubo(cursor); + // backtracking + if (hubo.length === 0) { + while (stack.length > 0) { + let backtrackedCursor = stack[stack.length - 1]; + hubo = getNextCursorHubo(backtrackedCursor); + if (hubo.length !== 0) break; + stack.pop(); + } + } else stack.push(cursor); + cursor = hubo[randInt(0, hubo.length)]; + } + stack.push(cursor); + + return stack; +} + +function getDirection(base, target) { + let dx = target[0] - base[0]; + let dy = target[1] - base[1]; + + if (dx === 0 && dy === 1) return 0b0001; // down + if (dx === 1 && dy === 0) return 0b0010; // right + if (dx === -1 && dy === 0) return 0b0100; // left + if (dx === 0 && dy === -1) return 0b1000; // up + return 0b0000; +} + +// ─│┌┐┘└ +function getShapeChar(before, after) { + const code = before | after; + switch (code) { + case 0b1100: + return "┘"; + case 0b1010: + return "└"; + case 0b1001: + return "│"; + case 0b0110: + return "─"; + case 0b0101: + return "┐"; + case 0b0011: + return "┌"; + default: + return "."; + } +} + +function generateRandomPuzzle() { + const WIDTH = 3; + const HEIGHT = 3; + const path = generateRandomPath(WIDTH, HEIGHT); + + // path에 대한 길 shape를 생성 + const shapes = [getShapeChar(0b0100, getDirection(path[0], path[1]))]; + for (let i = 1; i < path.length - 1; i++) { + const before = getDirection(path[i], path[i - 1]); + const after = getDirection(path[i], path[i + 1]); + shapes.push(getShapeChar(before, after)); + } + shapes.push( + getShapeChar( + getDirection(path[path.length - 1], path[path.length - 2]), + 0b0010, + ), + ); + + // shape 리스트를 3x3 그리드에 맞도록 재배열 + + const shapeBoard = new Array(WIDTH * HEIGHT).fill("."); + path.forEach(([x, y], i) => { + shapeBoard[y * HEIGHT + x] = shapes[i]; + }); + + const answer = generateAnswer(shapeBoard.join("")); + const board = generatePiece( + shapeBoard + .map((c) => { + if (c !== ".") return c; + if (Math.random() > 0.5) return "─"; + return "┘"; + }) + .join(""), + ); + board.forEach((piece) => (piece.rotate = randInt(0, 4))); + + return [answer, board]; +} + +export default generateRandomPuzzle; diff --git a/src/interactions/v2l/index.jsx b/src/interactions/v2l/index.jsx new file mode 100644 index 00000000..e7fce19b --- /dev/null +++ b/src/interactions/v2l/index.jsx @@ -0,0 +1,25 @@ +import ClientOnly from "@/common/ClientOnly.jsx"; +import InteractionDescription from "../InteractionDescription.jsx"; +import Puzzle from "./Puzzle.jsx"; +import style from "./style.module.css"; +//import PuzzleSkeleton from "./PuzzleSkeleton.jsx"; + +function V2LInteraction({ interactCallback, $ref }) { + return ( +
+ +
+ 스켈레톤 그릴 예정
}> + + + +
+ ); +} + +export default V2LInteraction; diff --git a/src/interactions/v2l/style.module.css b/src/interactions/v2l/style.module.css new file mode 100644 index 00000000..331e5752 --- /dev/null +++ b/src/interactions/v2l/style.module.css @@ -0,0 +1,32 @@ +.container { + top: max(var(--top-area, 11rem), calc(60% - 11.5rem)); + transform: scale(0.7); + --top-area: 11rem; +} + +.rotate { + animation: spin 1s linear infinite; +} + +@media (min-width: 768px) { + .container { + --top-area: 12rem; + transform: scale(0.9); + } +} + +@media (min-width: 1024px) { + .container { + --top-area: 15rem; + transform: scale(1); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/interactions/v2l/utils.js b/src/interactions/v2l/utils.js new file mode 100644 index 00000000..2f00b664 --- /dev/null +++ b/src/interactions/v2l/utils.js @@ -0,0 +1,81 @@ +import { LINEAR, CURVED, ANY } from "./constants.js"; + +// ─│┌┐┘└ + +class PieceData { + constructor(shapeChar) { + switch (shapeChar) { + case "─": + this.type = LINEAR; + this.rotate = 0; + break; + case "│": + this.type = LINEAR; + this.rotate = 1; + break; + case "┌": + this.type = CURVED; + this.rotate = 0; + break; + case "┐": + this.type = CURVED; + this.rotate = 1; + break; + case "┘": + this.type = CURVED; + this.rotate = 2; + break; + case "└": + this.type = CURVED; + this.rotate = 3; + break; + } + this.symbol = shapeChar; + } + rotated() { + const newPiece = new PieceData(this.symbol); + newPiece.rotate = this.rotate + 1; + return newPiece; + } + isCorrect(answer) { + if (answer === ANY) return true; + if (this.type === LINEAR) return this.rotate % 2 === answer; + return this.rotate % 4 === answer; + } + fixedRotated() { + const newPiece = new PieceData(this.symbol); + newPiece.rotate = this.rotate % 4; + return newPiece; + } +} + +export function generatePiece(shapeString) { + const rawString = [...shapeString.replace(/\s+/gm, "")]; + return rawString.map((c) => new PieceData(c)); +} + +export function generateAnswer(shapeString) { + const rawString = [...shapeString.replace(/\s+/gm, "")]; + return rawString.map((c) => { + switch (c) { + case "─": + return 0; + case "│": + return 1; + case "┌": + return 0; + case "┐": + return 1; + case "┘": + return 2; + case "└": + return 3; + default: + return ANY; + } + }); +} + +export function checkPuzzle(pieces, answer) { + return pieces.every((piece, i) => piece.isCorrect(answer[i])); +}