commit 01
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.idea/
|
||||||
|
node_modules/
|
||||||
|
*.env
|
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
22
|
14
index.html
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Level Up</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<script type="module" src="./src/index.ts"></script>
|
||||||
|
<link rel="stylesheet" href="./node_modules/@fontsource-variable/inter/wght.css"/>
|
||||||
|
<link rel="stylesheet" href="./node_modules/@fontsource/titan-one/400.css"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
53
package.json
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"name": "level-up",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start:ui": "vite preview --host --port 4000",
|
||||||
|
"start:server": "NODE_ENV=production tsx ./src/server/main.ts",
|
||||||
|
"build:ui": "vite build",
|
||||||
|
"dev:ui": "vite",
|
||||||
|
"dev:server": "NODE_ENV=development tsx watch --clear-screen=false ./src/server/main.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/ph": "^1.2.2",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node": "22.10.2",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"sass": "^1.83.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"unplugin-icons": "^0.22.0",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vite-plugin-pages": "^0.32.4",
|
||||||
|
"vite-plugin-windicss": "^1.9.4",
|
||||||
|
"windicss": "^3.5.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.1.0",
|
||||||
|
"@fontsource/titan-one": "^5.1.0",
|
||||||
|
"@headlessui/vue": "^1.7.23",
|
||||||
|
"@interactjs/actions": "^1.10.27",
|
||||||
|
"@interactjs/auto-start": "^1.10.27",
|
||||||
|
"@interactjs/interact": "^1.10.27",
|
||||||
|
"@trpc/client": "^10.45.2",
|
||||||
|
"@trpc/server": "^10.45.2",
|
||||||
|
"@vueuse/core": "^12.0.0",
|
||||||
|
"@vueuse/integrations": "^12.0.0",
|
||||||
|
"bufferutil": "^4.0.8",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"listhen": "^1.9.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"superjson": "^2.2.2",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
}
|
||||||
|
}
|
3068
pnpm-lock.yaml
generated
Normal file
BIN
public/objects/haustür.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/objects/kakao.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
public/objects/kakaopulver.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
public/objects/kühlschrank.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
public/objects/milch.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
public/objects/offene-haustür.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
public/objects/schlüssel.png
Normal file
After Width: | Height: | Size: 19 KiB |
93
src/App.vue
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<div class="bg-gray-900 h-100vh w-100vw overflow-hidden text-white">
|
||||||
|
<div :class="$style.noise"/>
|
||||||
|
<div :class="$style.vignette"/>
|
||||||
|
<div class="relative h-full">
|
||||||
|
<div v-if="isLoading" class="flex flex-col justify-center items-center text-lg inset-0 absolute">
|
||||||
|
<span class="text-center">Verbindung wird hergestellt…</span>
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
<InteractionsScreen/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
body {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise, .vignette {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise {
|
||||||
|
background: url("./assets/noise.png") repeat;
|
||||||
|
opacity: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette {
|
||||||
|
background: radial-gradient(rgb(0 0 0 / 0%) 40%, rgb(0 0 0 / 20%));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-enter-active,
|
||||||
|
.slide-down-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
transition: 200ms ease;
|
||||||
|
transition-property: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-enter-from,
|
||||||
|
.slide-down-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { trpcClient } from "./trpc"
|
||||||
|
import { useGame } from "./game"
|
||||||
|
import InteractionsScreen from "./screens/InteractionsScreen.vue"
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const game = useGame()
|
||||||
|
|
||||||
|
trpcClient.join.subscribe(undefined, {
|
||||||
|
onStarted: () => {
|
||||||
|
isLoading.value = false
|
||||||
|
},
|
||||||
|
onData: game.handleGameEvent,
|
||||||
|
onError: error => {
|
||||||
|
console.error("🔴", error)
|
||||||
|
},
|
||||||
|
onStopped() {
|
||||||
|
location.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
BIN
src/assets/noise.png
Normal file
After Width: | Height: | Size: 18 KiB |
82
src/components/ObjectCard.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-2 bg-dark-600 rounded-lg p-3 border border-solid"
|
||||||
|
:class="[$style.root, borderClass]"
|
||||||
|
:style="{
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<img :src="`/objects/${props.object.id}.png`" :alt="object.label" class="invert filter object-contain max-w-15"/>
|
||||||
|
<div class="text-sm text-gray-300">
|
||||||
|
{{ object.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.root {
|
||||||
|
touch-action: none;
|
||||||
|
transition: transform 50ms ease, background-color 200ms ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GameObject } from "../shared/script/types"
|
||||||
|
import interact from "@interactjs/interact"
|
||||||
|
import { useCurrentElement } from "@vueuse/core"
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
object: GameObject
|
||||||
|
isOverDropzone: boolean
|
||||||
|
markedFor: null | "use" | "combine"
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"drag-start": []
|
||||||
|
"drag-end": []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const element = useCurrentElement()
|
||||||
|
const dragPosition = ref<null | { x: number; y: number }>(null)
|
||||||
|
const pointerCoordinates = ref<null | { x: number; y: number }>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const interactable = interact(element.value as HTMLElement).draggable({
|
||||||
|
listeners: {
|
||||||
|
start(event) {
|
||||||
|
const elementBounds = (element.value as HTMLElement).getBoundingClientRect()
|
||||||
|
pointerCoordinates.value = {
|
||||||
|
x: event.clientX0 - elementBounds.x,
|
||||||
|
y: event.clientY0 - elementBounds.y,
|
||||||
|
}
|
||||||
|
|
||||||
|
dragPosition.value = { x: 0, y: 0 }
|
||||||
|
emit("drag-start")
|
||||||
|
},
|
||||||
|
move(event) {
|
||||||
|
dragPosition.value = {
|
||||||
|
x: (dragPosition.value?.x ?? 0) + event.dx,
|
||||||
|
y: (dragPosition.value?.y ?? 0) + event.dy,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
end(event) {
|
||||||
|
dragPosition.value = null
|
||||||
|
emit("drag-end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => interactable.unset())
|
||||||
|
})
|
||||||
|
|
||||||
|
const borderClass = computed(() => {
|
||||||
|
switch (props.markedFor) {
|
||||||
|
case null: return "border-dark-600"
|
||||||
|
case "use": return "border-green-600"
|
||||||
|
case "combine": return "border-blue-900"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
64
src/components/ObjectCardDropZone.vue
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center text-xl w-0 flex-grow py-4 bg-green-900 bg-opacity-60"
|
||||||
|
:class="$style.root"
|
||||||
|
:data-has-floating="hasFloating"
|
||||||
|
>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.root {
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
|
||||||
|
&[data-has-floating="true"] {
|
||||||
|
transform: scale(1.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import interact from "@interactjs/interact"
|
||||||
|
import { useCurrentElement } from "@vueuse/core"
|
||||||
|
import { onMounted, onUnmounted } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
hasFloating: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"object-change": [string, boolean]
|
||||||
|
"object-drop": [string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const element = useCurrentElement()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const interactable = interact(element.value as HTMLElement).dropzone({
|
||||||
|
overlap: "pointer",
|
||||||
|
ondragenter(event) {
|
||||||
|
const draggedElement = event.relatedTarget
|
||||||
|
const objectId = draggedElement.dataset?.objectId
|
||||||
|
if (objectId === undefined) return
|
||||||
|
emit("object-change", objectId, true)
|
||||||
|
},
|
||||||
|
ondragleave(event) {
|
||||||
|
const draggedElement = event.relatedTarget
|
||||||
|
const objectId = draggedElement.dataset?.objectId
|
||||||
|
if (objectId === undefined) return
|
||||||
|
emit("object-change", objectId, false)
|
||||||
|
},
|
||||||
|
ondrop(event) {
|
||||||
|
const draggedElement = event.relatedTarget
|
||||||
|
const objectId = draggedElement.dataset?.objectId
|
||||||
|
if (objectId === undefined) return
|
||||||
|
emit("object-change", objectId, false)
|
||||||
|
emit("object-drop", objectId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => interactable.unset())
|
||||||
|
})
|
||||||
|
</script>
|
63
src/game.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { computed, reactive, ref } from "vue"
|
||||||
|
import { script } from "./shared/script"
|
||||||
|
import type { Interaction, InteractionQueueItem } from "./shared/script/types"
|
||||||
|
import { trpcClient } from "./trpc"
|
||||||
|
import type { GameEvent } from "./shared/gameEvents"
|
||||||
|
import { getInteractionQueueItemId } from "./shared/util"
|
||||||
|
|
||||||
|
export const useGame = defineStore("gameState", () => {
|
||||||
|
const currentRoomId = ref(script.roomsById.values().next().value!.id)
|
||||||
|
const interactionQueue = reactive(new Map<string, InteractionQueueItem>())
|
||||||
|
const currentInteraction = ref<null | Interaction>(null)
|
||||||
|
const currentInteractionId = computed(() =>
|
||||||
|
currentInteraction.value === null ? null : getInteractionQueueItemId(currentInteraction.value))
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentRoomId,
|
||||||
|
interactionQueue,
|
||||||
|
currentRoom: computed(() => script.roomsById.get(currentRoomId.value)!),
|
||||||
|
currentInteraction,
|
||||||
|
currentInteractionId,
|
||||||
|
voteForInteraction(interaction: Interaction) {
|
||||||
|
if (currentInteractionId.value === getInteractionQueueItemId(interaction)) return
|
||||||
|
if (currentInteractionId.value !== null) trpcClient.player.removeInteractionVote.mutate({ queueItemId: currentInteractionId.value })
|
||||||
|
currentInteraction.value = interaction
|
||||||
|
trpcClient.player.voteForInteraction.mutate({ interaction })
|
||||||
|
},
|
||||||
|
removeInteractionVote(queueItemId: string) {
|
||||||
|
trpcClient.player.removeInteractionVote.mutate({ queueItemId })
|
||||||
|
currentInteraction.value = null
|
||||||
|
},
|
||||||
|
switchRoom(roomId: string) {
|
||||||
|
trpcClient.director.switchRoom.mutate({ roomId })
|
||||||
|
},
|
||||||
|
removeInteractionQueueItem(id: string) {
|
||||||
|
trpcClient.director.removeInteractionQueueItem.mutate({ id })
|
||||||
|
},
|
||||||
|
handleGameEvent(event: GameEvent) {
|
||||||
|
console.log(event)
|
||||||
|
switch (event.type) {
|
||||||
|
case "room-changed":
|
||||||
|
interactionQueue.clear()
|
||||||
|
currentRoomId.value = event.roomId
|
||||||
|
currentInteraction.value = null
|
||||||
|
break
|
||||||
|
|
||||||
|
case "interaction-queued":
|
||||||
|
interactionQueue.set(event.item.id, event.item)
|
||||||
|
break
|
||||||
|
|
||||||
|
case "interaction-votes-changed":
|
||||||
|
if (event.votes <= 0) {
|
||||||
|
interactionQueue.delete(event.id)
|
||||||
|
if (currentInteractionId.value === event.id) currentInteraction.value = null
|
||||||
|
} else {
|
||||||
|
interactionQueue.get(event.id)!.votes = event.votes
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
19
src/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import "virtual:windi.css"
|
||||||
|
import "@interactjs/actions"
|
||||||
|
import "@interactjs/auto-start"
|
||||||
|
|
||||||
|
import { createApp } from "vue"
|
||||||
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
|
import App from "./App.vue"
|
||||||
|
import routes from "virtual:generated-pages"
|
||||||
|
import { createPinia } from "pinia"
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routes,
|
||||||
|
history: createWebHistory()
|
||||||
|
})
|
||||||
|
|
||||||
|
createApp(App)
|
||||||
|
.use(router)
|
||||||
|
.use(createPinia())
|
||||||
|
.mount("#app")
|
11
src/screens/AdminScreen.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
91
src/screens/InteractionsScreen.vue
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
<template>
|
||||||
|
<div class="h-100vh flex flex-col relative">
|
||||||
|
<div class="flex-shrink-0 flex order-1">
|
||||||
|
<ObjectCardDropZone
|
||||||
|
label="Use"
|
||||||
|
class="bg-green-800"
|
||||||
|
:has-floating="useFloatingObjectIds.size > 0"
|
||||||
|
@object-change="(v, i) => createSetInclusionSetter(useFloatingObjectIds)(v, i)"
|
||||||
|
@object-drop="onObjectUseDrop"
|
||||||
|
/>
|
||||||
|
<ObjectCardDropZone
|
||||||
|
label="Combine"
|
||||||
|
class="bg-blue-900"
|
||||||
|
:has-floating="combineFloatingObjectIds.size > 0"
|
||||||
|
@object-change="(v, i) => createSetInclusionSetter(combineFloatingObjectIds)(v, i)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div ref="objectsContainerElement" class="grid gap-3 grid-cols-2 flex-grow auto-rows-min p-4">
|
||||||
|
<ObjectCard
|
||||||
|
v-for="object in game.currentRoom.initialObjects"
|
||||||
|
:key="object.id"
|
||||||
|
:object="object"
|
||||||
|
:is-over-dropzone="allFloatingObjectIds.has(object.id)"
|
||||||
|
:marked-for="getMarkedFor(object.id)"
|
||||||
|
@drag-start="onObjectDragStart"
|
||||||
|
@drag-end="onObjectDragEnd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ObjectCard from "../components/ObjectCard.vue"
|
||||||
|
import { useGame } from "../game"
|
||||||
|
import { computed, reactive, ref } from "vue"
|
||||||
|
import { useScrollLock } from "@vueuse/core"
|
||||||
|
import ObjectCardDropZone from "../components/ObjectCardDropZone.vue"
|
||||||
|
|
||||||
|
const game = useGame()
|
||||||
|
|
||||||
|
const dragCounter = ref(0)
|
||||||
|
const objectsContainerElement = ref<HTMLElement>()
|
||||||
|
const objectsContainerScrollLock = useScrollLock(objectsContainerElement)
|
||||||
|
|
||||||
|
const useFloatingObjectIds = reactive(new Set())
|
||||||
|
const combineFloatingObjectIds = reactive(new Set())
|
||||||
|
const allFloatingObjectIds = computed(() => new Set([...useFloatingObjectIds, ...combineFloatingObjectIds]))
|
||||||
|
|
||||||
|
function createSetInclusionSetter<T>(set: Set<T>) {
|
||||||
|
return (value: T, isIncluded: boolean) => {
|
||||||
|
if (isIncluded) set.add(value)
|
||||||
|
else set.delete(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkedFor(objectId: string) {
|
||||||
|
if (game.currentInteraction !== null) {
|
||||||
|
switch (game.currentInteraction.type) {
|
||||||
|
case "use":
|
||||||
|
if (game.currentInteraction.objectId === objectId) return "use"
|
||||||
|
break
|
||||||
|
|
||||||
|
case "combine":
|
||||||
|
if (game.currentInteraction.objectIds.has(objectId)) return "combine"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onObjectDragStart() {
|
||||||
|
dragCounter.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function onObjectDragEnd() {
|
||||||
|
dragCounter.value--
|
||||||
|
if (dragCounter.value <= 0) objectsContainerScrollLock.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onObjectUseDrop(objectId: string) {
|
||||||
|
game.voteForInteraction({
|
||||||
|
type: "use",
|
||||||
|
objectId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
69
src/server/game.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import EventEmitter from "eventemitter3"
|
||||||
|
import type { GameEvent } from "../shared/gameEvents"
|
||||||
|
import type { Interaction, InteractionQueueItem } from "../shared/script/types"
|
||||||
|
import { script } from "../shared/script"
|
||||||
|
import { getInteractionQueueItemId } from "../shared/util"
|
||||||
|
|
||||||
|
interface Events {
|
||||||
|
"game-event": [GameEvent]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Game extends EventEmitter<Events> {
|
||||||
|
private currentRoomId: string = script.roomsById.values().next()!.value!.id
|
||||||
|
private interactionQueue: Map<string, InteractionQueueItem> = new Map()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitialStateEvents(): GameEvent[] {
|
||||||
|
const events: GameEvent[] = []
|
||||||
|
|
||||||
|
events.push({ type: "room-changed", roomId: this.currentRoomId })
|
||||||
|
this.interactionQueue.forEach(item => events.push({ type: "interaction-queued", item }))
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
addInteractionVote(interaction: Interaction) {
|
||||||
|
const id = getInteractionQueueItemId(interaction)
|
||||||
|
const existingItem = this.interactionQueue.get(id)
|
||||||
|
if (existingItem === undefined) {
|
||||||
|
const item = {
|
||||||
|
id,
|
||||||
|
votes: 1,
|
||||||
|
interaction
|
||||||
|
}
|
||||||
|
|
||||||
|
this.interactionQueue.set(item.id, item)
|
||||||
|
this.emit("game-event", { type: "interaction-queued", item })
|
||||||
|
} else {
|
||||||
|
existingItem.votes += 1
|
||||||
|
this.emit("game-event", { type: "interaction-votes-changed", id: existingItem.id, votes: existingItem.votes })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInteractionVote(id: string) {
|
||||||
|
const item = this.interactionQueue.get(id)
|
||||||
|
if (item !== undefined) {
|
||||||
|
item.votes -= 1
|
||||||
|
this.emit("game-event", { type: "interaction-votes-changed", id: item.id, votes: item.votes })
|
||||||
|
|
||||||
|
if (item.votes <= 0) this.interactionQueue.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchRoom(roomId: string) {
|
||||||
|
this.currentRoomId = roomId
|
||||||
|
this.interactionQueue.clear()
|
||||||
|
this.emit("game-event", { type: "room-changed", roomId })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInteractionQueueItem(id: string) {
|
||||||
|
if (!this.interactionQueue.has(id)) return
|
||||||
|
this.emit("game-event", { type: "interaction-votes-changed", id: id, votes: 0 })
|
||||||
|
this.interactionQueue.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const game = new Game()
|
1
src/server/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type { AppRouter } from "./trpc"
|
1
src/server/isDev.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const isDev = process.env.NODE_ENV === "development"
|
38
src/server/main.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import createExpressApp from "express"
|
||||||
|
import { listen } from "listhen"
|
||||||
|
import { WebSocketServer } from "ws"
|
||||||
|
import { appRouter } from "./trpc"
|
||||||
|
import { createExpressMiddleware as createTrpcMiddleware } from "@trpc/server/adapters/express"
|
||||||
|
import { applyWSSHandler } from "@trpc/server/adapters/ws"
|
||||||
|
import { createContext } from "./trpc/base"
|
||||||
|
import { isDev } from "./isDev"
|
||||||
|
|
||||||
|
const expressApp = createExpressApp()
|
||||||
|
|
||||||
|
expressApp.use("/trpc", createTrpcMiddleware({
|
||||||
|
router: appRouter,
|
||||||
|
createContext: ({ req, res }) => createContext(res),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { server } = await listen(expressApp, { isProd: !isDev, autoClose: false })
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server, path: "/ws" })
|
||||||
|
const wssTrpcHandler = applyWSSHandler({
|
||||||
|
wss,
|
||||||
|
router: appRouter,
|
||||||
|
createContext: () => createContext(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
console.log("Received stop signal")
|
||||||
|
server.close()
|
||||||
|
wssTrpcHandler.broadcastReconnectNotification()
|
||||||
|
wss.close(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", stop)
|
||||||
|
process.on("SIGINT", stop)
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
console.log("exit")
|
||||||
|
})
|
17
src/server/trpc/base.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { initTRPC } from "@trpc/server"
|
||||||
|
import type { Response } from "express"
|
||||||
|
import superjson from "superjson"
|
||||||
|
|
||||||
|
export interface Context {
|
||||||
|
res?: Response
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContext(res: Response | undefined): Promise<Context> {
|
||||||
|
return {
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const t = initTRPC.context<Context>().create({
|
||||||
|
transformer: superjson
|
||||||
|
})
|
20
src/server/trpc/director.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { t } from "./base"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const directorRouter = t.router({
|
||||||
|
switchRoom: t.procedure
|
||||||
|
.input(z.object({
|
||||||
|
roomId: z.string()
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
ctx.game.switchRoom(input.roomId)
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeInteractionQueueItem: t.procedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.string()
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
ctx.game.removeInteractionQueueItem(input.id)
|
||||||
|
}),
|
||||||
|
})
|
29
src/server/trpc/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { t } from "./base"
|
||||||
|
import { playerRouter } from "./player"
|
||||||
|
import { observable } from "@trpc/server/observable"
|
||||||
|
import type { GameEvent } from "../../shared/gameEvents"
|
||||||
|
import { directorRouter } from "./director"
|
||||||
|
import { game } from "../game"
|
||||||
|
|
||||||
|
export const appRouter = t.router({
|
||||||
|
player: playerRouter,
|
||||||
|
director: directorRouter,
|
||||||
|
|
||||||
|
join: t.procedure
|
||||||
|
.subscription(({ ctx }) => {
|
||||||
|
return observable<GameEvent>(emit => {
|
||||||
|
const handleGameEvent = (event: GameEvent) => emit.next(event)
|
||||||
|
const handleDestroyed = () => setTimeout(() => emit.complete(), 500)
|
||||||
|
|
||||||
|
game.on("game-event", handleGameEvent)
|
||||||
|
|
||||||
|
game.getInitialStateEvents().forEach(event => emit.next(event))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
game.off("game-event", handleGameEvent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter
|
22
src/server/trpc/player.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { t } from "./base"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { interactionSchema } from "../../shared/script/types"
|
||||||
|
import { game } from "../game"
|
||||||
|
|
||||||
|
export const playerRouter = t.router({
|
||||||
|
voteForInteraction: t.procedure
|
||||||
|
.input(z.object({
|
||||||
|
interaction: interactionSchema
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
game.addInteractionVote(input.interaction)
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeInteractionVote: t.procedure
|
||||||
|
.input(z.object({
|
||||||
|
queueItemId: z.string()
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
game.removeInteractionVote(input.queueItemId)
|
||||||
|
}),
|
||||||
|
})
|
6
src/shared/gameEvents.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import type { InteractionQueueItem } from "./script/types"
|
||||||
|
|
||||||
|
export type GameEvent =
|
||||||
|
| { type: "room-changed"; roomId: string }
|
||||||
|
| { type: "interaction-queued"; item: InteractionQueueItem }
|
||||||
|
| { type: "interaction-votes-changed"; id: string; votes: number }
|
12
src/shared/script/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Script } from "./types"
|
||||||
|
import { roomHauseingang } from "./rooms/hauseingang"
|
||||||
|
import { roomKueche } from "./rooms/küche"
|
||||||
|
|
||||||
|
const script: Script = {
|
||||||
|
roomsById: new Map
|
||||||
|
}
|
||||||
|
|
||||||
|
script.roomsById.set("hauseingang", roomHauseingang)
|
||||||
|
script.roomsById.set("küche", roomKueche)
|
||||||
|
|
||||||
|
export { script }
|
41
src/shared/script/rooms/hauseingang.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import type { Room } from "../types"
|
||||||
|
import { cSet } from "../../util"
|
||||||
|
|
||||||
|
export const roomHauseingang: Room = {
|
||||||
|
id: "hauseingang",
|
||||||
|
label: "Hauseingang",
|
||||||
|
initialObjects: cSet(
|
||||||
|
{
|
||||||
|
id: "schlüssel",
|
||||||
|
label: "Schlüssel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "haustür",
|
||||||
|
label: "Haustür"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
hiddenObjects: cSet(
|
||||||
|
{
|
||||||
|
id: "offene-haustür",
|
||||||
|
label: "Offene Haustür"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
combinations: cSet(
|
||||||
|
{
|
||||||
|
id: "open-door",
|
||||||
|
inputs: cSet(
|
||||||
|
{
|
||||||
|
objectId: "schlüssel",
|
||||||
|
isConsumed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectId: "haustür",
|
||||||
|
isConsumed: true
|
||||||
|
}
|
||||||
|
),
|
||||||
|
outputIds: cSet(
|
||||||
|
"offene-haustür"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
49
src/shared/script/rooms/küche.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import type { Room } from "../types"
|
||||||
|
import { cSet } from "../../util"
|
||||||
|
|
||||||
|
export const roomKueche: Room = {
|
||||||
|
id: "küche",
|
||||||
|
label: "Küche",
|
||||||
|
initialObjects: cSet(
|
||||||
|
{
|
||||||
|
id: "schüssel",
|
||||||
|
label: "Schüssel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kühlschrank",
|
||||||
|
label: "Kühlschrank"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kakaopulver",
|
||||||
|
label: "Kakaopulver"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
hiddenObjects: cSet(
|
||||||
|
{
|
||||||
|
id: "milch",
|
||||||
|
label: "Milch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kakao",
|
||||||
|
label: "Kakao"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
combinations: cSet(
|
||||||
|
{
|
||||||
|
id: "kakao",
|
||||||
|
inputs: cSet(
|
||||||
|
{
|
||||||
|
objectId: "milch",
|
||||||
|
isConsumed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
objectId: "kakaopulver",
|
||||||
|
isConsumed: false
|
||||||
|
}
|
||||||
|
),
|
||||||
|
outputIds: cSet(
|
||||||
|
"kakao"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
46
src/shared/script/types.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export interface Script {
|
||||||
|
roomsById: Map<string, Room>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
initialObjects: Set<GameObject>
|
||||||
|
hiddenObjects: Set<GameObject>
|
||||||
|
combinations: Set<Combination>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameObject {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Combination {
|
||||||
|
id: string
|
||||||
|
inputs: Set<{
|
||||||
|
objectId: string
|
||||||
|
isConsumed: boolean
|
||||||
|
}>
|
||||||
|
outputIds: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const interactionSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("use"),
|
||||||
|
objectId: z.string()
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("combine"),
|
||||||
|
objectIds: z.set(z.string())
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
export type Interaction = z.infer<typeof interactionSchema>
|
||||||
|
|
||||||
|
export interface InteractionQueueItem {
|
||||||
|
id: string
|
||||||
|
votes: number
|
||||||
|
interaction: Interaction
|
||||||
|
}
|
12
src/shared/util.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import type { Interaction } from "./script/types"
|
||||||
|
|
||||||
|
export const cSet = <T>(...values: T[]) => new Set(values)
|
||||||
|
export const cMap = <K extends string | number | symbol, V>(object: Record<K, V>) => new Map(Object.entries(object))
|
||||||
|
|
||||||
|
export function getInteractionQueueItemId(interaction: Interaction) {
|
||||||
|
let id: string
|
||||||
|
if (interaction.type === "use") id = `use-${interaction.objectId}`
|
||||||
|
else if (interaction.type === "combine") id = `combine-${[...interaction.objectIds].sort().join("_")}`
|
||||||
|
else throw new Error("Unknown interaction type")
|
||||||
|
return id
|
||||||
|
}
|
19
src/trpc.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { createTRPCProxyClient, createWSClient, wsLink } from "@trpc/client"
|
||||||
|
import superjson from "superjson"
|
||||||
|
import type { AppRouter } from "./server"
|
||||||
|
|
||||||
|
const wsUrl = new URL(location.href)
|
||||||
|
wsUrl.protocol = wsUrl.protocol.replace("http", "ws")
|
||||||
|
wsUrl.pathname = "/ws"
|
||||||
|
wsUrl.hash = ""
|
||||||
|
|
||||||
|
export const trpcClient = createTRPCProxyClient<AppRouter>({
|
||||||
|
links: [
|
||||||
|
wsLink({
|
||||||
|
client: createWSClient({
|
||||||
|
url: wsUrl.href
|
||||||
|
})
|
||||||
|
})
|
||||||
|
],
|
||||||
|
transformer: superjson
|
||||||
|
})
|
31
tsconfig.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["esnext", "dom"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"importsNotUsedAsValues": "error",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"stripInternal": true,
|
||||||
|
"target": "esnext",
|
||||||
|
"types": [
|
||||||
|
"./src/types.d.ts",
|
||||||
|
"vite/client",
|
||||||
|
"unplugin-icons/types/vue",
|
||||||
|
"vite-plugin-pages/client"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.vue"
|
||||||
|
]
|
||||||
|
}
|
23
vite.config.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
import vuePlugin from "@vitejs/plugin-vue"
|
||||||
|
import iconsPlugin from "unplugin-icons/vite"
|
||||||
|
import pagesPlugin from "vite-plugin-pages"
|
||||||
|
import windiPlugin from "vite-plugin-windicss"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vuePlugin(),
|
||||||
|
iconsPlugin(),
|
||||||
|
pagesPlugin(),
|
||||||
|
windiPlugin()
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/trpc": "http://localhost:3000",
|
||||||
|
"/ws": {
|
||||||
|
target: "http://localhost:3000",
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
23
windi.config.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { defineConfig } from "vite-plugin-windicss"
|
||||||
|
import colors from "windicss/colors"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
theme: {
|
||||||
|
fontFamily: {
|
||||||
|
"normal": ["InterVariable", "sans-serif"],
|
||||||
|
"fat": ["Titan One", "sans-serif"]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
transparent: "transparent",
|
||||||
|
white: "white",
|
||||||
|
black: "black",
|
||||||
|
gray: colors.zinc,
|
||||||
|
light: colors.light,
|
||||||
|
dark: colors.dark,
|
||||||
|
red: colors.rose,
|
||||||
|
yellow: colors.yellow,
|
||||||
|
green: colors.green,
|
||||||
|
blue: colors.indigo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|