Skip to content

Commit 4bf7871

Browse files
committed
настроена взаиможействие между игроками и ведущим - теперь очки начисляются в минус если ирок ответил неверно и в карточку ответа для ведущего добавлены доп. кнопки. изменен вид карточки игроков и в карточках енота добавлена передача хода игрокам
1 parent 0e38e5c commit 4bf7871

23 files changed

Lines changed: 1012 additions & 206 deletions

artifacts/api-server/src/quiz-nav.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,19 @@ let gameStarted = false;
1414
/** Текущая квиз-доска (0..2) */
1515
let lastBoardIndex: number | null = null;
1616

17-
function lobbyPayload(): { gameStarted: boolean; boardIndex: number } {
17+
/** Ник на местах 1–5 на доске (после «Запуск игры», задаёт ведущий). */
18+
let seatPlayerNicks: string[] = [];
19+
20+
function lobbyPayload(): {
21+
gameStarted: boolean;
22+
boardIndex: number;
23+
seatPlayerNicks: string[];
24+
} {
1825
const boardIndex =
1926
lastBoardIndex !== null && lastBoardIndex >= 0 && lastBoardIndex <= MAX_BOARD
2027
? lastBoardIndex
2128
: 0;
22-
return { gameStarted, boardIndex };
29+
return { gameStarted, boardIndex, seatPlayerNicks: [...seatPlayerNicks] };
2330
}
2431

2532
export function setupQuizNav(io: Server) {
@@ -52,15 +59,24 @@ export function setupQuizNav(io: Server) {
5259
socket.emit("phase", { boardIndex: lastBoardIndex });
5360
}
5461

55-
socket.on("startGame", () => {
62+
socket.on("startGame", (payload: unknown) => {
63+
const po = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
64+
const raw = po["seatPlayerNicks"];
65+
seatPlayerNicks = Array.isArray(raw)
66+
? raw.map((x) => String(x ?? "").trim().slice(0, 64)).filter(Boolean).slice(0, 5)
67+
: [];
68+
5669
gameStarted = true;
5770
if (lastBoardIndex === null || lastBoardIndex < 0 || lastBoardIndex > MAX_BOARD) {
5871
lastBoardIndex = 0;
5972
}
60-
const payload = lobbyPayload();
61-
ns.emit("lobbyState", payload);
62-
ns.emit("phase", { boardIndex: payload.boardIndex });
63-
logger.info({ gameStarted: true, boardIndex: payload.boardIndex }, "Quiz game started");
73+
const out = lobbyPayload();
74+
ns.emit("lobbyState", out);
75+
ns.emit("phase", { boardIndex: out.boardIndex });
76+
logger.info(
77+
{ gameStarted: true, boardIndex: out.boardIndex, seatCount: seatPlayerNicks.length },
78+
"Quiz game started"
79+
);
6480
});
6581

6682
socket.on("hostNavigate", (payload: { boardIndex?: unknown }) => {
@@ -85,6 +101,7 @@ export function setupQuizNav(io: Server) {
85101
socket.on("hostReturnToLogin", () => {
86102
gameStarted = false;
87103
lastBoardIndex = null;
104+
seatPlayerNicks = [];
88105
clearQuizPlayers();
89106
ns.emit("returnToLogin", {});
90107
ns.emit("lobbyState", lobbyPayload());

artifacts/api-server/src/quiz-players-registry.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export type QuizPlayerRow = {
55
lastSeen: number;
66
};
77

8+
export type QuizPlayerRowWithStatus = QuizPlayerRow & {
9+
online: boolean;
10+
};
11+
812
const byNick = new Map<string, QuizPlayerRow>();
913

1014
/** socket.id → присутствие в квизе (только залогиненные клиенты шлют quizPlayerPresence). */
@@ -70,7 +74,6 @@ export function unbindQuizSocketPresence(socketId: string): void {
7074
set.delete(socketId);
7175
if (set.size === 0) {
7276
socketIdsByNick.delete(prev.nick);
73-
byNick.delete(prev.nick);
7477
} else {
7578
reconcileNickRole(prev.nick);
7679
}
@@ -90,6 +93,16 @@ export function listQuizPlayersOnline(now = Date.now()): QuizPlayerRow[] {
9093
.sort((a, b) => b.lastSeen - a.lastSeen);
9194
}
9295

96+
export function listQuizPlayersWithStatus(now = Date.now()): QuizPlayerRowWithStatus[] {
97+
const cutoff = now - QUIZ_PLAYER_PRESENCE_TTL_MS;
98+
return [...byNick.values()]
99+
.map((p) => ({
100+
...p,
101+
online: (socketIdsByNick.get(p.nick)?.size ?? 0) > 0 && p.lastSeen >= cutoff,
102+
}))
103+
.sort((a, b) => a.firstSeen - b.firstSeen);
104+
}
105+
93106
export function removeQuizPlayer(nick: string): void {
94107
const t = normalizeNick(nick);
95108
if (!t) return;

artifacts/api-server/src/visit-track-routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Express } from "express";
2-
import { listQuizPlayersOnline, removeQuizPlayer } from "./quiz-players-registry";
2+
import { listQuizPlayersWithStatus, removeQuizPlayer } from "./quiz-players-registry";
33

44
const visitCounts: Record<string, number> = {};
55

@@ -34,6 +34,6 @@ export function attachVisitTrackRoutes(app: Express): void {
3434
});
3535

3636
app.get("/api/admin/quiz-players", (_req, res) => {
37-
res.json({ players: listQuizPlayersOnline() });
37+
res.json({ players: listQuizPlayersWithStatus() });
3838
});
3939
}

artifacts/game-client/src/apps/adepts-game-2/hooks/useGameState.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useCallback, useRef } from "react";
22
import { io, Socket } from "socket.io-client";
33
import type { Player, Question } from "@/lib/adepts-quiz-types";
4+
import { mergeSeatRosterIntoQuizPlayers } from "@/lib/quizLobbyClientAssignments";
45

56
export type { Player, Question };
67

@@ -15,6 +16,8 @@ export type GameState = {
1516
themes: string[];
1617
questions: Question[][];
1718
activeQuizCard: ActiveQuizCard | null;
19+
/** Текущий ход: индекс места игрока 0..4 */
20+
currentTurnSeat: number;
1821
};
1922

2023
function gd(id: string) {
@@ -23,6 +26,7 @@ function gd(id: string) {
2326

2427
const DEFAULT_STATE: GameState = {
2528
activeQuizCard: null,
29+
currentTurnSeat: 0,
2630
players: Array.from({ length: 5 }, (_, i) => ({
2731
id: `p${i}`,
2832
name: `Player ${i + 1}`,
@@ -329,6 +333,9 @@ function loadInitialState(): GameState {
329333
...parsed,
330334
players,
331335
activeQuizCard: parsed.activeQuizCard ?? null,
336+
currentTurnSeat: Number.isInteger(parsed.currentTurnSeat)
337+
? ((Number(parsed.currentTurnSeat) % 5) + 5) % 5
338+
: 0,
332339
});
333340
}
334341
return restoreLegacyPandoraVideos({ ...DEFAULT_STATE, players });
@@ -354,17 +361,34 @@ export function useGameState() {
354361
socketRef.current = socket;
355362

356363
socket.on("sync", (incoming: GameState) => {
357-
skipEmitRef.current = true;
358-
setState({
364+
const basePlayers = incoming.players?.length ? incoming.players : DEFAULT_STATE.players;
365+
const { merged, hadRoster } = mergeSeatRosterIntoQuizPlayers([...basePlayers]);
366+
const hostResetTurn =
367+
hadRoster &&
368+
typeof localStorage !== "undefined" &&
369+
localStorage.getItem("player_role") === "host";
370+
const nextState = {
359371
...incoming,
372+
players: merged,
360373
activeQuizCard: incoming.activeQuizCard ?? null,
374+
currentTurnSeat: hostResetTurn
375+
? 0
376+
: Number.isInteger(incoming.currentTurnSeat)
377+
? ((Number(incoming.currentTurnSeat) % 5) + 5) % 5
378+
: 0,
361379
questions: DEFAULT_STATE.questions.map((themeQs, tIdx) =>
362380
themeQs.map((defaultQ, qIdx) => ({
363381
...defaultQ,
364382
used: incoming.questions?.[tIdx]?.[qIdx]?.used ?? defaultQ.used,
365383
}))
366384
),
367-
});
385+
};
386+
skipEmitRef.current = true;
387+
setState(nextState);
388+
if (hadRoster) {
389+
skipEmitRef.current = false;
390+
queueMicrotask(() => socketRef.current?.emit("update", nextState));
391+
}
368392
});
369393

370394
return () => {
@@ -454,6 +478,11 @@ export function useGameState() {
454478
setState((prev) => ({ ...prev, activeQuizCard: card }));
455479
}, []);
456480

481+
const setCurrentTurnSeat = useCallback((seat: number) => {
482+
const normalized = ((Number(seat) % 5) + 5) % 5;
483+
setState((prev) => ({ ...prev, currentTurnSeat: normalized }));
484+
}, []);
485+
457486
const patchActiveQuizCard = useCallback(
458487
(patch: Partial<NonNullable<GameState["activeQuizCard"]>>) => {
459488
setState((prev) => {
@@ -476,6 +505,7 @@ export function useGameState() {
476505
updateQuestion,
477506
resetScores,
478507
setActiveQuizCard,
508+
setCurrentTurnSeat,
479509
patchActiveQuizCard,
480510
resetGame,
481511
};

artifacts/game-client/src/apps/adepts-game-2/pages/Home.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo } from "react";
1+
import { useEffect, useMemo, useState } from "react";
22
import { useGameState } from "../hooks/useGameState";
33
import { Scoreboard } from "@/lib/adepts-scoreboard";
44
import { QuizBoard } from "@/lib/adepts-quiz-board";
@@ -14,7 +14,7 @@ function resolveUrl(url: string): string {
1414
}
1515

1616
export default function Home() {
17-
const { isSpectator } = useRole();
17+
const { isHost, isSpectator } = useRole();
1818
const {
1919
state,
2020
updatePlayerName,
@@ -23,6 +23,7 @@ export default function Home() {
2323
updateQuestion,
2424
resetScores,
2525
setActiveQuizCard,
26+
setCurrentTurnSeat,
2627
patchActiveQuizCard,
2728
} = useGameState();
2829

@@ -50,8 +51,20 @@ export default function Home() {
5051
? active
5152
: null;
5253

54+
const [raccoonSplashSeatPassUsed, setRaccoonSplashSeatPassUsed] = useState(false);
55+
const openCardKey = openCard ? `${openCard.themeIndex}-${openCard.questionIndex}` : null;
56+
useEffect(() => {
57+
setRaccoonSplashSeatPassUsed(false);
58+
}, [openCardKey]);
59+
60+
const seatRaw = Number(localStorage.getItem("player_seat_index"));
61+
const seatIndex =
62+
Number.isInteger(seatRaw) && seatRaw >= 0 && seatRaw <= 4 ? seatRaw : -1;
63+
const canOpenCards =
64+
isHost || (!isSpectator && seatIndex === state.currentTurnSeat);
65+
5366
const handleQuestionClick = (themeIndex: number, questionIndex: number) => {
54-
if (isSpectator) return;
67+
if (!canOpenCards) return;
5568
setActiveQuizCard({ themeIndex, questionIndex, stage: "question" });
5669
};
5770

@@ -76,7 +89,7 @@ export default function Home() {
7689
</span>
7790
<div className="ml-auto flex items-center gap-2">
7891
<QuizBoardReloadButton />
79-
{!isSpectator && <GamePhaseNav />}
92+
{isHost && <GamePhaseNav />}
8093
<div style={{ display: "flex", alignItems: "center", gap: 8, fontFamily: "monospace", fontSize: 11, color: "#2ecc71" }}>
8194
<div style={{ width: 8, height: 8, borderRadius: "50%", background: "#2ecc71", boxShadow: "0 0 8px #2ecc71" }} />
8295
Онлайн
@@ -91,7 +104,7 @@ export default function Home() {
91104
questions={state.questions}
92105
onUpdateTheme={updateThemeName}
93106
onQuestionClick={handleQuestionClick}
94-
readonly={isSpectator}
107+
readonly={!canOpenCards}
95108
/>
96109
</main>
97110

@@ -101,7 +114,8 @@ export default function Home() {
101114
onUpdateName={updatePlayerName}
102115
onUpdateScore={updatePlayerScore}
103116
onResetScores={resetScores}
104-
readonly={isSpectator}
117+
readonly={!isHost}
118+
currentTurnSeat={state.currentTurnSeat}
105119
/>
106120
</div>
107121

@@ -115,7 +129,22 @@ export default function Home() {
115129
players={state.players}
116130
quizStage={openCard.stage}
117131
onQuizStageChange={(s) => patchActiveQuizCard({ stage: s })}
118-
readonly={isSpectator}
132+
onPassTurn={() => {
133+
const seat = state.currentTurnSeat;
134+
const pts = (openCard.questionIndex + 1) * 100;
135+
const prev = state.players[seat]?.score ?? 0;
136+
updatePlayerScore(seat, prev - pts);
137+
setCurrentTurnSeat((state.currentTurnSeat + 1) % 5);
138+
}}
139+
onPassTurnNext={() => setCurrentTurnSeat((state.currentTurnSeat + 1) % 5)}
140+
currentTurnSeat={state.currentTurnSeat}
141+
viewerSeatIndex={isHost || isSpectator || seatIndex < 0 ? null : seatIndex}
142+
allowRaccoonSplashSeatPass={!raccoonSplashSeatPassUsed}
143+
onPassTurnToSeat={(target) => {
144+
setRaccoonSplashSeatPassUsed(true);
145+
setCurrentTurnSeat(target);
146+
}}
147+
readonly={!isHost}
119148
onClose={closeQuestion}
120149
onUpdate={(data) =>
121150
updateQuestion(openCard.themeIndex, openCard.questionIndex, data)

artifacts/game-client/src/apps/adepts-game-3/hooks/useGameState.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useEffect, useCallback, useRef } from "react";
22
import { io, Socket } from "socket.io-client";
33
import type { Player, Question } from "@/lib/adepts-quiz-types";
4+
import { mergeSeatRosterIntoQuizPlayers } from "@/lib/quizLobbyClientAssignments";
45

56
export type { Player, Question };
67

@@ -15,6 +16,8 @@ export type GameState = {
1516
themes: string[];
1617
questions: Question[][];
1718
activeQuizCard: ActiveQuizCard | null;
19+
/** Текущий ход: индекс места игрока 0..4 */
20+
currentTurnSeat: number;
1821
};
1922

2023
function gd(id: string) {
@@ -23,6 +26,7 @@ function gd(id: string) {
2326

2427
const DEFAULT_STATE: GameState = {
2528
activeQuizCard: null,
29+
currentTurnSeat: 0,
2630
players: Array.from({ length: 5 }, (_, i) => ({
2731
id: `p${i}`,
2832
name: `Player ${i + 1}`,
@@ -515,6 +519,9 @@ function loadInitialState(): GameState {
515519
...parsed,
516520
players,
517521
activeQuizCard: parsed.activeQuizCard ?? null,
522+
currentTurnSeat: Number.isInteger(parsed.currentTurnSeat)
523+
? ((Number(parsed.currentTurnSeat) % 5) + 5) % 5
524+
: 0,
518525
});
519526
}
520527
return restoreRaccoonCards({ ...DEFAULT_STATE, players });
@@ -540,17 +547,34 @@ export function useGameState() {
540547
socketRef.current = socket;
541548

542549
socket.on("sync", (incoming: GameState) => {
543-
skipEmitRef.current = true;
544-
setState({
550+
const basePlayers = incoming.players?.length ? incoming.players : DEFAULT_STATE.players;
551+
const { merged, hadRoster } = mergeSeatRosterIntoQuizPlayers([...basePlayers]);
552+
const hostResetTurn =
553+
hadRoster &&
554+
typeof localStorage !== "undefined" &&
555+
localStorage.getItem("player_role") === "host";
556+
const nextState = {
545557
...incoming,
558+
players: merged,
546559
activeQuizCard: incoming.activeQuizCard ?? null,
560+
currentTurnSeat: hostResetTurn
561+
? 0
562+
: Number.isInteger(incoming.currentTurnSeat)
563+
? ((Number(incoming.currentTurnSeat) % 5) + 5) % 5
564+
: 0,
547565
questions: DEFAULT_STATE.questions.map((themeQs, tIdx) =>
548566
themeQs.map((defaultQ, qIdx) => ({
549567
...defaultQ,
550568
used: incoming.questions?.[tIdx]?.[qIdx]?.used ?? defaultQ.used,
551569
}))
552570
),
553-
});
571+
};
572+
skipEmitRef.current = true;
573+
setState(nextState);
574+
if (hadRoster) {
575+
skipEmitRef.current = false;
576+
queueMicrotask(() => socketRef.current?.emit("update", nextState));
577+
}
554578
});
555579

556580
return () => {
@@ -640,6 +664,11 @@ export function useGameState() {
640664
setState((prev) => ({ ...prev, activeQuizCard: card }));
641665
}, []);
642666

667+
const setCurrentTurnSeat = useCallback((seat: number) => {
668+
const normalized = ((Number(seat) % 5) + 5) % 5;
669+
setState((prev) => ({ ...prev, currentTurnSeat: normalized }));
670+
}, []);
671+
643672
const patchActiveQuizCard = useCallback(
644673
(patch: Partial<NonNullable<GameState["activeQuizCard"]>>) => {
645674
setState((prev) => {
@@ -662,6 +691,7 @@ export function useGameState() {
662691
updateQuestion,
663692
resetScores,
664693
setActiveQuizCard,
694+
setCurrentTurnSeat,
665695
patchActiveQuizCard,
666696
resetGame,
667697
};

0 commit comments

Comments
 (0)