Skip to content

Commit

Permalink
Merge pull request #48 from softeerbootcamp4th/feature/22-v2lInteraction
Browse files Browse the repository at this point in the history
[feat] V2L 인터랙션 구현 ( resolved #22 )
  • Loading branch information
darkdulgi authored Jul 30, 2024
2 parents 8925770 + 73b57bc commit b6c816a
Show file tree
Hide file tree
Showing 18 changed files with 548 additions and 48 deletions.
36 changes: 36 additions & 0 deletions src/common/ClientOnly.jsx
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 7 additions & 8 deletions src/common/Suspense.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Suspense as ReactSuspense, useState, useEffect } from "react";
import { Suspense as ReactSuspense } from "react";
import ClientOnly from "./ClientOnly.jsx";

/**
* react <Suspense />의 래퍼 컴포넌트입니다.
Expand All @@ -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 <ReactSuspense fallback={fallback}>{children}</ReactSuspense>;
return (
<ClientOnly fallback={fallback}>
<ReactSuspense fallback={fallback}>{children}</ReactSuspense>
</ClientOnly>
);
}
12 changes: 0 additions & 12 deletions src/interactions/fastCharge/batteryStyle.module.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
9 changes: 3 additions & 6 deletions src/interactions/univasalIsland/Phone.jsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
28 changes: 6 additions & 22 deletions src/interactions/univasalIsland/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]";
Expand All @@ -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 (
<article className="relative w-full h-full overflow-hidden flex items-center flex-col">
Expand All @@ -52,7 +36,7 @@ function UnivasalIslandInteraction({ interactCallback, $ref }) {
/>
<div className={seatHullStyle}>
<img
className={seatStyle}
className={style.seat}
src={seat}
alt="left seat"
draggable="false"
Expand All @@ -79,7 +63,7 @@ function UnivasalIslandInteraction({ interactCallback, $ref }) {
<div className={snapAreaStyle} ref={phoneSnapArea}></div>
</div>
<img
className={seatStyle}
className={style.seat}
src={seat}
alt="right seat"
draggable="false"
Expand Down
75 changes: 75 additions & 0 deletions src/interactions/univasalIsland/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
.hull {
bottom: min(calc(100% - 800px), -140px);
}
.seat {
width: 317.44px;
height: 501.88px;
}
.island {
width: 158.2px;
height: 546px;
}
.phone {
top: 293px;
left: 541px;
width: 54px;
height: 97px;
}
.snap {
top: 40px;
left: 21px;
width: 54px;
height: 97px;
}

@media (min-width: 1024px) {
.hull {
bottom: min(calc(100% - 900px), -170px);
}
.seat {
width: 385.46px;
height: 610.64px;
}
.island {
width: 192.1px;
height: 663px;
}
.phone {
top: 185px;
left: 528px;
width: 66px;
height: 118px;
}
.snap {
top: 49px;
left: 25px;
width: 66px;
height: 118px;
}
}

@media (min-width: 1280px) {
.hull {
bottom: min(calc(100% - 1000px), -200px);
}
.seat {
width: 453.48px;
height: 718.4px;
}
.island {
width: 226px;
height: 780px;
}
.phone {
top: 75px;
left: 516px;
width: 77px;
height: 140px;
}
.snap {
top: 56px;
left: 30px;
width: 77px;
height: 140px;
}
}
112 changes: 112 additions & 0 deletions src/interactions/v2l/Puzzle.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useState, useEffect, useImperativeHandle } from "react";
import generateRandomPuzzle from "./generateRandom.js";
import { generatePiece, generateAnswer, checkPuzzle } from "./utils.js";
import PuzzlePiece from "./PuzzlePiece.jsx";
import style from "./style.module.css";
import car1x from "./assets/[email protected]";
import car2x from "./assets/[email protected]";
import panContainer1x from "./assets/[email protected]";
import panContainer2x from "./assets/[email protected]";
import pan from "./assets/pan.svg";

// ─│┌┐┘└

function Puzzle({ interactCallback, $ref }) {
const [answer, setAnswer] = useState(
generateAnswer(`
─┐.
.│.
.└─`),
);
const [piece, setPiece] = useState(
generatePiece(`
│┘─
─││
│┘│`),
);

function reset() {
const [randAnswer, randPiece] = generateRandomPuzzle();
setAnswer(randAnswer);
setPiece(randPiece);
}

useEffect(reset, []);
useImperativeHandle($ref, () => ({ reset }), []);

const isCorrect = checkPuzzle(piece, answer);

return (
<div className="relative flex flex-col md:flex-row gap-8 md:gap-0">
<div className="flex items-center h-28 -translate-x-16 md:translate-x-0">
<img
className="object-right w-72 h-28 object-scale-down"
width="279"
height="100"
src={car1x}
srcSet={`${car1x} 1x, ${car2x} 2x`}
alt="start position"
/>
<div className="w-8 h-2 bg-blue-300"></div>
<svg className="block md:hidden stroke-blue-300 w-12 h-28 absolute right-px overflow-visible fill-none">
<path
d="M 0 56 C 50 56 50 120 0 120 H -270 C -290 120 -300 130 -300 150 V 170 C -300 190 -290 200 -270 200 H -255"
strokeWidth="8"
/>
</svg>
</div>
<div className="grid grid-rows-3 grid-cols-3 gap-4 z-10 w-[23rem] flex-shrink-0">
{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 (
<PuzzlePiece
shape={shape}
key={`puzzle-${i}`}
onClick={onClick}
fixRotate={fixRotate}
/>
);
})}
</div>
<div className="flex items-end absolute bottom-0 -right-28 md:relative md:bottom-auto md:right-auto">
<div className="w-28 h-28 flex items-center relative">
<svg className="stroke-blue-300 w-12 h-28 overflow-visible fill-none">
<path
d="M 0 56 H 32 C 44 56 44 76 32 76 H 24 C 12 76 12 96 24 96 H 56"
strokeWidth="8"
/>
</svg>
<img
className="object-left w-16 h-28 object-scale-down"
width="80"
height="130"
src={panContainer1x}
srcSet={`${panContainer1x} 1x, ${panContainer2x} 2x`}
alt="start position"
/>
<img
className={`absolute right-2 top-3 ${isCorrect ? style.rotate : ""}`}
src={pan}
/>
</div>
</div>
</div>
);
}

export default Puzzle;
38 changes: 38 additions & 0 deletions src/interactions/v2l/PuzzlePiece.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={style}
className={`size-28 bg-black rounded-xl border-2 border-white transition-transform ease-out ${fixing ? "duration-0" : "duration-500"}`}
onClick={() => {
onClick();
setFixing(false);
}}
onTransitionEnd={() => {
if (shape.rotate < 4) return;
setFixing(true);
fixRotate();
}}
>
<svg
className="size-full stroke-blue-300 fill-transparent"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={shape.type === LINEAR ? "M 0 54 H 108" : "M108 54 H 54 V 108"}
strokeWidth="8"
/>
</svg>
</div>
);
}

export default PuzzlePiece;
Binary file added src/interactions/v2l/assets/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/interactions/v2l/assets/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/interactions/v2l/assets/pan.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/interactions/v2l/assets/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/interactions/v2l/assets/[email protected]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/interactions/v2l/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const LINEAR = Symbol("linear");
export const CURVED = Symbol("curved");
export const ANY = Symbol("any");
Loading

0 comments on commit b6c816a

Please sign in to comment.