This commit is contained in:
Moritz Ruth 2023-04-23 16:36:31 +02:00
parent 2975b2820b
commit 26772d9d30
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
17 changed files with 216 additions and 45 deletions

View file

@ -2,10 +2,11 @@ import { defineStore } from "pinia"
import { EventBusKey, useEventBus } from "@vueuse/core"
import type { GameAction } from "./shared/game/actions"
import { computed, reactive, readonly, ref } from "vue"
import { GameState, getUninitializedGameState, produceNewState } from "./shared/game/state"
import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "./shared/game/state"
import { trpcClient } from "./trpc"
import { useAuth } from "./auth"
import { read } from "fs"
import type { SpecialCardId } from "./shared/game/cards"
const gameActionsBusKey = Symbol() as EventBusKey<GameAction>
const useGameActionsBus = () => useEventBus(gameActionsBusKey)
@ -63,6 +64,8 @@ export const useGame = defineStore("game", () => {
lobbyCode: readonly(lobbyCode),
isActive: computed(() => lobbyCode.value !== null),
isOwnGame: computed(() => state.value.players.findIndex(p => p.id === (auth.authenticatedUser?.id ?? "")) === 0),
isYourTurn: computed(() => state.value.activePlayerId === auth.requiredUser.id),
isBust: computed(() => getNumberCardsSum(state.value.players.find(p => p.id === auth.requiredUser.id)!.numberCards) > state.value.targetSum),
state: readonly(state),
actions: readonly(actions),
join,
@ -75,6 +78,7 @@ export const useGame = defineStore("game", () => {
start: () => trpcClient.game.start.mutate({ lobbyCode: lobbyCode.value! }),
hit: () => trpcClient.game.hit.mutate({ lobbyCode: lobbyCode.value! }),
stay: () => trpcClient.game.stay.mutate({ lobbyCode: lobbyCode.value! }),
useSpecialCard: (id: SpecialCardId) => trpcClient.game.useSpecialCard.mutate({ lobbyCode: lobbyCode.value!, specialCardId: id }),
newRound: () => trpcClient.game.newRound.mutate({ lobbyCode: lobbyCode.value! }),
create: () => trpcClient.createGame.mutate()
}

View file

@ -1,5 +1,5 @@
<template>
<div class="flex-shrink-0 rounded-lg shadow-xl bg-gradient-to-br w-35 h-45" :class="$style.root">
<component :is="asButton ? 'button' : 'div'" class="block flex-shrink-0 rounded-lg shadow-xl bg-gradient-to-br" :class="$style.root">
<div class="relative flex flex-col items-center justify-center h-full">
<div class="absolute top-2 right-2 bg-dark-800 rounded-full flex gap-2 items-center px-2">
<div v-for="tag in tags" :key="tag.label" :title="tag.label">
@ -8,7 +8,7 @@
</div>
<slot/>
</div>
</div>
</component>
</template>
<style module lang="scss">
@ -25,6 +25,10 @@
background: url("../assets/noise.png") repeat;
opacity: 10%;
}
button:disabled {
cursor: not-allowed;
}
}
</style>
@ -38,6 +42,7 @@
icon: FunctionalComponent
}>>,
default: []
}
},
asButton: Boolean
})
</script>

View file

@ -13,18 +13,18 @@
</template>
<template v-else>
<div class="font-fat opacity-20 text-3xl pr-2">
<template v-if="isBust">You are bust.</template>
<template v-if="game.isBust">You are bust.</template>
</div>
<BigButton
class="bg-gradient-to-br from-red-800 to-red-900 uppercase"
:disabled="!isYourTurn || isBust"
:disabled="!game.isYourTurn || game.isBust"
@click="game.hit()"
>
Hit
</BigButton>
<BigButton
class="bg-gradient-to-br from-blue-800 to-blue-900 uppercase"
:disabled="!isYourTurn"
:disabled="!game.isYourTurn"
@click="game.stay()"
>
Stay
@ -50,14 +50,12 @@
const game = useGame()
const auth = useAuth()
const isYourTurn = computed(() => game.state.activePlayerId === auth.requiredUser.id)
const isBust = computed(() => getNumberCardsSum(game.state.players.find(p => p.id === auth.requiredUser.id)!.numberCards) > game.state.targetSum)
const reorderedPlayerIds = computed(() => {
const ids = game.state.players.map(p => p.id)
const selfIndex = ids.findIndex(id => id === auth.requiredUser.id)
let before = ids.slice(0, selfIndex)
let rest = ids.slice(selfIndex)
return [...rest, ...before]
let front = ids.slice(0, selfIndex + 1)
let back = ids.slice(selfIndex + 1)
return [...back, ...front]
})
</script>

View file

