Fix UI screens

This commit is contained in:
Moritz Ruth 2025-04-12 21:51:01 +02:00
parent 34fa93ad44
commit c92ff91e01
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
35 changed files with 480 additions and 180 deletions

View file

@ -23,6 +23,10 @@ export class Game {
constructor() {
this.switchScene("pre-start")
setInterval(() => {
this.eventBus.emit("player-broadcast", { type: "keep-alive" })
}, 10000)
}
getConnectionPlayerBroadcasts(): PlayerBroadcast[] {

View file

@ -19,6 +19,7 @@ const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false }
const stop = () => {
console.log("Received stop signal")
server.closeAllConnections()
server.close()
}

View file

@ -19,7 +19,7 @@ export class InteractionSceneState implements SceneState<InteractionSceneEvent>
private objectVisibilityById = new Map<string, boolean>()
constructor(private game: Game, private definition: InteractionSceneDefinition) {
Object.values(definition.objects).forEach(o => this.objectVisibilityById.set(o.id, o.reveal))
definition.objectsById.entries().forEach(([id, o]) => this.objectVisibilityById.set(id, o.reveal))
}
getConnectionEvents(): InteractionSceneEvent[] {

View file

@ -10,7 +10,7 @@ export interface Context {
}
export async function createContext(req: Request, res: Response | undefined): Promise<Context> {
const sessionId = req.headers["auio-session"]
const sessionId = req.headers["auio-session-id"]
if (sessionId === null || typeof sessionId !== "string" || sessionId.length !== 64) throw new Error(`Missing or invalid session ID: ${sessionId}`)
return {

View file

@ -10,12 +10,12 @@ const crewProcedure = t.procedure.use(({ ctx, next }) => {
})
export const crewRouter = t.router({
switchRoom: crewProcedure
switchScene: crewProcedure
.input(z.object({
roomId: z.string()
sceneId: z.string()
}))
.mutation(async ({ input }) => {
game.switchScene(input.roomId)
game.switchScene(input.sceneId)
}),
interactionScene: t.router({

View file

@ -7,7 +7,7 @@ import type { PlayerBroadcast } from "../../shared/broadcast"
export const appRouter = t.router({
player: playerRouter,
director: crewRouter,
crew: crewRouter,
join: t.procedure
.subscription(async function*({ signal, ctx }) {
@ -21,9 +21,11 @@ export const appRouter = t.router({
yield broadcast
}
for await (const broadcast of iterable) {
for await (const broadcasts of iterable) {
for (const broadcast of broadcasts) {
yield broadcast as unknown as PlayerBroadcast
}
}
}),
})

View file

@ -2,31 +2,8 @@
<div class="bg-gray-900 h-[100dvh] overflow-hidden text-white">
<div :class="$style.noise"/>
<div :class="$style.vignette"/>
<div class="h-full relative flex flex-col">
<nav class="mx-auto p-4 flex gap-4 overflow-x-auto max-w-full">
<div class="flex-shrink-0 flex items-center rounded-lg bg-dark-400 overflow-hidden">
<button
v-for="tab in screens"
:key="tab.id"
class="px-4 py-2 bg-gray-700"
:class="activeScreenId !== tab.id && 'bg-opacity-0'"
@click="activeScreenId = tab.id"
>
{{ tab.label }}
</button>
</div>
<button v-if="!isFullscreen" class="px-3 rounded-lg bg-dark-400 flex items-center justify-center" @click="enterFullscreen()">
<CornersOutIcon/>
</button>
</nav>
<main class="flex-grow">
<transition name="fade" mode="out-in">
<component :is="screens.find(t => t.id === activeScreenId)!.component" @switch-screen="(id: string) => (activeScreenId = id)"/>
</transition>
</main>
</div>
<div class="flex flex-col justify-center items-center text-lg inset-0 absolute bg-dark-900 transition" :class="!isLoading && 'opacity-0 pointer-events-none'">
<span class="text-center">Verbindung wird hergestellt</span>
<div class="absolute inset-0">
<component :is="SCREENS.find(s => s.id === screenId)!.component"/>
</div>
</div>
</template>
@ -101,68 +78,42 @@
</style>
<script setup lang="ts">
import { type Component, computed, ref, watchEffect } from "vue"
import { trpcClient } from "./trpc"
import { useGame } from "./game"
import InteractionsScreen from "./screens/InteractionsScreen.vue"
import QueueScreen from "./screens/QueueScreen.vue"
import CornersOutIcon from "virtual:icons/ph/corners-out-bold"
import { useBrowserLocation, useFullscreen } from "@vueuse/core"
import DirectorScreen from "./screens/DirectorScreen.vue"
import { type Component, computed } from "vue"
import { useBrowserLocation } from "@vueuse/core"
import PlayerScreen from "./screens/PlayerScreen.vue"
import CrewLoginScreen from "./screens/CrewLoginScreen.vue"
import DuoScreen from "./screens/DuoScreen.vue"
const isLoading = ref(true)
const activeScreenId = ref<string>("interactions")
const game = useGame()
const { isFullscreen, enter: enterFullscreen } = useFullscreen()
const location = useBrowserLocation()
const isDirector = computed(() => location.value.hash === "#director")
watchEffect(() => {
if (isDirector.value) activeScreenId.value = "director"
else activeScreenId.value = "interactions"
})
const screenId = computed(() => location.value.search?.slice(1))
interface Screen {
id: string
label: string
isCrewOnly: boolean
component: Component
}
const screens = computed(() => {
const result: Screen[] = [
const SCREENS: Screen[] = [
{
id: "interactions",
label: "Interagieren",
component: InteractionsScreen
id: "player",
isCrewOnly: false,
component: PlayerScreen
},
{
id: "queue",
label: "Abstimmen",
component: QueueScreen
id: "crew-login",
isCrewOnly: false,
component: CrewLoginScreen
},
{
id: "duo",
isCrewOnly: true,
component: DuoScreen
}
]
if (isDirector.value) result.push({
id: "director",
label: "Regie",
component: DirectorScreen
})
return result
})
trpcClient.join.subscribe(undefined, {
onStarted: () => {
isLoading.value = false
},
onData: game.handlePlayerBroadcast,
onError: error => {
console.error("🔴", error)
},
onStopped() {
window.location.reload()
},
})
const matchingScreen = SCREENS.find(s => s.id === screenId.value)
if (matchingScreen === undefined || (matchingScreen.isCrewOnly && !Boolean(window.localStorage.getItem("crew-token")))) {
location.value.search = "?player"
}
</script>

View file

@ -0,0 +1,51 @@
<template>
<div class="h-full relative flex flex-col">
<nav class="mx-auto p-4 flex gap-4 overflow-x-auto max-w-full">
<div class="flex-shrink-0 flex items-center rounded-lg bg-dark-400 overflow-hidden">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 bg-gray-700"
:class="activeTabId !== tab.id && 'bg-opacity-0'"
@click="activeTabId = tab.id"
>
{{ tab.label }}
</button>
</div>
</nav>
<main class="flex-grow">
<transition name="fade" mode="out-in">
<component :is="tabs.find(t => t.id === activeTabId)!.content" @switch-screen="(id: string) => (activeTabId = id)"/>
</transition>
</main>
</div>
</template>
<style module lang="scss">
</style>
<script lang="ts">
import type { Component } from "vue"
export interface Tab {
id: string
label: string
content: Component
}
</script>
<script setup lang="ts">
import { useVModel } from "@vueuse/core"
const props = defineProps<{
tabs: Tab[]
activeTabId: string
}>()
const emit = defineEmits<{
"update:activeTabId": [string]
}>()
const activeTabId = useVModel(props, "activeTabId", emit)
</script>

View file

@ -1,16 +1,71 @@
import { defineStore } from "pinia"
import { computed, ref } from "vue"
import { ref, shallowRef } from "vue"
import { script } from "../shared/script"
import type { PlayerBroadcast } from "../shared/broadcast"
import { trpcClient } from "./trpc"
import type { SceneDefinition, TextSceneDefinition } from "../shared/script/types"
import { sceneTypesById } from "./scene-types"
import { throwError } from "../shared/util"
export interface CurrentScene<Type extends keyof typeof sceneTypesById> {
id: string
type: typeof sceneTypesById[Type]
definition: SceneDefinition & { type: Type }
controller: ReturnType<typeof sceneTypesById[Type]["createController"]>
}
export const useGame = defineStore("gameState", () => {
const currentSceneId = ref(script.scenesById.values().next().value!.id)
const currentScene = computed(() => script.scenesById.get(currentSceneId.value)!)
const currentScene = shallowRef<CurrentScene<keyof typeof sceneTypesById>>({
id: "pre-start",
type: sceneTypesById.text,
definition: script.scenesById.get("pre-start") as TextSceneDefinition,
controller: sceneTypesById.text.createController(script.scenesById.get("pre-start") as TextSceneDefinition)
})
const isConnected = ref(false)
const isCrew = window.localStorage.getItem("crew-token") !== null
async function switchScene(id: string) {
await trpcClient.crew.switchScene.mutate({ sceneId: id })
}
function handlePlayerBroadcast(broadcast: PlayerBroadcast) {
switch (broadcast.type) {
case "scene-changed":
const definition = script.scenesById.get(broadcast.sceneId) ?? throwError(`Unknown scene: ${broadcast.sceneId}`)
const type = sceneTypesById[definition.type]
currentScene.value = {
id: broadcast.sceneId,
type,
definition,
controller: type.createController(definition as any)
}
break
case "scene-event":
currentScene.value.controller.handleEvent(broadcast.event)
break
}
}
trpcClient.join.subscribe(undefined, {
onStarted: () => {
isConnected.value = true
},
onData: handlePlayerBroadcast,
onError(error) {
console.error("🔴", error)
},
onStopped() {
window.location.reload()
},
})
return {
isCrew,
isConnected,
currentScene,
handlePlayerBroadcast(broadcast: PlayerBroadcast) {
console.log(broadcast)
}
switchScene
}
})

View file

@ -3,7 +3,8 @@ import type { Component } from "vue"
export interface SceneType<DefinitionT extends SceneDefinitionBase, ControllerT extends SceneController> {
id: DefinitionT["type"]
playerView: Component<{ controller: ControllerT }>
playerView: Component<{ controller: ControllerT, definition: DefinitionT }>,
duoView: Component<{ controller: ControllerT, definition: DefinitionT }>
createController(definition: DefinitionT): ControllerT
}

View file

@ -0,0 +1,17 @@
<template>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import type { ChoiceSceneDefinition } from "../../../shared/script/types"
import type { ChoiceSceneController } from "./index"
const props = defineProps<{
controller: ChoiceSceneController
definition: ChoiceSceneDefinition
}>()
</script>

View file

@ -0,0 +1,17 @@
<template>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import type { ChoiceSceneDefinition } from "../../../shared/script/types"
import type { ChoiceSceneController } from "./index"
const props = defineProps<{
controller: ChoiceSceneController
definition: ChoiceSceneDefinition
}>()
</script>

View file

@ -0,0 +1,22 @@
import type { SceneController, SceneType } from "../base"
import type { ChoiceSceneDefinition } from "../../../shared/script/types"
import PlayerView from "./PlayerView.vue"
import type { ChoiceSceneEvent } from "../../../shared/scene-types/choice"
import DuoView from "./DuoView.vue"
export const ChoiceSceneType: SceneType<ChoiceSceneDefinition, ChoiceSceneController> = {
id: "choice",
playerView: PlayerView,
duoView: DuoView,
createController(definition: ChoiceSceneDefinition): ChoiceSceneController {
return {
handleEvent(event: ChoiceSceneEvent) {
}
}
}
}
export interface ChoiceSceneController extends SceneController {
}

View file

@ -1,5 +1,9 @@
import { TextSceneType } from "./text/text"
import { TextSceneType } from "./text"
import { ChoiceSceneType } from "./choice"
import { InteractionSceneType } from "./interaction"
export const sceneTypesById = {
"text": TextSceneType,
"choice": ChoiceSceneType,
"interaction": InteractionSceneType,
} as const

View file

@ -0,0 +1,54 @@
<template>
<section class="flex-grow">
<div class="font-bold text-2xl pb-2">Versteckte Objekte</div>
<div class="grid gap-3 grid-cols-2 flex-grow auto-rows-min p-4 pt-0 relative">
<button
v-for="[objectId, object] in definition.objectsById.entries().filter(([id, _o]) => !controller.visibleObjectIds.has(id))"
:key="objectId"
class="flex flex-col items-center gap-2 bg-dark-600 rounded-lg p-3"
@click="controller.setObjectVisibility(objectId, true)"
>
<ObjectPicture :object-id="objectId"/>
<span class="text-sm text-gray-200 text-center">
{{ object.label }}
</span>
</button>
</div>
</section>
<section class="w-80">
<div class="font-bold text-2xl pb-2">Interaktionen</div>
<transition name="fade" mode="out-in">
<div v-if="controller.sortedInteractionQueue.value.length === 0" class="text-xl text-gray-200">
Keine Interaktionen vorhanden.
</div>
<transition-group v-else tag="div" name="list" class="flex flex-col gap-4 relative h-80vh">
<InteractionQueueItemCard
v-for="(item, index) in controller.sortedInteractionQueue.value"
:key="item.id"
:style="{ zIndex: 1000 - index }"
:item="item"
mode="director"
/>
</transition-group>
</transition>
</section>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import ObjectPicture from "./ObjectPicture.vue"
import InteractionQueueItemCard from "./InteractionQueueItemCard.vue"
import type { InteractionSceneController } from "./index"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
import { useGame } from "../../game"
const props = defineProps<{
controller: InteractionSceneController
definition: InteractionSceneDefinition
}>()
const game = useGame()
</script>

View file

@ -62,10 +62,8 @@
import TrashIcon from "virtual:icons/ph/trash-bold"
import CheckIcon from "virtual:icons/ph/check-bold"
import HandPointingIcon from "virtual:icons/ph/hand-pointing-duotone"
import type { InteractionQueueItem } from "../../shared/script/types"
import ObjectPicture from "./ObjectPicture.vue"
import { useGame } from "../game"
import { findMatchingCombinationInteraction } from "../../shared/util"
import { useGame } from "../../game"
const props = defineProps<{
item: InteractionQueueItem

View file

@ -37,7 +37,7 @@
</style>
<script setup lang="ts">
import type { SceneObjectDefinition } from "../../shared/script/types"
import type { SceneObjectDefinition } from "../../../shared/script/types"
import interact from "@interactjs/interact"
import { useCurrentElement } from "@vueuse/core"
import { computed, onMounted, onUnmounted, ref, useCssModule } from "vue"

View file

@ -35,11 +35,11 @@
</style>
<script setup lang="ts">
import ObjectCard from "../components/ObjectCard.vue"
import { useGame } from "../game"
import ObjectCard from "./ObjectCard.vue"
import { useGame } from "../../game"
import { computed, reactive, ref } from "vue"
import { useScrollLock } from "@vueuse/core"
import ObjectCardDropZone from "../components/ObjectCardDropZone.vue"
import ObjectCardDropZone from "./ObjectCardDropZone.vue"
const game = useGame()

View file

@ -0,0 +1,17 @@
<template>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import type { InteractionSceneController } from "./index"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
const props = defineProps<{
controller: InteractionSceneController
definition: InteractionSceneDefinition
}>()
</script>

View file

@ -28,8 +28,8 @@
</style>
<script setup lang="ts">
import InteractionQueueItemCard from "../components/InteractionQueueItemCard.vue"
import { useGame } from "../game"
import InteractionQueueItemCard from "./InteractionQueueItemCard.vue"
import { useGame } from "../../game"
import ArrowRightIcon from "virtual:icons/ph/arrow-right"
const emit = defineEmits<{

View file

@ -0,0 +1,44 @@
import type { SceneController, SceneType } from "../base"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
import PlayerView from "./PlayerView.vue"
import type { InteractionSceneEvent } from "../../../shared/scene-types/interaction"
import DuoView from "./DuoView.vue"
import type { SuggestedInteraction } from "../../../shared/mutations"
import { computed, reactive, type Reactive, type Ref } from "vue"
import { trpcClient } from "../../trpc"
export const InteractionSceneType: SceneType<InteractionSceneDefinition, InteractionSceneController> = {
id: "interaction",
playerView: PlayerView,
duoView: DuoView,
createController(definition: InteractionSceneDefinition): InteractionSceneController {
const visibleObjectIds = reactive(new Set<string>())
const interactionQueue = reactive(new Map<string, InteractionQueueItem>())
return {
visibleObjectIds,
interactionQueue,
sortedInteractionQueue: computed(() => [...interactionQueue.values()].sort((a, b) => a.votes - b.votes)),
handleEvent(event: InteractionSceneEvent) {
},
async setObjectVisibility(objectId: string, isVisible: boolean) {
await trpcClient.crew.interactionScene.setObjectVisibility.mutate({ objectId, isVisible })
}
}
}
}
export interface InteractionQueueItem {
id: string
interaction: SuggestedInteraction
votes: number
}
export interface InteractionSceneController extends SceneController {
visibleObjectIds: Reactive<Set<string>>
interactionQueue: Reactive<Map<string, InteractionQueueItem>>
sortedInteractionQueue: Readonly<Ref<Array<InteractionQueueItem>>>
setObjectVisibility(objectId: string, isVisible: boolean): Promise<void>
}

View file

@ -0,0 +1,17 @@
<template>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import type { TextSceneDefinition } from "../../../shared/script/types"
import type { TextSceneController } from "./index"
const props = defineProps<{
controller: TextSceneController
definition: TextSceneDefinition
}>()
</script>

View file

@ -1,5 +1,9 @@
<template>
<div class="absolute inset-0 flex flex-col justify-center items-center p-10">
<div class="text-center text-xl leading-relaxed">
{{ definition.text }}
</div>
</div>
</template>
<style module lang="scss">
@ -7,9 +11,11 @@
</style>
<script setup lang="ts">
import type { TextSceneController } from "./text"
import type { TextSceneController } from "./index"
import type { TextSceneDefinition } from "../../../shared/script/types"
const props = defineProps<{
controller: TextSceneController
definition: TextSceneDefinition
}>()
</script>

View file

@ -2,10 +2,12 @@ import type { SceneController, SceneType } from "../base"
import type { TextSceneDefinition } from "../../../shared/script/types"
import type { TextSceneEvent } from "../../../shared/scene-types/text"
import PlayerView from "./PlayerView.vue"
import DuoView from "./DuoView.vue"
export const TextSceneType: SceneType<TextSceneDefinition, TextSceneController> = {
id: "text",
playerView: PlayerView,
duoView: DuoView,
createController(definition: TextSceneDefinition): TextSceneController {
return {
handleEvent(event: TextSceneEvent) {

View file

@ -0,0 +1,15 @@
<template>
<div class="absolute inset-0 flex flex-col justify-center items-center p-10">
<input type="text" v-model="token">
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { useLocalStorage } from "@vueuse/core"
const token = useLocalStorage("crew-token", null, { writeDefaults: false })
</script>

View file

@ -1,72 +0,0 @@
<template>
<div class="flex gap-6 p-4">
<section class="w-80">
<div class="font-bold text-2xl pb-2">Raum</div>
<div class="flex flex-col overflow-y-auto bg-dark-900 bg-opacity-50 h-80vh">
<template
v-for="(room, index) in script.roomsById.values()"
:key="room.id"
>
<div v-if="index !== 0" class="w-full h-1px bg-dark-300"/>
<div class="flex-shrink-0 bg-dark-600 flex items-center">
<div class="px-3 py-2 flex-grow">
{{ room.label }}
</div>
<button v-if="game.currentRoomId === room.id" disabled class="bg-green-900 h-full px-3 text-sm cursor-not-allowed">
Aktiv
</button>
<button v-else class="bg-dark-300 h-full px-3 text-sm" @click="game.switchRoom(room.id)">
Aktivieren
</button>
</div>
</template>
</div>
</section>
<section class="flex-grow">
<div class="font-bold text-2xl pb-2">Versteckte Objekte</div>
<div class="grid gap-3 grid-cols-2 flex-grow auto-rows-min p-4 pt-0 relative">
<button
v-for="object in game.allObjectsById.values().filter(o => !game.visibleObjectIds.has(o.id))"
:key="object.id"
class="flex flex-col items-center gap-2 bg-dark-600 rounded-lg p-3"
@click="game.setObjectVisibility(object.id, true)"
>
<ObjectPicture :object-id="object.id"/>
<span class="text-sm text-gray-200 text-center">
{{ object.label }}
</span>
</button>
</div>
</section>
<section class="w-80">
<div class="font-bold text-2xl pb-2">Interaktionen</div>
<transition name="fade" mode="out-in">
<div v-if="game.sortedInteractionQueue.length === 0" class="text-xl text-gray-200">
Keine Interaktionen vorhanden.
</div>
<transition-group v-else tag="div" name="list" class="flex flex-col gap-4 relative h-80vh">
<InteractionQueueItemCard
v-for="(item, index) in game.sortedInteractionQueue"
:key="item.id"
:style="{ zIndex: 1000 - index }"
:item="item"
mode="director"
/>
</transition-group>
</transition>
</section>
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { script } from "../../shared/script"
import { useGame } from "../game"
import ObjectPicture from "../components/ObjectPicture.vue"
import InteractionQueueItemCard from "../components/InteractionQueueItemCard.vue"
const game = useGame()
</script>

View file

@ -0,0 +1,35 @@
<template>
<div class="flex gap-6 p-4">
<section class="w-80">
<div class="font-bold text-2xl pb-2">Szene</div>
<div class="flex flex-col overflow-y-auto bg-dark-900 bg-opacity-50 h-80vh">
<template v-for="(scene, index) in script.scenesById.values()" :key="scene.id">
<div v-if="index !== 0" class="w-full h-1px bg-dark-300"/>
<div class="flex-shrink-0 bg-dark-600 flex items-center">
<div class="px-3 py-2 flex-grow">
{{ scene.label }}
</div>
<button v-if="game.currentScene.id === scene.id" disabled class="bg-green-900 h-full px-3 text-sm cursor-not-allowed">
Aktiv
</button>
<button v-else class="bg-dark-300 h-full px-3 text-sm cursor-pointer" @click="game.switchScene(scene.id)">
Aktivieren
</button>
</div>
</template>
</div>
</section>
<component :is="game.currentScene.type.duoView" :controller="game.currentScene.controller" :definition="game.currentScene.definition"/>
</div>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { script } from "../../shared/script"
import { useGame } from "../game"
const game = useGame()
</script>

View file

@ -0,0 +1,16 @@
<template>
<div class="flex flex-col justify-center items-center text-lg inset-0 absolute bg-dark-900 transition" :class="game.isConnected && 'opacity-0 pointer-events-none'">
<span class="text-center">Verbindung wird hergestellt</span>
</div>
<component :is="game.currentScene.type.playerView" :controller="game.currentScene.controller" :definition="game.currentScene.definition"/>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
import { useGame } from "../game"
const game = useGame()
</script>

View file

@ -1,14 +1,34 @@
import { createTRPCClient, httpLink, httpSubscriptionLink, loggerLink, splitLink } from "@trpc/client"
import superjson from "superjson"
import type { AppRouter } from "../backend"
import { EventSourcePolyfill } from "event-source-polyfill"
import { nanoid } from "nanoid"
const crewToken = window.localStorage.getItem("crew-token")
const sessionId = nanoid(64)
const headers = {
"auio-crew-token": crewToken ?? "",
"auio-session-id": sessionId,
}
export const trpcClient = createTRPCClient<AppRouter>({
links: [
loggerLink(),
splitLink({
condition: op => op.type === "subscription",
true: httpSubscriptionLink({ url: "/trpc", transformer: superjson }),
false: httpLink({ url: "/trpc", transformer: superjson }),
true: httpSubscriptionLink({
url: "/trpc",
transformer: superjson,
EventSource: EventSourcePolyfill,
eventSourceOptions: {
headers
}
}),
false: httpLink({
url: "/trpc",
transformer: superjson,
headers
}),
})
]
})

View file

@ -10,6 +10,7 @@
},
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"@types/event-source-polyfill": "^1.0.5",
"@types/express": "^5.0.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.0",
@ -36,6 +37,7 @@
"bufferutil": "^4.0.9",
"core-js": "^3.41.0",
"date-fns": "^4.1.0",
"event-source-polyfill": "^1.0.31",
"eventemitter3": "^5.0.1",
"express": "^5.1.0",
"listhen": "^1.9.0",

16
pnpm-lock.yaml generated
View file

@ -47,6 +47,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
event-source-polyfill:
specifier: ^1.0.31
version: 1.0.31
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
@ -90,6 +93,9 @@ importers:
'@iconify-json/ph':
specifier: ^1.2.2
version: 1.2.2
'@types/event-source-polyfill':
specifier: ^1.0.5
version: 1.0.5
'@types/express':
specifier: ^5.0.1
version: 5.0.1
@ -585,6 +591,9 @@ packages:
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/event-source-polyfill@1.0.5':
resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==}
'@types/express-serve-static-core@5.0.6':
resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==}
@ -1001,6 +1010,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-source-polyfill@1.0.31:
resolution: {integrity: sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@ -1998,6 +2010,8 @@ snapshots:
'@types/estree@1.0.7': {}
'@types/event-source-polyfill@1.0.5': {}
'@types/express-serve-static-core@5.0.6':
dependencies:
'@types/node': 22.14.0
@ -2486,6 +2500,8 @@ snapshots:
etag@1.8.1: {}
event-source-polyfill@1.0.31: {}
eventemitter3@5.0.1: {}
execa@8.0.1:

View file

@ -1,5 +1,6 @@
import type { SceneEvent } from "../backend/scene-types"
export type PlayerBroadcast =
| { type: "keep-alive" }
| { type: "scene-changed"; sceneId: string }
| { type: "scene-event"; event: SceneEvent }

View file

@ -5,3 +5,7 @@ export const cMap = <K extends string, V>(object: Partial<Record<K, V>>): Map<K,
Object.entries(object).forEach(([k, v]) => result.set(k as K, v as V))
return result
}
export const throwError = (message: string): never => {
throw new Error(message)
}