This commit is contained in:
Moritz Ruth 2023-04-23 15:14:44 +02:00
parent 14c4a434f7
commit 2975b2820b
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
10 changed files with 102 additions and 76 deletions

View file

@ -32,33 +32,40 @@ export const useGame = defineStore("game", () => {
console.log(`${action.type}`, action)
})
function join(code: string) {
return new Promise<void>((resolve, reject) => {
trpcClient.join.subscribe({ lobbyCode: code }, {
onStarted: () => {
lobbyCode.value = code
resolve()
},
onData: event => {
switch (event.type) {
case "action":
actionsBus.emit(event.action)
break
case "new_round":
state.value = getUninitializedGameState()
actions.splice(0, actions.length)
join(code).catch(() => {})
}
},
onError: error => {
console.error("🔴", error)
reject(error)
}
})
})
}
return {
lobbyCode: readonly(lobbyCode),
isActive: computed(() => lobbyCode.value !== null),
isOwnGame: computed(() => state.value.players.findIndex(p => p.id === (auth.authenticatedUser?.id ?? "")) === 0),
state: readonly(state),
actions: readonly(actions),
join(code: string) {
return new Promise<void>((resolve, reject) => {
trpcClient.join.subscribe({ lobbyCode: code }, {
onStarted: () => {
lobbyCode.value = code
resolve()
},
onData: event => {
switch (event.type) {
case "action":
actionsBus.emit(event.action)
break
}
},
onError: error => {
console.error("🔴", error)
reject(error)
}
})
})
},
join,
async reset() {
window.location.hash = ""
lobbyCode.value = null
@ -68,6 +75,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! }),
newRound: () => trpcClient.game.newRound.mutate({ lobbyCode: lobbyCode.value! }),
create: () => trpcClient.createGame.mutate()
}
})

View file

@ -4,10 +4,11 @@
<div class="flex gap-5 justify-end items-center transform transition ease duration-500">
<template v-if="game.state.phase === 'end'">
<BigButton
class="bg-gradient-to-br from-gray-600 to-gray-800"
@click="game.reset()"
class="bg-gradient-to-br from-green-700 to-green-800"
v-if="game.isOwnGame"
@click="game.newRound()"
>
Leave
New round
</BigButton>
</template>
<template v-else>

View file

@ -16,8 +16,8 @@ export function getGameByLobbyCode(code: string): Game | null {
return gamesByLobbyCode.get(code.toUpperCase()) ?? null
}
export function createGame() {
const game = new Game()
export function createGame(lobbyCode: string = generateLobbyCode()) {
const game = new Game(lobbyCode)
gamesByLobbyCode.set(game.lobbyCode, game)
return game
}
@ -43,12 +43,11 @@ function redactGameAction<T extends GameAction>(action: T, receiverId: string):
interface Events {
broadcast_action: [GameAction]
private_action: [string, GameAction]
new_round: []
destroyed: []
}
export class Game extends EventEmitter<Events> {
lobbyCode = generateLobbyCode()
/**
* @deprecated
*/
@ -65,7 +64,7 @@ export class Game extends EventEmitter<Events> {
return this.actions[this.actions.length - 1].index + 1
}
constructor() {
constructor(public lobbyCode: string) {
super()
}
@ -107,6 +106,7 @@ export class Game extends EventEmitter<Events> {
}
async start() {
if (this.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${this.state.phase}`)
const players = await this.getPlayers()
if (players.length < 2) throw new Error("At least two players are required for starting the game")
@ -181,6 +181,11 @@ export class Game extends EventEmitter<Events> {
this.actions.forEach(action => this.sendActionTo(action, receiverId))
}
announceNewRoundAndDestroy() {
this.emit("new_round")
this.destroy()
}
private dealNumberTo(playerId: string, isCovert: boolean) {
const number = this.state.numberCardsStack[random(0, this.state.numberCardsStack.length - 1)]
@ -230,7 +235,7 @@ export class Game extends EventEmitter<Events> {
}
private destroy() {
gamesByLobbyCode.delete(this.lobbyCode)
if (gamesByLobbyCode.get(this.lobbyCode) === this) gamesByLobbyCode.delete(this.lobbyCode)
this.emit("destroyed")
}
}

View file

@ -1,7 +1,7 @@
import { requireAuthentication, t } from "./base"
import { getNumberCardsSum } from "../../shared/game/state"
import { z } from "zod"
import { getGameByLobbyCode } from "../game"
import { createGame, getGameByLobbyCode } from "../game"
const gameProcedure = t.procedure
.use(requireAuthentication)
@ -22,7 +22,6 @@ const gameProcedure = t.procedure
export const gameRouter = t.router({
start: gameProcedure
.mutation(async ({ ctx }) => {
if (ctx.game.state.phase !== "pre-start") throw new Error(`Cannot start the game in this phase: ${ctx.game.state.phase}`)
if (ctx.game.state.players.findIndex(p => p.id === ctx.user.id) !== 0) throw new Error("Only the creator can start the game")
ctx.game.start()
}),
@ -40,5 +39,14 @@ export const gameRouter = t.router({
.mutation(async ({ ctx }) => {
if (ctx.game.state.activePlayerId !== ctx.user.id) throw new Error("It is not the players turn")
await ctx.game.stay()
}),
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}`)
if (ctx.game.state.players.findIndex(p => p.id === ctx.user.id) !== 0) throw new Error("Only the creator can start a new round")
const newGame = createGame(ctx.game.lobbyCode)
newGame.addPlayer(ctx.user.id, ctx.user.name)
ctx.game.announceNewRoundAndDestroy()
})
})