@ -42,7 +42,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue"
import { useGame } from "../clientGame"
import { LOBBY_CODE_LENGTH } from "../shared/lobbyCode"
import { LOBBY_CODE_LENGTH } from "../shared/constants"
import { useBrowserLocation } from "@vueuse/core"
const location = useBrowserLocation()

View file

@ -32,7 +32,7 @@
<script setup lang="ts">
import { ref, watchEffect } from "vue"
import { useGame } from "../clientGame"
import { LOBBY_CODE_LENGTH } from "../shared/lobbyCode"
import { LOBBY_CODE_LENGTH } from "../shared/constants"
import { useBrowserLocation } from "@vueuse/core"
import { useAuth } from "../auth"

View file

@ -1,5 +1,6 @@
<template>
<Card
class="w-35 h-45"
:class="isUnknown ? 'bg-gradient-to-br from-gray-700 to-gray-800' : 'bg-gradient-to-br from-yellow-600 to-yellow-900'"
:tags="tags"
>

View file

@ -19,6 +19,15 @@
:is-own="isYou"
/>
</div>
<div v-if="isYou" class="p-4 border border-dark-200 rounded-xl flex gap-5 flex-wrap w-full">
<template v-for="(count, id) in playerState.specialCardCountByType" :key="id">
<SpecialCard
v-if="count > 0"
:id="id"
:count="count"
/>
</template>
</div>
<div class="font-fat opacity-30 text-3xl">
{{ getNumberCardsSum(playerState.numberCards) }}
<template v-if="!isYou && game.state.phase !== 'end' && playerState.numberCards.some(c => c.isCovert)">+ ?</template>
@ -38,6 +47,7 @@
import { useAuth } from "../auth"
import { getNumberCardsSum } from "../shared/game/state"
import CrownIcon from "virtual:icons/ph/crown-simple-bold"
import SpecialCard from "./SpecialCard.vue"
const props = defineProps<{
playerId: string

View file

@ -0,0 +1,46 @@
<template>
<Card
class="bg-gradient-to-br w-50 h-32"
:class="meta_.type === 'permanent' ? 'from-green-800 to-green-900' : 'from-blue-700 to-blue-800'"
as-button
:tags="[{ label: `${count}x`, icon: numberCircleIcons[count] }]"
:disabled="!game.isYourTurn"
@click="use()"
>
<div class="flex justify-center items-center text-5xl opacity-90 flex-grow">
<component :is="specialCardIcons[id]"/>
</div>
<div class="px-3 pb-2 text-sm flex items-end flex-grow">
<div>
{{ meta_.description }}
</div>
</div>
</Card>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import Card from "./Card.vue"
import type { SpecialCardId } from "../shared/game/cards"
import { numberCircleIcons, specialCardIcons } from "../icons"
import { specialCardsMeta } from "../shared/game/cards"
import { computed } from "vue"
import { useGame } from "../clientGame"
import { useThrottleFn } from "@vueuse/core"
const props = defineProps<{
id: SpecialCardId
count: number
}>()
const game = useGame()
const meta_ = computed(() => specialCardsMeta[props.id])
const use = useThrottleFn(() => {
game.useSpecialCard(props.id)
}, 1000)
</script>

30
src/icons.ts Normal file
View file

@ -0,0 +1,30 @@
import type { SpecialCardId } from "./shared/game/cards"
import type { Component } from "vue"
import ArrowArcRightIcon from "virtual:icons/ph/arrow-arc-right-bold"
import PlusCircleIcon from "virtual:icons/ph/plus-circle"
import NumberCircleOneIcon from "virtual:icons/ph/number-circle-one"
import NumberCircleTwoIcon from "virtual:icons/ph/number-circle-two"
import NumberCircleThreeIcon from "virtual:icons/ph/number-circle-three"
import NumberCircleFourIcon from "virtual:icons/ph/number-circle-four"
import NumberCircleFiveIcon from "virtual:icons/ph/number-circle-five"
import NumberCircleSixIcon from "virtual:icons/ph/number-circle-six"
import NumberCircleSevenIcon from "virtual:icons/ph/number-circle-seven"
import NumberCircleEightIcon from "virtual:icons/ph/number-circle-eight"
import NumberCircleNineIcon from "virtual:icons/ph/number-circle-nine"
export const specialCardIcons: Record<SpecialCardId, Component> = {
"return-last-opponent": ArrowArcRightIcon,
"increase-target-by-2": PlusCircleIcon
}
export const numberCircleIcons = {
1: NumberCircleOneIcon,
2: NumberCircleTwoIcon,
3: NumberCircleThreeIcon,
4: NumberCircleFourIcon,
5: NumberCircleFiveIcon,
6: NumberCircleSixIcon,
7: NumberCircleSevenIcon,
8: NumberCircleEightIcon,
9: NumberCircleNineIcon
}

View file

@ -6,7 +6,8 @@ import type { RemoveKey } from "../shared/RemoveKey"
import { random } from "lodash-es"
import type { DeepReadonly } from "vue"
import { customAlphabet as createNanoIdWithCustomAlphabet } from "nanoid/non-secure"
import { LOBBY_CODE_LENGTH } from "../shared/lobbyCode"
import { LOBBY_CODE_LENGTH, SPECIAL_CARD_PROBABILITY } from "../shared/constants"
import { SpecialCardId, weightedSpecialCardIds } from "../shared/game/cards"
const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH)
@ -35,6 +36,16 @@ function redactGameAction<T extends GameAction>(action: T, receiverId: string):
number: 0
}
}
break
case "deal-special":
if (action.toPlayerId === receiverId) return action
return {
...action,
cardId: undefined
}
}
return NO_REDACTION
@ -118,18 +129,23 @@ export class Game extends EventEmitter<Events> {
for (const player of players) {
this.dealNumberTo(player.id, true)
this.dealSpecialTo(player.id)
}
}
hit() {
const playerId = this.state.activePlayerId
this.addAction({
type: "hit",
initiatingPlayerId: playerId
})
if (Math.random() > SPECIAL_CARD_PROBABILITY) {
this.dealNumberTo(playerId, false)
this.dealNumberTo(playerId, false)
this.addAction({
type: "hit",
initiatingPlayerId: playerId
})
}
else this.dealSpecialTo(playerId)
}
stay() {
@ -140,9 +156,18 @@ export class Game extends EventEmitter<Events> {
initiatingPlayerId: playerId
})
if (this.state.players.every(p => p.stayed)) {
this.end()
}
if (this.state.players.every(p => p.stayed)) this.end()
}
useSpecialCard(cardId: SpecialCardId) {
const player = this.state.players.find(p => p.id === this.state.activePlayerId)!
if (player.specialCardCountByType[cardId] <= 0) throw new Error("The player does not have this card")
this.addAction({
type: "use-special",
initiatingPlayerId: player.id,
cardId
})
}
end() {
@ -197,6 +222,14 @@ export class Game extends EventEmitter<Events> {
})
}
private dealSpecialTo(playerId: string) {
this.addAction({
type: "deal-special",
toPlayerId: playerId,
cardId: weightedSpecialCardIds[random(0, weightedSpecialCardIds.length - 1)]
})
}
private addAction<T extends RemoveKey<GameAction, "index">>(action: T): T & { index: number } {
const fullAction = {
...action,

View file

@ -2,6 +2,7 @@ import { requireAuthentication, t } from "./base"
import { getNumberCardsSum } from "../../shared/game/state"
import { z } from "zod"
import { createGame, getGameByLobbyCode } from "../game"
import { SpecialCardId, specialCardIds } from "../../shared/game/cards"
const gameProcedure = t.procedure
.use(requireAuthentication)
@ -41,6 +42,15 @@ export const gameRouter = t.router({
await ctx.game.stay()
}),
useSpecialCard: gameProcedure
.input(z.object({
specialCardId: z.string().refine(a => specialCardIds.includes(a as any)).transform(a => a as SpecialCardId)
}))
.mutation(async ({ ctx, input }) => {
if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the players turn")
await ctx.game.useSpecialCard(input.specialCardId)
}),
newRound: gameProcedure
.mutation(async ({ ctx }) => {
if (ctx.game.state.phase !== "end") throw new Error(`Cannot start a new round in this phase: ${ctx.game.state.phase}`)

2
src/shared/constants.ts Normal file
View file

@ -0,0 +1,2 @@
export const LOBBY_CODE_LENGTH = 5
export const SPECIAL_CARD_PROBABILITY = 0.2

View file

@ -15,7 +15,7 @@ type PlayerAction = {
}
| {
type: "use-special"
cardType: SpecialCardId
cardId: SpecialCardId
}
| {
type: "leave"
@ -38,7 +38,7 @@ type ServerAction = {
| {
type: "deal-special"
toPlayerId: string
cardType?: SpecialCardId // undefined if redacted
cardId?: SpecialCardId // undefined if redacted
}
| {
type: "end"

View file

@ -1,6 +1,7 @@
interface SpecialCardMeta {
type: "single-use" | "permanent"
description: string
weight: number
}
// const specialCardTypesObject = {
@ -15,13 +16,31 @@ interface SpecialCardMeta {
// "get-11": true
// }
export type SpecialCardId = "return-last-opponent"
export type SpecialCardId = "return-last-opponent" | "increase-target-by-2"
export const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
"return-last-opponent": {
type: "single-use",
description: "Return the last card your opponent drew to the stack"
description: "Return the last card your opponent drew to the stack.",
weight: 5
},
"increase-target-by-2": {
type: "permanent",
description: "Increases the target card value by two.",
weight: 1
}
}
export const specialCardTypes = Object.keys(specialCardsMeta)
export const specialCardIds = Object.keys(specialCardsMeta) as SpecialCardId[]
const weightedSpecialCardIds: SpecialCardId[] = []
for (const id of specialCardIds) {
const meta = specialCardsMeta[id]
for (let i = 0; i < meta.weight; i++) {
weightedSpecialCardIds.push(id)
}
}
export { weightedSpecialCardIds }

View file

@ -1,7 +1,7 @@
import type { SpecialCardId } from "./cards"
import type { GameAction } from "./actions"
import { produce } from "immer"
import { specialCardTypes } from "./cards"
import { specialCardIds, specialCardsMeta } from "./cards"
import { cloneDeep, tail, without } from "lodash-es"
type SpecialCardCountByType = Record<SpecialCardId, number>
@ -33,14 +33,18 @@ export interface GameState {
winnerIds: string[] | null
}
const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardTypes.map(t => [t, 0])) as SpecialCardCountByType
const ALL_ZERO_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(specialCardIds.map(t => [t, 0])) as SpecialCardCountByType
const ALL_ZERO_PERMANENT_SPECIAL_CARD_COUNTS: SpecialCardCountByType = Object.fromEntries(
specialCardIds.filter(id => specialCardsMeta[id].type === "permanent").map(t => [t, 0])
) as SpecialCardCountByType
const UNINITIALIZED_GAME_STATE: GameState = {
phase: "pre-start",
players: [],
activePlayerId: "",
targetSum: 0,
activeSpecialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
activeSpecialCardCountByType: ALL_ZERO_PERMANENT_SPECIAL_CARD_COUNTS,
numberCardsStack: [],
actualNumberCardsStackSize: 0,
winnerIds: null
@ -49,6 +53,13 @@ const UNINITIALIZED_GAME_STATE: GameState = {
export const getUninitializedGameState = () => cloneDeep(UNINITIALIZED_GAME_STATE)
export const getFullNumbersCardStack = () => tail([...Array(12).keys()])
const getPreviousPlayer = (state: GameState) => {
const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId)
let index = (activePlayerIndex - 1) % state.players.length
if (index < 0) index = state.players.length + index
return state.players[index]
}
export const produceNewState = (oldState: GameState, action: GameAction) => produce(oldState, state => {
const activateNextPlayer = () => {
const activePlayerIndex = state.players.findIndex(p => p.id === state.activePlayerId)
@ -72,7 +83,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
state.phase = "running"
state.activePlayerId = state.players[0].id
state.targetSum = action.targetSum
state.activeSpecialCardCountByType = ALL_ZERO_SPECIAL_CARD_COUNTS
state.activeSpecialCardCountByType = ALL_ZERO_PERMANENT_SPECIAL_CARD_COUNTS
state.numberCardsStack = getFullNumbersCardStack()
state.actualNumberCardsStackSize = state.numberCardsStack.length
@ -96,9 +107,9 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
break
case "deal-special":
if (action.cardType !== undefined) {
if (action.cardId !== undefined) {
const p2 = state.players.find(p => p.id === action.toPlayerId)!
p2.specialCardCountByType[action.cardType]++
p2.specialCardCountByType[action.cardId]++
}
break
@ -117,7 +128,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
case "use-special":
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
applySpecialCardUsage(state, action.cardType, p3)
applySpecialCardUsage(state, action.cardId, p3)
break
case "end":
@ -130,11 +141,21 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
})
function applySpecialCardUsage(state: GameState, type: SpecialCardId, player: GameStatePlayer) {
player.specialCardCountByType[type]--
player.specialCardCountByType[type] = Math.max(player.specialCardCountByType[type] - 1, 0)
switch (type) {
case "return-last-opponent":
// TODO
const previousPlayer = getPreviousPlayer(state)
const removedCard = previousPlayer.numberCards.pop()
if (removedCard !== undefined) {
state.numberCardsStack.push(removedCard.number)
state.actualNumberCardsStackSize++
}
break
case "increase-target-by-2":
state.targetSum += 2
break
}
}

View file

@ -1 +0,0 @@
export const LOBBY_CODE_LENGTH = 5

View file

@ -1,7 +0,0 @@
import type { SpecialCardId } from "./shared/game/cards"
import type { Component } from "vue"
import ArrowArcRightIcon from "virtual:icons/*"
export const specialCardIcons: Record<SpecialCardId, Component> = {
"return-last-opponent": ArrowArcRightIcon
}