diff --git a/service/app/src/app/create/components/questionList.tsx b/service/app/src/app/create/components/questionList.tsx index 3abbf1ae..527d6b79 100644 --- a/service/app/src/app/create/components/questionList.tsx +++ b/service/app/src/app/create/components/questionList.tsx @@ -1,12 +1,10 @@ "use client" -import { PrimaryBoxButton } from "@ject-5-fe/design/components/button" -import { StickyActionBar } from "@ject-5-fe/design/components/stickyActionBar" -import { Add } from "@ject-5-fe/design/icons" import { useRef } from "react" import { useShallow } from "zustand/react/shallow" import { useListboxNavigation } from "@/shared/lib/useListBoxNavigation" +import { FloatingAddButton } from "@/shared/ui/floatingAddButton" import { useCreateGameStore } from "../store/useCreateGameStore" import { Question } from "./question" @@ -35,10 +33,10 @@ export function QuestionList() { ) return ( -
+
- - - - - +
) } diff --git a/service/app/src/app/game/[gameId]/layout.tsx b/service/app/src/app/game/[gameId]/layout.tsx index fe610461..2e651c5d 100644 --- a/service/app/src/app/game/[gameId]/layout.tsx +++ b/service/app/src/app/game/[gameId]/layout.tsx @@ -34,7 +34,7 @@ export default async function GameLayout({ return ( -
+
{children}
diff --git a/service/app/src/app/game/[gameId]/play/__tests__/game-play-e2e-scenarios.md b/service/app/src/app/game/[gameId]/play/__tests__/game-play-e2e-scenarios.md index 848bdc76..038aa85a 100644 --- a/service/app/src/app/game/[gameId]/play/__tests__/game-play-e2e-scenarios.md +++ b/service/app/src/app/game/[gameId]/play/__tests__/game-play-e2e-scenarios.md @@ -4,6 +4,7 @@ - **URL**: `/game/[gameId]/play` - **핵심 기능**: 게임 진행, 문제 네비게이션, 점수 관리, 정답 확인 +- **조작 방식**: 버튼 클릭 또는 키보드 (Enter, →, ←) ## 테스트 시나리오 @@ -11,12 +12,11 @@ - **홈 로고**: 클릭 시 나가기 다이얼로그 표시 → "네" 클릭 시 홈 이동 - **프로그레스바**: 현재 라운드/전체 라운드 비율로 진행률 표시 -- **이전 문제 버튼**: 첫 번째 문제에서는 숨김 처리 -- **다음 문제 버튼**: 항상 표시 - **게임 종료 버튼**: Cross 아이콘, 클릭 시 나가기 다이얼로그 - **문제 텍스트**: 현재 라운드의 질문 표시 - **문제 이미지**: 이미지가 있는 경우 표시, 없는 경우 숨김 -- **정답 보기 버튼**: 기본 상태에서 표시 +- **"정답은?" 버튼**: 정답 숨김 상태에서 표시 +- **"다음" 버튼**: 정답 공개 상태에서 표시 ### 2. 점수판 기능 @@ -33,33 +33,37 @@ - **점수 범위 제한**: -100점 ~ +100점 범위 내에서만 조정 가능 - **점수 표시**: "X점" 형태로 표시 -### 3. 문제 네비게이션 +### 3. 정답 공개 및 문제 이동 -#### 3.1 이전 문제 +#### 3.1 버튼 클릭 조작 -- **첫 번째 문제**: "이전 문제" 버튼 숨김 -- **두 번째 문제 이상**: "이전 문제" 버튼 표시 -- **이전 문제 이동**: 클릭 시 이전 라운드로 이동, 프로그레스바 감소 -- **정답 상태 초기화**: 이전 문제 이동 시 정답 숨김 상태로 리셋 +- **"정답은?" 버튼 클릭**: 정답 공개, URL에 `answer=true` 추가, "다음" 버튼 표시 +- **"다음" 버튼 클릭**: 다음 문제로 이동, 정답 숨김 상태로 시작 -#### 3.2 다음 문제 +#### 3.2 키보드 조작 -- **중간 문제**: "다음 문제" 버튼 클릭 시 다음 라운드로 이동 -- **마지막 문제**: "다음 문제" 버튼 클릭 시 결과 페이지(`/game/[gameId]/result`)로 이동 -- **정답 상태 초기화**: 다음 문제 이동 시 정답 숨김 상태로 리셋 +| 키 | 정답 숨김 상태 | 정답 공개 상태 | +|---|---|---| +| Enter | 정답 공개 | 다음 문제 (정답 숨김) | +| → (ArrowRight) | 정답 공개 | 다음 문제 (정답 숨김) | +| ← (ArrowLeft) | 이전 문제 (정답 숨김) | 이전 문제 (정답 숨김) | -### 4. 정답 확인 기능 +- **1번 문제에서 ← 키**: 무반응 (이전 문제 없음) +- **마지막 문제 정답 공개 후 Enter/→**: 결과 페이지(`/game/[gameId]/result`)로 이동 -#### 4.1 정답 보기 +### 4. 문제 네비게이션 -- **정답 보기 버튼**: 클릭 시 정답 텍스트 표시 -- **정답 숨기기 버튼**: 정답이 표시된 상태에서 클릭 시 정답 숨김 -- **정답 텍스트**: `currentQuestion.questionAnswer` 표시 +#### 4.1 이전 문제 -#### 4.2 정답 상태 유지 +- **1번 문제**: ← 키 입력 시 무반응 +- **2번 문제 이상**: ← 키 입력 시 이전 라운드로 이동 +- **정답 상태 초기화**: 이전 문제 이동 시 정답 숨김 상태로 시작 -- **라운드 변경 시**: 이전/다음 문제 이동 시 정답 상태 초기화 -- **페이지 새로고침**: 정답 상태 유지 (Zustand persist) +#### 4.2 다음 문제 + +- **중간 문제**: Enter/→ 키 또는 "다음" 버튼으로 다음 라운드 이동 +- **마지막 문제**: 결과 페이지로 이동 +- **정답 상태 초기화**: 다음 문제 이동 시 정답 숨김 상태로 시작 ### 5. 나가기 기능 @@ -77,15 +81,15 @@ ### 6. 프로그레스바 동작 - **진행률 계산**: `(currentRound / totalRounds) * 100` -- **첫 번째 문제**: 0% 또는 최소값 -- **마지막 문제**: 100% 또는 최대값 -- **중간 문제**: 해당 라운드에 맞는 진행률 표시 +- **다음 문제 이동**: 진행률 증가 +- **이전 문제 이동**: 진행률 감소 ### 7. URL 파라미터 동작 -- **라운드 변경 시**: `?q=${newRound}` 형태로 URL 업데이트 -- **이전 문제**: `router.replace()` 사용하여 히스토리 스택에 추가하지 않음 -- **다음 문제**: `router.replace()` 사용하여 히스토리 스택에 추가하지 않음 +- **라운드 변경**: `?q=${round}` 형태로 URL 업데이트 +- **정답 공개**: `?q=${round}&answer=true` 형태로 URL 업데이트 +- **정답 숨김**: `answer` 파라미터 제거 +- **초기 상태**: query parameter 없음 (1번 문제, 정답 숨김) ### 8. 게임 상태 관리 @@ -94,15 +98,41 @@ - **게임 상세 정보**: 문제 목록과 질문/답변 데이터 관리 - **상태 지속성**: 페이지 새로고침 시에도 상태 유지 (팀 구성은 reload 후에도 유지, 점수는 이탈/종료 시 초기화됨) +## 구현된 테스트 목록 + +### 게임 진행 - 기본 UI +- [x] 1번 문제: '정답은?' 버튼이 표시되어야 한다 +- [x] 홈 로고 클릭 시 나가기 다이얼로그가 뜨고, '아니요' 클릭 시 페이지에 머문다 +- [x] 게임 종료 아이콘 클릭 시 나가기 다이얼로그가 뜬다 +- [x] 점수판을 접고 다시 펼칠 수 있어야 한다 +- [x] 팀 점수를 클릭 당 +1, -1씩 조정할 수 있다 + +### 게임 진행 - 버튼 클릭 조작 +- [x] '정답은?' 버튼 클릭 시 정답이 공개되고 '다음' 버튼이 표시된다 +- [x] 정답 공개 후 '다음' 버튼 클릭 시 다음 문제로 이동한다 + +### 게임 진행 - 키보드 조작 +- [x] Enter 키로 정답을 공개할 수 있다 +- [x] → 키로 정답을 공개할 수 있다 +- [x] 정답 공개 후 Enter 키로 다음 문제로 이동할 수 있다 +- [x] 정답 공개 후 → 키로 다음 문제로 이동할 수 있다 +- [x] ← 키로 이전 문제로 이동할 수 있다 (2번 문제에서) +- [x] 1번 문제에서 ← 키는 무반응이다 +- [x] 이전 문제로 돌아가면 정답 숨김 상태로 시작한다 + +### 게임 진행 - 문제 이동 및 진행률 +- [x] 다음 문제로 이동하면 진행률이 증가한다 +- [x] 이전 문제로 이동하면 진행률이 감소한다 + ## 우선순위 -- **Critical**: 문제 네비게이션, 정답 확인, 나가기 기능 +- **Critical**: 정답 공개/숨김, 문제 네비게이션, 키보드 조작, 나가기 기능 - **High**: 점수 관리, 프로그레스바, 기본 UI 렌더링 - **Medium**: 점수판 펼침/접음, URL 파라미터 ## 테스트 데이터 요구사항 -- **최소 2개 이상의 문제가 있는 게임** +- **최소 3개 이상의 문제가 있는 게임** (진행률 테스트를 위해) - **이미지가 있는 문제와 없는 문제 혼재** - **기본 A팀, B팀 설정** - **다양한 점수 범위 테스트 (-100 ~ +100)** diff --git a/service/app/src/app/game/[gameId]/play/__tests__/game-play.spec.ts b/service/app/src/app/game/[gameId]/play/__tests__/game-play.spec.ts index 9ce75d04..eb22f1be 100644 --- a/service/app/src/app/game/[gameId]/play/__tests__/game-play.spec.ts +++ b/service/app/src/app/game/[gameId]/play/__tests__/game-play.spec.ts @@ -3,40 +3,28 @@ import { expect, test } from "@playwright/test" import { GameSetupPOM } from "../../setup/__tests__/gameSetupPOM" import { GamePlayPOM } from "./gamePlayPOM" -// E2E: 이미지 맞추기 게임 - 두 번째 문제 플로우 (503/504/505) +// E2E: 게임 플레이 페이지 테스트 // guides/playwright-convention.mdc 및 test-convention.mdc 준수 -test.describe("게임 진행 - 두 번째 문제 플로우", () => { +test.describe("게임 진행 - 기본 UI", () => { let pageObj: GamePlayPOM let setupPage: GameSetupPOM test.beforeEach(async ({ page }) => { - // Given: /setup에서 게임 시작을 통해 playing 상태로 전환 setupPage = new GameSetupPOM(page) await setupPage.goto("1") await setupPage.startGame() - - // When: /play로 이동 (초기 1번 문제) pageObj = new GamePlayPOM(page) }) - test("1번 문제: 이전 버튼은 비활성화되고 다음 버튼은 활성화되어야 한다", async () => { - await expect(pageObj.prevQuestionButton).toBeDisabled() - await expect(pageObj.nextQuestionButton).toBeEnabled() + test("1번 문제: '정답은?' 버튼이 표시되어야 한다", async () => { + await expect(pageObj.showAnswerButton).toBeVisible() + await expect(pageObj.nextQuestionButton).not.toBeVisible() const progress = await pageObj.getProgressValue() expect(progress).not.toBeNull() }) - test("2번 문제: 이전/다음 버튼이 모두 활성화되어야 한다", async ({ - page, - }) => { - await pageObj.goToNextQuestion() - await expect(page).toHaveURL(/\?q=2/) - await expect(pageObj.prevQuestionButton).toBeEnabled() - await expect(pageObj.nextQuestionButton).toBeEnabled() - }) - test("홈 로고 클릭 시 나가기 다이얼로그가 뜨고, '아니요' 클릭 시 페이지에 머문다", async ({ page, }) => { @@ -52,8 +40,8 @@ test.describe("게임 진행 - 두 번째 문제 플로우", () => { }) test("점수판을 접고 다시 펼칠 수 있어야 한다", async () => { - await pageObj.toggleScoreboard() // 접기 - await pageObj.toggleScoreboard() // 펼치기 + await pageObj.toggleScoreboard() + await pageObj.toggleScoreboard() }) test("팀 점수를 클릭 당 +1, -1씩 조정할 수 있다", async () => { @@ -64,37 +52,146 @@ test.describe("게임 진행 - 두 번째 문제 플로우", () => { await pageObj.decreaseScore(teamName, 1) await pageObj.expectTeamScore(teamName, "0점") }) +}) + +test.describe("게임 진행 - 버튼 클릭 조작", () => { + let pageObj: GamePlayPOM + let setupPage: GameSetupPOM + + test.beforeEach(async ({ page }) => { + setupPage = new GameSetupPOM(page) + await setupPage.goto("1") + await setupPage.startGame() + pageObj = new GamePlayPOM(page) + }) - test("'이전 문제' 클릭 시 이전 라운드로 이동하고 진행률이 감소한다", async ({ + test("'정답은?' 버튼 클릭 시 정답이 공개되고 '다음' 버튼이 표시된다", async ({ page, }) => { - // 1번 → 2번 문제로 이동하고 URL 변경 대기 - await pageObj.goToNextQuestion() - await page.waitForURL(/\?q=2/, { timeout: 1000 }) + await pageObj.clickShowAnswer() + await expect(page).toHaveURL(/answer=true/) + await expect(pageObj.nextQuestionButton).toBeVisible() + await expect(pageObj.showAnswerButton).not.toBeVisible() + }) - // 2번 → 3번 문제로 이동하고 URL 변경 대기 - await pageObj.goToNextQuestion() - await page.waitForURL(/\?q=3/, { timeout: 1000 }) + test("정답 공개 후 '다음' 버튼 클릭 시 다음 문제로 이동한다", async ({ + page, + }) => { + await pageObj.clickShowAnswer() + await pageObj.clickNext() + await expect(page).toHaveURL(/\?q=2/) + await expect(pageObj.showAnswerButton).toBeVisible() + }) +}) - // 진행률 측정 후 이전 문제로 이동 - const before = await pageObj.getProgressValue() - await pageObj.goToPrevQuestion() - await page.waitForURL(/\?q=2/, { timeout: 1000 }) +test.describe("게임 진행 - 키보드 조작", () => { + let pageObj: GamePlayPOM + let setupPage: GameSetupPOM - const after = await pageObj.getProgressValue() - expect(after).toBeLessThanOrEqual(before!) + test.beforeEach(async ({ page }) => { + setupPage = new GameSetupPOM(page) + await setupPage.goto("1") + await setupPage.startGame() + pageObj = new GamePlayPOM(page) + }) + + test("Enter 키로 정답을 공개할 수 있다", async ({ page }) => { + await pageObj.pressEnter() + await expect(page).toHaveURL(/answer=true/) + await expect(pageObj.nextQuestionButton).toBeVisible() + }) + + test("→ 키로 정답을 공개할 수 있다", async ({ page }) => { + await pageObj.pressArrowRight() + await expect(page).toHaveURL(/answer=true/) + await expect(pageObj.nextQuestionButton).toBeVisible() }) - //실제 문제 개수를 알지 못하기 때문에, 정확한 value 측정보다는 비교 연산으로 테스트 진행 - test("'다음 문제' 클릭 시 다음 라운드로 이동하고 진행률이 증가한다 (1→2)", async ({ + test("정답 공개 후 Enter 키로 다음 문제로 이동할 수 있다", async ({ page, }) => { + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressEnter() + await expect(page).toHaveURL(/\?q=2/) + await expect(pageObj.showAnswerButton).toBeVisible() + }) + + test("정답 공개 후 → 키로 다음 문제로 이동할 수 있다", async ({ page }) => { + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressArrowRight() + await expect(page).toHaveURL(/\?q=2/) + await expect(pageObj.showAnswerButton).toBeVisible() + }) + + test("← 키로 이전 문제로 이동할 수 있다 (2번 문제에서)", async ({ page }) => { + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressEnter() + await page.waitForURL(/\?q=2/) + + await pageObj.pressArrowLeft() + await expect(page).toHaveURL(/\?q=1/) + }) + + test("1번 문제에서 ← 키는 무반응이다", async ({ page }) => { + const urlBefore = page.url() + await pageObj.pressArrowLeft() + await expect(page).toHaveURL(urlBefore) + await expect(pageObj.showAnswerButton).toBeVisible() + }) + + test("이전 문제로 돌아가면 정답 숨김 상태로 시작한다", async ({ page }) => { + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressEnter() + await page.waitForURL(/\?q=2/) + + await pageObj.pressArrowLeft() + await expect(page).toHaveURL(/\?q=1/) + await expect(page).not.toHaveURL(/answer=true/) + await expect(pageObj.showAnswerButton).toBeVisible() + }) +}) + +test.describe("게임 진행 - 문제 이동 및 진행률", () => { + let pageObj: GamePlayPOM + let setupPage: GameSetupPOM + + test.beforeEach(async ({ page }) => { + setupPage = new GameSetupPOM(page) + await setupPage.goto("1") + await setupPage.startGame() + pageObj = new GamePlayPOM(page) + }) + + test("다음 문제로 이동하면 진행률이 증가한다", async ({ page }) => { const before = await pageObj.getProgressValue() - await pageObj.goToNextQuestion() + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressEnter() await expect(page).toHaveURL(/\?q=2/) const after = await pageObj.getProgressValue() + expect(after).toBeGreaterThan(before!) + }) - expect(after).toBeGreaterThanOrEqual(before!) + test("이전 문제로 이동하면 진행률이 감소한다", async ({ page }) => { + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressEnter() + await page.waitForURL(/\?q=2/) + await pageObj.pressEnter() + await page.waitForURL(/answer=true/) + await pageObj.pressEnter() + await page.waitForURL(/\?q=3/) + + const before = await pageObj.getProgressValue() + await pageObj.pressArrowLeft() + await page.waitForURL(/\?q=2/) + + const after = await pageObj.getProgressValue() + expect(after).toBeLessThan(before!) }) }) diff --git a/service/app/src/app/game/[gameId]/play/__tests__/gamePlayPOM.ts b/service/app/src/app/game/[gameId]/play/__tests__/gamePlayPOM.ts index d250cf40..8272979f 100644 --- a/service/app/src/app/game/[gameId]/play/__tests__/gamePlayPOM.ts +++ b/service/app/src/app/game/[gameId]/play/__tests__/gamePlayPOM.ts @@ -5,14 +5,16 @@ import { expect, type Locator, type Page } from "@playwright/test" * - 테스트 컨벤션: guides/test-convention.mdc를 따름 * - 가능한 한 접근성 셀렉터(getByRole, getByAltText 등)를 우선 사용 * - 접근성 속성이 없는 요소는 구조 기반 셀렉터로 보완 + * + * 키보드 조작: + * - Enter / ArrowRight: 정답 숨김 → 정답 공개, 정답 공개 → 다음 문제 + * - ArrowLeft: 이전 문제로 이동 (1번 문제에서는 무반응) */ export class GamePlayPOM { readonly page: Page // 헤더 영역 readonly homeLogoImage: Locator - readonly prevQuestionButton: Locator - readonly nextQuestionButton: Locator readonly exitIconButton: Locator readonly progressbar: Locator @@ -22,7 +24,11 @@ export class GamePlayPOM { // 메인 콘텐츠 readonly questionHeading: Locator readonly questionImage: Locator + readonly answerArea: Locator + + // 액션 버튼 ("정답은?" 또는 "다음") readonly showAnswerButton: Locator + readonly nextQuestionButton: Locator // 나가기 다이얼로그 readonly exitDialogConfirmButton: Locator @@ -33,8 +39,6 @@ export class GamePlayPOM { // 헤더 this.homeLogoImage = page.getByAltText("홈 로고") - this.prevQuestionButton = page.getByRole("button", { name: "이전 문제" }) - this.nextQuestionButton = page.getByRole("button", { name: "다음 문제" }) this.exitIconButton = page.getByRole("button", { name: "게임 종료" }) // Progress @@ -48,15 +52,20 @@ export class GamePlayPOM { // 메인 콘텐츠 this.questionHeading = page.getByRole("heading", { level: 1 }) this.questionImage = page.getByAltText("문제 이미지") - this.showAnswerButton = page.getByRole("button", { name: "정답 보기" }) + this.answerArea = page.locator("[data-testid='answer-area']") + + // 액션 버튼 + this.showAnswerButton = page.getByRole("button", { name: "정답은?" }) + this.nextQuestionButton = page.getByRole("button", { name: "다음" }) // 나가기 다이얼로그 버튼 this.exitDialogConfirmButton = page.getByRole("button", { name: "네" }) this.exitDialogCancelButton = page.getByRole("button", { name: "아니요" }) } - async goto(gameId: string = "1", round?: number) { - const search = round ? `?q=${round}` : "" + async goto(gameId: string = "1", round?: number, showAnswer?: boolean) { + let search = round ? `?q=${round}` : "" + if (showAnswer) search += "&answer=true" await this.page.goto(`game/${gameId}/play${search}`) } @@ -64,14 +73,6 @@ export class GamePlayPOM { await this.homeLogoImage.click() } - async goToPrevQuestion() { - await this.prevQuestionButton.click() - } - - async goToNextQuestion() { - await this.nextQuestionButton.click() - } - async clickExitIcon() { await this.exitIconButton.click() } @@ -85,15 +86,57 @@ export class GamePlayPOM { await this.scoreboardToggleButton.click() } + // 정답 공개 버튼 클릭 + async clickShowAnswer() { + await this.showAnswerButton.click() + } + + // 다음 버튼 클릭 (정답 공개 상태에서) + async clickNext() { + await this.nextQuestionButton.click() + } + + // 키보드 조작: Enter 키 + async pressEnter() { + await this.page.locator("body").press("Enter") + } + + // 키보드 조작: → 키 + async pressArrowRight() { + await this.page.locator("body").press("ArrowRight") + } + + // 키보드 조작: ← 키 + async pressArrowLeft() { + await this.page.locator("body").press("ArrowLeft") + } + + // 정답 공개 (버튼 또는 키보드) + async showAnswer() { + await this.pressEnter() + } + + // 다음 문제로 이동 (정답 공개 상태에서) + async goToNextQuestion() { + await expect(this.nextQuestionButton).toBeVisible() + await this.pressEnter() + } + + // 이전 문제로 이동 (키보드) + async goToPrevQuestion() { + await this.pressArrowLeft() + } + + // 현재 정답 공개 상태인지 확인 + async isAnswerVisible() { + return await this.nextQuestionButton.isVisible() + } + // 팀 관련 접근성 셀렉터 기반 teamCard(teamName: string) { return this.page.getByRole("group", { name: `${teamName} 점수 카드` }) } - teamScoreLabel(teamName: string) { - return this.page.getByLabel(`${teamName} 현재 점수`) - } - teamDecreaseButton(teamName: string) { return this.page.getByRole("button", { name: `${teamName} 점수 감소` }) } @@ -117,7 +160,8 @@ export class GamePlayPOM { } async expectTeamScore(teamName: string, expectedText: string) { - await expect(this.teamScoreLabel(teamName)).toHaveText(expectedText) + const scoreButton = this.teamIncreaseButton(teamName) + await expect(scoreButton).toContainText(expectedText) } async getQuestionText() { @@ -128,26 +172,6 @@ export class GamePlayPOM { return await this.questionImage.isVisible() } - async showAnswerAndGetText() { - await this.showAnswerButton.click() - const answerButton = this.page - .getByRole("button") - .filter({ hasNotText: "정답 보기" }) - .first() - await expect(this.showAnswerButton).not.toBeVisible() - const answerText = (await answerButton.textContent())?.trim() ?? "" - return answerText - } - - async hideAnswer() { - const answerButton = this.page - .getByRole("button") - .filter({ hasNotText: "정답 보기" }) - .first() - await answerButton.click() - await expect(this.showAnswerButton).toBeVisible() - } - async confirmExit() { await this.exitDialogConfirmButton.click() } diff --git a/service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx b/service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx index 656a15ca..6b3d324c 100644 --- a/service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx +++ b/service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx @@ -1,15 +1,13 @@ +import { PrimaryBoxButton } from "@ject-5-fe/design/components/button" import Image from "next/image" -import Link from "next/link" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import type { GameQuestion } from "@/entities/game/model" - -import { QuestionNavigationButton } from "./questionNavigationButton" - interface GamePlayContentProps { currentQuestion: GameQuestion currentRound: number - totalRounds: number + showAnswer: boolean + onShowAnswer: () => void onPrevQuestion: () => void onNextQuestion: () => void } @@ -17,73 +15,98 @@ interface GamePlayContentProps { export const GamePlayContent = ({ currentQuestion, currentRound, - totalRounds, + showAnswer, + onShowAnswer, onPrevQuestion, onNextQuestion, }: GamePlayContentProps) => { - const [showAnswer, setShowAnswer] = useState(false) + const [imageWidths, setImageWidths] = useState>({}) + const currentImageWidth = imageWidths[currentRound] ?? null - useEffect(() => { - setShowAnswer(false) - }, [currentRound]) + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const isDialogOpen = document.querySelector("[role='dialog']") !== null + if (isDialogOpen) return - return ( -
-

- {currentQuestion.questionText} -

+ if (e.key === "Enter" || e.key === "ArrowRight") { + if (showAnswer) { + onNextQuestion() + } else { + onShowAnswer() + } + } - {currentQuestion?.imageUrl && ( -
- 문제 이미지 -
- )} + if (e.key === "ArrowLeft" && currentRound > 1) { + onPrevQuestion() + } + }, + [showAnswer, currentRound, onNextQuestion, onPrevQuestion, onShowAnswer], + ) -
- + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [handleKeyDown]) -
- {showAnswer ? ( -
setShowAnswer(false)} - className="typography-heading-3xl-semibold inline-flex w-[572px] cursor-pointer flex-row items-center justify-center gap-12 rounded-12 border-[3px] border-border-interactive-secondary bg-background-interactive-inverse px-32 py-12 text-text-interactive-secondary hover:bg-background-interactive-secondary-hovered active:border-none active:bg-background-interactive-secondary-pressed" - > - {currentQuestion?.questionAnswer} -
- ) : ( -
setShowAnswer(true)} - className="typography-heading-3xl-semibold inline-flex w-[572px] cursor-pointer flex-row items-center justify-center gap-8 rounded-12 border-[3px] border-transparent bg-background-interactive-primary px-32 py-12 text-text-interactive-inverse hover:bg-background-interactive-primary-hovered active:border-transparent active:bg-background-interactive-primary-pressed" - > - 정답 보기 -
- )} + const hasImage = !!currentQuestion?.imageUrl + + return ( +
+
+ {hasImage && ( +

+ {currentQuestion.questionText} +

+ )} - {currentRound === totalRounds && ( - - 결과 화면으로 이동하기 - +
+ {hasImage ? ( + showAnswer ? ( +
+ {currentQuestion?.questionAnswer} +
+ ) : ( + 문제 이미지 { + const img = e.currentTarget + setImageWidths((prev) => ({ + ...prev, + [currentRound]: img.offsetWidth, + })) + }} + /> + ) + ) : ( + + {showAnswer + ? currentQuestion?.questionAnswer + : currentQuestion.questionText} + )}
+
- = totalRounds} - /> +
+ (showAnswer ? onNextQuestion() : onShowAnswer())} + > + {showAnswer ? "다음" : "정답은?"} +
) diff --git a/service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx b/service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx index 35ed7d9f..2a14c9b7 100644 --- a/service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx +++ b/service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx @@ -1,9 +1,9 @@ import { SecondaryPlainIconButton } from "@ject-5-fe/design/components/button" import { Progress } from "@ject-5-fe/design/components/progress" import { Cross } from "@ject-5-fe/design/icons" -import Image from "next/image" import { ThemeToggle } from "@/shared/themeToggleButton" +import { HomeButton } from "@/widgets/components/homeButton" interface GamePlayHeaderProps { currentRound: number @@ -17,20 +17,9 @@ export const GamePlayHeader = ({ onExit, }: GamePlayHeaderProps) => { return ( -
+
- +
diff --git a/service/app/src/app/game/[gameId]/play/components/scoreboardSidebar.tsx b/service/app/src/app/game/[gameId]/play/components/scoreboardSidebar.tsx index 008138a4..031dd8ab 100644 --- a/service/app/src/app/game/[gameId]/play/components/scoreboardSidebar.tsx +++ b/service/app/src/app/game/[gameId]/play/components/scoreboardSidebar.tsx @@ -1,6 +1,7 @@ import { PrimarySolidIconButton } from "@ject-5-fe/design/components/button" import { PlayerStatus } from "@ject-5-fe/design/components/playerStatus" import { Hidden, Show } from "@ject-5-fe/design/icons" +import { cn } from "@ject-5-fe/design/utils/cn" import { useState } from "react" import type { Team } from "../../store/useGameStore" @@ -21,21 +22,24 @@ export const ScoreboardSidebar = ({ const clampScore = (value: number) => Math.max(-100, Math.min(100, value)) return ( -
-
- - 점수판 - + ) } diff --git a/service/app/src/app/game/[gameId]/play/page.tsx b/service/app/src/app/game/[gameId]/play/page.tsx index c3b5d314..07c4825d 100644 --- a/service/app/src/app/game/[gameId]/play/page.tsx +++ b/service/app/src/app/game/[gameId]/play/page.tsx @@ -15,7 +15,7 @@ import { gamePlaySchema } from "./schemas" const ScoreboardGame = () => { const router = useRouter() - const [{ q: currentRound }, setSearchParams] = + const [{ q: currentRound, answer: showAnswer }, setSearchParams] = useTypedSearchParams(gamePlaySchema) const { gameDetail, teams, totalRounds, updateTeamScore, scores } = @@ -42,7 +42,7 @@ const ScoreboardGame = () => { router.push("./result") return } - setSearchParams({ q: (currentRound + 1).toString() }) + setSearchParams({ q: (currentRound + 1).toString(), answer: "" }) } const { goBackToEntry } = useGameEntryNavigation() @@ -60,21 +60,22 @@ const ScoreboardGame = () => { }) } /> -
+
-
-
{ + setSearchParams({ q: currentRound.toString(), answer: "true" }) + }} onPrevQuestion={() => { if (currentRound <= 1) return - setSearchParams({ q: (currentRound - 1).toString() }) + setSearchParams({ q: (currentRound - 1).toString(), answer: "" }) }} onNextQuestion={handleNextQuestion} /> diff --git a/service/app/src/app/game/[gameId]/play/schemas/index.ts b/service/app/src/app/game/[gameId]/play/schemas/index.ts index 1ff12a2e..1f1fe010 100644 --- a/service/app/src/app/game/[gameId]/play/schemas/index.ts +++ b/service/app/src/app/game/[gameId]/play/schemas/index.ts @@ -2,4 +2,8 @@ import { z } from "zod" export const gamePlaySchema = z.object({ q: z.string().default("1").transform(Number), + answer: z + .string() + .optional() + .transform((v) => v === "true"), }) diff --git a/service/app/src/app/game/[gameId]/result/components/gameResultContent.tsx b/service/app/src/app/game/[gameId]/result/components/gameResultContent.tsx index c6bb3a2c..63e9a515 100644 --- a/service/app/src/app/game/[gameId]/result/components/gameResultContent.tsx +++ b/service/app/src/app/game/[gameId]/result/components/gameResultContent.tsx @@ -17,9 +17,9 @@ export const GameResultContent = ({ }, [teams, scores]) return ( -
+
{/* Winner Title */} -
+

🎉 이번 게임의 Winner는?

@@ -30,7 +30,7 @@ export const GameResultContent = ({ {topTeams.map((team) => (
{team.name} diff --git a/service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx b/service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx index 4d78cac0..cfb0451c 100644 --- a/service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx +++ b/service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx @@ -2,6 +2,7 @@ import { PrimaryBoxButton, SecondaryPlainIconButton, } from "@ject-5-fe/design/components/button" +import { Navigation } from "@ject-5-fe/design/components/navigation" import { Cross } from "@ject-5-fe/design/icons" import Image from "next/image" @@ -17,30 +18,20 @@ export const GameResultNavigation = ({ onPreviousQuestion, }: GameResultNavigationProps) => { return ( -
-
- -
- -

- 게임 종료 -

- -
-
+ } + centerContent={ +

+ 게임 종료 +

+ } + rightContent={ + <> - 이전 문제 - -
-
-
+ + } + /> ) } diff --git a/service/app/src/app/game/[gameId]/setup/components/gameNavigation.tsx b/service/app/src/app/game/[gameId]/setup/components/gameNavigation.tsx index 8a7484a6..28117dd2 100644 --- a/service/app/src/app/game/[gameId]/setup/components/gameNavigation.tsx +++ b/service/app/src/app/game/[gameId]/setup/components/gameNavigation.tsx @@ -1,28 +1,19 @@ "use client" + import { SecondaryPlainIconButton } from "@ject-5-fe/design/components/button" import { Navigation } from "@ject-5-fe/design/components/navigation" import { Cross } from "@ject-5-fe/design/icons" -import Image from "next/image" import { useGameEntryNavigation } from "@/entities/game/hooks/useGameEntryNavigation" import { ThemeToggle } from "@/shared/themeToggleButton" +import { HomeButton } from "@/widgets/components/homeButton" export function GameNavigation() { const { goBackToEntry } = useGameEntryNavigation() return ( - 홈 로고 - - } + leftContent={} centerContent={

참가자 설정 diff --git a/service/app/src/app/game/[gameId]/setup/components/teamInputForm.tsx b/service/app/src/app/game/[gameId]/setup/components/teamInputForm.tsx index 70aadee2..163a4be4 100644 --- a/service/app/src/app/game/[gameId]/setup/components/teamInputForm.tsx +++ b/service/app/src/app/game/[gameId]/setup/components/teamInputForm.tsx @@ -54,7 +54,7 @@ export function TeamInputForm() { return (
{ e.preventDefault() if (teams.length >= MAX_TEAMS) return @@ -66,48 +66,50 @@ export function TeamInputForm() { }) }} > - {teams.map((team, index) => { - const name = `team-${team.id}-${index}` - const state = teamErrors[team.id] ? "error" : "default" - return ( - - - { - const value = e.target.value - if (value.length > MAX_TEAM_NAME_LENGTH) return - updateTeamName(team.id, value.trim()) - }} - maxLength={MAX_TEAM_NAME_LENGTH} - /> - { - if (teams.length > MIN_TEAMS) { - removeTeam(team.id) - } - }} - > - - - - {teamErrors[team.id] && ( - {teamErrors[team.id]} - )} - - ) - })} +
+ {teams.map((team, index) => { + const name = `team-${team.id}-${index}` + const state = teamErrors[team.id] ? "error" : "default" + return ( + + + { + const value = e.target.value + if (value.length > MAX_TEAM_NAME_LENGTH) return + updateTeamName(team.id, value.trim()) + }} + maxLength={MAX_TEAM_NAME_LENGTH} + /> + { + if (teams.length > MIN_TEAMS) { + removeTeam(team.id) + } + }} + > + + + + {teamErrors[team.id] && ( + {teamErrors[team.id]} + )} + + ) + })} +
- 게임 시작 + 게임 시작하기
) diff --git a/service/app/src/app/game/[gameId]/setup/components/teamSidebar.tsx b/service/app/src/app/game/[gameId]/setup/components/teamSidebar.tsx index da316a17..ed33feaf 100644 --- a/service/app/src/app/game/[gameId]/setup/components/teamSidebar.tsx +++ b/service/app/src/app/game/[gameId]/setup/components/teamSidebar.tsx @@ -1,11 +1,11 @@ "use client" -import { PrimaryBoxButton } from "@ject-5-fe/design/components/button" import { PlayerStatus } from "@ject-5-fe/design/components/playerStatus" -import { StickyActionBar } from "@ject-5-fe/design/components/stickyActionBar" import { v4 as uuidv4 } from "uuid" import { useShallow } from "zustand/react/shallow" +import { FloatingAddButton } from "@/shared/ui/floatingAddButton" + import { useGameStore } from "../../store/useGameStore" import { generateTeamName } from "../../utils/generateTeamName" import { MAX_TEAMS } from "../../utils/teamValidation" @@ -29,30 +29,23 @@ export function TeamSidebar() { } return ( -