Fix UI screens
All checks were successful
Build / build (push) Successful in 1m26s

This commit is contained in:
Moritz Ruth 2025-04-12 23:29:25 +02:00
parent c92ff91e01
commit 97e70432f0
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
36 changed files with 149 additions and 57 deletions

View file

@ -2,7 +2,7 @@ import EventEmitter from "node:events"
import type { PlayerBroadcast } from "../shared/broadcast"
import { script } from "../shared/script"
import type { SceneDefinition } from "../shared/script/types"
import { sceneTypesById } from "./scene-types"
import { type SceneEvent, sceneTypesById } from "./scene-types"
import type { Tagged } from "type-fest"
import type { SessionId } from "./session"
@ -32,7 +32,7 @@ export class Game {
getConnectionPlayerBroadcasts(): PlayerBroadcast[] {
const events: PlayerBroadcast[] = []
events.push({ type: "scene-changed", sceneId: this.currentScene.id })
events.push(...this.currentScene.state.getConnectionEvents())
events.push(...this.currentScene.state.getConnectionEvents().map((event: SceneEvent) => ({ type: "scene-event", event })))
return events
}
@ -45,12 +45,14 @@ export class Game {
const definition = script.scenesById.get(sceneId)
if (definition === undefined) throw new Error(`Unknown scene: ${sceneId}`)
const type = sceneTypesById[definition.type]
this.eventBus.emit("player-broadcast", { type: "scene-changed", sceneId: sceneId })
this.currentScene = {
id: sceneId,
definition,
state: type.createState(this, definition as any)
}
this.eventBus.emit("player-broadcast", { type: "scene-changed", sceneId: sceneId })
}
withSceneState<Type extends keyof typeof sceneTypesById, R>(type: Type, block: (state: ReturnType<typeof sceneTypesById[Type]["createState"]>) => R): R | null {

View file

@ -19,14 +19,14 @@ export class InteractionSceneState implements SceneState<InteractionSceneEvent>
private objectVisibilityById = new Map<string, boolean>()
constructor(private game: Game, private definition: InteractionSceneDefinition) {
definition.objectsById.entries().forEach(([id, o]) => this.objectVisibilityById.set(id, o.reveal))
definition.objectsById.entries().forEach(([id, o]) => this.setObjectVisibility(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 }))
this.objectVisibilityById.entries().forEach(([id, isVisible]) => events.push({ type: "object-visibility-changed", objectId: id, isVisible }))
if (this.ongoingInteractionExecution !== null) events.push({ type: "interaction-execution-started", interaction: this.ongoingInteractionExecution })
@ -107,7 +107,7 @@ export class InteractionSceneState implements SceneState<InteractionSceneEvent>
switch (interaction.type) {
case "use":
if (interaction.consume) {
this.emit({ type: "object-visibility-changed", id: interaction.objectId, isVisible: false })
this.emit({ type: "object-visibility-changed", objectId: interaction.objectId, isVisible: false })
}
break
@ -139,10 +139,9 @@ export class InteractionSceneState implements SceneState<InteractionSceneEvent>
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 })
this.emit({ type: "object-visibility-changed", objectId: objectId, isVisible })
}
}

View file

@ -1,8 +1,8 @@
<template>
<div class="bg-gray-900 h-[100dvh] overflow-hidden text-white">
<div class="bg-gray-900 h-[100dvh] text-white">
<div :class="$style.noise"/>
<div :class="$style.vignette"/>
<div class="absolute inset-0">
<div class="absolute inset-0 overflow-hidden">
<component :is="SCREENS.find(s => s.id === screenId)!.component"/>
</div>
</div>

View file

@ -15,7 +15,10 @@
</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)"/>
<component
:is="tabs.find(t => t.id === activeTabId)!.content"
@switch-screen="(id: string) => (activeTabId = id)"
/>
</transition>
</main>
</div>
@ -45,6 +48,7 @@
const emit = defineEmits<{
"update:activeTabId": [string]
[k: `content:${string}`]: [unknown]
}>()
const activeTabId = useVModel(props, "activeTabId", emit)

