Fix UI screens
This commit is contained in:
parent
34fa93ad44
commit
c92ff91e01
35 changed files with 480 additions and 180 deletions
|
@ -23,6 +23,10 @@ export class Game {
|
|||
|
||||
constructor() {
|
||||
this.switchScene("pre-start")
|
||||
|
||||
setInterval(() => {
|
||||
this.eventBus.emit("player-broadcast", { type: "keep-alive" })
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
getConnectionPlayerBroadcasts(): PlayerBroadcast[] {
|
||||
|
|
|
@ -19,6 +19,7 @@ const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false }
|
|||
|
||||
const stop = () => {
|
||||
console.log("Received stop signal")
|
||||
server.closeAllConnections()
|
||||
server.close()
|
||||
}
|
||||
|
||||
|
|
|
@ -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[] {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
51
frontend/components/TabbedContainer.vue
Normal file
51
frontend/components/TabbedContainer.vue
Normal 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>
|
|
@ -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
|
||||
}
|
||||
})
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
17
frontend/scene-types/choice/DuoView.vue
Normal file
17
frontend/scene-types/choice/DuoView.vue
Normal 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>
|
17
frontend/scene-types/choice/PlayerView.vue
Normal file
17
frontend/scene-types/choice/PlayerView.vue
Normal 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>
|
22
frontend/scene-types/choice/index.ts
Normal file
22
frontend/scene-types/choice/index.ts
Normal 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 {
|
||||
|
||||
}
|
|
@ -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
|
54
frontend/scene-types/interaction/DuoView.vue
Normal file
54
frontend/scene-types/interaction/DuoView.vue
Normal 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>
|
|
@ -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
|
|
@ -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"
|
|
@ -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()
|
||||
|
17
frontend/scene-types/interaction/PlayerView.vue
Normal file
17
frontend/scene-types/interaction/PlayerView.vue
Normal 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>
|
|
@ -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<{
|
44
frontend/scene-types/interaction/index.ts
Normal file
44
frontend/scene-types/interaction/index.ts
Normal 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>
|
||||
}
|
17
frontend/scene-types/text/DuoView.vue
Normal file
17
frontend/scene-types/text/DuoView.vue
Normal 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>
|
|
@ -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>
|
|
@ -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) {
|
15
frontend/screens/CrewLoginScreen.vue
Normal file
15
frontend/screens/CrewLoginScreen.vue
Normal 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>
|
|
@ -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>
|
35
frontend/screens/DuoScreen.vue
Normal file
35
frontend/screens/DuoScreen.vue
Normal 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>
|
16
frontend/screens/PlayerScreen.vue
Normal file
16
frontend/screens/PlayerScreen.vue
Normal 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>
|
|
@ -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
|
||||
}),
|
||||
})
|
||||
]
|
||||
})
|
||||
|
|
|
@ -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
16
pnpm-lock.yaml
generated
|
@ -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:
|
||||
|
|
|
@ -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 }
|
|
@ -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)
|
||||
}
|
Loading…
Add table
Reference in a new issue