This commit is contained in:
Moritz Ruth 2023-04-23 21:08:04 +02:00
parent 26772d9d30
commit a63315e44c
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
15 changed files with 236 additions and 174 deletions

View file

@ -2,7 +2,7 @@
<div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10">
<div :class="$style.noise"/>
<div :class="$style.vignette"/>
<div class="relative max-w-1200px h-full mx-auto">
<div class="relative h-full">
<div v-if="isLoading" class="flex flex-col justify-center items-center text-4xl">
<span>Loading</span>
</div>

View file

@ -5,7 +5,6 @@ import { computed, reactive, readonly, ref } from "vue"
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>
@ -60,12 +59,20 @@ export const useGame = defineStore("game", () => {
})
}
const ownNumberCardsSum = computed(() => getNumberCardsSum(state.value.players.find(p => p.id === auth.requiredUser.id)!.numberCards))
const isForcedToHit = computed(
() => state.value.activeSpecialCards.find(c => c.id === "force-hit" && c.ownerId !== auth.requiredUser.id) !== undefined
)
return {
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),
isBust: computed(() => ownNumberCardsSum.value > state.value.targetSum),
mayHit: computed(() => ownNumberCardsSum.value < state.value.targetSum || isForcedToHit.value),
isForcedToHit,
ownNumberCardsSum,
state: readonly(state),
actions: readonly(actions),
join,

View file

@ -1,5 +1,23 @@
<template>
<div class="flex flex-col gap-14">
<div class="flex gap-15 justify-center">
<div>
<div class="font-bold text-lg pb-2 pl-2">Active special cards</div>
<div class="p-4 border border-dark-200 rounded-xl flex flex-col gap-5 flex-wrap w-full">
<div
v-if="game.state.activeSpecialCards.length === 0"
class="font-fat opacity-40 text-xl w-50 flex p-2"
>
<span>There are none.</span>
</div>
<SpecialCard
v-for="card in game.state.activeSpecialCards"
:key="card.id"
:id="card.id"
:owner-id="card.ownerId"
/>
</div>
</div>
<div class="flex flex-col gap-14 max-w-1200px flex-grow">
<PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/>
<div class="flex gap-5 justify-end items-center transform transition ease duration-500">
<template v-if="game.state.phase === 'end'">
@ -17,20 +35,21 @@
</div>
<BigButton
class="bg-gradient-to-br from-red-800 to-red-900 uppercase"
:disabled="!game.isYourTurn || game.isBust"
:disabled="!game.isYourTurn || !game.mayHit"
@click="game.hit()"
>
Hit
</BigButton>
<BigButton
class="bg-gradient-to-br from-blue-800 to-blue-900 uppercase"
:disabled="!game.isYourTurn"
:disabled="!game.isYourTurn || game.isForcedToHit"
@click="game.stay()"
>
Stay
</BigButton>
</template>
</div>
</div>
<GameEndModal/>
</div>
</template>
@ -45,8 +64,8 @@
import BigButton from "./BigButton.vue"
import { computed } from "vue"
import { useAuth } from "../auth"
import { getNumberCardsSum } from "../shared/game/state"
import GameEndModal from "./GameEndModal.vue"
import SpecialCard from "./SpecialCard.vue"
const game = useGame()
const auth = useAuth()

View file

@ -1,26 +0,0 @@
<template>
<div class="flex flex-col gap-1">
<label :class="$style.label" :for="id">
{{ label }}
</label>
<div>
<slot :id="id"/>
</div>
</div>
</template>
<style module lang="scss">
.label {
@apply font-bold text-lg;
}
</style>
<script setup lang="ts">
import { nanoid } from "nanoid/non-secure"
const props = defineProps<{
label: string
}>()
const id = nanoid()
</script>

View file

