Compare commits

...

21 Commits

Author SHA1 Message Date
bc12f855ce Merge pull request 'tristan-wip' (#2) from tristan-wip into main
Reviewed-on: #2
2025-07-19 19:58:57 +00:00
Trit0
09fcc8f76d fix a couple of stuff 2025-07-19 19:58:57 +00:00
Trit0
0ab4b1f832 control helper and cute tags 2025-07-19 19:58:57 +00:00
club
af42d6c61c hahahahahahah 2025-07-19 19:58:57 +00:00
Trit0
889a00fc49 not always decompress 2025-07-19 19:58:57 +00:00
Trit0
789e0c1e13 better ui, uses fake data, wont be able to start games 2025-07-19 19:58:57 +00:00
club
2872bd6220 all two controllers and no extract every time 2025-07-19 19:58:57 +00:00
Trit0
5c7c429c70 try to support both controllers 2025-07-19 19:58:57 +00:00
club
6ca7501e8b it only support one controller but thats already good 2025-07-19 19:58:57 +00:00
Trit0
c20554c4f6 test 2025-07-19 19:58:57 +00:00
Trit0
8bf08218c0 image! 2025-07-19 19:58:57 +00:00
Trit0
fd33b6df73 test 2 2025-07-19 19:58:57 +00:00
Trit0
900210eeb7 test 2025-07-19 19:58:57 +00:00
Trit0
76997a60a1 chui tanner 2025-07-19 19:58:57 +00:00
Trit0
34864e96ac chui tanner 2025-07-19 19:58:57 +00:00
Trit0
6f9f80a190 images perchance? 3 2025-07-19 19:58:57 +00:00
Trit0
10174b1232 images perchance? 2025-07-19 19:58:57 +00:00
Trit0
39a35e4eaa stupid mistake 2025-07-19 19:58:57 +00:00
Trit0
938e8e3195 start game perchance? 2025-07-19 19:58:57 +00:00
Trit0
73e68bf2bb wip 4 2025-07-19 19:58:57 +00:00
Trit0
7ba175204d wip 3 2025-07-19 19:58:57 +00:00
49 changed files with 1610 additions and 347 deletions

105
app.go
View File

@ -1,16 +1,25 @@
package main package main
import ( import (
"conjure-os/lib/inputs"
"conjure-os/lib/models" "conjure-os/lib/models"
"conjure-os/lib/provider"
"context" "context"
"fmt" "fmt"
"os"
"conjure-os/lib/inputs" "os/exec"
"conjure-os/lib/provider" "time"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
var (
games []models.Metadata
lastEmitTimestamp = time.Now().Add(-10 * time.Second)
emitInterval = 150 * time.Millisecond
gameIsOpen = false
)
// App struct // App struct
type App struct { type App struct {
ctx context.Context ctx context.Context
@ -21,23 +30,93 @@ func NewApp() *App {
return &App{} return &App{}
} }
// startup is called when the app starts. The context is saved // Startup is called when the app starts. The context is saved
// so we can call the runtime methods // so we can call the runtime methods
func (a *App) startup(ctx context.Context) { func (a *App) Startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
inputs.Start() inputs.Start(a.onControllerChange)
provider.Update() provider.Update()
} }
// Greet returns a greeting for the given name func (a *App) onControllerChange(state inputs.ControllerState) {
func (a *App) Greet(name string) string { now := time.Now()
runtime.LogInfo(a.ctx, "Test : "+name)
return fmt.Sprintf("Hello %s, It's show time!", name) if now.Sub(lastEmitTimestamp) >= emitInterval && !gameIsOpen {
if state.Buttons != 0 {
for _, button := range inputs.ConjureControllerButtons {
if state.Buttons&(1<<button) != 0 {
fmt.Printf("Button %s pressed\n", (button).String())
}
}
fmt.Printf("Button was pressed! %d\n", state.Buttons)
}
if state.Joystick.X != 127 || state.Joystick.Y != 127 {
fmt.Printf("Joystick moved! %d - %d\n", state.Joystick.X, state.Joystick.Y)
}
fmt.Printf("Joystick: X=%d Y=%d Buttons=%08b\n", state.Joystick.X, state.Joystick.Y, state.Buttons)
runtime.EventsEmit(a.ctx, "controller_change", state)
lastEmitTimestamp = now
}
} }
func (a *App) SelectGame(id string) { func (a *App) StartGame(id string) {
found := false
fmt.Println(id)
for _, game := range games {
fmt.Println(game.Id)
if game.Id == id {
found = true
gamePath := provider.ExtractGame(game)
cmd := exec.Command(gamePath)
gameIsOpen = true
// Optional: attach current terminal's std streams
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// Start the process
if err := cmd.Start(); err != nil {
fmt.Println("Failed to start:", err)
return
}
fmt.Printf("Process started with PID %d\n", cmd.Process.Pid)
// Optional: wait for it to finish
if err := cmd.Wait(); err != nil {
fmt.Println("Process exited with error:", err)
} else {
fmt.Println("Process exited successfully.")
}
gameIsOpen = false
}
}
fmt.Printf("Found %b", found)
fmt.Println()
} }
func (a *App) LoadGames() []models.Game { func (a *App) LoadGames() []models.Metadata {
return provider.GetConjureGameInfo() games = provider.GetConjureGameInfo()
return games
}
func (a *App) LoadGamesNewModel() []models.Game {
return []models.Game{}
}
func (a *App) Log(message string) {
fmt.Println(message)
}
func (a *App) LoadImage(gameId string, imageSrc string) provider.FileBlob {
blob := provider.LoadImage(gameId, imageSrc)
if blob == nil {
return provider.FileBlob{}
}
return *blob
} }

BIN
build/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -15,6 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/types": "^7.18.10", "@babel/types": "^7.18.10",
"@iconify/vue": "^5.0.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
@ -512,6 +513,27 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"dev": true
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"dev": true,
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",

View File

@ -20,6 +20,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/types": "^7.18.10", "@babel/types": "^7.18.10",
"@iconify/vue": "^5.0.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",

View File

@ -1 +1 @@
0439ac4c00128949ad585ea9e2f68e8e 063583417fe9e58bc0e2d6b154be7c49

View File

@ -1,7 +1,9 @@
<template> <template>
<div class="flex h-screen w-screen bg-black text-white"> <div class="flex h-screen w-screen text-white">
<div class="flex flex-1 overflow-hidden" :style="backgroundStyle">
<!-- Sidebar --> <!-- Sidebar -->
<Sidebar <Sidebar
v-if="showSidebar"
:tags="tags" :tags="tags"
:selectedTag="selectedTag" :selectedTag="selectedTag"
@selectTag="store.selectTag" @selectTag="store.selectTag"
@ -10,53 +12,70 @@
<!-- Main Content --> <!-- Main Content -->
<div class="flex flex-col flex-1 overflow-hidden"> <div class="flex flex-col flex-1 overflow-hidden">
<div class="flex-1 overflow-hidden">
<GamePreview <GamePreview
:game="selectedGame" :game="selectedGame"
@qr="store.showQr" @qr="store.showQr"
/> />
</div> <div class="flex flex-col">
<GameCarousel <GameCarousel
:games="store.filteredGames" :games="store.filteredGames"
:selectedGame="selectedGame" :selectedGame="selectedGame"
:selectedTag="selectedTag" :selectedTag="selectedTag"
:direction="transitionDirection" :direction="transitionDirection"
@selectGame="store.selectGame" @selectGame="store.selectGame"
class="mb-2"
/> />
<CurrentActionsHelp />
</div>
</div>
</div> </div>
<OptionsModal v-if="optionsOpen" @close="optionsOpen = false" /> <OptionsModal v-if="optionsOpen" @close="optionsOpen = false"/>
<QrModal v-if="qrLink" :link="qrLink" @close="qrLink = ''" /> <QrModal v-if="qrLink" :link="qrLink" @close="qrLink = ''"/>
<LoadingModal v-if="gameIsStarting"/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { onMounted, computed } from 'vue';
import Sidebar from './components/Sidebar.vue'; import Sidebar from './components/Sidebar.vue';
import GamePreview from './components/GamePreview.vue'; import GamePreview from './components/GamePreview.vue';
import GameCarousel from './components/GameCarousel.vue'; import GameCarousel from './components/GameCarousel.vue';
import OptionsModal from './components/OptionsModal.vue'; import OptionsModal from './components/OptionsModal.vue';
import QrModal from './components/QrModal.vue'; import QrModal from './components/QrModal.vue';
import { useKeyboardNavigation } from './utils/use-keyboard-navigation'; import LoadingModal from './components/LoadingModal.vue';
import { fetchGames } from './services/game-service';
import { useAppStore } from "./stores/app-store"; import { useAppStore } from "./stores/app-store";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { KeyboardManager } from "./utils/keyboard-manager"; import CurrentActionsHelp from "./components/CurrentActionsHelp.vue";
const store = useAppStore(); const store = useAppStore();
const { selectedTag, selectedGame, tags, games, transitionDirection, qrLink } = storeToRefs(store); const {
selectedTag,
const optionsOpen = ref(false); selectedGame,
tags,
transitionDirection,
qrLink,
gameIsStarting,
optionsOpen,
showSidebar
} = storeToRefs(store);
onMounted(async () => { onMounted(async () => {
games.value = await fetchGames(); await store.loadGames();
tags.value = [...new Set(games.value.flatMap(game => game.Genres))];
selectedTag.value = tags.value[0];
selectedGame.value = store.filteredGames[0];
}); });
KeyboardManager.switchContext("sidebar") // Create the gradient background style
useKeyboardNavigation(); const backgroundStyle = computed(() => {
const primary = selectedGame?.value?.colorScheme?.primary ?? "#4d97f8";
const secondary = selectedGame?.value?.colorScheme?.secondary ?? "#100a7d";
const backgroundImage = `linear-gradient(135deg, ${primary}, ${secondary})`;
// const backgroundImage = !!bgImage ? `url(${bgImage})` : `linear-gradient(135deg, ${primary}, ${secondary})`;
return {
backgroundImage
};
});
</script> </script>
<style scoped> <style scoped>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,51 @@
<template>
<div class="bg-black min-h-10">
<div v-if="currentActions" class="flex justify-around w-full h-full items-center px-4">
<span v-for="tip in currentActions"> {{ tip }} </span>
</div>
<div v-else>
<span>Chungus</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useAppStore } from "../stores/app-store";
import { storeToRefs } from "pinia";
import { computed } from "vue";
const store = useAppStore();
const {
currentInputDevice,
currentAvailableActions,
} = storeToRefs(store);
const currentActions = computed(() => {
const { order, ...rest } = currentAvailableActions.value as any;
console.log(order, rest);
if (!order) {
return "";
}
const possibleControllers = order.split(",");
const index = possibleControllers.findIndex(controller => controller === currentInputDevice.value);
const list = [];
console.log(index);
console.log(rest);
for (const [key, value] of Object.entries(rest)) {
console.log(key, value);
const split = key.split(",");
const myKey = split[index];
list.push(`${myKey}: ${rest[key]}`);
}
return list;
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,34 @@
<template>
<div class="dev-card flex border-1 rounded p-2 border-gray-300 gap-3 items-center" :tabindex="!!tabbable ? 10 : -1" @click="$emit('click')">
<div>
<img v-if="!!picture && !useIcon" :src="picture" alt="picture" class="w-14 rounded-full" />
<Icon v-else-if="!!picture" :icon="picture" class="w-10 h-10"/>
</div>
<div class="flex flex-col gap-1">
<span class="text-sm">{{ title }}</span>
<span>{{ content }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import { models } from "../../wailsjs/go/models";
import Developer = models.Developer;
defineProps<{
title: string,
content: string,
picture: string,
useIcon: boolean,
tabbable: boolean,
}>();
defineEmits(['click']);
</script>
<style scoped>
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="dev-card flex border-1 rounded p-2 border-gray-300 gap-3" tabindex="0" @click="$emit('qr', dev.link)">
<img v-if="dev.picture" :src="dev.picture" alt="picture" class="w-14 rounded-full" />
<Icon v-else icon="fluent:person-circle-24-filled" class="w-14 h-14"/>
<div class="flex flex-col gap-1">
<span>{{ dev.name }}</span>
<span>{{ dev.role }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue";
import { models } from "../../wailsjs/go/models";
import Developer = models.Developer;
defineProps<{
dev: Developer,
}>();
defineEmits(['qr']);
</script>
<style scoped>
</style>

View File

@ -0,0 +1,31 @@
<template>
<div
:key="game.id"
:class="[
'transition-transform transform cursor-pointer rounded-lg overflow-hidden border-4',
selected ? 'scale-110 border-blue-500' : 'scale-100 border-transparent'
]"
>
<LocalImage
:src="game.thumbnailPath"
class="h-32 w-48 object-cover"
:alt="game.title"
:gameId="game.id"
/>
</div>
</template>
<style scoped>
</style>
<script setup lang="ts">
import { models } from "../../wailsjs/go/models";
import Game = models.Game;
import LocalImage from "./LocalImage.vue";
defineProps<{
game: Game
selected: boolean
}>()
</script>

View File

@ -1,23 +1,14 @@
<template> <template>
<div class="relative h-[170px]"> <div class="relative h-[170px]">
<Transition :name="`carousel-${direction}`" mode="out-in"> <Transition :name="`carousel-${direction}`" mode="out-in">
<div :key="selectedTag" class="w-full py-4 px-6 flex overflow-x-auto space-x-4 items-end transition-inner"> <div :key="selectedTag" class="w-full py-4 px-6 flex overflow-hidden space-x-4 items-end transition-inner">
<div <GameCard
v-for="game in games" v-for="game in games"
:key="game.Id" :game="game"
:selected="game.id === selectedGame?.id"
@click="$emit('selectGame', game)" @click="$emit('selectGame', game)"
:class="[
'transition-transform transform cursor-pointer rounded-lg overflow-hidden border-4',
game.Id === selectedGame.Id ? 'scale-110 border-blue-500' : 'scale-100 border-transparent'
]"
>
<img
:src="game.ThumbnailPath"
class="h-32 w-48 object-cover"
:alt="game.Game"
/> />
</div> </div>
</div>
<!-- Give the whole carousel a key based on selectedTag to trigger transition --> <!-- Give the whole carousel a key based on selectedTag to trigger transition -->
<!-- <div :key="selectedTag" class="flex gap-4 w-full transition-inner">--> <!-- <div :key="selectedTag" class="flex gap-4 w-full transition-inner">-->
<!-- <GameCard--> <!-- <GameCard-->
@ -36,6 +27,7 @@
import { models } from "../../wailsjs/go/models"; import { models } from "../../wailsjs/go/models";
import Game = models.Game; import Game = models.Game;
import GameCard from "./GameCard.vue";
defineProps<{ defineProps<{
games: Game[], games: Game[],

View File

@ -1,41 +1,131 @@
<template> <template>
<div class="p-6 h-full overflow-auto"> <div id="preview" class="p-6 h-full w-full overflow-auto flex flex-col items-center">
<div v-if="game" class="space-y-4"> <div v-if="game" class="space-y-4 flex flex-col items-center w-full max-w-400">
<h1 class="text-4xl font-bold">{{ game.title }}</h1> <img v-if="game.logoPath" :src="game.logoPath" :alt="game.title" class="h-72"/>
<div class="text-sm text-gray-400">{{ game.genre }} - {{ game.players }} players</div> <h1 v-else class="text-4xl font-bold py-8">{{ game.title }}</h1>
<p class="text-lg">{{ game.description }}</p>
<div class="grid grid-cols-2 gap-4"> <!-- Actual carousel pls -->
<img <!-- <div class="flex">-->
v-for="(img, index) in game.media" <!-- <LocalImage-->
:key="index" <!-- v-for="(img, index) in game.media_paths"-->
:src="img" <!-- :key="index"-->
class="rounded-lg border border-gray-600 max-h-48 object-cover" <!-- :src="img"-->
<!-- :alt="img"-->
<!-- :gameId="game.id"-->
<!-- class="rounded-lg border border-gray-600 max-h-48 object-cover"-->
<!-- />-->
<!-- </div>-->
<div class="flex justify-between w-full space-y-1 gap-6">
<ImageCarousel
v-if="game.mediaPaths"
:gameId="game.id"
:links="game.mediaPaths"
class="basis-3/4 h-full"
/> />
<!-- <LocalImage-->
<!-- v-for="(img, index) in [game.media_paths[0]]"-->
<!-- :key="index"-->
<!-- :src="img"-->
<!-- :alt="img"-->
<!-- :gameId="game.id"-->
<!-- class="rounded-lg border border-gray-600 max-h-48 object-cover basis-1/4"/>-->
<div class="flex flex-col basis-1/4 gap-2">
<CustomCard title="Number of players" :content="game.players + ' players'" :tabbable=false :use-icon=true picture="fluent:person-24-filled" />
<CustomCard title="Genres" :content="game.genres.split(',').map(s => toUpperCamelCase(s.trim())).join(', ')" :tabbable=false :use-icon=true picture="fluent:apps-list-detail-24-filled" />
<CustomCard title="Collections" :content="game.collections" :tabbable=false :use-icon=true picture="fluent:collections-empty-24-filled" />
<CustomCard title="Updated" :content="game.modification" :tabbable=false :use-icon=true picture="fluent:arrow-upload-24-filled" />
<CustomCard title="Release" :content="game.release" :tabbable=false :use-icon=true picture="fluent:clock-24-filled" />
<CustomCard title="Version" :content="game.version" :tabbable=false :use-icon=true picture="fluent:tag-24-filled" />
</div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2 w-full flex items-start">
<div><strong>Languages:</strong> {{ game.languages.join(', ') }}</div> <div class="flex gap-2">
<div class="flex gap-2 mt-2">
<button <button
v-for="(link, name) in game.links" v-if="game.executable"
:key="name" id="btn-play"
class="bg-blue-600 px-3 py-1 rounded hover:bg-blue-500" key="play"
@click="$emit('qr', link)" tabindex="0"
:style="buttonStyle"
@click="store.startSelectedGame()"
class="bg-blue-600 px-6 py-2 rounded hover:bg-blue-500 rounded-full text-2xl"
> >
{{ name }} 🔗 Play
</button>
<button
v-if="game.publicRepositoryLink"
id="btn-repo"
key="repo"
tabindex="0"
:style="buttonStyle"
@click="$emit('qr', game.publicRepositoryLink)"
class="bg-blue-600 px-6 py-2 rounded hover:bg-blue-500 rounded-full text-2xl"
>
Repo
</button>
<button
v-if="game.itchLink"
id="btn-itch"
key="itch"
tabindex="0"
:style="buttonStyle"
@click="$emit('qr', game.itchLink)"
class="bg-blue-600 px-6 py-2 rounded hover:bg-blue-500 rounded-full text-2xl"
>
Itch
</button> </button>
</div> </div>
</div> </div>
<div class="text-lg w-full wrap min-h-15">{{ game.description }}</div>
<div class="flex w-full items-start">
<div class="flex gap-3 flex-wrap max-w-full">
<DevCard
class="min-w-60"
v-for="dev in game.developers"
:dev="dev"
@qr="(value) => $emit('qr', value)"
/>
</div>
</div>
</div>
<div v-else class="h-full flex flex-col items-center justify-center text-gray-500">
<h1 class="text-3xl font-bold">No game selected</h1>
</div> </div>
<div v-else class="text-center text-gray-500">No game selected</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps({ import { models } from "../../wailsjs/go/models";
game: Object, import Game = models.Game;
}); import { computed } from "vue";
import DevCard from "./DevCard.vue";
import { useAppStore } from "../stores/app-store";
import ImageCarousel from "./ImageCarousel.vue";
import CustomCard from "./CustomCard.vue";
import { toUpperCamelCase } from "../utils/string-utils";
const store = useAppStore();
const props = defineProps<{
game: Game,
}>();
const buttonStyle = computed(() => {
const ternary = props.game?.colorScheme?.ternary ?? "#ffffff";
const secondary = props.game?.colorScheme?.primary ?? "#100a99";
return {
backgroundColor: secondary,
textColor: ternary
};
})
defineEmits(['qr']); defineEmits(['qr']);
</script> </script>
@ -44,6 +134,7 @@ defineEmits(['qr']);
img { img {
transition: transform 0.2s; transition: transform 0.2s;
} }
img:hover { img:hover {
transform: scale(1.05); transform: scale(1.05);
} }

View File

@ -0,0 +1,67 @@
<template>
<div>
<LocalImage v-if="imag" :src="imag" :alt="imag" :gameId="gameId" class="rounded-lg border border-gray-600 max-h-124 w-full h-full object-cover" />
<div class="flex justify-center">
<div class="flex gap-2 relative top-1 points bg-black items-center justify-center px-2 py-1 rounded-full opacity-75">
<div
v-for="media in links"
:class="media === imag ? 'bg-white w-2 h-2 rounded-full opacity-100' : 'bg-white w-1 h-1 rounded-full opacity-100'"
>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import LocalImage from "./LocalImage.vue";
const props = defineProps<{
links: string[],
gameId: string
}>();
const index = ref(0)
const imag = computed(() => {
if (index.value < (props.links?.length ?? -1)) {
return props.links[index.value];
}
return "";
});
watch(() => [props.gameId], async () => {
index.value = -1;
changeImage();
}, { immediate: true });
function delay(time: number) {
return new Promise(resolve => setTimeout(resolve, time));
}
function changeImage() {
index.value = (index.value + 1) % props.links?.length;
}
function clock() {
delay(3000).then(() => {
changeImage();
clock();
});
}
onMounted(() => {
clock();
})
</script>
<style scoped>
.points {
top: -25px;
}
</style>

View File

@ -0,0 +1,13 @@
<template>
<div class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
<div class="bg-white text-black p-6 rounded-lg text-center max-w-sm w-full">
Loading...
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>

View File

@ -0,0 +1,27 @@
<template>
<img
:src="blobUrl"
:alt="alt"
>
</template>
<style scoped>
</style>
<script setup lang="ts">
import { ref, watch } from "vue";
import { ImageService } from "../services/image-service";
const props = defineProps<{
gameId: string
src: string
alt: string
}>();
const blobUrl = ref<string | null>(null);
watch(() => [props.src, props.gameId], async ([newUrl, key]) => {
blobUrl.value = await ImageService.getImage(key, newUrl);
}, { immediate: true });
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50"> <div class="fixed inset-0 bg-color flex items-center justify-center z-50">
<div class="bg-white text-black p-6 rounded-lg w-full max-w-md"> <div class="bg-white text-black p-6 rounded-lg w-full max-w-md">
<h2 class="text-2xl font-bold mb-4">Options</h2> <h2 class="text-2xl font-bold mb-4">Options</h2>
@ -40,3 +40,9 @@ function save() {
emit('close'); emit('close');
} }
</script> </script>
<style scoped>
.bg-color {
background-color: rgba(0, 0, 0, 0.75);
}
</style>

View File

@ -1,25 +1,27 @@
<template> <template>
<div class="w-20 flex flex-col items-center bg-gray-900 py-4"> <div class="w-20 flex flex-col items-center py-4">
<div class="flex-1 space-y-4"> <div class="flex-1 space-y-4">
<button <div
v-for="tag in tags" v-for="tag in tags"
:key="tag" :key="tag"
:class="[ :class="[
'w-12 h-12 rounded-full flex items-center justify-center', 'w-12 h-12 rounded-full flex items-center justify-center',
tag === selectedTag ? 'bg-blue-500' : 'bg-gray-700 hover:bg-gray-600' tag === selectedTag ? 'bg-blue-500' : 'bg-gray-700 hover:bg-gray-600'
]" ]"
tabindex="-1"
@click="$emit('selectTag', tag)" @click="$emit('selectTag', tag)"
> >
{{ tag }} {{ tag }}
</button> </div>
</div> </div>
<button <div
class="mt-auto w-12 h-12 bg-gray-600 hover:bg-gray-500 rounded-full flex items-center justify-center" class="mt-auto w-12 h-12 bg-gray-600 hover:bg-gray-500 rounded-full flex items-center justify-center"
tabindex="-1"
@click="$emit('openOptions')" @click="$emit('openOptions')"
> >
</button> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,56 @@
import { ControllerState } from "../models/controller-state";
import { CarouselKeyContext } from "./key-contexts/carousel-key-context";
import { KeyContext } from "./key-contexts/key-context";
import { OptionsKeyContext } from "./key-contexts/options-key-context";
import { SidebarKeyContext } from "./key-contexts/sidebar-key-context";
import { GamePreviewKeyContext } from "./key-contexts/game-preview-key-context";
import { EventsOn } from "../../wailsjs/runtime";
import { log } from "../services/logger-service";
export class InputManager {
private static current?: KeyContext;
public static loaded = false;
public static loadedCount = 0;
static switchContext(name: 'sidebar' | 'carousel' | 'options' | 'preview') {
console.log("Switching context to " + name);
switch (name) {
case 'carousel':
this.current = new CarouselKeyContext();
break;
case 'sidebar':
this.current = new SidebarKeyContext();
break;
case 'preview':
this.current = new GamePreviewKeyContext();
break;
case 'options':
this.current = new OptionsKeyContext();
break;
default:
console.log("Unknown context key " + name);
break;
}
this.current?.setAvailableActions();
}
static handle(event: KeyboardEvent) {
this.current?.handleKey(event);
}
static handleState(controllerState: ControllerState) {
this.current?.handleState(controllerState);
}
static bind() {
console.log("Trying to load inputs")
if (!InputManager.loaded) {
log("Loading inputs");
EventsOn("controller_change", InputManager.handleState.bind(InputManager));
window.addEventListener('keydown', InputManager.handle.bind(InputManager));
InputManager.loaded = true;
InputManager.loadedCount += 1;
log(InputManager.loadedCount);
}
}
}

View File

@ -0,0 +1,78 @@
import { KeyContext } from "./key-context";
import { InputManager } from "../input-manager";
export class CarouselKeyContext extends KeyContext {
readonly name: string = "CarouselContext";
protected onKeyUp() {
super.onKeyUp();
if (this.store.showSidebar) {
this.store.moveTagUp();
}
}
protected onKeyDown() {
super.onKeyDown();
if (this.store.showSidebar) {
this.store.moveTagDown();
}
}
protected onKeyRight() {
super.onKeyRight();
this.store.moveGameRight();
}
protected onKeyLeft() {
super.onKeyLeft();
if (this.store.selectedGameIndex === 0 && this.store.showSidebar) {
InputManager.switchContext("sidebar");
this.store.selectGame(-1);
}
else {
this.store.moveGameLeft();
}
}
protected onEscape() {
super.onEscape();
this.store.optionsOpen = true;
InputManager.switchContext("options");
}
protected onEnter() {
super.onEnter();
this.store.startSelectedGame().then();
}
protected onSpace() {
super.onSpace();
if (this.store.selectedGameIndex !== -1) {
InputManager.switchContext("preview");
const playBtn = document.getElementById("btn-play");
const repoBtn = document.getElementById("btn-repo");
const itchBtn = document.getElementById("btn-itch");
const preview = document.getElementById("preview");
if (playBtn) {
playBtn.focus();
} else if (repoBtn) {
repoBtn.focus();
} else if (itchBtn) {
itchBtn.focus();
} else {
preview.focus();
}
}
}
public setAvailableActions() {
this.store.currentAvailableActions = {
"order": "controller,keyboard",
"1,Enter": "Start",
"2,Space": "Details",
"Power,Escape": "See options"
};
}
}

View File

@ -0,0 +1,94 @@
import { KeyContext } from "./key-context";
import { InputManager } from "../input-manager";
export class GamePreviewKeyContext extends KeyContext {
readonly name: string = "GamePreviewKeyContext";
protected onKeyUp() {
super.onKeyUp();
this.focusLastElement()
}
protected onKeyDown() {
super.onKeyDown();
this.focusNextElement();
// const activeElement = document.activeElement;
// activeElement.nextSibling.parentElement.focus();
// const tabEvent = new KeyboardEvent('keydown', {
// key: 'Tab',
// code: 'Tab',
// bubbles: true, // Allow the event to bubble up the DOM tree
// cancelable: true // Allow the event to be canceled
// });
//
// // Dispatch the event on the desired element
// window.dispatchEvent(tabEvent);
}
private focusNextElement() {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
const focusableElements = Array.from(document.querySelectorAll(focusableSelectors.join(',')))
.filter(el => (el as any).offsetParent !== null); // filter out hidden elements
const currentIndex = focusableElements.indexOf(document.activeElement);
const nextIndex = (currentIndex + 1) % focusableElements.length;
(focusableElements[nextIndex] as any).focus();
}
private focusLastElement() {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
const focusableElements = Array.from(document.querySelectorAll(focusableSelectors.join(',')))
.filter(el => (el as any).offsetParent !== null); // filter out hidden elements
const currentIndex = focusableElements.indexOf(document.activeElement);
const nextIndex = (currentIndex - 1) % focusableElements.length;
(focusableElements[nextIndex] as any).focus();
}
protected onKeyRight() {
super.onKeyRight();
this.focusNextElement();
}
protected onKeyLeft() {
super.onKeyLeft();
this.focusLastElement();
}
protected onEnter() {
super.onEnter();
(document.activeElement as any).click();
}
protected onEscape() {
super.onEscape();
(document.activeElement as any).blur();
InputManager.switchContext("carousel");
}
setAvailableActions() {
this.store.currentAvailableActions = {
"order": "controller,keyboard",
"1,Enter": "Click",
"Power,Escape": "Back"
};
}
}

View File

@ -1,3 +1,4 @@
import { ControllerState } from "../../models/controller-state";
import { useAppStore } from "../../stores/app-store"; import { useAppStore } from "../../stores/app-store";
export abstract class KeyContext { export abstract class KeyContext {
@ -6,6 +7,9 @@ export abstract class KeyContext {
protected store = useAppStore(); protected store = useAppStore();
public handleKey(event: KeyboardEvent): void { public handleKey(event: KeyboardEvent): void {
this.store.currentInputDevice = "keyboard";
event.preventDefault();
switch (event.key) { switch (event.key) {
case 'ArrowRight': case 'ArrowRight':
this.onKeyRight(); this.onKeyRight();
@ -25,11 +29,49 @@ export abstract class KeyContext {
case 'Enter': case 'Enter':
this.onEnter(); this.onEnter();
break; break;
case ' ':
this.onSpace();
break;
case 'r':
console.log("Loading games");
this.store.loadGames().then();
break;
default: default:
break; break;
} }
} }
public handleState(state: ControllerState) {
this.store.currentInputDevice = "controller";
if (state.joystick.x === 0) {
this.onKeyLeft()
}
else if (state.joystick.x === 255) {
this.onKeyRight()
}
if (state.joystick.y === 0) {
this.onKeyUp()
}
else if (state.joystick.y === 255) {
this.onKeyDown()
}
if ((state.buttons & 0x04) !== 0) {
this.onEnter()
}
if ((state.buttons & 0x08) !== 0) {
this.onSpace()
}
// TODO should be 0x01 when the power button will work
if ((state.buttons & 0x01) !== 0) {
this.onEscape()
}
}
protected onKeyRight(): void { protected onKeyRight(): void {
console.log('onKeyRight'); console.log('onKeyRight');
} }
@ -53,4 +95,10 @@ export abstract class KeyContext {
protected onEnter(): void { protected onEnter(): void {
console.log('onEnter'); console.log('onEnter');
} }
protected onSpace(): void {
console.log('onSpace');
}
public setAvailableActions() {}
} }

View File

@ -0,0 +1,14 @@
import { InputManager } from "../input-manager";
import { ModalKeyContext } from "./modal-key-context";
export class OptionsKeyContext extends ModalKeyContext {
public name: string = "OptionsKeyContext";
protected override onEscape(): void {
super.onEscape();
this.store.optionsOpen = false;
this.store.qrLink = "";
InputManager.switchContext('carousel');
}
}

View File

@ -1,5 +1,5 @@
import { KeyContext } from "./key-context"; import { KeyContext } from "./key-context";
import { KeyboardManager } from "../keyboard-manager"; import { InputManager } from "../input-manager";
export class SidebarKeyContext extends KeyContext { export class SidebarKeyContext extends KeyContext {
readonly name: string = "SidebarContext"; readonly name: string = "SidebarContext";
@ -17,13 +17,13 @@ export class SidebarKeyContext extends KeyContext {
protected onKeyRight() { protected onKeyRight() {
super.onKeyRight(); super.onKeyRight();
this.store.moveGameRight(); this.store.moveGameRight();
KeyboardManager.switchContext("carousel") InputManager.switchContext("carousel")
} }
protected onEnter() { protected onEnter() {
super.onEnter(); super.onEnter();
this.store.moveGameRight(); this.store.moveGameRight();
KeyboardManager.switchContext("carousel") InputManager.switchContext("carousel")
} }
protected onEscape() { protected onEscape() {

View File

@ -2,7 +2,10 @@ import {createApp} from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css'; import './style.css';
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import { InputManager } from "./inputs/input-manager";
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
app.mount('#app'); app.mount('#app');
InputManager.bind();
InputManager.switchContext("carousel")

View File

@ -0,0 +1,8 @@
export interface ControllerState {
id?: number,
joystick: {
x: number,
y: number,
},
buttons: number
}

View File

@ -1,52 +1,129 @@
import {LoadGames} from "../../wailsjs/go/main/App"; import {LoadGames} from "../../wailsjs/go/main/App";
import {models} from "../../wailsjs/go/models"; import {models} from "../../wailsjs/go/models";
import Metadata = models.Metadata;
import Game = models.Game; import Game = models.Game;
import Developer = models.Developer;
// const localGames: Game[] = [ const localGames: Game[] = [
// { new Game({
// id: 'g1', id: "ddf1ab0c-d86e-442f-8fd8-cfe8a0dc0a52",
// title: 'Neon Rush', title: "Soul Shaper",
// description: 'A fast-paced 2D platformer through glowing neon levels.', version: "2.0.0",
// playerCount: '1', description: "Laissez votre imagination prendre forme en maniant le pouvoir de la création contre la seule chose qui vous sépare du paradis. Manipulez l'espace et le temps contre votre adversaire dans Soul Shaper!",
// languages: ['English', 'French'], players: "1-1",
// tags: ['platformer', 'neon', 'funny'], release: "2023-12-15",
// media: [ modification: "2023-12-15",
// '/assets/neon-rush-1.png', executable: "game\\Soul Shaper.exe",
// '/assets/neon-rush-2.png' publicRepositoryLink: "https://github.com/PFE033-ConjureOS/ConjureOS-SoulShaper",
// ], genres: "action",
// thumbnail: '/assets/neon-rush-thumb.png', developers: [
// links: { new Developer({
// Website: 'https://neonrush.dev', name: "William Gingras"
// Itch: 'https://hyperbyte.itch.io/neonrush' }),
// }, new Developer({
// executablePath: "." name: "Yussef Shehadeh"
// }, }),
// { new Developer({
// id: 'g2', name: "Leah Fortin"
// title: 'Ghost Bakery', }),
// description: 'Bake spooky pastries in a haunted kitchen.', new Developer({
// playerCount: '1-2', name: "Cameron Lamoureux"
// languages: ['English'], }),
// tags: ['cooking', 'funny', 'co-op'], ],
// media: [ thumbnailPath: "https://img.itch.zone/aW1hZ2UvMTkwMzc5MS8xMTgzNzY0Ny5wbmc=/original/r7iVIj.png",
// '/assets/ghost-bakery-1.jpg' itchLink: "https://craftelia.itch.io/gamelab2023-blood-god",
// ], mediaPaths: [
// thumbnail: '/assets/ghost-bakery-thumb.jpg', "https://img.itch.zone/aW1hZ2UvMTkwMzc5MS8xMTgyMjc0Ni5wbmc=/original/65c%2FQT.png",
// links: { "https://img.itch.zone/aW1hZ2UvMTkwMzc5MS8xMTgyMjc0Ny5wbmc=/original/4FiVFR.png",
// Itch: 'https://phantomforks.itch.io/ghostbakery' "https://img.itch.zone/aW1hZ2UvMTkwMzc5MS8xMTgyMjc0OC5wbmc=/original/IkUG5I.png",
// }, "https://img.itch.zone/aW1hZ2UvMTkwMzc5MS8xMTgzNzY1My5wbmc=/original/jtOMly.png"
// executablePath: "." ],
// } logoPath: "https://img.itch.zone/aW1nLzExODI4OTQyLnBuZw==/original/0MBnt3.png",
// ]; backgroundImagePath: "https://img.itch.zone/aW1nLzExODIyNzg4LnBuZw==/original/QVGL4L.png",
collections: "Ubi Gamelab",
colorScheme: {
primary: "#66258a",
secondary: "#80f071",
ternary: "#ffffff"
},
}),
new Game({
id: "random-id",
title: "Froggin' Around",
version: "2.0.0",
description: "Incarnez deux grenouilles s'échappant d'un atelier magique. Travaillez ensemble et utilisez vos langues élastiques et rebondissantes pour progresser et résoudre des énigmes.",
players: "2-2",
release: "2025-04-13",
modification: "2023-05-05",
genres: "action, adventure, puzzle",
developers: [
new Developer({
name: "Jeanne Castonguay",
role: "Designer"
}),
new Developer({
name: "Mathieu Vézina",
role: "Developer",
link: "https://www.linkedin.com/in/mathieu-vezina/",
picture: "https://media.licdn.com/dms/image/v2/C4E03AQElTJL0iSscHg/profile-displayphoto-shrink_400_400/profile-displayphoto-shrink_400_400/0/1637863156486?e=1757548800&v=beta&t=oJQ1GfgqFx6dBgtIxS6GpsPp_eOYRGhVpAzmO1_ygr0"
}),
new Developer({
name: "Loïc Cyr",
role: "Developer"
}),
new Developer({
name: "Céline",
role: "Art"
}),
],
thumbnailPath: "https://img.itch.zone/aW1nLzIwNzAwNDg1LnBuZw==/original/qGDxOj.png",
itchLink: "https://jeanne444.itch.io/froggin-around",
mediaPaths: [
"https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDcxMDI1Mi5wbmc=/original/owXY2q.png",
"https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDcwMDgwOC5wbmc=/original/PTa96E.png",
"https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDcwMDgwNy5wbmc=/original/PXw75v.png",
"https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDcwMDY2OC5wbmc=/original/MwyNjV.png",
"https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDcwMDgwOS5wbmc=/original/FYGa9e.png",
"https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDcyNDg1Mi5naWY=/original/%2BLoLem.gif"
],
logoPath: "https://img.itch.zone/aW1hZ2UvMzQ2ODYzOS8yMDg2MDc1OC5wbmc=/original/tgccKa.png",
collections: "Ubi Gamelab",
colorScheme: {
primary: "#cf2d30",
secondary: "#b3cf43",
ternary: "#ffffff"
},
}),
];
export async function fetchGames(): Promise<Game[]> { export async function fetchGames(): Promise<Game[]> {
// const source = localStorage.getItem('dataSource') || 'local'; const source = localStorage.getItem('dataSource') || 'local';
// if (source === 'local') return localGames; if (source === 'local') return localGames;
// TODO games should be loaded from and started from the wails/go code
const games = await LoadGames(); const games = await LoadGames();
for (const game in games) { for (const game of games) {
console.log(game) console.log(game)
} }
return games; return [...games.map(convertToNewFormat), ...localGames];
}
function convertToNewFormat(metadata: Metadata): Game {
return new Game({
id: metadata.Id,
title: metadata.Game,
description: metadata.Description,
collections: metadata.Collection,
thumbnailPath: metadata.ThumbnailPath,
mediaPaths: [metadata.ImagePath],
genres: metadata.Genres,
executable: metadata.Files,
release: metadata.Release,
modification: metadata.Modification,
version: metadata.Version,
players: metadata.Players,
publicRepositoryLink: metadata.PublicRepositoryLink,
developers: metadata.Developers.split(",").map((dev) => new Developer({
name: dev
})),
});
} }

View File

@ -0,0 +1,35 @@
import { LoadImage } from "../../wailsjs/go/main/App";
export class ImageService {
static Dictionary: {[key: string]: string} = {}
public static async getImage(gameId: string, src: string): Promise<string> {
if (!src || src.startsWith("http")) {
return src;
}
const id = gameId + "\\" + src;
console.log(gameId, src, id)
if (this.Dictionary[id])
return this.Dictionary[id]
const fileBlob = await LoadImage(gameId, src);
console.log(fileBlob)
const byteCharacters = atob(fileBlob.Data as any as string);
const byteNumbers = Array.from(byteCharacters).map(c => c.charCodeAt(0));
console.log(byteCharacters, byteNumbers)
const bytes = new Uint8Array(byteNumbers);
console.log(bytes)
const blob = new Blob([bytes], {type: fileBlob.MimeType });
console.log(blob)
const url = URL.createObjectURL(blob);
console.log(url)
this.Dictionary[id] = url
console.log(this.Dictionary)
return url;
}
}

View File

@ -0,0 +1,6 @@
import { Log } from "../../wailsjs/go/main/App";
export function log(msg: any): void {
console.log(msg)
Log("[Frontend] " + msg.toString()).then();
}

View File

@ -1,6 +1,9 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import {models} from "../../wailsjs/go/models"; import {models} from "../../wailsjs/go/models";
import Game = models.Game; import Game = models.Game;
import { StartGame } from "../../wailsjs/go/main/App";
import { fetchGames } from '../services/game-service';
import { InputManager } from '../inputs/input-manager';
export const useAppStore = defineStore('app', { export const useAppStore = defineStore('app', {
state: () => ({ state: () => ({
@ -10,14 +13,30 @@ export const useAppStore = defineStore('app', {
transitionDirection: 'down' as 'up' | 'down', transitionDirection: 'down' as 'up' | 'down',
selectedGame: null as Game | null, selectedGame: null as Game | null,
selectedGameIndex: 0, selectedGameIndex: 0,
qrLink: '' as string qrLink: '' as string,
optionsOpen: false as boolean,
gameIsStarting: false as boolean,
showSidebar: false as boolean,
currentInputDevice: 'controller' as 'controller' | 'keyboard',
currentAvailableActions: {} as object
}), }),
getters: { getters: {
filteredGames(state): Game[] { filteredGames(state): Game[] {
return state.games.filter(game => game.Genres.includes(state.selectedTag ?? '')); if (state.showSidebar) {
return state.games.filter(game => game.genres.split(",").includes(state.selectedTag ?? ''));
}
return state.games;
} }
}, },
actions: { actions: {
async loadGames() {
this.games = await fetchGames();
console.log(this.games);
this.tags = [...new Set(this.games.flatMap(game => game.genres.split(",")))];
if (this.tags.length > 0) {
this.selectTag(this.tags[0])
}
},
moveGameRight() { moveGameRight() {
this.selectGame(this.selectedGameIndex + 1); this.selectGame(this.selectedGameIndex + 1);
}, },
@ -36,18 +55,33 @@ export const useAppStore = defineStore('app', {
}, },
selectGame(index: number) { selectGame(index: number) {
const games = this.filteredGames; const games = this.filteredGames;
if (index >= 0 && index < games.length) { console.log(index)
index = mod(index, games.length);
console.log(index)
this.selectedGameIndex = index; this.selectedGameIndex = index;
this.selectedGame = games[index]; this.selectedGame = games[index]
}
}, },
selectTag(tag: string) { selectTag(tag: string) {
this.selectedTag = tag; this.selectedTag = tag;
this.selectedGameIndex = 0; this.selectGame(this.selectedGameIndex);
this.selectedGame = this.filteredGames[0] ?? null;
}, },
showQr(link: string) { showQr(link: string) {
this.qrLink = link; this.qrLink = link;
InputManager.switchContext("options");
},
async startSelectedGame() {
if (this.selectedGame && !this.gameIsStarting) {
this.gameIsStarting = true;
await StartGame(this.selectedGame.id);
this.gameIsStarting = false;
}
else {
console.log("No game selected")
}
} }
} }
}); });
function mod(n, m) {
return ((n % m) + m) % m;
}

View File

@ -1,14 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body { body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif; sans-serif;
@ -24,5 +16,6 @@ body {
#app { #app {
height: 100vh; height: 100vh;
text-align: center; background-color: black;
color: white;
} }

View File

@ -1,39 +0,0 @@
import { KeyContext } from "./key-context";
import { KeyboardManager } from "../keyboard-manager";
export class CarouselKeyContext extends KeyContext {
readonly name: string = "CarouselContext";
protected onKeyUp() {
super.onKeyUp();
this.store.moveTagUp();
}
protected onKeyDown() {
super.onKeyDown();
this.store.moveTagDown();
}
protected onKeyRight() {
super.onKeyRight();
this.store.moveGameRight();
}
protected onKeyLeft() {
super.onKeyLeft();
if (this.store.selectedGameIndex === 0) {
KeyboardManager.switchContext("sidebar");
}
else {
this.store.moveGameLeft();
}
}
protected onEscape() {
super.onEscape();
}
protected onEnter() {
super.onEnter();
}
}

View File

@ -1,25 +0,0 @@
import { KeyContext } from "./key-context";
export class GamePreviewKeyContext extends KeyContext {
readonly name: string = "GamePreviewKeyContext";
protected onKeyUp() {
super.onKeyUp();
}
protected onKeyDown() {
super.onKeyDown();
}
protected onKeyRight() {
super.onKeyRight();
}
protected onEnter() {
super.onEnter();
}
protected onEscape() {
super.onEscape();
}
}

View File

@ -1,17 +0,0 @@
import { CarouselKeyContext } from "./key-contexts/carousel-key-context";
import { KeyContext } from "./key-contexts/key-context";
import { SidebarKeyContext } from "./key-contexts/sidebar-key-context";
export class KeyboardManager {
private static current?: KeyContext;
static switchContext(name: 'sidebar' | 'carousel') {
console.log("Switching context to " + name);
if (name === 'sidebar') this.current = new SidebarKeyContext();
else this.current = new CarouselKeyContext();
}
static handle(event: KeyboardEvent) {
this.current?.handleKey(event);
}
}

View File

@ -0,0 +1,4 @@
export function toUpperCamelCase(str: string): string {
const [firstChar, ...otherChars] = str;
return firstChar.toUpperCase() + otherChars.join('').toLowerCase();
}

View File

@ -1,12 +0,0 @@
import { onMounted, onBeforeUnmount } from 'vue';
import { KeyboardManager } from "./keyboard-manager";
export function useKeyboardNavigation(): void {
onMounted(() => {
window.addEventListener('keydown', KeyboardManager.handle.bind(KeyboardManager));
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', KeyboardManager.handle);
});
}

View File

@ -1,9 +1,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {models} from '../models'; import {models} from '../models';
import {provider} from '../models';
export function Greet(arg1:string):Promise<string>; export function LoadGames():Promise<Array<models.Metadata>>;
export function LoadGames():Promise<Array<models.Game>>; export function LoadGamesNewModel():Promise<Array<models.Game>>;
export function SelectGame(arg1:string):Promise<void>; export function LoadImage(arg1:string,arg2:string):Promise<provider.FileBlob>;
export function Log(arg1:string):Promise<void>;
export function StartGame(arg1:string):Promise<void>;

View File

@ -2,14 +2,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}
export function LoadGames() { export function LoadGames() {
return window['go']['main']['App']['LoadGames'](); return window['go']['main']['App']['LoadGames']();
} }
export function SelectGame(arg1) { export function LoadGamesNewModel() {
return window['go']['main']['App']['SelectGame'](arg1); return window['go']['main']['App']['LoadGamesNewModel']();
}
export function LoadImage(arg1, arg2) {
return window['go']['main']['App']['LoadImage'](arg1, arg2);
}
export function Log(arg1) {
return window['go']['main']['App']['Log'](arg1);
}
export function StartGame(arg1) {
return window['go']['main']['App']['StartGame'](arg1);
} }

View File

@ -1,6 +1,108 @@
export namespace models { export namespace models {
export class ColorScheme {
primary: string;
secondary: string;
ternary: string;
static createFrom(source: any = {}) {
return new ColorScheme(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.primary = source["primary"];
this.secondary = source["secondary"];
this.ternary = source["ternary"];
}
}
export class Developer {
id: string;
name: string;
link: string;
picture: string;
role: string;
static createFrom(source: any = {}) {
return new Developer(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.link = source["link"];
this.picture = source["picture"];
this.role = source["role"];
}
}
export class Game { export class Game {
id: string;
title: string;
description: string;
version: string;
players: string;
release: string;
modification: string;
publicRepositoryLink: string;
itchLink: string;
genres: string;
collections: string;
executable: string;
thumbnailPath: string;
logoPath: string;
backgroundImagePath: string;
mediaPaths: string[];
audioPath: string;
developers: Developer[];
colorScheme: ColorScheme;
static createFrom(source: any = {}) {
return new Game(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.description = source["description"];
this.version = source["version"];
this.players = source["players"];
this.release = source["release"];
this.modification = source["modification"];
this.publicRepositoryLink = source["publicRepositoryLink"];
this.itchLink = source["itchLink"];
this.genres = source["genres"];
this.collections = source["collections"];
this.executable = source["executable"];
this.thumbnailPath = source["thumbnailPath"];
this.logoPath = source["logoPath"];
this.backgroundImagePath = source["backgroundImagePath"];
this.mediaPaths = source["mediaPaths"];
this.audioPath = source["audioPath"];
this.developers = this.convertValues(source["developers"], Developer);
this.colorScheme = this.convertValues(source["colorScheme"], ColorScheme);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Metadata {
Collection: string; Collection: string;
Launch: string; Launch: string;
Id: string; Id: string;
@ -16,10 +118,9 @@ export namespace models {
PublicRepositoryLink: string; PublicRepositoryLink: string;
Genres: string; Genres: string;
Developers: string; Developers: string;
Title: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Game(source); return new Metadata(source);
} }
constructor(source: any = {}) { constructor(source: any = {}) {
@ -39,7 +140,27 @@ export namespace models {
this.PublicRepositoryLink = source["PublicRepositoryLink"]; this.PublicRepositoryLink = source["PublicRepositoryLink"];
this.Genres = source["Genres"]; this.Genres = source["Genres"];
this.Developers = source["Developers"]; this.Developers = source["Developers"];
this.Title = source["Title"]; }
}
}
export namespace provider {
export class FileBlob {
Name: string;
MimeType: string;
Data: number[];
static createFrom(source: any = {}) {
return new FileBlob(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Name = source["Name"];
this.MimeType = source["MimeType"];
this.Data = source["Data"];
} }
} }

1
go.mod
View File

@ -5,6 +5,7 @@ go 1.22.0
toolchain go1.23.5 toolchain go1.23.5
require ( require (
github.com/karalabe/hid v1.0.0
github.com/wailsapp/wails/v2 v2.9.2 github.com/wailsapp/wails/v2 v2.9.2
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

2
go.sum
View File

@ -11,6 +11,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/karalabe/hid v1.0.0 h1:+/CIMNXhSU/zIJgnIvBD2nKHxS/bnRHhhs9xBryLpPo=
github.com/karalabe/hid v1.0.0/go.mod h1:Vr51f8rUOLYrfrWDFlV12GGQgM5AT8sVh+2fY4MPeu8=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=

View File

@ -1,7 +1,6 @@
package config package config
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@ -21,13 +20,11 @@ func GetDefaultConjureOsDirectory() string {
if err != nil { if err != nil {
panic(err) panic(err)
} }
fmt.Println("User config dir:", configDir)
cacheDir, err := os.UserCacheDir() cacheDir, err := os.UserCacheDir()
if err != nil { if err != nil {
panic(err) panic(err)
} }
fmt.Println("User cache dir:", cacheDir)
switch runtime.GOOS { switch runtime.GOOS {
// I want the data to be store in LocalAppData not RoamingAppData // I want the data to be store in LocalAppData not RoamingAppData

View File

@ -1,10 +1,9 @@
package inputs package inputs
import ( import (
"encoding/binary"
"fmt" "fmt"
"os"
"time" "github.com/karalabe/hid"
) )
type JoystickEvent struct { type JoystickEvent struct {
@ -20,30 +19,136 @@ const (
JS_EVENT_INIT = 0x80 // Initial state of device JS_EVENT_INIT = 0x80 // Initial state of device
) )
func Start() { type Controller struct {
// Open the joystick device file Device *hid.Device
file, err := os.Open("/dev/input/js0") }
func (c *Controller) ReadState(buf []byte) (*ControllerState, error) {
_, err := c.Device.Read(buf)
if err != nil { if err != nil {
fmt.Println("Error opening joystick:", err) return nil, err
}
x := buf[0] // Horizontal axis (0255)
y := buf[1] // Vertical axis (0255)
buttons := buf[6] // Buttons as bitfield
state := ControllerState{
Joystick: Vec2B{x, y},
Buttons: buttons,
}
return &state, nil
}
type Vec2B struct {
X byte `json:"x"`
Y byte `json:"y"`
}
type ControllerState struct {
Id int `json:"id"`
Joystick Vec2B `json:"joystick"`
Buttons byte `json:"buttons"`
}
type ConjureControllerButton int
const (
ButtonPower ConjureControllerButton = iota //0
ButtonStart
Button1
Button2
Button3
ButtonA
ButtonB
ButtonC // 7
)
var ConjureControllerButtons = []ConjureControllerButton{
ButtonStart,
ButtonPower,
Button1,
Button2,
Button3,
ButtonA,
ButtonB,
ButtonC,
}
func (s ConjureControllerButton) String() string {
switch s {
case ButtonA:
return "ButtonA"
case ButtonB:
return "ButtonB"
case ButtonC:
return "ButtonC"
case Button1:
return "Button1"
case Button2:
return "Button2"
case Button3:
return "Button3"
case ButtonStart:
return "ButtonStart"
case ButtonPower:
return "ButtonPower"
default:
return "Unknown"
}
}
func Start(onStateChange func(ControllerState)) {
fmt.Println("Opening devices")
const vendorID = 0x0079
const productID = 0x0006
devices := hid.Enumerate(vendorID, productID)
if len(devices) == 0 {
fmt.Printf("Device with VID:PID %04X:%04X not found\n", vendorID, productID)
return return
} }
defer file.Close()
// Continuously read joystick events for i, d := range devices {
fmt.Printf("Found device %d: %s - VID:%04X PID:%04X\n", i, d.Product, d.VendorID, d.ProductID)
go func(i int, d hid.DeviceInfo) {
device, err := d.Open()
if err != nil {
fmt.Printf("Failed to open device %d: %v\n", i, err)
return
}
defer device.Close()
controller := Controller{Device: device}
buf := make([]byte, 32)
fmt.Printf("Reading data from device %d... Press Ctrl+C to exit\n", i)
for { for {
var e JoystickEvent state, err := controller.ReadState(buf)
err := binary.Read(file, binary.LittleEndian, &e)
if err != nil { if err != nil {
fmt.Println("Error reading joystick event:", err) fmt.Printf("Read error on device %d: %v\n", i, err)
return return
} }
// Handle the event state.Id = i
handleJoystickEvent(e)
// Sleep to avoid flooding output // TODO Samuel please fix help me
time.Sleep(10 * time.Millisecond) if (state.Buttons != 0 || state.Joystick.X != 127 || state.Joystick.Y != 127) && !(state.Id == 1 && state.Buttons == 128 && state.Joystick.X == 127 && state.Joystick.Y == 127) {
// fmt.Printf("Id: %d - %d buttons ", state.Id, state.Buttons)
onStateChange(*state)
} }
}
}(i, d)
}
// Prevent the function from exiting
select {}
} }
// handleJoystickEvent processes joystick events. // handleJoystickEvent processes joystick events.

View File

@ -1,6 +1,6 @@
package models package models
type Game struct { type Metadata struct {
Collection string Collection string
Launch string Launch string
Id string Id string
@ -8,13 +8,48 @@ type Game struct {
Version string Version string
Description string Description string
Players string Players string
ThumbnailPath string ThumbnailPath string `yaml:"thumbnailPath"`
ImagePath string ImagePath string `yaml:"imagePath"`
Release string Release string
Modification string Modification string
Files string Files string
PublicRepositoryLink string PublicRepositoryLink string `yaml:"publicRepositoryLink"`
Genres string Genres string
Developers string Developers string
Title string }
type Game struct {
Id string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Version string `json:"version"`
Players string `json:"players"`
Release string `json:"release"`
Modification string `json:"modification"`
PublicRepositoryLink string `json:"publicRepositoryLink"`
ItchLink string `json:"itchLink"`
Genres string `json:"genres"`
Collections string `json:"collections"`
Executable string `json:"executable"`
ThumbnailPath string `json:"thumbnailPath"`
LogoPath string `json:"logoPath"`
BackgroundImagePath string `json:"backgroundImagePath"`
MediaPaths []string `json:"mediaPaths"`
AudioPath string `json:"audioPath"`
Developers []Developer `json:"developers"`
ColorScheme ColorScheme `json:"colorScheme"`
}
type Developer struct {
Id string `json:"id"`
Name string `json:"name"`
Link string `json:"link"`
Picture string `json:"picture"`
Role string `json:"role"`
}
type ColorScheme struct {
Primary string `json:"primary"`
Secondary string `json:"secondary"`
Ternary string `json:"ternary"`
} }

View File

@ -0,0 +1,53 @@
package provider
import (
"conjure-os/lib/config"
"mime"
"net/http"
"os"
"path/filepath"
)
func LoadImage(gameId string, imageSrc string) *FileBlob {
imagePath := filepath.Join(config.GetDefaultConjureGamesDirectory(), gameId, imageSrc)
blob, err := GetFileBlob(imagePath)
if err != nil {
return nil
}
return blob
}
type FileBlob struct {
Name string
MimeType string
Data []byte
}
func GetFileBlob(path string) (*FileBlob, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Try to guess MIME type from content
mimeType := http.DetectContentType(data)
// Fallback to extension-based detection
if mimeType == "application/octet-stream" {
ext := filepath.Ext(path)
if ext != "" {
if t := mime.TypeByExtension(ext); t != "" {
mimeType = t
}
}
}
name := filepath.Base(path)
return &FileBlob{
Name: name,
MimeType: mimeType,
Data: data,
}, nil
}

View File

@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@ -70,59 +69,178 @@ func GetOrSetEnvKey(key string, defaultValue string) string {
return defaultValue return defaultValue
} }
func GetConjureGameInfo() []models.Game { func extractZipToSiblingFolder(zipPath string) error {
// Determine destination folder name (same name as zip file, without .zip)
zipBase := strings.TrimSuffix(filepath.Base(zipPath), ".conj")
zipBase = strings.TrimSuffix(filepath.Base(zipBase), ".zip")
destDir := filepath.Join(filepath.Dir(zipPath), zipBase)
gamePath := config.GetDefaultConjureGamesDirectory() // Delete destination folder if it exists
if _, err := os.Stat(destDir); err == nil {
entrie, err := os.ReadDir(gamePath) err = os.RemoveAll(destDir)
if err != nil { if err != nil {
log.Fatal(err) return fmt.Errorf("failed to remove existing folder: %v", err)
}
} }
var games []models.Game // Open the zip archive
for _, e := range entrie {
if e.IsDir() {
newGames := readFolder(e, gamePath)
games = append(newGames, games...)
} else if filepath.Ext(e.Name()) == ".conj" {
zipPath := filepath.Join(gamePath, e.Name())
r, err := zip.OpenReader(zipPath) r, err := zip.OpenReader(zipPath)
if err != nil { if err != nil {
log.Fatal(err) return err
} }
defer r.Close() defer r.Close()
fmt.Println("Contents of", zipPath) // Create the destination directory
err = os.MkdirAll(destDir, os.ModePerm)
if err != nil {
return err
}
for _, f := range r.File { for _, f := range r.File {
printIndentedPath(f.Name) destPath := filepath.Join(destDir, f.Name)
if f.Name == "metadata.txt" {
rc, err := f.Open() // ZipSlip protection
if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path: %s", destPath)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, os.ModePerm); err != nil {
return err
}
continue
}
// Ensure parent directories exist
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
return err
}
// Create and copy the file
srcFile, err := f.Open()
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
}
return nil
}
func extractZipsInFolder(folder string) error {
entries, err := os.ReadDir(folder)
if err != nil {
return fmt.Errorf("reading directory failed: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasSuffix(strings.ToLower(entry.Name()), ".zip") {
zipPath := filepath.Join(folder, entry.Name())
fmt.Println("Extracting:", zipPath)
if err := extractZipToSiblingFolder(zipPath); err != nil {
return fmt.Errorf("failed to extract %s: %w", zipPath, err)
}
}
}
return nil
}
func ExtractGame(game models.Metadata) string {
info, err := os.Stat(filepath.Join(config.GetDefaultConjureGamesDirectory(), game.Id))
if err != nil || !info.IsDir() {
gamePath := filepath.Join(config.GetDefaultConjureGamesDirectory(), fmt.Sprintf("%s.conj", game.Id))
err = extractZipToSiblingFolder(gamePath)
check(err)
}
gamePath := filepath.Join(config.GetDefaultConjureGamesDirectory(), game.Id)
_, err = os.Stat(filepath.Join(gamePath, game.Files))
if err == nil {
return filepath.Join(gamePath, game.Files)
}
err = extractZipsInFolder(gamePath)
check(err)
gamePath = filepath.Join(gamePath, game.Files)
return gamePath
}
func GetConjureGameInfo() []models.Metadata {
gamePath := config.GetDefaultConjureGamesDirectory()
fmt.Println("Loading games....")
fmt.Println(gamePath)
entries, err := os.ReadDir(gamePath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
var games []models.Metadata
for _, e := range entries {
if e.IsDir() {
continue
} else if filepath.Ext(e.Name()) == ".conj" {
conjPath := filepath.Join(gamePath, e.Name())
conjBase := strings.TrimSuffix(conjPath, ".conj")
// Check if the destination folder already exists
if _, err := os.Stat(conjBase); os.IsNotExist(err) {
err = extractZipToSiblingFolder(conjPath)
check(err)
}
// Now read metadata from the extracted directory
entries, err := os.ReadDir(conjBase)
check(err)
fmt.Println("Contents of", conjPath)
for _, f := range entries {
if f.Name() == "metadata.txt" {
rc, err := os.Open(filepath.Join(conjBase, f.Name()))
check(err)
defer rc.Close() defer rc.Close()
fmt.Println("Contents of metadata.txt:") fmt.Println("Contents of metadata.txt:")
metadata, err := io.ReadAll(rc) metadata, err := io.ReadAll(rc)
game := parseGameInfo(metadata) check(err)
game := parseGameInfo([]byte(escapeBackslashes(string(metadata))))
fmt.Println(game.ThumbnailPath)
games = append(games, game) games = append(games, game)
if err != nil {
log.Fatal(err)
}
} }
} }
} }
} }
if len(games) > 0 { if len(games) > 0 {
fmt.Println("Found Conjure Games: " + string(rune(len(games)))) fmt.Println("Found Conjure Games:", len(games))
} else { } else {
fmt.Println("No Conjure games Found") fmt.Println("No Conjure games Found")
} }
return games return games
} }
// Function to escape backslashes in the YAML string
func escapeBackslashes(input string) string {
// Replace every single backslash with double backslashes
return strings.ReplaceAll(input, `\`, `\\`)
}
// Helper to print tree-like structure // Helper to print tree-like structure
func printIndentedPath(path string) { func printIndentedPath(path string) {
parts := strings.Split(path, "/") parts := strings.Split(path, "/")
@ -135,36 +253,8 @@ func printIndentedPath(path string) {
} }
} }
func readFolder(entry fs.DirEntry, path string) []models.Game { func parseGameInfo(data []byte) models.Metadata {
newPath := path + "/" + entry.Name() game := models.Metadata{}
entries, err := os.ReadDir(newPath)
check(err)
var games []models.Game
for _, e := range entries {
if e.IsDir() {
games = append(games, readFolder(e, newPath)...)
} else {
filenamesplit := strings.Split(e.Name(), ".")
if filenamesplit[1] == "conf" && filenamesplit[2] == "yml" {
game := parseGameInfoFromFile(newPath + "/" + e.Name())
games = append(games, game)
}
}
}
return games
}
func parseGameInfoFromFile(path string) models.Game {
data, err := os.ReadFile(path)
check(err)
return parseGameInfo(data)
}
func parseGameInfo(data []byte) models.Game {
game := models.Game{}
err := yaml.Unmarshal(data, &game) err := yaml.Unmarshal(data, &game)
check(err) check(err)
return game return game

30
main.go
View File

@ -2,6 +2,10 @@ package main
import ( import (
"embed" "embed"
"fmt"
"net/http"
"os"
"strings"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
@ -11,6 +15,27 @@ import (
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
type FileLoader struct {
http.Handler
}
func NewFileLoader() *FileLoader {
return &FileLoader{}
}
func (h *FileLoader) ServeHTTP(res http.ResponseWriter, req *http.Request) {
var err error
requestedFilename := strings.TrimPrefix(req.URL.Path, "/")
println("Requesting file:", requestedFilename)
fileData, err := os.ReadFile(requestedFilename)
if err != nil {
res.WriteHeader(http.StatusBadRequest)
res.Write([]byte(fmt.Sprintf("Could not load file %s", requestedFilename)))
}
res.Write(fileData)
}
func main() { func main() {
// Create an instance of the app structure // Create an instance of the app structure
app := NewApp() app := NewApp()
@ -20,11 +45,12 @@ func main() {
Title: "conjure-os", Title: "conjure-os",
Width: 1024, Width: 1024,
Height: 768, Height: 768,
Fullscreen: true,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
Handler: NewFileLoader(),
}, },
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.Startup,
OnStartup: app.startup,
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },