commit #8
This commit is contained in:
parent
26772d9d30
commit
a63315e44c
15 changed files with 236 additions and 174 deletions
|
@ -2,7 +2,7 @@
|
||||||
<div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10">
|
<div class="bg-gray-900 h-100vh w-100vw overflow-y-auto text-white p-10">
|
||||||
<div :class="$style.noise"/>
|
<div :class="$style.noise"/>
|
||||||
<div :class="$style.vignette"/>
|
<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">
|
<div v-if="isLoading" class="flex flex-col justify-center items-center text-4xl">
|
||||||
<span>Loading…</span>
|
<span>Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { computed, reactive, readonly, ref } from "vue"
|
||||||
import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "./shared/game/state"
|
import { GameState, getNumberCardsSum, getUninitializedGameState, produceNewState } from "./shared/game/state"
|
||||||
import { trpcClient } from "./trpc"
|
import { trpcClient } from "./trpc"
|
||||||
import { useAuth } from "./auth"
|
import { useAuth } from "./auth"
|
||||||
import { read } from "fs"
|
|
||||||
import type { SpecialCardId } from "./shared/game/cards"
|
import type { SpecialCardId } from "./shared/game/cards"
|
||||||
|
|
||||||
const gameActionsBusKey = Symbol() as EventBusKey<GameAction>
|
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 {
|
return {
|
||||||
lobbyCode: readonly(lobbyCode),
|
lobbyCode: readonly(lobbyCode),
|
||||||
isActive: computed(() => lobbyCode.value !== null),
|
isActive: computed(() => lobbyCode.value !== null),
|
||||||
isOwnGame: computed(() => state.value.players.findIndex(p => p.id === (auth.authenticatedUser?.id ?? "")) === 0),
|
isOwnGame: computed(() => state.value.players.findIndex(p => p.id === (auth.authenticatedUser?.id ?? "")) === 0),
|
||||||
isYourTurn: computed(() => state.value.activePlayerId === auth.requiredUser.id),
|
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),
|
state: readonly(state),
|
||||||
actions: readonly(actions),
|
actions: readonly(actions),
|
||||||
join,
|
join,
|
||||||
|
|
|
@ -1,35 +1,54 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-14">
|
<div class="flex gap-15 justify-center">
|
||||||
<PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/>
|
<div>
|
||||||
<div class="flex gap-5 justify-end items-center transform transition ease duration-500">
|
<div class="font-bold text-lg pb-2 pl-2">Active special cards</div>
|
||||||
<template v-if="game.state.phase === 'end'">
|
<div class="p-4 border border-dark-200 rounded-xl flex flex-col gap-5 flex-wrap w-full">
|
||||||
<BigButton
|
<div
|
||||||
class="bg-gradient-to-br from-green-700 to-green-800"
|
v-if="game.state.activeSpecialCards.length === 0"
|
||||||
v-if="game.isOwnGame"
|
class="font-fat opacity-40 text-xl w-50 flex p-2"
|
||||||
@click="game.newRound()"
|
|
||||||
>
|
>
|
||||||
New round
|
<span>There are none.</span>
|
||||||
</BigButton>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="font-fat opacity-20 text-3xl pr-2">
|
|
||||||
<template v-if="game.isBust">You are bust.</template>
|
|
||||||
</div>
|
</div>
|
||||||
<BigButton
|
<SpecialCard
|
||||||
class="bg-gradient-to-br from-red-800 to-red-900 uppercase"
|
v-for="card in game.state.activeSpecialCards"
|
||||||
:disabled="!game.isYourTurn || game.isBust"
|
:key="card.id"
|
||||||
@click="game.hit()"
|
:id="card.id"
|
||||||
>
|
:owner-id="card.ownerId"
|
||||||
Hit
|
/>
|
||||||
</BigButton>
|
</div>
|
||||||
<BigButton
|
</div>
|
||||||
class="bg-gradient-to-br from-blue-800 to-blue-900 uppercase"
|
<div class="flex flex-col gap-14 max-w-1200px flex-grow">
|
||||||
:disabled="!game.isYourTurn"
|
<PlayerCards v-for="playerId in reorderedPlayerIds" :key="playerId" :player-id="playerId"/>
|
||||||
@click="game.stay()"
|
<div class="flex gap-5 justify-end items-center transform transition ease duration-500">
|
||||||
>
|
<template v-if="game.state.phase === 'end'">
|
||||||
Stay
|
<BigButton
|
||||||
</BigButton>
|
class="bg-gradient-to-br from-green-700 to-green-800"
|
||||||
</template>
|
v-if="game.isOwnGame"
|
||||||
|
@click="game.newRound()"
|
||||||
|
>
|
||||||
|
New round
|
||||||
|
</BigButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="font-fat opacity-20 text-3xl pr-2">
|
||||||
|
<template v-if="game.isBust">You are bust.</template>
|
||||||
|
</div>
|
||||||
|
<BigButton
|
||||||
|
class="bg-gradient-to-br from-red-800 to-red-900 uppercase"
|
||||||
|
: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 || game.isForcedToHit"
|
||||||
|
@click="game.stay()"
|
||||||
|
>
|
||||||
|
Stay
|
||||||
|
</BigButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GameEndModal/>
|
<GameEndModal/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -45,8 +64,8 @@
|
||||||
import BigButton from "./BigButton.vue"
|
import BigButton from "./BigButton.vue"
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { useAuth } from "../auth"
|
import { useAuth } from "../auth"
|
||||||
import { getNumberCardsSum } from "../shared/game/state"
|
|
||||||
import GameEndModal from "./GameEndModal.vue"
|
import GameEndModal from "./GameEndModal.vue"
|
||||||
|
import SpecialCard from "./SpecialCard.vue"
|
||||||
|
|
||||||
const game = useGame()
|
const game = useGame()
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
|
|
|
@ -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>
|
|
|
@ -9,6 +9,12 @@
|
||||||
>
|
>
|
||||||
<CrownIcon/>
|
<CrownIcon/>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
class="text-2xl absolute top-0.5 -left-10 transition"
|
||||||
|
:class="game.state.activePlayerId === playerState.id ? '' : 'opacity-0'"
|
||||||
|
>
|
||||||
|
<DotsThreeOutlineIcon/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-5 w-full flex-wrap">
|
<div class="flex items-center gap-5 w-full flex-wrap">
|
||||||
<NumberCard
|
<NumberCard
|
||||||
|
@ -20,13 +26,18 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isYou" class="p-4 border border-dark-200 rounded-xl flex gap-5 flex-wrap w-full">
|
<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
|
||||||
<SpecialCard
|
v-if="playerState.specialCards.length === 0"
|
||||||
v-if="count > 0"
|
class="font-fat opacity-40 text-2xl h-32 flex px-4 py-5"
|
||||||
:id="id"
|
>
|
||||||
:count="count"
|
<span>You don’t have any special cards.</span>
|
||||||
/>
|
</div>
|
||||||
</template>
|
<SpecialCard
|
||||||
|
v-for="id in playerState.specialCards"
|
||||||
|
:key="id"
|
||||||
|
:id="id"
|
||||||
|
is-usable
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-fat opacity-30 text-3xl">
|
<div class="font-fat opacity-30 text-3xl">
|
||||||
{{ getNumberCardsSum(playerState.numberCards) }}
|
{{ getNumberCardsSum(playerState.numberCards) }}
|
||||||
|
@ -47,6 +58,7 @@
|
||||||
import { useAuth } from "../auth"
|
import { useAuth } from "../auth"
|
||||||
import { getNumberCardsSum } from "../shared/game/state"
|
import { getNumberCardsSum } from "../shared/game/state"
|
||||||
import CrownIcon from "virtual:icons/ph/crown-simple-bold"
|
import CrownIcon from "virtual:icons/ph/crown-simple-bold"
|
||||||
|
import DotsThreeOutlineIcon from "virtual:icons/ph/dots-three-outline"
|
||||||
import SpecialCard from "./SpecialCard.vue"
|
import SpecialCard from "./SpecialCard.vue"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
|
@ -2,16 +2,16 @@
|
||||||
<Card
|
<Card
|
||||||
class="bg-gradient-to-br w-50 h-32"
|
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'"
|
:class="meta_.type === 'permanent' ? 'from-green-800 to-green-900' : 'from-blue-700 to-blue-800'"
|
||||||
as-button
|
:as-button="isUsable"
|
||||||
:tags="[{ label: `${count}x`, icon: numberCircleIcons[count] }]"
|
:tags="ownerId === undefined ? [] : [{ label: `Owned by ${game.state.players.find(p => p.id === ownerId).name}`, icon: UserIcon }]"
|
||||||
:disabled="!game.isYourTurn"
|
:disabled="!game.isYourTurn"
|
||||||
@click="use()"
|
@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]"/>
|
<component :is="specialCardIcons[id]"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 pb-2 text-sm flex items-end flex-grow">
|
<div class="px-3 pb-2 text-sm flex items-end flex-grow">
|
||||||
<div>
|
<div class="text-center">
|
||||||
{{ meta_.description }}
|
{{ meta_.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -25,22 +25,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Card from "./Card.vue"
|
import Card from "./Card.vue"
|
||||||
import type { SpecialCardId } from "../shared/game/cards"
|
import type { SpecialCardId } from "../shared/game/cards"
|
||||||
import { numberCircleIcons, specialCardIcons } from "../icons"
|
import { specialCardIcons } from "../icons"
|
||||||
import { specialCardsMeta } from "../shared/game/cards"
|
import { specialCardsMeta } from "../shared/game/cards"
|
||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
import { useGame } from "../clientGame"
|
import { useGame } from "../clientGame"
|
||||||
import { useThrottleFn } from "@vueuse/core"
|
import { useThrottleFn } from "@vueuse/core"
|
||||||
|
import UserIcon from "virtual:icons/ph/user-bold"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: SpecialCardId
|
id: SpecialCardId
|
||||||
count: number
|
isUsable?: boolean
|
||||||
|
ownerId?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const game = useGame()
|
const game = useGame()
|
||||||
|
|
||||||
const meta_ = computed(() => specialCardsMeta[props.id])
|
const meta_ = computed(() => specialCardsMeta[props.id])
|
||||||
|
|
||||||
const use = useThrottleFn(() => {
|
const use = useThrottleFn(() => {
|
||||||
|
if (!props.isUsable) return
|
||||||
game.useSpecialCard(props.id)
|
game.useSpecialCard(props.id)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
</script>
|
</script>
|
|
@ -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>
|
|
|
@ -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>
|
|
11
src/icons.ts
11
src/icons.ts
|
@ -2,6 +2,10 @@ import type { SpecialCardId } from "./shared/game/cards"
|
||||||
import type { Component } from "vue"
|
import type { Component } from "vue"
|
||||||
import ArrowArcRightIcon from "virtual:icons/ph/arrow-arc-right-bold"
|
import ArrowArcRightIcon from "virtual:icons/ph/arrow-arc-right-bold"
|
||||||
import PlusCircleIcon from "virtual:icons/ph/plus-circle"
|
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 NumberCircleOneIcon from "virtual:icons/ph/number-circle-one"
|
||||||
import NumberCircleTwoIcon from "virtual:icons/ph/number-circle-two"
|
import NumberCircleTwoIcon from "virtual:icons/ph/number-circle-two"
|
||||||
import NumberCircleThreeIcon from "virtual:icons/ph/number-circle-three"
|
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> = {
|
export const specialCardIcons: Record<SpecialCardId, Component> = {
|
||||||
"return-last-opponent": ArrowArcRightIcon,
|
"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 = {
|
export const numberCircleIcons = {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type { RemoveKey } from "../shared/RemoveKey"
|
||||||
import { random } from "lodash-es"
|
import { random } from "lodash-es"
|
||||||
import type { DeepReadonly } from "vue"
|
import type { DeepReadonly } from "vue"
|
||||||
import { customAlphabet as createNanoIdWithCustomAlphabet } from "nanoid/non-secure"
|
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"
|
import { SpecialCardId, weightedSpecialCardIds } from "../shared/game/cards"
|
||||||
|
|
||||||
const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH)
|
const generateLobbyCode = createNanoIdWithCustomAlphabet("ABCDEFGHKMNPQRSTUVWXYZ", LOBBY_CODE_LENGTH)
|
||||||
|
@ -96,7 +96,7 @@ export class Game extends EventEmitter<Events> {
|
||||||
addPlayer(id: string, name: string) {
|
addPlayer(id: string, name: string) {
|
||||||
if (this.lobbyPlayerIds.has(id)) return
|
if (this.lobbyPlayerIds.has(id)) return
|
||||||
if (this.state.phase !== "pre-start") throw new Error("The game was already started.")
|
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.lobbyPlayerIds.add(id)
|
||||||
this.addAction({
|
this.addAction({
|
||||||
type: "join",
|
type: "join",
|
||||||
|
@ -128,29 +128,40 @@ export class Game extends EventEmitter<Events> {
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const player of players) {
|
for (const player of players) {
|
||||||
this.dealNumberTo(player.id, true)
|
this.dealNumberTo(player.id)
|
||||||
this.dealSpecialTo(player.id)
|
this.dealSpecialTo(player.id)
|
||||||
|
this.addAction({
|
||||||
|
type: "hit",
|
||||||
|
initiatingPlayerId: player.id
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hit() {
|
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) {
|
if (Math.random() > SPECIAL_CARD_PROBABILITY) {
|
||||||
this.dealNumberTo(playerId, false)
|
this.dealNumberTo(player.id)
|
||||||
|
|
||||||
this.addAction({
|
this.addAction({
|
||||||
type: "hit",
|
type: "hit",
|
||||||
initiatingPlayerId: playerId
|
initiatingPlayerId: player.id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
else this.dealSpecialTo(playerId)
|
else this.dealSpecialTo(player.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
stay() {
|
stay() {
|
||||||
const playerId = this.state.activePlayerId
|
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({
|
this.addAction({
|
||||||
type: "stay",
|
type: "stay",
|
||||||
initiatingPlayerId: playerId
|
initiatingPlayerId: playerId
|
||||||
|
@ -161,7 +172,7 @@ export class Game extends EventEmitter<Events> {
|
||||||
|
|
||||||
useSpecialCard(cardId: SpecialCardId) {
|
useSpecialCard(cardId: SpecialCardId) {
|
||||||
const player = this.state.players.find(p => p.id === this.state.activePlayerId)!
|
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({
|
this.addAction({
|
||||||
type: "use-special",
|
type: "use-special",
|
||||||
|
@ -211,14 +222,25 @@ export class Game extends EventEmitter<Events> {
|
||||||
this.destroy()
|
this.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private dealNumberTo(playerId: string, isCovert: boolean) {
|
private dealNumberTo(playerId: string) {
|
||||||
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
|
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: player.nextRoundCovert
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
|
||||||
this.addAction({
|
this.addAction({
|
||||||
type: "deal-number",
|
type: "deal-number",
|
||||||
number,
|
number,
|
||||||
toPlayerId: playerId,
|
toPlayerId: playerId,
|
||||||
isCovert
|
isCovert: player.nextRoundCovert
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { requireAuthentication, t } from "./base"
|
import { requireAuthentication, t } from "./base"
|
||||||
import { getNumberCardsSum } from "../../shared/game/state"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { createGame, getGameByLobbyCode } from "../game"
|
import { createGame, getGameByLobbyCode } from "../game"
|
||||||
import { SpecialCardId, specialCardIds } from "../../shared/game/cards"
|
import { SpecialCardId, specialCardIds } from "../../shared/game/cards"
|
||||||
|
@ -30,9 +29,6 @@ export const gameRouter = t.router({
|
||||||
hit: gameProcedure
|
hit: gameProcedure
|
||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the player’s turn")
|
if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the player’s 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()
|
await ctx.game.hit()
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
export const LOBBY_CODE_LENGTH = 5
|
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
|
|
@ -4,19 +4,8 @@ interface SpecialCardMeta {
|
||||||
weight: number
|
weight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// const specialCardTypesObject = {
|
export type SpecialCardId =
|
||||||
// "return-last-opponent": true,
|
| "return-last-opponent" | "return-last-own" | "increase-target-by-2" | "decrease-target-by-2" | "next-round-covert" | "double-draw" | "force-hit"
|
||||||
// "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 const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
|
export const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
|
||||||
"return-last-opponent": {
|
"return-last-opponent": {
|
||||||
|
@ -24,10 +13,35 @@ export const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
|
||||||
description: "Return the last card your opponent drew to the stack.",
|
description: "Return the last card your opponent drew to the stack.",
|
||||||
weight: 5
|
weight: 5
|
||||||
},
|
},
|
||||||
|
"return-last-own": {
|
||||||
|
type: "single-use",
|
||||||
|
description: "Return the last card you drew to the stack.",
|
||||||
|
weight: 4
|
||||||
|
},
|
||||||
"increase-target-by-2": {
|
"increase-target-by-2": {
|
||||||
type: "permanent",
|
type: "permanent",
|
||||||
description: "Increases the target card value by two.",
|
description: "Increases the target card value by two.",
|
||||||
weight: 1
|
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 you’ll 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,20 +4,23 @@ import { produce } from "immer"
|
||||||
import { specialCardIds, specialCardsMeta } from "./cards"
|
import { specialCardIds, specialCardsMeta } from "./cards"
|
||||||
import { cloneDeep, tail, without } from "lodash-es"
|
import { cloneDeep, tail, without } from "lodash-es"
|
||||||
|
|
||||||
type SpecialCardCountByType = Record<SpecialCardId, number>
|
|
||||||
|
|
||||||
export interface GameStateNumberCard {
|
export interface GameStateNumberCard {
|
||||||
number: number
|
number: number
|
||||||
isCovert: boolean
|
isCovert: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameStateActiveSpecialCard {
|
||||||
|
id: SpecialCardId
|
||||||
|
ownerId: string
|
||||||
|
}
|
||||||
|
|
||||||
export const getNumberCardsSum = (cards: Readonly<GameStateNumberCard[]>) => cards.reduce((acc, card) => acc + card.number, 0)
|
export const getNumberCardsSum = (cards: Readonly<GameStateNumberCard[]>) => cards.reduce((acc, card) => acc + card.number, 0)
|
||||||
|
|
||||||
export interface GameStatePlayer {
|
export interface GameStatePlayer {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
numberCards: GameStateNumberCard[]
|
numberCards: GameStateNumberCard[]
|
||||||
specialCardCountByType: SpecialCardCountByType
|
specialCards: SpecialCardId[]
|
||||||
stayed: boolean
|
stayed: boolean
|
||||||
nextRoundCovert: boolean
|
nextRoundCovert: boolean
|
||||||
}
|
}
|
||||||
|
@ -25,7 +28,7 @@ export interface GameStatePlayer {
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
phase: "pre-start" | "running" | "end"
|
phase: "pre-start" | "running" | "end"
|
||||||
players: GameStatePlayer[]
|
players: GameStatePlayer[]
|
||||||
activeSpecialCardCountByType: SpecialCardCountByType
|
activeSpecialCards: GameStateActiveSpecialCard[]
|
||||||
activePlayerId: string
|
activePlayerId: string
|
||||||
targetSum: number
|
targetSum: number
|
||||||
numberCardsStack: number[] // if redacted: contains more cards than there actually are
|
numberCardsStack: number[] // if redacted: contains more cards than there actually are
|
||||||
|
@ -33,18 +36,12 @@ export interface GameState {
|
||||||
winnerIds: string[] | null
|
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 = {
|
const UNINITIALIZED_GAME_STATE: GameState = {
|
||||||
phase: "pre-start",
|
phase: "pre-start",
|
||||||
players: [],
|
players: [],
|
||||||
activePlayerId: "",
|
activePlayerId: "",
|
||||||
targetSum: 0,
|
targetSum: 0,
|
||||||
activeSpecialCardCountByType: ALL_ZERO_PERMANENT_SPECIAL_CARD_COUNTS,
|
activeSpecialCards: [],
|
||||||
numberCardsStack: [],
|
numberCardsStack: [],
|
||||||
actualNumberCardsStackSize: 0,
|
actualNumberCardsStackSize: 0,
|
||||||
winnerIds: null
|
winnerIds: null
|
||||||
|
@ -72,9 +69,9 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
id: action.initiatingPlayerId,
|
id: action.initiatingPlayerId,
|
||||||
name: action.name,
|
name: action.name,
|
||||||
numberCards: [],
|
numberCards: [],
|
||||||
specialCardCountByType: ALL_ZERO_SPECIAL_CARD_COUNTS,
|
specialCards: [],
|
||||||
stayed: false,
|
stayed: false,
|
||||||
nextRoundCovert: false
|
nextRoundCovert: true
|
||||||
})
|
})
|
||||||
|
|
||||||
break
|
break
|
||||||
|
@ -83,7 +80,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
state.phase = "running"
|
state.phase = "running"
|
||||||
state.activePlayerId = state.players[0].id
|
state.activePlayerId = state.players[0].id
|
||||||
state.targetSum = action.targetSum
|
state.targetSum = action.targetSum
|
||||||
state.activeSpecialCardCountByType = ALL_ZERO_PERMANENT_SPECIAL_CARD_COUNTS
|
state.activeSpecialCards = []
|
||||||
state.numberCardsStack = getFullNumbersCardStack()
|
state.numberCardsStack = getFullNumbersCardStack()
|
||||||
state.actualNumberCardsStackSize = state.numberCardsStack.length
|
state.actualNumberCardsStackSize = state.numberCardsStack.length
|
||||||
|
|
||||||
|
@ -96,7 +93,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
isCovert: action.isCovert
|
isCovert: action.isCovert
|
||||||
})
|
})
|
||||||
|
|
||||||
state.numberCardsStack = without(state.numberCardsStack, action.number)
|
state.numberCardsStack.splice(state.numberCardsStack.indexOf(action.number), 1)
|
||||||
state.actualNumberCardsStackSize--
|
state.actualNumberCardsStackSize--
|
||||||
|
|
||||||
if (state.actualNumberCardsStackSize === 0) {
|
if (state.actualNumberCardsStackSize === 0) {
|
||||||
|
@ -109,7 +106,7 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
case "deal-special":
|
case "deal-special":
|
||||||
if (action.cardId !== undefined) {
|
if (action.cardId !== undefined) {
|
||||||
const p2 = state.players.find(p => p.id === action.toPlayerId)!
|
const p2 = state.players.find(p => p.id === action.toPlayerId)!
|
||||||
p2.specialCardCountByType[action.cardId]++
|
p2.specialCards.push(action.cardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
@ -118,6 +115,17 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
activateNextPlayer()
|
activateNextPlayer()
|
||||||
const p4 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
const p4 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
||||||
p4.stayed = false
|
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
|
break
|
||||||
|
|
||||||
case "stay":
|
case "stay":
|
||||||
|
@ -129,6 +137,11 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
case "use-special":
|
case "use-special":
|
||||||
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
const p3 = state.players.find(p => p.id === action.initiatingPlayerId)!
|
||||||
applySpecialCardUsage(state, action.cardId, p3)
|
applySpecialCardUsage(state, action.cardId, p3)
|
||||||
|
|
||||||
|
for (const player of state.players) {
|
||||||
|
player.stayed = false
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case "end":
|
case "end":
|
||||||
|
@ -140,15 +153,24 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function applySpecialCardUsage(state: GameState, type: SpecialCardId, player: GameStatePlayer) {
|
function applySpecialCardUsage(state: GameState, id: SpecialCardId, player: GameStatePlayer) {
|
||||||
player.specialCardCountByType[type] = Math.max(player.specialCardCountByType[type] - 1, 0)
|
player.specialCards.splice(player.specialCards.indexOf(id), 1)
|
||||||
|
|
||||||
switch (type) {
|
switch (id) {
|
||||||
case "return-last-opponent":
|
case "return-last-opponent":
|
||||||
const previousPlayer = getPreviousPlayer(state)
|
const previousPlayer = getPreviousPlayer(state)
|
||||||
const removedCard = previousPlayer.numberCards.pop()
|
const removedCard1 = previousPlayer.numberCards.pop()
|
||||||
if (removedCard !== undefined) {
|
if (removedCard1 !== undefined) {
|
||||||
state.numberCardsStack.push(removedCard.number)
|
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++
|
state.actualNumberCardsStackSize++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,6 +178,38 @@ function applySpecialCardUsage(state: GameState, type: SpecialCardId, player: Ga
|
||||||
|
|
||||||
case "increase-target-by-2":
|
case "increase-target-by-2":
|
||||||
state.targetSum += 2
|
state.targetSum += 2
|
||||||
|
state.activeSpecialCards.push({
|
||||||
|
id,
|
||||||
|
ownerId: player.id
|
||||||
|
})
|
||||||
|
|
||||||
break
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -18,7 +18,7 @@
|
||||||
"stripInternal": true,
|
"stripInternal": true,
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"types": [
|
"types": [
|
||||||
"src/types.d.ts",
|
"./src/types.d.ts",
|
||||||
"vite/client",
|
"vite/client",
|
||||||
"unplugin-icons/types/vue",
|
"unplugin-icons/types/vue",
|
||||||
"vite-plugin-pages/client"
|
"vite-plugin-pages/client"
|
||||||
|
|
Loading…
Add table
Reference in a new issue