@ -9,6 +9,12 @@
>
<CrownIcon/>
</span>
<span
class="text-2xl absolute top-0.5 -left-10 transition"
:class="game.state.activePlayerId === playerState.id ? '' : 'opacity-0'"
>
<DotsThreeOutlineIcon/>
</span>
</div>
<div class="flex items-center gap-5 w-full flex-wrap">
<NumberCard
@ -20,13 +26,18 @@
/>
</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">
<div
v-if="playerState.specialCards.length === 0"
class="font-fat opacity-40 text-2xl h-32 flex px-4 py-5"
>
<span>You dont have any special cards.</span>
</div>
<SpecialCard
v-if="count > 0"
v-for="id in playerState.specialCards"
:key="id"
:id="id"
:count="count"
is-usable
/>
</template>
</div>
<div class="font-fat opacity-30 text-3xl">
{{ getNumberCardsSum(playerState.numberCards) }}
@ -47,6 +58,7 @@
import { useAuth } from "../auth"
import { getNumberCardsSum } from "../shared/game/state"
import CrownIcon from "virtual:icons/ph/crown-simple-bold"
import DotsThreeOutlineIcon from "virtual:icons/ph/dots-three-outline"
import SpecialCard from "./SpecialCard.vue"
const props = defineProps<{

View file

@ -2,16 +2,16 @@
<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] }]"
:as-button="isUsable"
:tags="ownerId === undefined ? [] : [{ label: `Owned by ${game.state.players.find(p => p.id === ownerId).name}`, icon: UserIcon }]"
:disabled="!game.isYourTurn"
@click="use()"
>
<div class="flex justify-center items-center text-5xl opacity-90 flex-grow">
<div class="flex justify-center items-center text-5xl opacity-90 flex-grow relative top-1">
<component :is="specialCardIcons[id]"/>
</div>
<div class="px-3 pb-2 text-sm flex items-end flex-grow">
<div>
<div class="text-center">
{{ meta_.description }}
</div>
</div>
@ -25,22 +25,24 @@
<script setup lang="ts">
import Card from "./Card.vue"
import type { SpecialCardId } from "../shared/game/cards"
import { numberCircleIcons, specialCardIcons } from "../icons"
import { specialCardIcons } from "../icons"
import { specialCardsMeta } from "../shared/game/cards"
import { computed } from "vue"
import { useGame } from "../clientGame"
import { useThrottleFn } from "@vueuse/core"
import UserIcon from "virtual:icons/ph/user-bold"
const props = defineProps<{
id: SpecialCardId
count: number
isUsable?: boolean
ownerId?: string
}>()
const game = useGame()
const meta_ = computed(() => specialCardsMeta[props.id])
const use = useThrottleFn(() => {
if (!props.isUsable) return
game.useSpecialCard(props.id)
}, 1000)
</script>

View file

@ -1,22 +0,0 @@
<template>
<TextualInput :class="$style.root" v-model="value"/>
</template>
<style module lang="scss">
.root {
@apply bg-surface0 border-surface2 border rounded-lg;
width: 100%;
}
</style>
<script setup lang="ts">
import { useVModel } from "@vueuse/core"
import TextualInput from "./TextualInput.vue"
const props = defineProps<{
modelValue: string
}>()
const value = useVModel(props)
</script>

View file

@ -1,26 +0,0 @@
<template>
<input
v-bind="$attrs"
v-model="value"
:class="$style.root"
/>
</template>
<style module lang="scss">
.root {
@apply bg-surface0 border-surface2 border rounded-lg focus:outline-none focus:border-blue-400 py-2 px-3;
width: 100%;
transition: 200ms ease border-color;
}
</style>
<script setup lang="ts">
import { useVModel } from "@vueuse/core"
const props = defineProps<{
modelValue: string
}>()
const value = useVModel(props)
</script>

View file

