diff --git a/.github/workflows/push_docker.yaml b/.github/workflows/push_docker.yaml
index 9e0094a..4e91a69 100644
--- a/.github/workflows/push_docker.yaml
+++ b/.github/workflows/push_docker.yaml
@@ -3,6 +3,8 @@ on:
branches:
- main
- development
+ tags:
+ - '**'
jobs:
push_to_registry:
@@ -10,6 +12,10 @@ jobs:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
+ - name: Dump job github var
+ env:
+ GITHUB_VAR: ${{ toJson(github) }}
+ run: echo "$GITHUB_VAR"
-
name: Checkout
uses: actions/checkout@v3
@@ -28,6 +34,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push Docker dev image
+ if: startsWith(github.ref, 'refs/heads/development')
uses: docker/build-push-action@v6
with:
context: .
diff --git a/client/sfx/select_slot.opus b/client/sfx/select_slot.opus
new file mode 100644
index 0000000..e7b776d
Binary files /dev/null and b/client/sfx/select_slot.opus differ
diff --git a/client/sfx/slot_unavailable.opus b/client/sfx/slot_unavailable.opus
new file mode 100644
index 0000000..75e0518
Binary files /dev/null and b/client/sfx/slot_unavailable.opus differ
diff --git a/client/src/events.ts b/client/src/events.ts
index be50eb5..0445800 100644
--- a/client/src/events.ts
+++ b/client/src/events.ts
@@ -1,4 +1,4 @@
-import { Game, GameMode, Hit, Player } from "./entities"
+import { Game, GameMode, Hit, Player, Slot } from "./entities"
export enum Sfx {
joinGame,
@@ -7,6 +7,8 @@ export enum Sfx {
payToken,
playHit,
receiveToken,
+ selectSlot,
+ slotUnavailable,
stopHit,
youClaim,
youFail,
@@ -17,6 +19,7 @@ export enum Sfx {
export interface SfxData {
sfx: Sfx
+ pan?: number
}
export interface PlaySfxData extends SfxData {}
@@ -77,6 +80,14 @@ export interface TokenReceivedData {
game_mode: GameMode
}
+export interface SlotSelectedData {
+ slot: Slot | null
+ slot_count: number
+ from_year: number
+ to_year: number
+ unavailable: boolean
+}
+
export enum Events {
claimedHit = "Claimed hit",
gameEnded = "Game ended",
@@ -90,5 +101,6 @@ export enum Events {
scored = "Scored",
sfxEnded = "Sfx ended",
skippedHit = "Skipped hit",
+ slotSelected = "Slot selected",
tokenReceived = "Token received",
}
diff --git a/client/src/hooks.ts b/client/src/hooks.ts
index 763338d..a11c35d 100644
--- a/client/src/hooks.ts
+++ b/client/src/hooks.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from "react"
+import { useCallback, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
export const useRevalidate = () => {
@@ -30,3 +30,19 @@ export const useRevalidateOnInterval = ({
[revalidate],
)
}
+
+export const useModalShown = (): boolean => {
+ let [shown, setShown] = useState(false)
+
+ useEffect(() => {
+ let id = setInterval(() => {
+ setShown(document.querySelector(".modal") !== null)
+ }, 50)
+
+ return () => {
+ clearInterval(id)
+ }
+ }, [])
+
+ return shown
+}
diff --git a/client/src/locale/de.json b/client/src/locale/de.json
index 5546fe8..0aa6334 100644
--- a/client/src/locale/de.json
+++ b/client/src/locale/de.json
@@ -35,7 +35,9 @@
"confirmHeading": "Du musst nun bestätigen, ob <0>{{player}}0> den Titel und Interpreten korrekt erraten hat. Sei fair!",
"confirmText": "Hat <0>{{player}}0> den Titel und Interpreten korrekt erraten?",
"no": "Nein",
+ "noShortcut": "Alt+Umschalt+N",
"yes": "Ja",
+ "yesShortcut": "Alt+Umschalt+Y",
"guessText": "Wo, glaubst du, gehört dieser Hit hin?",
"waitingText": "Dies sind die Möglichkeiten:",
"dontIntercept": "Keine Vermutung äußern",
@@ -43,14 +45,19 @@
"afterYear": "nach {{year}}",
"betweenYears": "von {{year1}} bis {{year2}}",
"submitGuess": "Vermutung abschicken",
+ "submitGuessShortcut": "Alt+Umschalt+Enter",
"selectSlotFirst": "Wähle erst eine Möglichkeit",
"cannotSubmitGuess": "Du kannst derzeit keine Vermutung abgeben",
"game_one": "Spiel",
"gameActions": "Spielaktionen:",
"leaveGame": "Spiel verlassen",
+ "leaveGameShortcut": "Alt+Umschalt+Q",
"joinGame": "Spiel beitreten",
+ "joinGameShortcut": "Alt+Umschalt+J",
"stopGame": "Spiel stoppen",
+ "stopGameShortcut": "Alt+Umschalt+S",
"startGame": "Spiel starten",
+ "startGameShortcut": "Alt+Umschalt+S",
"name": "Name",
"token_one": "Chip",
"token_other": "Chips",
@@ -110,8 +117,11 @@
"save": "Speichern",
"sfxVolume": "Lautstärke der Sound Effekte",
"publicGame": "Öffentliches Spiel",
+ "publicGameShortcut": "Alt+Umschalt+U",
"privateGame": "Privates Spiel",
+ "privateGameShortcut": "Alt+Umschalt+R",
"localGame": "Lokales Spiel",
+ "localGameShortcut": "Alt+Umschalt+L",
"addPlayer": "Lokalen Spieler hinzufügen",
"addPlayerNotLocalGame": "Du kannst lokale Spieler nur in einem lokalen Spiel hinzufügen",
"addPlayerNotWaiting": "Du kannst lokale Spieler nur hinzufügen, während das Spiel gestoppt ist",
@@ -144,6 +154,22 @@
"guessNothing": "{{player}} behauptet nichts Gegenteiliges",
"guess": "{{player}} vermutet: {{guess}}",
"youReceivedToken": "Du hast einen Token erhalten, da du Künstler und Titel dieses Hits wusstest",
- "otherReceivedToken": "{{player}} hat einen Token erhalten, da der Künstler und Titel dieses Hits genannt wurde"
+ "otherReceivedToken": "{{player}} hat einen Token erhalten, da der Künstler und Titel dieses Hits genannt wurde",
+ "keyboardShortcut_one": "Tastenkombination",
+ "keyboardShortcut_other": "Tastenkombinationen",
+ "section": "Abschnitt",
+ "action": "Aktion",
+ "game": "Spiel",
+ "confirmYes": "Titel und Interpret des Hits wurde richtig erraten",
+ "confirmNo": "Titel und Interpret des Hits wurde nicht richtig erraten",
+ "selectPreviousSlot": "Vorherigen Slot auswählen",
+ "selectPreviousSlotShortcut": "Alt+Umschalt+Pfeil nach oben",
+ "selectNextSlot": "Nächsten Slot auswählen",
+ "selectNextSlotShortcut": "Alt+Umschalt+Pfeil nach unten",
+ "selectNoSlot": "Keinen Slot auswählen",
+ "selectNoSlotShortcut": "Alt+Umschalt+Rücktaste",
+ "playerStatsNotification": "{{player}}: {{hits}} Hits, {{tokens}} Chips",
+ "speakPlayerInfo": "Sprich wichtige Informationen zu Spieler {{player}}",
+ "speakPlayerInfoShortcut": "Alt+Umschalt+{{player}}"
}
}
diff --git a/client/src/locale/en.json b/client/src/locale/en.json
index 4a79e57..05109c9 100644
--- a/client/src/locale/en.json
+++ b/client/src/locale/en.json
@@ -35,7 +35,9 @@
"confirmHeading": "You now need to confirm if <0>{{player}}0> guessed title and artist of the song correctly. Be fair!",
"confirmText": "Did <0>{{player}}0> guess artist and title correctly?",
"no": "No",
+ "noShortcut": "Alt+Shift+N",
"yes": "Yes",
+ "yesShortcut": "Alt+Shift+Y",
"guessText": "Where do you think this hit belongs?",
"waitingText": "These are the possible slots:",
"dontIntercept": "Don't intercept",
@@ -43,14 +45,19 @@
"afterYear": "after {{year}}",
"betweenYears": "between {{year1}} and {{year2}}",
"submitGuess": "Submit guess",
+ "submitGuessShortcut": "Alt+Shift+Return",
"selectSlotFirst": "Select a slot first",
"cannotSubmitGuess": "You cannot submit a guess right now",
"game_one": "Game",
"gameActions": "Game actions:",
"leaveGame": "Leave game",
+ "leaveGameShortcut": "Alt+Shift+Q",
"joinGame": "Join game",
+ "joinGameShortcut": "Alt+Shift+J",
"stopGame": "Stop game",
+ "stopGameShortcut": "Alt+Shift+S",
"startGame": "Start game",
+ "startGameShortcut": "Alt+Shift+S",
"name": "Name",
"token_one": "Token",
"token_other": "Tokens",
@@ -110,8 +117,11 @@
"save": "Save",
"sfxVolume": "SFX Volume",
"publicGame": "Public game",
+ "publicGameShortcut": "Alt+Shift+U",
"privateGame": "Private game",
+ "privateGameShortcut": "Alt+Shift+R",
"localGame": "Local game",
+ "localGameShortcut": "Alt+Shift+L",
"addPlayer": "Add local player",
"addPlayerNotLocalGame": "You can only add local players in a local game",
"addPlayerNotWaiting": "You can only add local players while the game is stopped",
@@ -144,6 +154,22 @@
"guessNothing": "{{player}} doesn't intercept",
"guess": "{{player}} guesses: {{guess}}",
"youReceivedToken": "You received a token for guessing artist and title of this hit correctly",
- "otherReceivedToken": "{{player}} received a token for guessing artist and title of this hit correctly"
+ "otherReceivedToken": "{{player}} received a token for guessing artist and title of this hit correctly",
+ "keyboardShortcut_one": "Keyboard shortcut",
+ "keyboardShortcut_other": "Keyboard shortcuts",
+ "section": "Abschnitt",
+ "action": "Aktion",
+ "game": "Game",
+ "confirmYes": "title and artist of the hit was guessed correctly",
+ "confirmNo": "title and artist of the hit was guessed incorrectly",
+ "selectPreviousSlot": "Select previous slot",
+ "selectPreviousSlotShortcut": "Alt+Shift+Up arrow",
+ "selectNextSlot": "Select next slot",
+ "selectNextSlotShortcut": "Alt+Shift+Down arrow",
+ "selectNoSlot": "Select no slot",
+ "selectNoSlotShortcut": "Alt+Shift+Backspace",
+ "playerStatsNotification": "{{player}}: {{hits}} hits, {{tokens}} tokens",
+ "speakPlayerInfo": "Speak important information about player {{player}}",
+ "speakPlayerInfoShortcut": "Alt+Shift+{{player}}"
}
}
diff --git a/client/src/navigation.tsx b/client/src/navigation.tsx
index 2ae0a97..d55cd09 100644
--- a/client/src/navigation.tsx
+++ b/client/src/navigation.tsx
@@ -9,11 +9,13 @@ import { LinkContainer } from "react-router-bootstrap"
import { useNavigate } from "react-router-dom"
import { User } from "./entities"
import Settings from "./settings"
+import Shortcuts from "./shortcuts"
export default function Navigation({ user }: { user: User | null }) {
let navigate = useNavigate()
const { t } = useTranslation()
let [showSettings, setShowSettings] = useState(false)
+ let [showShortcuts, setShowShortcuts] = useState(false)
let [_, setWelcome] = useLocalStorage("welcome")
return (
@@ -48,6 +50,14 @@ export default function Navigation({ user }: { user: User | null }) {
{t("welcome")}
+
+ setShowShortcuts(true)}
+ >
+ {t("keyboardShortcut", { count: 2 })}
+
+
setShowSettings(false)}
/>
+ setShowShortcuts(false)}
+ />
>
)
}
diff --git a/client/src/notification-player.tsx b/client/src/notification-player.tsx
index 2dd760c..edd9ccc 100644
--- a/client/src/notification-player.tsx
+++ b/client/src/notification-player.tsx
@@ -26,7 +26,6 @@ const TIMER_DURATION: number = 150
export default function NotificationPlayer({ user }: { user: User | null }) {
let { t } = useTranslation()
let [politeness, setPoliteness] = useState<"polite" | "assertive">("polite")
- let [hidden, setHidden] = useState(true)
let output = useRef(null)
let events = useRef([])
let timer = useRef | null>(null)
@@ -42,7 +41,6 @@ export default function NotificationPlayer({ user }: { user: User | null }) {
const handleSpeechEvent = () => {
if (events.current.length === 0) {
if (output.current) output.current.innerHTML = ""
- setHidden(true)
timer.current = null
return
}
@@ -76,11 +74,7 @@ export default function NotificationPlayer({ user }: { user: User | null }) {
},
})
if (timer.current === null) {
- setHidden(false)
- timer.current = setTimeout(
- handleSpeechEvent,
- TIMER_DURATION,
- )
+ handleSpeechEvent()
}
},
)
@@ -360,7 +354,6 @@ export default function NotificationPlayer({ user }: { user: User | null }) {
aria-atomic={true}
ref={output}
className="visually-hidden"
- aria-hidden={hidden}
/>
)
}
diff --git a/client/src/pages/game.tsx b/client/src/pages/game.tsx
index 3b23042..b6da079 100644
--- a/client/src/pages/game.tsx
+++ b/client/src/pages/game.tsx
@@ -36,6 +36,7 @@ import {
SkippedHitData,
TokenReceivedData,
} from "../events"
+import { useModalShown } from "../hooks"
import GameService from "../services/games.service"
import AddLocalPlayerScreen from "./game/add-local-player"
import GameEndScreen from "./game/end-screen"
@@ -70,6 +71,13 @@ export function Game() {
let navigate = useNavigate()
let { t } = useTranslation()
let [winner, setWinner] = useImmer(null)
+ let modalShown = useModalShown()
+
+ const joinOrLeaveGame = async () => {
+ if (game.players.some((p) => p.id === user?.id))
+ await gameService.leave(game.id)
+ else await gameService.join(game.id)
+ }
const startOrStopGame = async () => {
if (game.state === GameState.Open) {
@@ -273,26 +281,37 @@ export function Game() {
// register keystrokes
useEffect(() => {
- let startOrStopHandler = {
+ let handleJoinGame = {
+ onPressed: () => {
+ joinOrLeaveGame()
+ },
+ }
+ let handleLeaveGame = {
+ onPressed: () => {
+ joinOrLeaveGame()
+ },
+ }
+ let handleStartOrStopGame = {
onPressed: () => {
startOrStopGame()
},
}
- if (
- !showSettings &&
- !showHits.some((s) => s) &&
- !showAddPlayer &&
- !gameEndedState &&
- canStartOrStopGame()
- ) {
- bindKeyCombo("alt + s", startOrStopHandler)
+ if (!modalShown) {
+ bindKeyCombo("alt + shift + j", handleJoinGame)
+ bindKeyCombo("alt + shift + q", handleLeaveGame)
+
+ if (canStartOrStopGame()) {
+ bindKeyCombo("alt + shift + s", handleStartOrStopGame)
+ }
}
return () => {
- unbindKeyCombo("alt + s", startOrStopHandler)
+ unbindKeyCombo("alt + shift + j", handleJoinGame)
+ unbindKeyCombo("alt + shift + q", handleLeaveGame)
+ unbindKeyCombo("alt + shift + s", handleStartOrStopGame)
}
- }, [showSettings, showHits, showAddPlayer, gameEndedState, game, user])
+ }, [game, user, modalShown])
const canStartOrStopGame = (): boolean => {
return (
@@ -321,11 +340,12 @@ export function Game() {
game.state !== GameState.Open &&
!game.players.some((p) => p.id === user?.id)
}
- onClick={async () => {
- if (game.players.some((p) => p.id === user?.id))
- await gameService.leave(game.id)
- else await gameService.join(game.id)
- }}
+ onClick={joinOrLeaveGame}
+ aria-keyshortcuts={
+ game.players.some((p) => p.id === user?.id)
+ ? t("leaveGameShortcut")
+ : t("joinGameShortcut")
+ }
>
{game.players.some((p) => p.id === user?.id)
? t("leaveGame")
@@ -337,7 +357,13 @@ export function Game() {
className="me-2"
disabled={!canStartOrStopGame()}
onClick={startOrStopGame}
- aria-keyshortcuts="Alt+S"
+ aria-keyshortcuts={
+ canStartOrStopGame()
+ ? game.state !== GameState.Open
+ ? t("stopGameShortcut")
+ : t("startGameShortcut")
+ : ""
+ }
>
{canStartOrStopGame()
? game.state !== GameState.Open
diff --git a/client/src/pages/game/slot-selector.tsx b/client/src/pages/game/slot-selector.tsx
index b336f89..394c4bc 100644
--- a/client/src/pages/game/slot-selector.tsx
+++ b/client/src/pages/game/slot-selector.tsx
@@ -1,19 +1,20 @@
-import { useEffect } from "react"
+import EventManager from "@lomray/event-manager"
+import { bindKeyCombo, unbindKeyCombo } from "@rwh/keystrokes"
+import { useEffect, useState } from "react"
import Button from "react-bootstrap/Button"
import ToggleButton from "react-bootstrap/ToggleButton"
import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup"
import { Trans, useTranslation } from "react-i18next"
-import { useNavigate } from "react-router-dom"
-import { useImmer } from "use-immer"
import { useContext } from "../../context"
-import type { Game } from "../../entities"
+import type { Game, Slot } from "../../entities"
import { GameMode, GameState, Player, PlayerState } from "../../entities"
+import { Events, NotificationData, SlotSelectedData } from "../../events"
import GameService from "../../services/games.service"
export default ({ game }: { game: Game }) => {
const { user } = useContext()
- const [selectedSlot, setSelectedSlot] = useImmer("0")
- const navigate = useNavigate()
+ const [selectedSlot, setSelectedSlot] = useState("0")
+ const [selectedKeySlot, setSelectedKeySlot] = useState("0")
let { t } = useTranslation()
const actionRequired = (): PlayerState => {
@@ -66,14 +67,242 @@ export default ({ game }: { game: Game }) => {
}
}
+ const guess = async () => {
+ if (selectedSlot === selectedKeySlot) {
+ try {
+ let gs = new GameService()
+ await gs.guess(
+ game.id,
+ selectedSlot !== "0" ? parseInt(selectedSlot, 10) : null,
+ game.mode === GameMode.Local
+ ? actionPlayer()?.id
+ : undefined,
+ )
+ setSelectedSlot("0")
+ setSelectedKeySlot("0")
+ } catch (e) {
+ console.log(e)
+ }
+ }
+ }
+
useEffect(() => {
game.players.forEach((p) => {
- if (p.guess?.id === parseInt(selectedSlot, 10)) {
+ if (p.guess?.id.toString() === selectedSlot) {
setSelectedSlot("0")
- navigate("", { replace: true })
}
})
- }, [game])
+
+ if (
+ selectedSlot === "0" &&
+ selectedKeySlot !== "0" &&
+ !game.players.some(
+ (p) => p.guess?.id.toString() === selectedKeySlot,
+ )
+ ) {
+ setSelectedSlot(selectedKeySlot)
+ }
+ }, [game, selectedKeySlot, selectedSlot])
+
+ useEffect(() => {
+ let handlePreviousSlot = {
+ onPressed: () => {
+ let slot = "0"
+ let p = game.players.find((p) => p.turn_player) as Player
+
+ if (selectedKeySlot === "0" || selectedKeySlot === "1")
+ slot = p.slots.length.toString()
+ else slot = (parseInt(selectedKeySlot, 10) - 1).toString()
+
+ let s = p.slots.find((s) => s.id === parseInt(slot, 10)) as Slot
+ let u =
+ (actionRequired() !== PlayerState.Guessing &&
+ actionRequired() !== PlayerState.Intercepting) ||
+ game.players.some((p) => p.guess?.id === s.id)
+
+ let text = ""
+
+ if (s.from_year === 0)
+ text = t("beforeYear", {
+ year: s.to_year,
+ })
+ else if (s.to_year === 0)
+ text = t("afterYear", {
+ year: s.from_year,
+ })
+ else
+ text = t("betweenYears", {
+ year1: s.from_year,
+ year2: s.to_year,
+ })
+
+ EventManager.publish(Events.notification, {
+ toast: false,
+ interruptTts: true,
+ text: text,
+ } satisfies NotificationData)
+
+ EventManager.publish(Events.slotSelected, {
+ unavailable: u,
+ slot: s,
+ from_year: p.slots[0].to_year,
+ to_year: p.slots[p.slots.length - 1].from_year,
+ slot_count: p.slots.length,
+ } satisfies SlotSelectedData)
+
+ setSelectedKeySlot(slot)
+ if (!u) setSelectedSlot(slot)
+ else setSelectedSlot("0")
+ },
+ }
+
+ let handleNextSlot = {
+ onPressed: () => {
+ let slot = "0"
+ let p = game.players.find((p) => p.turn_player) as Player
+
+ if (
+ selectedKeySlot === "0" ||
+ selectedKeySlot === p.slots.length.toString()
+ )
+ slot = "1"
+ else slot = (parseInt(selectedKeySlot, 10) + 1).toString()
+
+ let s = p.slots.find((s) => s.id === parseInt(slot, 10)) as Slot
+ let u =
+ (actionRequired() !== PlayerState.Guessing &&
+ actionRequired() !== PlayerState.Intercepting) ||
+ game.players.some((p) => p.guess?.id === s.id)
+
+ let text = ""
+
+ if (s.from_year === 0)
+ text = t("beforeYear", {
+ year: s.to_year,
+ })
+ else if (s.to_year === 0)
+ text = t("afterYear", {
+ year: s.from_year,
+ })
+ else
+ text = t("betweenYears", {
+ year1: s.from_year,
+ year2: s.to_year,
+ })
+
+ EventManager.publish(Events.notification, {
+ toast: false,
+ interruptTts: true,
+ text: text,
+ } satisfies NotificationData)
+
+ EventManager.publish(Events.slotSelected, {
+ unavailable: u,
+ slot: s,
+ from_year: p.slots[0].to_year,
+ to_year: p.slots[p.slots.length - 1].from_year,
+ slot_count: p.slots.length,
+ } satisfies SlotSelectedData)
+
+ setSelectedKeySlot(slot)
+ if (!u) setSelectedSlot(slot)
+ else setSelectedSlot("0")
+ },
+ }
+
+ let handleResetSlot = {
+ onPressed: () => {
+ if (selectedKeySlot !== "0") {
+ setSelectedKeySlot("0")
+ setSelectedSlot("0")
+
+ let p = game.players.find((p) => p.turn_player) as Player
+
+ EventManager.publish(Events.slotSelected, {
+ unavailable: true,
+ slot: null,
+ from_year: p.slots[0].to_year,
+ to_year: p.slots[p.slots.length - 1].from_year,
+ slot_count: p.slots.length,
+ } satisfies SlotSelectedData)
+ }
+ },
+ }
+
+ let handleGuess = {
+ onPressed: () => {
+ guess()
+ },
+ }
+
+ let handleConfirmYes = {
+ onPressed: () => {
+ confirm(true)
+ },
+ }
+
+ let handleConfirmNo = {
+ onPressed: () => {
+ confirm(false)
+ },
+ }
+
+ let handleReadPlayerStats = Array.from({ length: 10 }, (_, i) => ({
+ onPressed: () => {
+ if (!game.players[i]) {
+ return
+ }
+
+ EventManager.publish(Events.notification, {
+ toast: false,
+ interruptTts: true,
+ text: t("playerStatsNotification", {
+ player: game.players[i].name,
+ hits: game.players[i].hits.length,
+ tokens: game.players[i].tokens,
+ }),
+ } satisfies NotificationData)
+ },
+ }))
+
+ if (game.state !== GameState.Open) {
+ for (let i = 0; i < 10; i++) {
+ bindKeyCombo(
+ "alt + shift + @Digit" + (i !== 9 ? i + 1 : 0).toString(),
+ handleReadPlayerStats[i],
+ )
+ }
+ }
+
+ if (
+ game.state !== GameState.Confirming &&
+ game.state !== GameState.Open
+ ) {
+ bindKeyCombo("alt + shift + ArrowUp", handlePreviousSlot)
+ bindKeyCombo("alt + shift + ArrowDown", handleNextSlot)
+ bindKeyCombo("alt + shift + Backspace", handleResetSlot)
+ bindKeyCombo("alt + shift + Enter", handleGuess)
+ } else if (actionRequired() === PlayerState.Confirming) {
+ bindKeyCombo("alt + shift + y", handleConfirmYes)
+ bindKeyCombo("alt + shift + n", handleConfirmNo)
+ }
+
+ return () => {
+ unbindKeyCombo("alt + shift + ArrowUp", handlePreviousSlot)
+ unbindKeyCombo("alt + shift + ArrowDown", handleNextSlot)
+ unbindKeyCombo("alt + shift + Backspace", handleResetSlot)
+ unbindKeyCombo("alt + shift + Enter", handleGuess)
+ unbindKeyCombo("alt + shift + y", handleConfirmYes)
+ unbindKeyCombo("alt + shift + n", handleConfirmNo)
+
+ for (let i = 0; i < 10; i++) {
+ unbindKeyCombo(
+ "alt + shift + @Digit" + (i !== 9 ? i + 1 : 0).toString(),
+ handleReadPlayerStats[i],
+ )
+ }
+ }
+ }, [selectedKeySlot, game])
if (game.state === GameState.Open)
return {t("gameNotStarted")}
@@ -150,12 +379,14 @@ export default ({ game }: { game: Game }) => {
@@ -197,7 +428,24 @@ export default ({ game }: { game: Game }) => {
type="radio"
defaultValue="0"
value={selectedSlot}
- onChange={(e) => setSelectedSlot(e)}
+ onChange={(e) => {
+ let p = game.players.find(
+ (p) => p.turn_player,
+ ) as Player
+ let s = p.slots.find(
+ (s) => s.id === parseInt(e, 10),
+ ) as Slot
+
+ EventManager.publish(Events.slotSelected, {
+ unavailable: false,
+ slot: s,
+ from_year: p.slots[0].to_year,
+ to_year: p.slots[p.slots.length - 1].from_year,
+ slot_count: p.slots.length,
+ } satisfies SlotSelectedData)
+ setSelectedKeySlot(e)
+ setSelectedSlot(e)
+ }}
>
{game.players
.find((p) => p.turn_player === true)
@@ -258,28 +506,19 @@ export default ({ game }: { game: Game }) => {
actionRequired() === PlayerState.Guessing) ||
actionRequired() === PlayerState.Waiting
}
- onClick={async () => {
- try {
- let gs = new GameService()
- await gs.guess(
- game.id,
- parseInt(selectedSlot, 10) > 0
- ? parseInt(selectedSlot, 10)
- : null,
- game.mode === GameMode.Local
- ? actionPlayer()?.id
- : undefined,
- )
- setSelectedSlot("0")
- } catch (e) {
- console.log(e)
- }
- }}
+ onClick={guess}
+ aria-keyshortcuts={
+ (actionRequired() === PlayerState.Guessing &&
+ selectedSlot !== "0") ||
+ actionRequired() === PlayerState.Intercepting
+ ? t("submitGuessShortcut")
+ : ""
+ }
>
{actionRequired() === PlayerState.Guessing ||
actionRequired() === PlayerState.Intercepting
? actionRequired() === PlayerState.Intercepting ||
- parseInt(selectedSlot, 10) > 0
+ selectedSlot !== "0"
? t("submitGuess")
: t("selectSlotFirst")
: t("cannotSubmitGuess")}
diff --git a/client/src/pages/lobby.tsx b/client/src/pages/lobby.tsx
index ab4aa7f..e87e532 100644
--- a/client/src/pages/lobby.tsx
+++ b/client/src/pages/lobby.tsx
@@ -1,5 +1,6 @@
import EventManager from "@lomray/event-manager"
-import { useMemo } from "react"
+import { bindKeyCombo, unbindKeyCombo } from "@rwh/keystrokes"
+import { useEffect, useMemo } from "react"
import Dropdown from "react-bootstrap/Dropdown"
import Table from "react-bootstrap/Table"
import { Helmet } from "react-helmet-async"
@@ -8,7 +9,7 @@ import { Link, useLoaderData, useNavigate } from "react-router-dom"
import { useContext } from "../context"
import { Game, GameMode, GameState } from "../entities"
import { Events, JoinedGameData } from "../events"
-import { useRevalidateOnInterval } from "../hooks"
+import { useModalShown, useRevalidateOnInterval } from "../hooks"
import GameService from "../services/games.service"
export async function loader(): Promise {
@@ -22,9 +23,36 @@ export function Lobby() {
let games = useLoaderData() as Game[]
let navigate = useNavigate()
let { t } = useTranslation()
+ let modalShown = useModalShown()
useRevalidateOnInterval({ enabled: true, interval: 5000 })
+ useEffect(() => {
+ let handleNewPublicGame = {
+ onPressed: () => createGame(GameMode.Public),
+ }
+
+ let handleNewPrivateGame = {
+ onPressed: () => createGame(GameMode.Private),
+ }
+
+ let handleNewLocalGame = {
+ onPressed: () => createGame(GameMode.Local),
+ }
+
+ if (!modalShown) {
+ bindKeyCombo("alt + shift + u", handleNewPublicGame)
+ bindKeyCombo("alt + shift + r", handleNewPrivateGame)
+ bindKeyCombo("alt + shift + l", handleNewLocalGame)
+ }
+
+ return () => {
+ unbindKeyCombo("alt + shift + u", handleNewPublicGame)
+ unbindKeyCombo("alt + shift + r", handleNewPrivateGame)
+ unbindKeyCombo("alt + shift + l", handleNewLocalGame)
+ }
+ }, [modalShown])
+
const createGame = async (mode: GameMode) => {
let game = await gameService.create(mode)
EventManager.publish(Events.joinedGame, {
@@ -44,13 +72,22 @@ export function Lobby() {
{t("createNewGame")}
- createGame(GameMode.Public)}>
+ createGame(GameMode.Public)}
+ aria-keyshortcuts={t("publicGameShortcut")}
+ >
{t("publicGame")}
- createGame(GameMode.Private)}>
+ createGame(GameMode.Private)}
+ aria-keyshortcuts={t("privateGameShortcut")}
+ >
{t("privateGame")}
- createGame(GameMode.Local)}>
+ createGame(GameMode.Local)}
+ aria-keyshortcuts={t("localGameShortcut")}
+ >
{t("localGame")}
diff --git a/client/src/sfx-player.tsx b/client/src/sfx-player.tsx
index 01db3b7..6e56174 100644
--- a/client/src/sfx-player.tsx
+++ b/client/src/sfx-player.tsx
@@ -12,6 +12,7 @@ import {
ScoredData,
Sfx,
SfxEndedData,
+ SlotSelectedData,
TokenReceivedData,
} from "./events"
@@ -35,6 +36,14 @@ const getSfx = (sfx: Sfx): Howl => {
url = new URL("../sfx/receive_token.opus", import.meta.url).href
break
}
+ case Sfx.selectSlot: {
+ url = new URL("../sfx/select_slot.opus", import.meta.url).href
+ break
+ }
+ case Sfx.slotUnavailable: {
+ url = new URL("../sfx/slot_unavailable.opus", import.meta.url).href
+ break
+ }
case Sfx.stopHit: {
url = new URL("../sfx/stop_hit.opus", import.meta.url).href
break
@@ -93,6 +102,9 @@ export default function SfxPlayer({ user }: { user: User | null }) {
} satisfies SfxEndedData)
})
+ if (e.pan) s.stereo(e.pan)
+ else s.stereo(0)
+
s.play()
}
},
@@ -198,6 +210,37 @@ export default function SfxPlayer({ user }: { user: User | null }) {
},
)
+ let unsubscribeSlotSelected = EventManager.subscribe(
+ Events.slotSelected,
+ (e: SlotSelectedData) => {
+ let pan = 0
+
+ if (e.slot) {
+ if (e.slot.from_year === 0) pan = -1
+ else if (e.slot.to_year === 0) pan = 1
+ else
+ pan =
+ -1 +
+ 2 *
+ ((e.slot.from_year +
+ (e.slot.to_year - e.slot.from_year) / 2 -
+ e.from_year) /
+ (e.to_year - e.from_year))
+
+ EventManager.publish(Events.playSfx, {
+ sfx: Sfx.selectSlot,
+ pan: pan,
+ } satisfies PlaySfxData)
+ }
+
+ if (e.unavailable || e.slot === null)
+ EventManager.publish(Events.playSfx, {
+ sfx: Sfx.slotUnavailable,
+ pan: pan,
+ } satisfies PlaySfxData)
+ },
+ )
+
return () => {
unsubscribeClaimed()
unsubscribeGuessed()
@@ -206,6 +249,7 @@ export default function SfxPlayer({ user }: { user: User | null }) {
unsubscribeJoinedGame()
unsubscribeLeftGame()
unsubscribeReceivedToken()
+ unsubscribeSlotSelected()
}
}, [user])