diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a45954 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# level-up + +> Interactive theater experience. + + +## Features + +- [ ] Interaction scenes + - [ ] voting + - [ ] selecting an interaction for execution + - [ ] notifying when the chosen interaction is executed + - [ ] showing/hiding items manually + - [ ] vote rigging + - [ ] textual notes attached to specific interactions + - [ ] items marked as "not essential to the completion of the scene" + - [ ] multistep crafting without prescribed order of _combine_ interactions + +- [ ] Choice scenes + - [ ] voting + - [ ] concluding the voting + - [ ] vote rigging + +- [ ] temporarily disabling player input/output +- [ ] pre-start scene +- [ ] scene timers +- [ ] authentication +- [ ] achievements \ No newline at end of file diff --git a/backend/game.ts b/backend/game.ts index bcdd539..3360d9d 100644 --- a/backend/game.ts +++ b/backend/game.ts @@ -1,115 +1,53 @@ -import EventEmitter from "eventemitter3" -import type { GameEvent } from "../shared/gameEvents" -import type { Interaction, InteractionQueueItem } from "../shared/script/types" +import EventEmitter from "node:events" +import type { PlayerBroadcast } from "../shared/broadcast" import { script } from "../shared/script" -import { findMatchingCombination, getInteractionQueueItemId } from "../shared/util" +import type { SceneDefinition } from "../shared/script/types" +import { sceneTypesById } from "./scene-types" interface Events { - "game-event": [GameEvent] + "player-broadcast": [PlayerBroadcast] } -export class Game extends EventEmitter { - private currentRoomId: string = script.roomsById.values().next()!.value!.id - private interactionQueue: Map = new Map() - private visibleObjectIds = new Set() +export interface CurrentScene { + id: string + definition: SceneDefinition & { type: Type } + state: ReturnType +} + +export class Game { + // @ts-expect-error + private currentScene: CurrentScene + public eventBus = new EventEmitter({ captureRejections: false }) constructor() { - super() - this.switchRoom(this.currentRoomId) + this.switchScene("pre-start") } - getInitialStateEvents(): GameEvent[] { - const events: GameEvent[] = [] - - events.push({ type: "room-changed", roomId: this.currentRoomId }) - - this.interactionQueue.forEach(item => events.push({ type: "interaction-queued", item })) - script.roomsById.get(this.currentRoomId)!.initialObjects.forEach(o => { - if (!this.visibleObjectIds.has(o.id)) events.push({ type: "object-visibility-changed", id: o.id, isVisible: false }) - }) - - script.roomsById.get(this.currentRoomId)!.hiddenObjects.forEach(o => { - if (this.visibleObjectIds.has(o.id)) events.push({ type: "object-visibility-changed", id: o.id, isVisible: true }) - }) + getConnectionPlayerBroadcasts(): PlayerBroadcast[] { + const events: PlayerBroadcast[] = [] + events.push({ type: "scene-changed", sceneId: this.currentScene.id }) + events.push(...this.currentScene.state.getConnectionEvents()) return events } - addInteractionVote(interaction: Interaction) { - const id = getInteractionQueueItemId(interaction) - const existingItem = this.interactionQueue.get(id) - if (existingItem === undefined) { - const item = { - id, - votes: 1, - interaction - } - - this.interactionQueue.set(item.id, item) - this.emit("game-event", { type: "interaction-queued", item }) - } else { - existingItem.votes += 1 - this.emit("game-event", { type: "interaction-votes-changed", id: existingItem.id, votes: existingItem.votes }) + switchScene(sceneId: string) { + const definition = script.scenesById.get(sceneId) + if (definition === undefined) throw new Error(`Unknown scene: ${sceneId}`) + const type = sceneTypesById[definition.type] + this.currentScene = { + id: sceneId, + definition, + state: type.createState(this, definition as any) } + this.eventBus.emit("player-broadcast", { type: "scene-changed", sceneId: sceneId }) } - removeInteractionVote(id: string) { - const item = this.interactionQueue.get(id) - if (item !== undefined) { - item.votes -= 1 - this.emit("game-event", { type: "interaction-votes-changed", id: item.id, votes: item.votes }) - - if (item.votes <= 0) this.interactionQueue.delete(id) + withSceneState(type: Type, block: (state: ReturnType) => void) { + if (this.currentScene.definition.type === type) { + block(this.currentScene.state) } } - - switchRoom(roomId: string) { - this.currentRoomId = roomId - this.interactionQueue.clear() - this.visibleObjectIds.clear() - script.roomsById.get(this.currentRoomId)!.initialObjects.forEach(o => this.visibleObjectIds.add(o.id)) - this.emit("game-event", { type: "room-changed", roomId }) - } - - activateInteractionQueueItem(id: string) { - const item = this.interactionQueue.get(id) - if (item === undefined) return - this.interactionQueue.delete(id) - this.emit("game-event", { type: "interaction-votes-changed", id, votes: 0 }) - switch (item.interaction.type) { - case "use": - this.setObjectVisibility(item.interaction.objectId, false) - break - - case "combine": - const matchingCombination = findMatchingCombination(script.roomsById.get(this.currentRoomId)!.combinations, item.interaction.objectIds) - - if (matchingCombination !== undefined) { - matchingCombination.inputs.forEach(input => { - if (input.isConsumed) this.setObjectVisibility(input.objectId, false) - }) - - matchingCombination.outputIds.forEach(outputId => { - this.setObjectVisibility(outputId, true) - }) - } - - break - } - } - - removeInteractionQueueItem(id: string) { - if (!this.interactionQueue.has(id)) return - this.emit("game-event", { type: "interaction-votes-changed", id, votes: 0 }) - this.interactionQueue.delete(id) - } - - setObjectVisibility(id: string, isVisible: boolean) { - if (isVisible) this.visibleObjectIds.add(id) - else this.visibleObjectIds.delete(id) - - this.emit("game-event", { type: "object-visibility-changed", id, isVisible }) - } } export const game = new Game() \ No newline at end of file diff --git a/backend/main.ts b/backend/main.ts index 5eed1b6..8a100b0 100644 --- a/backend/main.ts +++ b/backend/main.ts @@ -1,9 +1,7 @@ import createExpressApp, { static as staticMiddleware } from "express" import { listen } from "listhen" -import { WebSocketServer } from "ws" import { appRouter } from "./trpc" import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express" -import { applyWSSHandler } from "@trpc/server/adapters/ws" import { createContext } from "./trpc/base" import { isDev } from "./isDev" import { resolve } from "node:path" @@ -19,23 +17,10 @@ if (!isDev) expressApp.use(staticMiddleware(resolve(import.meta.dirname, "../dis const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false }) -const wss = new WebSocketServer({ server, path: "/ws" }) -const wssTrpcHandler = applyWSSHandler({ - wss, - router: appRouter, - createContext: () => createContext(undefined) -}) - const stop = () => { console.log("Received stop signal") server.close() - wssTrpcHandler.broadcastReconnectNotification() - wss.close(console.error) } process.on("SIGTERM", stop) -process.on("SIGINT", stop) - -process.on("exit", () => { - console.log("exit") -}) \ No newline at end of file +process.on("SIGINT", stop) \ No newline at end of file diff --git a/backend/scene-types/base.ts b/backend/scene-types/base.ts new file mode 100644 index 0000000..894bfc6 --- /dev/null +++ b/backend/scene-types/base.ts @@ -0,0 +1,11 @@ +import type { Game } from "../game" +import type { SceneDefinitionBase } from "../../shared/script/types" + +export interface SceneType { + id: DefinitionT["id"] + createState(game: Game, definition: DefinitionT): StateT +} + +export interface SceneState { + getConnectionEvents(): EventT[] +} diff --git a/backend/scene-types/choice.ts b/backend/scene-types/choice.ts new file mode 100644 index 0000000..a67d9c0 --- /dev/null +++ b/backend/scene-types/choice.ts @@ -0,0 +1,14 @@ +import { type SceneState, type SceneType } from "./base" +import type { Game } from "../game" +import type { ChoiceSceneDefinition, TextSceneDefinition } from "../../shared/script/types" + +export const ChoiceSceneType: SceneType = { + id: "choice", + createState(game: Game, definition: ChoiceSceneDefinition): SceneState { + return { + getConnectionEvents() { + return [] + } + } + } +} \ No newline at end of file diff --git a/backend/scene-types/index.ts b/backend/scene-types/index.ts new file mode 100644 index 0000000..c934cee --- /dev/null +++ b/backend/scene-types/index.ts @@ -0,0 +1,14 @@ +import type { InteractionSceneEvent } from "../../shared/scene-types/interaction" +import type { ChoiceSceneEvent } from "../../shared/scene-types/choice" +import type { TextSceneEvent } from "../../shared/scene-types/text" +import { InteractionSceneType } from "./interaction" +import { TextSceneType } from "./text" +import { ChoiceSceneType } from "./choice" + +export type SceneEvent = InteractionSceneEvent | ChoiceSceneEvent | TextSceneEvent + +export const sceneTypesById = { + "interaction": InteractionSceneType, + "choice": ChoiceSceneType, + "text": TextSceneType +} as const \ No newline at end of file diff --git a/backend/scene-types/interaction.ts b/backend/scene-types/interaction.ts new file mode 100644 index 0000000..5922e60 --- /dev/null +++ b/backend/scene-types/interaction.ts @@ -0,0 +1,121 @@ +import { type SceneState, type SceneType } from "./base" +import type { Game } from "../game" +import type { SuggestedInteraction } from "../../shared/mutations" +import type { InteractionSceneDefinition } from "../../shared/script/types" +import { getSuggestedInteractionId, type InteractionSceneEvent } from "../../shared/scene-types/interaction" + +export const InteractionSceneType = { + id: "interaction", + createState(game: Game, definition: InteractionSceneDefinition): InteractionSceneState { + return new InteractionSceneState(game, definition) + } +} as const satisfies SceneType + +export class InteractionSceneState implements SceneState { + private interactionQueue: Map = new Map() + private ongoingInteractionExecution: SuggestedInteraction | null = null + private objectVisibilityById = new Map() + + constructor(private game: Game, private definition: InteractionSceneDefinition) { + Object.values(definition.objects).forEach(o => this.objectVisibilityById.set(o.id, o.reveal)) + } + + getConnectionEvents(): InteractionSceneEvent[] { + const events: InteractionSceneEvent[] = [] + events.push(this.getVotesChangedEvent()) + + this.objectVisibilityById.entries().forEach(([id, isVisible]) => events.push({ type: "object-visibility-changed", id, isVisible })) + + if (this.ongoingInteractionExecution !== null) events.push({ type: "interaction-execution-started", interaction: this.ongoingInteractionExecution }) + + return events + } + + private emit(event: InteractionSceneEvent) { + this.game.emit("player-broadcast", { type: "scene-event", event }) + } + + private getVotesChangedEvent() { + return { type: "votes-changed", votesByInteractionId: Object.fromEntries(this.interactionQueue.entries().map(([id, { votes }]) => ([id, votes]))) } as const + } + + private emitVotesChanged() { + this.emit(this.getVotesChangedEvent()) + } + + addInteractionVote(interaction: SuggestedInteraction) { + const id = getSuggestedInteractionId(interaction) + const existingItem = this.interactionQueue.get(id) + if (existingItem === undefined) { + const item = { + id, + votes: 1, + interaction + } + + this.interactionQueue.set(item.id, item) + this.emit({ type: "interaction-queued", interaction }) + } else { + existingItem.votes += 1 + this.emitVotesChanged() + } + } + + removeInteractionVote(id: string) { + const item = this.interactionQueue.get(id) + if (item !== undefined) { + item.votes -= 1 + if (item.votes <= 0) this.interactionQueue.delete(id) + this.emitVotesChanged() + } + } + + startInteractionExecution(interaction: SuggestedInteraction) { + this.ongoingInteractionExecution = interaction + this.emit({ type: "interaction-execution-started", interaction }) + } + + finishInteractionExecution(id: string, requireStarted: boolean = true) { + if (requireStarted && (this.ongoingInteractionExecution === null || id !== getSuggestedInteractionId(this.ongoingInteractionExecution))) return + this.ongoingInteractionExecution = null + this.emit({ type: "interaction-execution-finished" }) + this.interactionQueue.delete(id) + this.emitVotesChanged() + + const interaction = this.definition.interactionsById.get(id) + if (interaction === undefined) return + + switch (interaction.type) { + case "use": + if (interaction.consume) { + this.emit({ type: "object-visibility-changed", id: interaction.objectId, isVisible: false }) + } + + break + + case "combine": + interaction.inputObjects.entries().forEach(([id, { consume }]) => { + if (consume) this.setObjectVisibility(id, false) + }) + + interaction.outputObjectIds.forEach(id => this.setObjectVisibility(id, true)) + + break + } + } + + removeInteractionQueueItem(id: string) { + if (!this.interactionQueue.has(id)) return + this.interactionQueue.delete(id) + this.emitVotesChanged() + } + + setObjectVisibility(objectId: string, isVisible: boolean) { + const current = this.objectVisibilityById.get(objectId) + if (current === undefined) throw new Error(`Unknown object: ${objectId}`) + if (current === isVisible) return + + this.objectVisibilityById.set(objectId, isVisible) + this.emit({ type: "object-visibility-changed", id: objectId, isVisible }) + } +} \ No newline at end of file diff --git a/backend/scene-types/text.ts b/backend/scene-types/text.ts new file mode 100644 index 0000000..ce201f5 --- /dev/null +++ b/backend/scene-types/text.ts @@ -0,0 +1,14 @@ +import { type SceneState, type SceneType } from "./base" +import type { Game } from "../game" +import type { TextSceneDefinition } from "../../shared/script/types" + +export const TextSceneType: SceneType = { + id: "text", + createState(game: Game, definition: TextSceneDefinition): SceneState { + return { + getConnectionEvents() { + return [] + } + } + } +} \ No newline at end of file diff --git a/backend/trpc/director.ts b/backend/trpc/director.ts index 4763427..e630fcb 100644 --- a/backend/trpc/director.ts +++ b/backend/trpc/director.ts @@ -8,31 +8,6 @@ export const directorRouter = t.router({ roomId: z.string() })) .mutation(async ({ input }) => { - game.switchRoom(input.roomId) - }), - - removeInteractionQueueItem: t.procedure - .input(z.object({ - id: z.string() - })) - .mutation(async ({ input }) => { - game.removeInteractionQueueItem(input.id) - }), - - activateInteractionQueueItem: t.procedure - .input(z.object({ - id: z.string() - })) - .mutation(async ({ input }) => { - game.activateInteractionQueueItem(input.id) - }), - - setObjectVisibility: t.procedure - .input(z.object({ - id: z.string(), - isVisible: z.boolean() - })) - .mutation(async ({ input }) => { - game.setObjectVisibility(input.id, input.isVisible) - }), + game.switchScene(input.roomId) + }) }) \ No newline at end of file diff --git a/backend/trpc/index.ts b/backend/trpc/index.ts index 52e519d..9f2721f 100644 --- a/backend/trpc/index.ts +++ b/backend/trpc/index.ts @@ -1,28 +1,24 @@ import { t } from "./base" import { playerRouter } from "./player" -import { observable } from "@trpc/server/observable" -import type { GameEvent } from "../../shared/gameEvents" import { directorRouter } from "./director" import { game } from "../game" +import { on } from "node:events" export const appRouter = t.router({ player: playerRouter, director: directorRouter, join: t.procedure - .subscription(({ ctx }) => { - return observable(emit => { - const handleGameEvent = (event: GameEvent) => emit.next(event) - const handleDestroyed = () => setTimeout(() => emit.complete(), 500) + .subscription(async function*() { + const iterable = on(game.eventBus, "player-broadcast") - game.on("game-event", handleGameEvent) + for (const broadcast of game.getConnectionPlayerBroadcasts()) { + yield broadcast + } - game.getInitialStateEvents().forEach(event => emit.next(event)) - - return () => { - game.off("game-event", handleGameEvent) - } - }) + for await (const broadcast of iterable) { + yield broadcast + } }), }) diff --git a/backend/trpc/player.ts b/backend/trpc/player.ts index 3246cec..44ec3a7 100644 --- a/backend/trpc/player.ts +++ b/backend/trpc/player.ts @@ -1,22 +1,5 @@ import { t } from "./base" -import { z } from "zod" -import { interactionSchema } from "../../shared/script/types" -import { game } from "../game" export const playerRouter = t.router({ - voteForInteraction: t.procedure - .input(z.object({ - interaction: interactionSchema - })) - .mutation(async ({ input, ctx }) => { - game.addInteractionVote(input.interaction) - }), - removeInteractionVote: t.procedure - .input(z.object({ - queueItemId: z.string() - })) - .mutation(async ({ input, ctx }) => { - game.removeInteractionVote(input.queueItemId) - }), }) \ No newline at end of file diff --git a/frontend/components/InteractionQueueItemCard.vue b/frontend/components/InteractionQueueItemCard.vue index 5c9c32d..d9542e6 100644 --- a/frontend/components/InteractionQueueItemCard.vue +++ b/frontend/components/InteractionQueueItemCard.vue @@ -36,7 +36,7 @@ +1