View file

@ -87,6 +87,7 @@ export const appRouter = t.router({
return observable<GameEvent>(emit => {
const handleBroadcastAction = (action: GameAction) => emit.next({ type: "action", action })
const handleNewRound = () => emit.next({ type: "new_round" })
const handleDestroyed = () => setTimeout(() => emit.complete(), 500)
const handlePrivateAction = (playerId: string, action: GameAction) => {
@ -95,6 +96,7 @@ export const appRouter = t.router({
game.on("broadcast_action", handleBroadcastAction)
game.on("private_action", handlePrivateAction)
game.on("new_round", handleNewRound)
game.on("destroyed", handleDestroyed)
game.sendAllOldActionsTo(ctx.user.id)
@ -102,6 +104,7 @@ export const appRouter = t.router({
return () => {
game.off("broadcast_action", handleBroadcastAction)
game.off("private_action", handlePrivateAction)
game.off("new_round", handleNewRound)
game.off("destroyed", handleDestroyed)
}
})

View file

@ -1,4 +1,4 @@
import type { SpecialCardType } from "./cards"
import type { SpecialCardId } from "./cards"
type PlayerAction = {
initiatingPlayerId: string
@ -15,7 +15,7 @@ type PlayerAction = {
}
| {
type: "use-special"
cardType: SpecialCardType
cardType: SpecialCardId
}
| {
type: "leave"
@ -38,7 +38,7 @@ type ServerAction = {
| {
type: "deal-special"
toPlayerId: string
cardType: SpecialCardType
cardType?: SpecialCardId // undefined if redacted
}
| {
type: "end"

View file

@ -1,14 +1,27 @@
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
interface SpecialCardMeta {
type: "single-use" | "permanent"
description: string
}
export const specialCardTypes = Object.keys(specialCardTypesObject)
export type SpecialCardType = keyof typeof specialCardTypesObject
// 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"
export const specialCardsMeta: Record<SpecialCardId, SpecialCardMeta> = {
"return-last-opponent": {
type: "single-use",
description: "Return the last card your opponent drew to the stack"
}
}
export const specialCardTypes = Object.keys(specialCardsMeta)

View file

@ -3,4 +3,6 @@ import type { GameAction } from "./actions"
export type GameEvent = {
type: "action"
action: GameAction
} | {
type: "new_round"
}

View file

@ -1,10 +1,10 @@
import type { SpecialCardType } from "./cards"
import type { SpecialCardId } from "./cards"
import type { GameAction } from "./actions"
import { produce } from "immer"
import { specialCardTypes } from "./cards"
import { cloneDeep, tail, without } from "lodash-es"
type SpecialCardCountByType = Record<SpecialCardType, number>
type SpecialCardCountByType = Record<SpecialCardId, number>
export interface GameStateNumberCard {
number: number
@ -96,8 +96,11 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
break
case "deal-special":
const p2 = state.players.find(p => p.id === action.toPlayerId)!
p2.specialCardCountByType[action.cardType]++
if (action.cardType !== undefined) {
const p2 = state.players.find(p => p.id === action.toPlayerId)!
p2.specialCardCountByType[action.cardType]++
}
break
case "hit":
@ -126,36 +129,12 @@ export const produceNewState = (oldState: GameState, action: GameAction) => prod
}
})
function applySpecialCardUsage(state: GameState, type: SpecialCardType, player: GameStatePlayer) {
function applySpecialCardUsage(state: GameState, type: SpecialCardId, player: GameStatePlayer) {
player.specialCardCountByType[type]--
switch (type) {
case "add-2-opponent":
// nothing
break
case "destroy-last-opponent-special":
// TODO
break
case "double-draw":
// nothing
break
case "increase-target-by-2":
state.targetSum += 2
break
case "next-round-covert":
player.nextRoundCovert = true
break
case "return-last-opponent":
// TODO
break
case "return-last-own":
// TODO
break
}
}

7
src/specialCards.ts Normal file
View file

@ -0,0 +1,7 @@
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
}