@ -2,6 +2,10 @@ 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 MinusCircleIcon from "virtual:icons/ph/minus-circle"
import EyeSlashIcon from "virtual:icons/ph/eye-slash-bold"
import FlagIcon from "virtual:icons/ph/flag-bold"
import CaretCircleDoubleRightIcon from "virtual:icons/ph/caret-circle-double-right-bold"
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"
@ -14,7 +18,12 @@ import NumberCircleNineIcon from "virtual:icons/ph/number-circle-nine"
export const specialCardIcons: Record<SpecialCardId, Component> = {
"return-last-opponent": ArrowArcRightIcon,
"increase-target-by-2": PlusCircleIcon
"return-last-own": ArrowArcRightIcon,
"increase-target-by-2": PlusCircleIcon,
"decrease-target-by-2": MinusCircleIcon,
"next-round-covert": EyeSlashIcon,
"force-hit": FlagIcon,
"double-draw": CaretCircleDoubleRightIcon
}
export const numberCircleIcons = {

View file

@ -6,7 +6,7 @@ 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, SPECIAL_CARD_PROBABILITY } from "../shared/constants"
import { LOBBY_CODE_LENGTH, LOBBY_SIZE, SPECIAL_CARD_PROBABILITY } from "../shared/constants"
import { SpecialCardId, weightedSpecialCardIds } from "../shared/game/cards"
const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH)
@ -96,7 +96,7 @@ export class Game extends EventEmitter<Events> {
addPlayer(id: string, name: string) {
if (this.lobbyPlayerIds.has(id)) return
if (this.state.phase !== "pre-start") throw new Error("The game was already started.")
if (this.lobbyPlayerIds.size >= 3) throw new Error("The game is full.")
if (this.lobbyPlayerIds.size >= LOBBY_SIZE) throw new Error("The game is full.")
this.lobbyPlayerIds.add(id)
this.addAction({
type: "join",
@ -128,29 +128,40 @@ export class Game extends EventEmitter<Events> {
})
for (const player of players) {
this.dealNumberTo(player.id, true)
this.dealNumberTo(player.id)
this.dealSpecialTo(player.id)
this.addAction({
type: "hit",
initiatingPlayerId: player.id
})
}
}
hit() {
const playerId = this.state.activePlayerId
const player = this.state.players.find(p => p.id === this.state.activePlayerId)!
const sum = getNumberCardsSum(player.numberCards)
if (sum >= this.state.targetSum && this.state.activeSpecialCards.find(c => c.id === "force-hit" && c.ownerId !== player.id) === undefined)
throw new Error("The player is not allowed to hit if they have reached the target sum.")
if (Math.random() > SPECIAL_CARD_PROBABILITY) {
this.dealNumberTo(playerId, false)
this.dealNumberTo(player.id)
this.addAction({
type: "hit",
initiatingPlayerId: playerId
initiatingPlayerId: player.id
})
}
else this.dealSpecialTo(playerId)
else this.dealSpecialTo(player.id)
}
stay() {
const playerId = this.state.activePlayerId
if (this.state.activeSpecialCards.find(c => c.id === "force-hit" && c.ownerId !== playerId) !== undefined)
throw new Error("An active special card forces the player to hit.")
this.addAction({
type: "stay",
initiatingPlayerId: playerId
@ -161,7 +172,7 @@ export class Game extends EventEmitter<Events> {
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")
if (!player.specialCards.includes(cardId)) throw new Error("The player does not have this card.")
this.addAction({
type: "use-special",
@ -211,14 +222,25 @@ export class Game extends EventEmitter<Events> {
this.destroy()
}
private dealNumberTo(playerId: string, isCovert: boolean) {
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
private dealNumberTo(playerId: string) {
const player = this.state.players.find(p => p.id === playerId)!
if (this.state.activeSpecialCards.find(c => c.id === "double-draw" && c.ownerId === player.id) !== undefined) {
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
this.addAction({
type: "deal-number",
number,
toPlayerId: playerId,
isCovert
isCovert: player.nextRoundCovert
})
}
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
this.addAction({
type: "deal-number",
number,
toPlayerId: playerId,
isCovert: player.nextRoundCovert
})
}

View file

@ -1,5 +1,4 @@
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"
@ -30,9 +29,6 @@ export const gameRouter = t.router({
hit: gameProcedure
.mutation(async ({ ctx }) => {
if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the players turn")
if (getNumberCardsSum(ctx.game.state.players.find(p => p.id === ctx.user.id)!.numberCards) > ctx.game.state.targetSum)
throw new Error("The player cannot hit when bust")
await ctx.game.hit()
}),

View file

@ -1,2 +1,3 @@
export const LOBBY_CODE_LENGTH = 5
export const SPECIAL_CARD_PROBABILITY = 0.2
export const LOBBY_SIZE = 4
export const SPECIAL_CARD_PROBABILITY = 0.5

View file

@ -4,19 +4,8 @@ interface SpecialCardMeta {
weight: number
}
// const specialCardTypesObject = {
// "return-last-opponent": true,
// "return-last-own": true,
// "destroy-last-opponent-special": true,
// "increase-target-by-2": true,
// "next-round-covert": true,
// "double-draw": true,
// "add-2-opponent": true,
// "get-1": true,
// "get-11": true
// }
export type SpecialCardId = "return-last-opponent" | "increase-target-by-2"
export type SpecialCardId =
| "return-last-opponent" | "return-last-own" | "increase-target-by-2" | "decrease-target-by-2" | "next-round-covert" | "double-draw" | "force-hit"
export const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
"return-last-opponent": {
@ -24,10 +13,35 @@ export const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
description: "Return the last card your opponent drew to the stack.",
weight: 5
},
"return-last-own": {
type: "single-use",
description: "Return the last card you drew to the stack.",
weight: 4
},
"increase-target-by-2": {
type: "permanent",
description: "Increases the target card value by two.",
weight: 1
},
"decrease-target-by-2": {
type: "permanent",
description: "Decreases the target card value by two.",
weight: 1
},
"next-round-covert": {
type: "permanent",
description: "The next card youll draw will be covert.",
weight: 2
},
"double-draw": {
type: "permanent",
description: "You will draw two number cards at once.",
weight: 5
},
"force-hit": {
type: "permanent",
description: "Your opponent is not allowed to stay",
weight: 3
}
}

View file

@ -4,20 +4,23 @@ import { produce } from "immer"
import { specialCardIds, specialCardsMeta } from "./cards"
import { cloneDeep, tail, without } from "lodash-es"
type SpecialCardCountByType = Record<SpecialCardId, number>
export interface GameStateNumberCard {
number: number
isCovert: boolean
}
export interface GameStateActiveSpecialCard {
id: SpecialCardId
ownerId: string
}
export const getNumberCardsSum = (cards: Readonly<GameStateNumberCard[]>) => cards.reduce((acc, card) => acc + card.number, 0)
export interface GameStatePlayer {
id: string
name: string
numberCards: GameStateNumberCard[]
specialCardCountByType: SpecialCardCountByType
specialCards: SpecialCardId[]
stayed: boolean
nextRoundCovert: boolean
}
@ -25,7 +28,7 @@ export interface GameStatePlayer {
export interface GameState {
phase: "pre-start" | "running" | "end"
players: GameStatePlayer[]
activeSpecialCardCountByType: SpecialCardCountByType
activeSpecialCards: GameStateActiveSpecialCard[]
activePlayerId: string
targetSum: number
numberCardsStack: number[] // if redacted: contains more cards than there actually are
@ -33,18 +36,12 @@ export interface GameState {
winnerIds: string[] | null
}
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_PERMANENT_SPECIAL_CARD_COUNTS,
activeSpecialCards: [],
numberCardsStack: [],
actualNumberCardsStackSize: 0,
winnerIds: null
@ -72,9 +69,9 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
id: action.initiatingPlayerId,
name: action.name,
numberCards: [],
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
specialCards: [],
stayed: false,
nextRoundCovert: false
nextRoundCovert: true
})
break
@ -83,7 +80,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_PERMANENT_SPECIAL_CARD_COUNTS
state.activeSpecialCards = []
state.numberCardsStack = getFullNumbersCardStack()
state.actualNumberCardsStackSize = state.numberCardsStack.length
@ -96,7 +93,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
isCovert: action.isCovert
})
state.numberCardsStack = without(state.numberCardsStack, action.number)
state.numberCardsStack.splice(state.numberCardsStack.indexOf(action.number), 1)
state.actualNumberCardsStackSize--
if (state.actualNumberCardsStackSize === 0) {
@ -109,7 +106,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
case "deal-special":
if (action.cardId !== undefined) {
const p2 = state.players.find(p => p.id === action.toPlayerId)!
p2.specialCardCountByType[action.cardId]++
p2.specialCards.push(action.cardId)
}
break
@ -118,6 +115,17 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
activateNextPlayer()
const p4 = state.players.find(p => p.id === action.initiatingPlayerId)!
p4.stayed = false
p4.nextRoundCovert = false
const specialCardIndex1 = state.activeSpecialCards.findIndex(c => c.id === "double-draw" && c.ownerId === p4.id)
if (specialCardIndex1 !== -1) state.activeSpecialCards.splice(specialCardIndex1, 1)
const specialCardIndex2 = state.activeSpecialCards.findIndex(c => c.id === "next-round-covert" && c.ownerId === p4.id)
if (specialCardIndex2 !== -1) state.activeSpecialCards.splice(specialCardIndex2, 1)
const specialCardIndex3 = state.activeSpecialCards.findIndex(c => c.id === "force-hit" && c.ownerId !== p4.id)
if (specialCardIndex3 !== -1) state.activeSpecialCards.splice(specialCardIndex3, 1)
break
case "stay":
@ -129,6 +137,11 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
case "use-special":
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
applySpecialCardUsage(state, action.cardId, p3)
for (const player of state.players) {
player.stayed = false
}
break
case "end":
@ -140,15 +153,24 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
}
})
function applySpecialCardUsage(state: GameState, type: SpecialCardId, player: GameStatePlayer) {
player.specialCardCountByType[type] = Math.max(player.specialCardCountByType[type] - 1, 0)
function applySpecialCardUsage(state: GameState, id: SpecialCardId, player: GameStatePlayer) {
player.specialCards.splice(player.specialCards.indexOf(id), 1)
switch (type) {
switch (id) {
case "return-last-opponent":
const previousPlayer = getPreviousPlayer(state)
const removedCard = previousPlayer.numberCards.pop()
if (removedCard !== undefined) {
state.numberCardsStack.push(removedCard.number)
const removedCard1 = previousPlayer.numberCards.pop()
if (removedCard1 !== undefined) {
state.numberCardsStack.push(removedCard1.number)
state.actualNumberCardsStackSize++
}
break
case "return-last-own":
const removedCard2 = player.numberCards.pop()
if (removedCard2 !== undefined) {
state.numberCardsStack.push(removedCard2.number)
state.actualNumberCardsStackSize++
}
@ -156,6 +178,38 @@ function applySpecialCardUsage(state: GameState, type: SpecialCardId, player: Ga
case "increase-target-by-2":
state.targetSum += 2
state.activeSpecialCards.push({
id,
ownerId: player.id
})
break
case "decrease-target-by-2":
state.targetSum -= 2
state.activeSpecialCards.push({
id,
ownerId: player.id
})
break
case "next-round-covert":
player.nextRoundCovert = true
state.activeSpecialCards.push({
id,
ownerId: player.id
})
break
case "force-hit":
case "double-draw":
state.activeSpecialCards.push({
id,
ownerId: player.id
})
break
}
}

View file

@ -18,7 +18,7 @@
"stripInternal": true,
"target": "esnext",
"types": [
"src/types.d.ts",
"./src/types.d.ts",
"vite/client",
"unplugin-icons/types/vue",
"vite-plugin-pages/client"