View file

@ -1,5 +1,5 @@
import { defineStore } from "pinia"
import { ref, shallowRef } from "vue"
import { markRaw, ref, shallowRef } from "vue"
import { script } from "../shared/script"
import type { PlayerBroadcast } from "../shared/broadcast"
import { trpcClient } from "./trpc"
@ -19,7 +19,7 @@ export const useGame = defineStore("gameState", () => {
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)
controller: markRaw(sceneTypesById.text.createController(script.scenesById.get("pre-start") as TextSceneDefinition))
})
const isConnected = ref(false)
@ -30,6 +30,7 @@ export const useGame = defineStore("gameState", () => {
}
function handlePlayerBroadcast(broadcast: PlayerBroadcast) {
console.log(broadcast)
switch (broadcast.type) {
case "scene-changed":
const definition = script.scenesById.get(broadcast.sceneId) ?? throwError(`Unknown scene: ${broadcast.sceneId}`)
@ -38,7 +39,7 @@ export const useGame = defineStore("gameState", () => {
id: broadcast.sceneId,
type,
definition,
controller: type.createController(definition as any)
controller: markRaw(type.createController(definition as any))
}
break

View file

@ -18,11 +18,12 @@
</div>
<transition-group tag="div" name="list" class="grid gap-3 grid-cols-2 flex-grow auto-rows-min p-4 pt-0 relative">
<ObjectCard
v-for="object in game.visibleObjectsById.values()"
:key="object.id"
:object="object"
:is-over-dropzone="allFloatingObjectIds.has(object.id)"
:marked-for="getMarkedFor(object.id)"
v-for="objectId in controller.visibleObjectIds.values()"
:key="objectId"
:objectId="objectId"
:object="definition.objectsById.get(objectId)!"
:is-over-dropzone="allFloatingObjectIds.has(objectId)"
:marked-for="getMarkedFor(objectId)"
@drag-start="onObjectDragStart"
@drag-end="onObjectDragEnd"
/>
@ -38,11 +39,16 @@
import ObjectCard from "./ObjectCard.vue"
import { useGame } from "../../game"
import { computed, reactive, ref } from "vue"
import { useScrollLock } from "@vueuse/core"
import ObjectCardDropZone from "./ObjectCardDropZone.vue"
import type { InteractionSceneController } from "./index"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
const props = defineProps<{
controller: InteractionSceneController
definition: InteractionSceneDefinition
}>()
const game = useGame()
const dragCounter = ref(0)
const useFloatingObjectIds = reactive(new Set())
@ -61,14 +67,14 @@
function getMarkedFor(objectId: string) {
if (firstCombinationObjectId.value !== null) {
if (firstCombinationObjectId.value === objectId) return "combine-first"
} else if (game.currentInteraction !== null) {
switch (game.currentInteraction.type) {
} else if (props.controller.suggestedInteraction.value !== null) {
switch (props.controller.suggestedInteraction.value.type) {
case "use":
if (game.currentInteraction.objectId === objectId) return "use"
if (props.controller.suggestedInteraction.value.objectId === objectId) return "use"
break
case "combine":
if (game.currentInteraction.objectIds.has(objectId)) return "combine"
if (props.controller.suggestedInteraction.value.objectIds.has(objectId)) return "combine"
break
}
}
@ -88,7 +94,7 @@
}
function onObjectUseDrop(objectId: string) {
game.voteForInteraction({
props.controller.setInteractionVote({
type: "use",
objectId
})
@ -96,7 +102,7 @@
function onObjectInteractionDrop(objectId: string) {
if (firstCombinationObjectId.value === null) {
game.revokeCurrentInteractionVote()
props.controller.setInteractionVote(null)
firstCombinationObjectId.value = objectId
} else {
if (firstCombinationObjectId.value === objectId) {
@ -104,7 +110,7 @@
return
}
game.voteForInteraction({
props.controller.setInteractionVote({
type: "combine",
objectIds: new Set([firstCombinationObjectId.value, objectId])
})

View file

@ -1,51 +1,49 @@
<template>
<div class="bg-dark-600 rounded-lg flex overflow-hidden">
<div class="flex-grow flex items-center justify-center gap-2 px-3 py-4">
<template v-if="item.interaction.type === 'use'">
<template v-if="interaction.type === 'use'">
<HandPointingIcon class="text-4xl mb-6"/>
<div class="flex flex-col items-center gap-2">
<ObjectPicture :object-id="item.interaction.objectId"/>
<ObjectPicture :object-id="interaction.objectId"/>
<div class="text-sm text-gray-200 text-center">
{{ game.allObjectsById.get(item.interaction.objectId)!.label }}
{{ definition.objectsById.get(interaction.objectId)!.label }}
</div>
</div>
</template>
<template v-else v-for="(objectId, index) in item.interaction.objectIds" :key="objectId">
<template v-else v-for="(objectId, index) in interaction.objectIds" :key="objectId">
<div class="flex flex-col items-center gap-2">
<ObjectPicture :object-id="objectId"/>
<div class="text-sm text-gray-200 text-center">
{{ game.allObjectsById.get(objectId)!.label }}
{{ definition.objectsById.get(objectId)!.label }}
</div>
</div>
<PlusIcon v-if="index < item.interaction.objectIds.size - 1" class="text-3xl mb-6"/>
<PlusIcon v-if="index < interaction.objectIds.size - 1" class="text-3xl mb-6"/>
</template>
</div>
<div class="flex flex-col justify-between items-center bg-gray-800 w-17">
<div class="flex flex-col justify-center items-center pt-3 pb-2">
<div class="text-2xl">{{ item.votes }}</div>
<div class="text-2xl">{{ votes }}</div>
<div class="text-sm text-center">
Vote{{item.votes === 1 ? "" : "s" }}
Vote{{votes === 1 ? "" : "s" }}
</div>
</div>
<button
v-if="mode === 'audience'"
class="align-end py-2 w-full"
:class="game.currentInteractionId === item.id ? 'bg-blue-500' : 'bg-gray-700'"
:class="isCurrentSuggestion ? 'bg-blue-500' : 'bg-gray-700'"
@click="toggleVote()"
>
+1
</button>
<button
v-if="mode === 'director' && (item.interaction.type !== 'combine' || findMatchingCombinationInteraction(game.currentRoom.combinations, item.interaction.objectIds))"
v-if="mode === 'director'"
class="align-end py-1 w-full bg-green-800"
@click="game.activateInteractionQueueItem(item.id)"
>
<CheckIcon class="relative top-2px"/>
</button>
<button
v-if="mode === 'director'"
class="align-end py-1 w-full bg-red-900"
@click="game.removeInteractionQueueItem(item.id)"
>
<TrashIcon class="relative top-2px"/>
</button>
@ -64,19 +62,29 @@
import HandPointingIcon from "virtual:icons/ph/hand-pointing-duotone"
import ObjectPicture from "./ObjectPicture.vue"
import { useGame } from "../../game"
import type { SuggestedInteraction } from "../../../shared/mutations"
import type { InteractionSceneController } from "./index"
import { getSuggestedInteractionId } from "../../../shared/scene-types/interaction"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
import { computed } from "vue"
const props = defineProps<{
item: InteractionQueueItem
interaction: SuggestedInteraction
votes: number
controller: InteractionSceneController
definition: InteractionSceneDefinition
mode: "audience" | "director"
}>()
const game = useGame()
const isCurrentSuggestion = computed(() => props.controller.suggestedInteractionId.value === getSuggestedInteractionId(props.interaction))
function toggleVote() {
if (game.currentInteractionId === props.item.id) {
game.revokeCurrentInteractionVote()
if (isCurrentSuggestion.value) {
props.controller.setInteractionVote(null)
} else {
game.voteForInteraction(props.item.interaction)
props.controller.setInteractionVote(props.interaction)
}
}
</script>

View file

@ -6,9 +6,9 @@
transform: dragPosition === null ? undefined : `translate(${dragPosition.x}px, ${dragPosition.y}px) scale(${isOverDropzone ? 0.5 : 1})`,
transformOrigin: pointerCoordinates === null ? undefined : `${pointerCoordinates.x}px ${pointerCoordinates.y}px`,
}"
:data-object-id="object.id"
:data-object-id="objectId"
>
<ObjectPicture :object-id="object.id"/>
<ObjectPicture :object-id="objectId"/>
<div class="text-sm text-gray-200">
{{ object.label }}
</div>
@ -44,6 +44,7 @@
import ObjectPicture from "./ObjectPicture.vue"
const props = defineProps<{
objectId: string
object: SceneObjectDefinition
isOverDropzone: boolean
markedFor: null | "use" | "combine" | "combine-first"

View file

@ -1,5 +1,5 @@
<template>
<img :src="`/objects/${objectId}.png`" alt="" class="invert filter object-contain max-w-15 pointer-events-none" draggable="false"/>
<img :src="`/objects/${objectId}.png`" alt="" class="object-contain max-w-15 pointer-events-none" draggable="false"/>
</template>
<style module lang="scss">

View file

@ -1,5 +1,6 @@
<template>
<TabbedContainer v-model:activeTabId="activeTabId" :tabs="TABS" :content-props="{ controller, definition }"
@event:switch-tab="(tabId: any) => (activeTabId = tabId)"/>
</template>
<style module lang="scss">
@ -9,9 +10,36 @@
<script setup lang="ts">
import type { InteractionSceneController } from "./index"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
import TabbedContainer, { type Tab } from "../../components/TabbedContainer.vue"
import InteractTab from "./InteractTab.vue"
import QueueTab from "./QueueTab.vue"
import { h, ref } from "vue"
import { reactiveComputed } from "@vueuse/core"
const props = defineProps<{
controller: InteractionSceneController
definition: InteractionSceneDefinition
}>()
const activeTabId = ref<"interact" | "queue">("interact")
const TABS: Tab[] = [
{
id: "interact",
label: "Interagieren",
content: () => {
return h(InteractTab, reactiveComputed(() => ({ controller: props.controller, definition: props.definition })) as any)
}
},
{
id: "queue",
label: "Abstimmen",
content: () => h(QueueTab, reactiveComputed(() => ({
controller: props.controller, definition: props.definition, onSwitchTab: tabId => {
activeTabId.value = tabId
}
})) as any)
}
]
</script>

View file

@ -1,22 +1,25 @@
<template>
<div class="h-full p-4 pt-0">
<transition name="fade" mode="out-in">
<div v-if="game.sortedInteractionQueue.length === 0" class="h-full flex flex-col justify-center items-center gap-4 p-6">
<div v-if="controller.sortedInteractionQueue.value.length === 0" class="h-full flex flex-col justify-center items-center gap-4 p-6">
<div class="text-xl text-center text-gray-200">
Noch keine Interaktionen zum Abstimmen vorhanden.
</div>
<button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-800 text-lg" @click="emit('switch-screen', 'interactions')">
<button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-800 text-lg" @click="emit('switch-tab', 'interact')">
<ArrowRightIcon/>
<span class="relative top-1px">Interagieren</span>
</button>
</div>
<transition-group v-else tag="div" name="list" class="flex flex-col gap-4 relative">
<InteractionQueueItemCard
v-for="(item, index) in game.sortedInteractionQueue"
v-for="(item, index) in controller.sortedInteractionQueue.value"
:key="item.id"
:style="{ zIndex: 1000 - index }"
:item="item"
mode="audience"
:interaction="item.interaction"
:votes="item.votes"
:controller="controller"
:definition="definition"
/>
</transition-group>
</transition>
@ -31,9 +34,16 @@
import InteractionQueueItemCard from "./InteractionQueueItemCard.vue"
import { useGame } from "../../game"
import ArrowRightIcon from "virtual:icons/ph/arrow-right"
import type { InteractionSceneController } from "./index"
import type { InteractionSceneDefinition } from "../../../shared/script/types"
const props = defineProps<{
controller: InteractionSceneController
definition: InteractionSceneDefinition
}>()
const emit = defineEmits<{
"switch-screen": [string]
"switch-tab": [string]
}>()
const game = useGame()

View file

@ -2,9 +2,10 @@ 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 { getSuggestedInteractionId } 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 { computed, reactive, type Reactive, ref, type Ref } from "vue"
import { trpcClient } from "../../trpc"
export const InteractionSceneType: SceneType<InteractionSceneDefinition, InteractionSceneController> = {
@ -14,16 +15,44 @@ export const InteractionSceneType: SceneType<InteractionSceneDefinition, Interac
createController(definition: InteractionSceneDefinition): InteractionSceneController {
const visibleObjectIds = reactive(new Set<string>())
const interactionQueue = reactive(new Map<string, InteractionQueueItem>())
const suggestedInteraction = ref<SuggestedInteraction | null>(null)
const suggestedInteractionId = computed(() => suggestedInteraction.value === null ? null : getSuggestedInteractionId(suggestedInteraction.value))
return {
visibleObjectIds,
interactionQueue,
sortedInteractionQueue: computed(() => [...interactionQueue.values()].sort((a, b) => a.votes - b.votes)),
suggestedInteraction,
suggestedInteractionId,
handleEvent(event: InteractionSceneEvent) {
switch (event.type) {
case "votes-changed":
Object.entries(event.votesByInteractionId).forEach(([interactionId, votes]) => {
if (votes <= 0) {
interactionQueue.delete(interactionId)
if (interactionId === suggestedInteractionId.value) suggestedInteraction.value = null
} else interactionQueue.get(interactionId)!.votes = votes
})
break
case "interaction-queued":
const id = getSuggestedInteractionId(event.interaction)
interactionQueue.set(id, { id, interaction: event.interaction, votes: 1 })
break
case "object-visibility-changed":
if (event.isVisible) visibleObjectIds.delete(event.objectId)
else visibleObjectIds.add(event.objectId)
break
}
},
async setObjectVisibility(objectId: string, isVisible: boolean) {
await trpcClient.crew.interactionScene.setObjectVisibility.mutate({ objectId, isVisible })
},
async setInteractionVote(interaction: SuggestedInteraction | null) {
await trpcClient.player.interactionScene.setInteractionVote.mutate({ interaction })
suggestedInteraction.value = interaction
}
}
}
@ -39,6 +68,10 @@ export interface InteractionSceneController extends SceneController {
visibleObjectIds: Reactive<Set<string>>
interactionQueue: Reactive<Map<string, InteractionQueueItem>>
sortedInteractionQueue: Readonly<Ref<Array<InteractionQueueItem>>>
suggestedInteraction: Ref<SuggestedInteraction | null>
suggestedInteractionId: Readonly<Ref<string | null>>
setObjectVisibility(objectId: string, isVisible: boolean): Promise<void>
setInteractionVote(interaction: SuggestedInteraction | null): Promise<void>
}

View file

@ -1,8 +1,8 @@
<template>
<component :is="game.currentScene.type.playerView" :controller="game.currentScene.controller" :definition="game.currentScene.definition"/>
<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">

View file

@ -13,7 +13,7 @@ const headers = {
export const trpcClient = createTRPCClient<AppRouter>({
links: [
loggerLink(),
//loggerLink(),
splitLink({
condition: op => op.type === "subscription",
true: httpSubscriptionLink({

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
public/objects/escobar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/objects/h-milch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/objects/muesli.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

BIN
public/objects/peruecke.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/objects/redner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/objects/vorhang.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -24,7 +24,7 @@ export function findMatchingCombinationInteraction(combinations: Set<CombineInte
export type InteractionSceneEvent =
| { type: "interaction-queued"; interaction: SuggestedInteraction }
| { type: "votes-changed"; votesByInteractionId: Record<string, number> }
| { type: "object-visibility-changed"; id: string; isVisible: boolean }
| { type: "object-visibility-changed"; objectId: string; isVisible: boolean }
| { type: "interaction-execution-started"; interaction: SuggestedInteraction }
| { type: "interaction-execution-finished" }
| { type: "interaction-execution-cancelled" }