|
@ -2,7 +2,7 @@ import EventEmitter from "node:events"
|
||||||
import type { PlayerBroadcast } from "../shared/broadcast"
|
import type { PlayerBroadcast } from "../shared/broadcast"
|
||||||
import { script } from "../shared/script"
|
import { script } from "../shared/script"
|
||||||
import type { SceneDefinition } from "../shared/script/types"
|
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 { Tagged } from "type-fest"
|
||||||
import type { SessionId } from "./session"
|
import type { SessionId } from "./session"
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ export class Game {
|
||||||
getConnectionPlayerBroadcasts(): PlayerBroadcast[] {
|
getConnectionPlayerBroadcasts(): PlayerBroadcast[] {
|
||||||
const events: PlayerBroadcast[] = []
|
const events: PlayerBroadcast[] = []
|
||||||
events.push({ type: "scene-changed", sceneId: this.currentScene.id })
|
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
|
return events
|
||||||
}
|
}
|
||||||
|
@ -45,12 +45,14 @@ export class Game {
|
||||||
const definition = script.scenesById.get(sceneId)
|
const definition = script.scenesById.get(sceneId)
|
||||||
if (definition === undefined) throw new Error(`Unknown scene: ${sceneId}`)
|
if (definition === undefined) throw new Error(`Unknown scene: ${sceneId}`)
|
||||||
const type = sceneTypesById[definition.type]
|
const type = sceneTypesById[definition.type]
|
||||||
|
|
||||||
|
this.eventBus.emit("player-broadcast", { type: "scene-changed", sceneId: sceneId })
|
||||||
|
|
||||||
this.currentScene = {
|
this.currentScene = {
|
||||||
id: sceneId,
|
id: sceneId,
|
||||||
definition,
|
definition,
|
||||||
state: type.createState(this, definition as any)
|
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 {
|
withSceneState<Type extends keyof typeof sceneTypesById, R>(type: Type, block: (state: ReturnType<typeof sceneTypesById[Type]["createState"]>) => R): R | null {
|
||||||
|
|
|
@ -19,14 +19,14 @@ export class InteractionSceneState implements SceneState<InteractionSceneEvent>
|
||||||
private objectVisibilityById = new Map<string, boolean>()
|
private objectVisibilityById = new Map<string, boolean>()
|
||||||
|
|
||||||
constructor(private game: Game, private definition: InteractionSceneDefinition) {
|
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[] {
|
getConnectionEvents(): InteractionSceneEvent[] {
|
||||||
const events: InteractionSceneEvent[] = []
|
const events: InteractionSceneEvent[] = []
|
||||||
events.push(this.getVotesChangedEvent())
|
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 })
|
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) {
|
switch (interaction.type) {
|
||||||
case "use":
|
case "use":
|
||||||
if (interaction.consume) {
|
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
|
break
|
||||||
|
@ -139,10 +139,9 @@ export class InteractionSceneState implements SceneState<InteractionSceneEvent>
|
||||||
|
|
||||||
setObjectVisibility(objectId: string, isVisible: boolean) {
|
setObjectVisibility(objectId: string, isVisible: boolean) {
|
||||||
const current = this.objectVisibilityById.get(objectId)
|
const current = this.objectVisibilityById.get(objectId)
|
||||||
if (current === undefined) throw new Error(`Unknown object: ${objectId}`)
|
|
||||||
if (current === isVisible) return
|
if (current === isVisible) return
|
||||||
|
|
||||||
this.objectVisibilityById.set(objectId, isVisible)
|
this.objectVisibilityById.set(objectId, isVisible)
|
||||||
this.emit({ type: "object-visibility-changed", id: objectId, isVisible })
|
this.emit({ type: "object-visibility-changed", objectId: objectId, isVisible })
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<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.noise"/>
|
||||||
<div :class="$style.vignette"/>
|
<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"/>
|
<component :is="SCREENS.find(s => s.id === screenId)!.component"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,7 +15,10 @@
|
||||||
</nav>
|
</nav>
|
||||||
<main class="flex-grow">
|
<main class="flex-grow">
|
||||||
<transition name="fade" mode="out-in">
|
<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>
|
</transition>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -45,6 +48,7 @@
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"update:activeTabId": [string]
|
"update:activeTabId": [string]
|
||||||
|
[k: `content:${string}`]: [unknown]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeTabId = useVModel(props, "activeTabId", emit)
|
const activeTabId = useVModel(props, "activeTabId", emit)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
import { ref, shallowRef } from "vue"
|
import { markRaw, ref, shallowRef } from "vue"
|
||||||
import { script } from "../shared/script"
|
import { script } from "../shared/script"
|
||||||
import type { PlayerBroadcast } from "../shared/broadcast"
|
import type { PlayerBroadcast } from "../shared/broadcast"
|
||||||
import { trpcClient } from "./trpc"
|
import { trpcClient } from "./trpc"
|
||||||
|
@ -19,7 +19,7 @@ export const useGame = defineStore("gameState", () => {
|
||||||
id: "pre-start",
|
id: "pre-start",
|
||||||
type: sceneTypesById.text,
|
type: sceneTypesById.text,
|
||||||
definition: script.scenesById.get("pre-start") as TextSceneDefinition,
|
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)
|
const isConnected = ref(false)
|
||||||
|
@ -30,6 +30,7 @@ export const useGame = defineStore("gameState", () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlayerBroadcast(broadcast: PlayerBroadcast) {
|
function handlePlayerBroadcast(broadcast: PlayerBroadcast) {
|
||||||
|
console.log(broadcast)
|
||||||
switch (broadcast.type) {
|
switch (broadcast.type) {
|
||||||
case "scene-changed":
|
case "scene-changed":
|
||||||
const definition = script.scenesById.get(broadcast.sceneId) ?? throwError(`Unknown scene: ${broadcast.sceneId}`)
|
const definition = script.scenesById.get(broadcast.sceneId) ?? throwError(`Unknown scene: ${broadcast.sceneId}`)
|
||||||
|
@ -38,7 +39,7 @@ export const useGame = defineStore("gameState", () => {
|
||||||
id: broadcast.sceneId,
|
id: broadcast.sceneId,
|
||||||
type,
|
type,
|
||||||
definition,
|
definition,
|
||||||
controller: type.createController(definition as any)
|
controller: markRaw(type.createController(definition as any))
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
|
@ -18,11 +18,12 @@
|
||||||
</div>
|
</div>
|
||||||
<transition-group tag="div" name="list" class="grid gap-3 grid-cols-2 flex-grow auto-rows-min p-4 pt-0 relative">
|
<transition-group tag="div" name="list" class="grid gap-3 grid-cols-2 flex-grow auto-rows-min p-4 pt-0 relative">
|
||||||
<ObjectCard
|
<ObjectCard
|
||||||
v-for="object in game.visibleObjectsById.values()"
|
v-for="objectId in controller.visibleObjectIds.values()"
|
||||||
:key="object.id"
|
:key="objectId"
|
||||||
:object="object"
|
:objectId="objectId"
|
||||||
:is-over-dropzone="allFloatingObjectIds.has(object.id)"
|
:object="definition.objectsById.get(objectId)!"
|
||||||
:marked-for="getMarkedFor(object.id)"
|
:is-over-dropzone="allFloatingObjectIds.has(objectId)"
|
||||||
|
:marked-for="getMarkedFor(objectId)"
|
||||||
@drag-start="onObjectDragStart"
|
@drag-start="onObjectDragStart"
|
||||||
@drag-end="onObjectDragEnd"
|
@drag-end="onObjectDragEnd"
|
||||||
/>
|
/>
|
||||||
|
@ -38,11 +39,16 @@
|
||||||
import ObjectCard from "./ObjectCard.vue"
|
import ObjectCard from "./ObjectCard.vue"
|
||||||
import { useGame } from "../../game"
|
import { useGame } from "../../game"
|
||||||
import { computed, reactive, ref } from "vue"
|
import { computed, reactive, ref } from "vue"
|
||||||
import { useScrollLock } from "@vueuse/core"
|
|
||||||
import ObjectCardDropZone from "./ObjectCardDropZone.vue"
|
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 game = useGame()
|
||||||
|
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
|
|
||||||
const useFloatingObjectIds = reactive(new Set())
|
const useFloatingObjectIds = reactive(new Set())
|
||||||
|
@ -61,14 +67,14 @@
|
||||||
function getMarkedFor(objectId: string) {
|
function getMarkedFor(objectId: string) {
|
||||||
if (firstCombinationObjectId.value !== null) {
|
if (firstCombinationObjectId.value !== null) {
|
||||||
if (firstCombinationObjectId.value === objectId) return "combine-first"
|
if (firstCombinationObjectId.value === objectId) return "combine-first"
|
||||||
} else if (game.currentInteraction !== null) {
|
} else if (props.controller.suggestedInteraction.value !== null) {
|
||||||
switch (game.currentInteraction.type) {
|
switch (props.controller.suggestedInteraction.value.type) {
|
||||||
case "use":
|
case "use":
|
||||||
if (game.currentInteraction.objectId === objectId) return "use"
|
if (props.controller.suggestedInteraction.value.objectId === objectId) return "use"
|
||||||
break
|
break
|
||||||
|
|
||||||
case "combine":
|
case "combine":
|
||||||
if (game.currentInteraction.objectIds.has(objectId)) return "combine"
|
if (props.controller.suggestedInteraction.value.objectIds.has(objectId)) return "combine"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,7 +94,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function onObjectUseDrop(objectId: string) {
|
function onObjectUseDrop(objectId: string) {
|
||||||
game.voteForInteraction({
|
props.controller.setInteractionVote({
|
||||||
type: "use",
|
type: "use",
|
||||||
objectId
|
objectId
|
||||||
})
|
})
|
||||||
|
@ -96,7 +102,7 @@
|
||||||
|
|
||||||
function onObjectInteractionDrop(objectId: string) {
|
function onObjectInteractionDrop(objectId: string) {
|
||||||
if (firstCombinationObjectId.value === null) {
|
if (firstCombinationObjectId.value === null) {
|
||||||
game.revokeCurrentInteractionVote()
|
props.controller.setInteractionVote(null)
|
||||||
firstCombinationObjectId.value = objectId
|
firstCombinationObjectId.value = objectId
|
||||||
} else {
|
} else {
|
||||||
if (firstCombinationObjectId.value === objectId) {
|
if (firstCombinationObjectId.value === objectId) {
|
||||||
|
@ -104,7 +110,7 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
game.voteForInteraction({
|
props.controller.setInteractionVote({
|
||||||
type: "combine",
|
type: "combine",
|
||||||
objectIds: new Set([firstCombinationObjectId.value, objectId])
|
objectIds: new Set([firstCombinationObjectId.value, objectId])
|
||||||
})
|
})
|
|
@ -1,51 +1,49 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-dark-600 rounded-lg flex overflow-hidden">
|
<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">
|
<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"/>
|
<HandPointingIcon class="text-4xl mb-6"/>
|
||||||
<div class="flex flex-col items-center gap-2">
|
<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">
|
<div class="text-sm text-gray-200 text-center">
|
||||||
{{ game.allObjectsById.get(item.interaction.objectId)!.label }}
|
{{ definition.objectsById.get(interaction.objectId)!.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<ObjectPicture :object-id="objectId"/>
|
<ObjectPicture :object-id="objectId"/>
|
||||||
<div class="text-sm text-gray-200 text-center">
|
<div class="text-sm text-gray-200 text-center">
|
||||||
{{ game.allObjectsById.get(objectId)!.label }}
|
{{ definition.objectsById.get(objectId)!.label }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col justify-between items-center bg-gray-800 w-17">
|
<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="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">
|
<div class="text-sm text-center">
|
||||||
Vote{{item.votes === 1 ? "" : "s" }}
|
Vote{{votes === 1 ? "" : "s" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="mode === 'audience'"
|
v-if="mode === 'audience'"
|
||||||
class="align-end py-2 w-full"
|
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()"
|
@click="toggleVote()"
|
||||||
>
|
>
|
||||||
+1
|
+1
|
||||||
</button>
|
</button>
|
||||||
<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"
|
class="align-end py-1 w-full bg-green-800"
|
||||||
@click="game.activateInteractionQueueItem(item.id)"
|
|
||||||
>
|
>
|
||||||
<CheckIcon class="relative top-2px"/>
|
<CheckIcon class="relative top-2px"/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="mode === 'director'"
|
v-if="mode === 'director'"
|
||||||
class="align-end py-1 w-full bg-red-900"
|
class="align-end py-1 w-full bg-red-900"
|
||||||
@click="game.removeInteractionQueueItem(item.id)"
|
|
||||||
>
|
>
|
||||||
<TrashIcon class="relative top-2px"/>
|
<TrashIcon class="relative top-2px"/>
|
||||||
</button>
|
</button>
|
||||||
|
@ -64,19 +62,29 @@
|
||||||
import HandPointingIcon from "virtual:icons/ph/hand-pointing-duotone"
|
import HandPointingIcon from "virtual:icons/ph/hand-pointing-duotone"
|
||||||
import ObjectPicture from "./ObjectPicture.vue"
|
import ObjectPicture from "./ObjectPicture.vue"
|
||||||
import { useGame } from "../../game"
|
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<{
|
const props = defineProps<{
|
||||||
item: InteractionQueueItem
|
interaction: SuggestedInteraction
|
||||||
|
votes: number
|
||||||
|
controller: InteractionSceneController
|
||||||
|
definition: InteractionSceneDefinition
|
||||||
mode: "audience" | "director"
|
mode: "audience" | "director"
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const game = useGame()
|
const game = useGame()
|
||||||
|
|
||||||
|
const isCurrentSuggestion = computed(() => props.controller.suggestedInteractionId.value === getSuggestedInteractionId(props.interaction))
|
||||||
|
|
||||||
function toggleVote() {
|
function toggleVote() {
|
||||||
if (game.currentInteractionId === props.item.id) {
|
if (isCurrentSuggestion.value) {
|
||||||
game.revokeCurrentInteractionVote()
|
props.controller.setInteractionVote(null)
|
||||||
} else {
|
} else {
|
||||||
game.voteForInteraction(props.item.interaction)
|
props.controller.setInteractionVote(props.interaction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
|
@ -6,9 +6,9 @@
|
||||||
transform: dragPosition === null ? undefined : `translate(${dragPosition.x}px, ${dragPosition.y}px) scale(${isOverDropzone ? 0.5 : 1})`,
|
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`,
|
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">
|
<div class="text-sm text-gray-200">
|
||||||
{{ object.label }}
|
{{ object.label }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,6 +44,7 @@
|
||||||
import ObjectPicture from "./ObjectPicture.vue"
|
import ObjectPicture from "./ObjectPicture.vue"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
objectId: string
|
||||||
object: SceneObjectDefinition
|
object: SceneObjectDefinition
|
||||||
isOverDropzone: boolean
|
isOverDropzone: boolean
|
||||||
markedFor: null | "use" | "combine" | "combine-first"
|
markedFor: null | "use" | "combine" | "combine-first"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<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>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
|
<TabbedContainer v-model:activeTabId="activeTabId" :tabs="TABS" :content-props="{ controller, definition }"
|
||||||
|
@event:switch-tab="(tabId: any) => (activeTabId = tabId)"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
@ -9,9 +10,36 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { InteractionSceneController } from "./index"
|
import type { InteractionSceneController } from "./index"
|
||||||
import type { InteractionSceneDefinition } from "../../../shared/script/types"
|
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<{
|
const props = defineProps<{
|
||||||
controller: InteractionSceneController
|
controller: InteractionSceneController
|
||||||
definition: InteractionSceneDefinition
|
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>
|
</script>
|
|
@ -1,22 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full p-4 pt-0">
|
<div class="h-full p-4 pt-0">
|
||||||
<transition name="fade" mode="out-in">
|
<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">
|
<div class="text-xl text-center text-gray-200">
|
||||||
Noch keine Interaktionen zum Abstimmen vorhanden.
|
Noch keine Interaktionen zum Abstimmen vorhanden.
|
||||||
</div>
|
</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/>
|
<ArrowRightIcon/>
|
||||||
<span class="relative top-1px">Interagieren</span>
|
<span class="relative top-1px">Interagieren</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<transition-group v-else tag="div" name="list" class="flex flex-col gap-4 relative">
|
<transition-group v-else tag="div" name="list" class="flex flex-col gap-4 relative">
|
||||||
<InteractionQueueItemCard
|
<InteractionQueueItemCard
|
||||||
v-for="(item, index) in game.sortedInteractionQueue"
|
v-for="(item, index) in controller.sortedInteractionQueue.value"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:style="{ zIndex: 1000 - index }"
|
:style="{ zIndex: 1000 - index }"
|
||||||
:item="item"
|
|
||||||
mode="audience"
|
mode="audience"
|
||||||
|
:interaction="item.interaction"
|
||||||
|
:votes="item.votes"
|
||||||
|
:controller="controller"
|
||||||
|
:definition="definition"
|
||||||
/>
|
/>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</transition>
|
</transition>
|
||||||
|
@ -31,9 +34,16 @@
|
||||||
import InteractionQueueItemCard from "./InteractionQueueItemCard.vue"
|
import InteractionQueueItemCard from "./InteractionQueueItemCard.vue"
|
||||||
import { useGame } from "../../game"
|
import { useGame } from "../../game"
|
||||||
import ArrowRightIcon from "virtual:icons/ph/arrow-right"
|
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<{
|
const emit = defineEmits<{
|
||||||
"switch-screen": [string]
|
"switch-tab": [string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const game = useGame()
|
const game = useGame()
|
||||||
|
|
|
@ -2,9 +2,10 @@ import type { SceneController, SceneType } from "../base"
|
||||||
import type { InteractionSceneDefinition } from "../../../shared/script/types"
|
import type { InteractionSceneDefinition } from "../../../shared/script/types"
|
||||||
import PlayerView from "./PlayerView.vue"
|
import PlayerView from "./PlayerView.vue"
|
||||||
import type { InteractionSceneEvent } from "../../../shared/scene-types/interaction"
|
import type { InteractionSceneEvent } from "../../../shared/scene-types/interaction"
|
||||||
|
import { getSuggestedInteractionId } from "../../../shared/scene-types/interaction"
|
||||||
import DuoView from "./DuoView.vue"
|
import DuoView from "./DuoView.vue"
|
||||||
import type { SuggestedInteraction } from "../../../shared/mutations"
|
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"
|
import { trpcClient } from "../../trpc"
|
||||||
|
|
||||||
export const InteractionSceneType: SceneType<InteractionSceneDefinition, InteractionSceneController> = {
|
export const InteractionSceneType: SceneType<InteractionSceneDefinition, InteractionSceneController> = {
|
||||||
|
@ -14,16 +15,44 @@ export const InteractionSceneType: SceneType<InteractionSceneDefinition, Interac
|
||||||
createController(definition: InteractionSceneDefinition): InteractionSceneController {
|
createController(definition: InteractionSceneDefinition): InteractionSceneController {
|
||||||
const visibleObjectIds = reactive(new Set<string>())
|
const visibleObjectIds = reactive(new Set<string>())
|
||||||
const interactionQueue = reactive(new Map<string, InteractionQueueItem>())
|
const interactionQueue = reactive(new Map<string, InteractionQueueItem>())
|
||||||
|
const suggestedInteraction = ref<SuggestedInteraction | null>(null)
|
||||||
|
const suggestedInteractionId = computed(() => suggestedInteraction.value === null ? null : getSuggestedInteractionId(suggestedInteraction.value))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
visibleObjectIds,
|
visibleObjectIds,
|
||||||
interactionQueue,
|
interactionQueue,
|
||||||
sortedInteractionQueue: computed(() => [...interactionQueue.values()].sort((a, b) => a.votes - b.votes)),
|
sortedInteractionQueue: computed(() => [...interactionQueue.values()].sort((a, b) => a.votes - b.votes)),
|
||||||
|
suggestedInteraction,
|
||||||
|
suggestedInteractionId,
|
||||||
handleEvent(event: InteractionSceneEvent) {
|
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) {
|
async setObjectVisibility(objectId: string, isVisible: boolean) {
|
||||||
await trpcClient.crew.interactionScene.setObjectVisibility.mutate({ objectId, isVisible })
|
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>>
|
visibleObjectIds: Reactive<Set<string>>
|
||||||
interactionQueue: Reactive<Map<string, InteractionQueueItem>>
|
interactionQueue: Reactive<Map<string, InteractionQueueItem>>
|
||||||
sortedInteractionQueue: Readonly<Ref<Array<InteractionQueueItem>>>
|
sortedInteractionQueue: Readonly<Ref<Array<InteractionQueueItem>>>
|
||||||
|
suggestedInteraction: Ref<SuggestedInteraction | null>
|
||||||
|
suggestedInteractionId: Readonly<Ref<string | null>>
|
||||||
|
|
||||||
setObjectVisibility(objectId: string, isVisible: boolean): Promise<void>
|
setObjectVisibility(objectId: string, isVisible: boolean): Promise<void>
|
||||||
|
|
||||||
|
setInteractionVote(interaction: SuggestedInteraction | null): Promise<void>
|
||||||
}
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<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'">
|
<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>
|
<span class="text-center">Verbindung wird hergestellt…</span>
|
||||||
</div>
|
</div>
|
||||||
<component :is="game.currentScene.type.playerView" :controller="game.currentScene.controller" :definition="game.currentScene.definition"/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
|
|
|
@ -13,7 +13,7 @@ const headers = {
|
||||||
|
|
||||||
export const trpcClient = createTRPCClient<AppRouter>({
|
export const trpcClient = createTRPCClient<AppRouter>({
|
||||||
links: [
|
links: [
|
||||||
loggerLink(),
|
//loggerLink(),
|
||||||
splitLink({
|
splitLink({
|
||||||
condition: op => op.type === "subscription",
|
condition: op => op.type === "subscription",
|
||||||
true: httpSubscriptionLink({
|
true: httpSubscriptionLink({
|
||||||
|
|
BIN
public/objects/buehnenbildner.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
public/objects/escobar.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
public/objects/h-milch.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
public/objects/haferflocken.png
Normal file
After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 12 KiB |
BIN
public/objects/kaffeebohnen.png
Normal file
After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 16 KiB |
BIN
public/objects/kettensaege.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
public/objects/kuehlschrank.png
Normal file
After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 30 KiB |
BIN
public/objects/muesli-unfertig.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
public/objects/muesli.png
Normal file
After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 12 KiB |
BIN
public/objects/peruecke.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
public/objects/redner.png
Normal file
After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 18 KiB |
BIN
public/objects/thunfisch.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
public/objects/vorhang.png
Normal file
After Width: | Height: | Size: 14 KiB |
|
@ -24,7 +24,7 @@ export function findMatchingCombinationInteraction(combinations: Set<CombineInte
|
||||||
export type InteractionSceneEvent =
|
export type InteractionSceneEvent =
|
||||||
| { type: "interaction-queued"; interaction: SuggestedInteraction }
|
| { type: "interaction-queued"; interaction: SuggestedInteraction }
|
||||||
| { type: "votes-changed"; votesByInteractionId: Record<string, number> }
|
| { 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-started"; interaction: SuggestedInteraction }
|
||||||
| { type: "interaction-execution-finished" }
|
| { type: "interaction-execution-finished" }
|
||||||
| { type: "interaction-execution-cancelled" }
|
| { type: "interaction-execution-cancelled" }
|