diff --git a/app.go b/app.go index c31f628..1e38f05 100644 --- a/app.go +++ b/app.go @@ -1,18 +1,28 @@ package main import ( - "context" + "io" + "net/http" "os" + "os/exec" "path/filepath" + "runtime" + "strings" "github.com/ncruces/zenity" rlbot "github.com/swz-git/go-interface" "github.com/swz-git/go-interface/flat" ) +type RawReleaseInfo struct { + repo string + content GhRelease +} + // App struct type App struct { - ctx context.Context + latest_release_json []RawReleaseInfo + rlbot_address string } func (a *App) IgnoreMe( @@ -22,37 +32,119 @@ func (a *App) IgnoreMe( ) { } -// NewApp creates a new App application struct -func NewApp() *App { - return &App{} +func (a *App) GetDefaultPath() string { + if runtime.GOOS == "windows" { + localappdata := os.Getenv("LOCALAPPDATA") + return filepath.Join(localappdata, "RLBotGUI") + } + + home := os.Getenv("HOME") + return filepath.Join(home, ".rlbotgui") } -// startup is called when the app starts. The context is saved -// so we can call the runtime methods -func (a *App) startup(ctx context.Context) { - a.ctx = ctx +func (a *App) GetLatestReleaseData(repo string) (*GhRelease, error) { + latest_release_url := "https://api.github.com/repos/" + repo + "/releases/latest" + + resp, err := http.Get(latest_release_url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + content, err := ParseReleaseData(body) + if err != nil { + return nil, err + } + + a.latest_release_json = append(a.latest_release_json, RawReleaseInfo{repo, content}) + + return &a.latest_release_json[len(a.latest_release_json)-1].content, nil } -// // Greet returns a greeting for the given name -// func (a *App) Greet(name string) string { -// return fmt.Sprintf("Hello %s, It's show time!", name) -// } +func (a *App) DownloadBotpack(repo string, installPath string) (string, error) { + var latest_release *GhRelease + + for _, release := range a.latest_release_json { + if release.repo == repo { + latest_release = &release.content + break + } + } + + if latest_release == nil { + content, err := a.GetLatestReleaseData(repo) + if err != nil { + return "", err + } + + latest_release = content + } + + var file_name string + if runtime.GOOS == "windows" { + file_name = "botpack_x86_64-windows.tar.xz" + } else { + file_name = "botpack_x86_64-linux.tar.xz" + } + + var download_url string + for _, asset := range latest_release.Assets { + if asset.Name == file_name { + download_url = asset.BrowserDownloadURL + break + } + } + + err := DownloadExtractArchive(download_url, installPath) + if err != nil { + return "", err + } + + return latest_release.TagName, nil +} -func recursiveFileSearch(root, pattern string) ([]string, error) { +// NewApp creates a new App application struct +func NewApp() *App { + ip := os.Getenv("RLBOT_SERVER_IP") + if ip == "" { + ip = "127.0.0.1" + } + + port := os.Getenv("RLBOT_SERVER_PORT") + if port == "" { + port = "23234" + } + + rlbot_address := ip + ":" + port + + var latest_release_json []RawReleaseInfo + + return &App{ + latest_release_json, + rlbot_address, + } +} + +func recursiveTomlSearch(root, tomlType string) ([]string, error) { var matches []string err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - if !info.IsDir() && (info.Name() == "bot.toml" || filepath.Ext(info.Name()) == ".bot.toml") { - matched, err := filepath.Match(pattern, info.Name()) - if err != nil { - return err - } - if matched { - matches = append(matches, path) - } + + if info.IsDir() || filepath.Ext(info.Name()) != ".toml" { + return nil + } + + if info.Name() == tomlType+".toml" || strings.HasSuffix(info.Name(), "."+tomlType+".toml") { + matches = append(matches, path) } + return nil }) return matches, err @@ -85,10 +177,9 @@ type StartMatchOptions struct { func (a *App) StartMatch(options StartMatchOptions) Result { // TODO: Save this in App struct - // TODO: Make dynamic, pull from env var? - conn, err := rlbot.Connect("127.0.0.1:23234") + conn, err := rlbot.Connect(a.rlbot_address) if err != nil { - return Result{false, "Failed to connect to rlbot"} + return Result{false, "Failed to connect to RLBotServer at " + a.rlbot_address} } var gameMode flat.GameMode @@ -107,6 +198,8 @@ func (a *App) StartMatch(options StartMatchOptions) Result { gameMode = flat.GameModeHeatseeker case "Gridiron": gameMode = flat.GameModeGridiron + case "Knockout": + gameMode = flat.GameModeKnockout default: println("No mode chosen, defaulting to soccer") gameMode = flat.GameModeSoccer @@ -137,8 +230,6 @@ func (a *App) StartMatch(options StartMatchOptions) Result { playerConfigs = append(playerConfigs, playerInfo.ToPlayer().ToPlayerConfig(1)) } - println(playerConfigs) - conn.SendPacket(&flat.MatchConfigurationT{ AutoStartBots: true, GameMapUpk: options.Map, @@ -180,3 +271,34 @@ func (a *App) PickFolder() string { } return path } + +func (a *App) ShowPathInExplorer(path string) error { + fileInfo, err := os.Stat(path) + if err != nil { + return err + } + + // if is dir + var folder string + if fileInfo.IsDir() { + folder = path + } else { + folder = filepath.Dir(path) + } + + if runtime.GOOS == "windows" { + cmd := exec.Command("explorer", folder) + err := cmd.Run() + if err != nil { + return err + } + } else { + cmd := exec.Command("xdg-open", folder) + err := cmd.Run() + if err != nil { + return err + } + } + + return nil +} diff --git a/frontend/package.json b/frontend/package.json index 4cdbcdd..c63d2fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,6 @@ "@rsbuild/core": "1.0.5", "@rsbuild/plugin-svelte": "1.0.5", "oxlint": "^0.13.2", - "typescript": "^5.7.3" + "typescript": "^5.8.2" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 02106bf..8cc029c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -26,13 +26,13 @@ importers: version: 1.0.5 '@rsbuild/plugin-svelte': specifier: 1.0.5 - version: 1.0.5(@rsbuild/core@1.0.5)(svelte@5.20.5)(typescript@5.7.3) + version: 1.0.5(@rsbuild/core@1.0.5)(svelte@5.20.5)(typescript@5.8.2) oxlint: specifier: ^0.13.2 version: 0.13.2 typescript: - specifier: ^5.7.3 - version: 5.7.3 + specifier: ^5.8.2 + version: 5.8.2 packages: @@ -337,8 +337,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@5.7.3: - resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} hasBin: true @@ -419,11 +419,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - '@rsbuild/plugin-svelte@1.0.5(@rsbuild/core@1.0.5)(svelte@5.20.5)(typescript@5.7.3)': + '@rsbuild/plugin-svelte@1.0.5(@rsbuild/core@1.0.5)(svelte@5.20.5)(typescript@5.8.2)': dependencies: '@rsbuild/core': 1.0.5 svelte-loader: 3.2.4(svelte@5.20.5) - svelte-preprocess: 6.0.3(svelte@5.20.5)(typescript@5.7.3) + svelte-preprocess: 6.0.3(svelte@5.20.5)(typescript@5.8.2) transitivePeerDependencies: - '@babel/core' - coffeescript @@ -579,11 +579,11 @@ snapshots: svelte-dev-helper: 1.1.9 svelte-hmr: 0.14.12(svelte@5.20.5) - svelte-preprocess@6.0.3(svelte@5.20.5)(typescript@5.7.3): + svelte-preprocess@6.0.3(svelte@5.20.5)(typescript@5.8.2): dependencies: svelte: 5.20.5 optionalDependencies: - typescript: 5.7.3 + typescript: 5.8.2 svelte-writable-derived@3.1.1(svelte@5.20.5): dependencies: @@ -608,6 +608,6 @@ snapshots: tslib@2.8.1: {} - typescript@5.7.3: {} + typescript@5.8.2: {} zimmerframe@1.1.2: {} diff --git a/frontend/src/components/BotList.svelte b/frontend/src/components/BotList.svelte index e59b6f1..cd4cb45 100644 --- a/frontend/src/components/BotList.svelte +++ b/frontend/src/components/BotList.svelte @@ -4,13 +4,33 @@ dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME, + alertToScreenReader, } from "svelte-dnd-action"; import defaultIcon from "../assets/rlbot_mono.png"; import type { DraggablePlayer } from "../index"; - let { items = [] }: { items: DraggablePlayer[] } = $props(); + import { App, BotInfo } from "../../bindings/gui"; + import Modal from "./Modal.svelte"; + import { Browser } from "@wailsio/runtime"; + import toast from "svelte-5-french-toast"; + + let { + items = [], + showHuman = $bindable(true), + searchQuery = "", + selectedTeam = null, + bluePlayers = $bindable(), + orangePlayers = $bindable(), + }: { + items: DraggablePlayer[], + showHuman: boolean, + searchQuery: string, + selectedTeam: 'blue' | 'orange' | null, + bluePlayers: DraggablePlayer[], + orangePlayers: DraggablePlayer[], + } = $props(); const flipDurationMs = 100; - let selectedTag = $state("All"); + let selectedTags: (string | null)[] = $state([null, null]); const extraModeTags = ["hoops", "dropshot", "snow-day", "spike-rush", "heatseaker"]; const categories = [ ["All"], @@ -19,35 +39,67 @@ ]; let filteredItems: DraggablePlayer[] = $state([]); + let showModal = $state(false); + let selectedBot: [BotInfo, string, string] | null = $state(null); + $effect(() => { - filteredItems = filterBots(selectedTag); + filteredItems = filterBots(selectedTags, showHuman, searchQuery); }); - function filterBots(filterTag: string) { - return items.filter((bot) => { - switch (filterTag) { - case "Standard": - return !bot.tags.some((tag) => - [...extraModeTags, "memebot", "human"].includes(tag), - ); - case "Extra Modes": - return bot.tags.some((tag) => extraModeTags.includes(tag)); - case "Special bots/scripts": - return bot.tags.some((tag) => tag === "memebot"); - case "Bots for 1v1": - return bot.tags.some((tag) => tag === "1v1"); - case "Bots with teamplay": - return bot.tags.some((tag) => tag === "teamplay"); - case "Goalie bots": - return bot.tags.some((tag) => tag === "goalie"); - default: - return items; - } - }); - } - - function handleTagClick(tag: string) { - selectedTag = tag; + function filterBots(filterTags: (string | null)[], showHuman: boolean, searchQuery: string) { + let filtered = items.slice(1); + + if (filterTags[0]) { + filtered = filtered.filter((bot) => { + switch (filterTags[0]) { + case categories[1][0]: + return !bot.tags.some((tag) => + [...extraModeTags, "memebot", "human"].includes(tag), + ); + case categories[1][1]: + return bot.tags.some((tag) => extraModeTags.includes(tag)); + case categories[1][2]: + return bot.tags.some((tag) => tag === "memebot"); + default: + return true; + } + }); + } + + if (filterTags[1]) { + filtered = filtered.filter((bot) => { + switch (filterTags[1]) { + case categories[2][0]: + return bot.tags.some((tag) => tag === "1v1"); + case categories[2][1]: + return bot.tags.some((tag) => tag === "teamplay"); + case categories[2][2]: + return bot.tags.some((tag) => tag === "goalie"); + default: + return true; + } + }); + } + + if (showHuman) { + filtered = [items[0], ...filtered]; + } + + if (searchQuery) { + filtered = filtered.filter((bot) => + bot.displayName.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + return filtered; + } + + function handleTagClick(tag: string, groupIndex: number) { + if (groupIndex === 0) { + selectedTags = [null, null]; + } else { + selectedTags[groupIndex-1] = selectedTags[groupIndex-1] === tag ? null : tag; + } } function handleDndConsider(e: any) { @@ -55,7 +107,6 @@ if (trigger === TRIGGERS.DRAG_STARTED) { const newId = `${id}-${Math.round(Math.random() * 100000)}`; const idx = filteredItems.findIndex((item) => item.id === id); - console.log(e.detail.items.filter((item: any) => item[SHADOW_ITEM_MARKER_PROPERTY_NAME]).forEach((item: any) => item.id = newId)); e.detail.items = e.detail.items.filter( (item: any) => !item[SHADOW_ITEM_MARKER_PROPERTY_NAME], ); @@ -66,13 +117,55 @@ function handleDndFinalize(e: any) { filteredItems = e.detail.items; } + + function handleBotClick(bot: DraggablePlayer) { + const newId = `${bot.id}-${Math.round(Math.random() * 100000)}`; + const idx = filteredItems.findIndex((item) => item.id === bot.id); + //@ts-ignore + filteredItems.splice(idx, 1, { ...filteredItems[idx], id: newId }); + + if (selectedTeam === "blue") { + bluePlayers = [bot, ...bluePlayers]; + } else if (selectedTeam === "orange") { + orangePlayers = [bot, ...orangePlayers]; + } + } + + function handleInfoClick(bot: DraggablePlayer) { + if (bot.player instanceof BotInfo) { + selectedBot = [bot.player, bot.displayName, bot.icon]; + showModal = true; + } + } + + function OpenSelectedBotSource() { + if (selectedBot) { + Browser.OpenURL(selectedBot[0].config.details.sourceLink); + } + } + + function EditSelectedBotAppearance() { + if (selectedBot) { + alert.bind(null, "Not implemented yet")(); + } + } + + function ShowSelectedBotFiles() { + if (selectedBot) { + App.ShowPathInExplorer(selectedBot[0].tomlPath) + .catch((err) => toast.error(err, {duration: 10000})); + } + }
- {#each categories as tagGroup} + {#each categories as tagGroup, groupIndex}
{#each tagGroup as tag} - {/each} @@ -92,14 +185,56 @@ onconsider={handleDndConsider} onfinalize={handleDndFinalize} > + + {#each filteredItems as bot (bot.id)} -
+
handleBotClick(bot)}> icon

{bot.displayName}

+ {#if bot.player && bot.player instanceof BotInfo} + + {/if}
{/each}
+ +{#if selectedBot} + +{/if} + + + .info-button { + background: none; + border: none; + color: var(--foreground); + cursor: pointer; + font-size: 1rem; + } + .tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + } + .tag { + background-color: grey; + color: white; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + } + .modal-content { + display: flex; + flex-direction: row; + gap: 1rem; + } + .bot-left-column { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 60vw; + } + .bot-right-column { + display: flex; + align-items: center; + justify-content: center; + } + .bot-right-column img { + max-height: 250px; + max-width: 250px; + width: auto; + } + .show-button { + background: var(--background-alt); + color: var(--foreground); + cursor: pointer; + font-size: 1rem; + align-self: flex-start; + border: solid 1px gray; + } + #toml-path { + font-size: 0.8rem; + color: grey; + } + #button-group { + display: flex; + } + \ No newline at end of file diff --git a/frontend/src/components/MatchSettings/Main.svelte b/frontend/src/components/MatchSettings/Main.svelte index 181b7a8..0448af3 100644 --- a/frontend/src/components/MatchSettings/Main.svelte +++ b/frontend/src/components/MatchSettings/Main.svelte @@ -4,6 +4,7 @@ import { MAPS_NON_STANDARD, MAPS_STANDARD } from "../../arena-names"; import { mutators as mutatorOptions } from "./rlmutators"; import LauncherSelector from "../LauncherSelector.svelte"; + import { gamemodes } from "./rlmodes"; let { map = $bindable(), @@ -15,7 +16,10 @@ } = $props(); let showExtraOptions = $state(false); let showMutators = $state(false); - let randomizeMap = $state(false); + let randomizeMap = $state(localStorage.getItem("MS_RANDOMIZE_MAP") === "true"); + $effect(() => { + localStorage.setItem("MS_RANDOMIZE_MAP", randomizeMap.toString()); + }); const existingMatchBehaviors: { [n: string]: number } = { "Restart if different": 0, @@ -28,6 +32,44 @@ return toClean.charAt(0).toUpperCase() + toClean.slice(1); } + function resetMutators() { + for (let key of Object.keys(mutators)) { + mutators[key] = 0; + } + } + + function setPreset(preset: string) { + if (preset === "") { + mode = mutatorOptions.game_mode[0]; + map = MAPS_STANDARD["DFH Stadium"]; + randomizeMap = true; + resetMutators(); + return; + } + + const presetData = gamemodes[preset]; + if (!presetData) return; + + if (presetData.match["game_mode"] !== undefined) { + mode = presetData.match["game_mode"]; + } + + if (presetData.match["game_map_upk"] !== undefined) { + map = presetData.match["game_map_upk"]; + randomizeMap = false; + } else { + randomizeMap = true; + } + + for (let key of filteredMutatorOptions) { + if (presetData.mutators[key] !== undefined) { + mutators[key] = mutatorOptions[key].indexOf(presetData.mutators[key]); + } else { + mutators[key] = 0; + } + } + } + const ALL_MAPS = {...MAPS_STANDARD, ...MAPS_NON_STANDARD}; const filteredMutatorOptions = Object.keys(mutatorOptions).filter((key) => key !== 'game_mode'); @@ -99,16 +141,24 @@
{/each} +
+
+ + +

Settings are saved automatically

Reset
diff --git a/frontend/src/components/MatchSettings/rlmodes.ts b/frontend/src/components/MatchSettings/rlmodes.ts new file mode 100644 index 0000000..e406c78 --- /dev/null +++ b/frontend/src/components/MatchSettings/rlmodes.ts @@ -0,0 +1,150 @@ +import { mutators } from "./rlmutators" + +export const gamemodes: { [x: string]: { match: { [x: string]: string }, mutators: { [x: string]: string } } } = { + "Heatseeker Ricochet": { + match: { + game_mode: mutators.game_mode[5], + game_map_upk: "Labs_PillarGlass_P", + }, + mutators: {}, + }, + "Gotham City Rumble": { + match: { + game_mode: mutators.game_mode[0], + game_map_upk: "Park_Bman_P", + }, + mutators: { + rumble_option: mutators.rumble_option[10], + }, + }, + "Speed Demon": { + match: { + game_mode: mutators.game_mode[0], + }, + mutators: { + boost_amount_option: mutators.boost_amount_option[1], + boost_strength_option: mutators.boost_strength_option[2], + ball_max_speed_option: mutators.ball_max_speed_option[3], + ball_bounciness_option: mutators.ball_bounciness_option[1], + ball_size_option: mutators.ball_size_option[3], + respawn_time_option: mutators.respawn_time_option[2], + demolish_option: mutators.demolish_option[3], + }, + }, + "Ghost Hunt": { + match: { + game_mode: mutators.game_mode[0], + game_map_upk: "Haunted_TrainStation_P", + }, + mutators: { + ball_type_option: mutators.ball_type_option[6], + rumble_option: mutators.rumble_option[8], + game_event_option: mutators.game_event_option[1], + audio_option: mutators.audio_option[1], + }, + }, + "Dropshot Rumble": { + match: { + game_mode: mutators.game_mode[2], + game_map_upk: "ShatterShot_P", + }, + mutators: { + rumble_option: mutators.rumble_option[1], + }, + }, + "Nike Fc Showdown": { + match: { + game_mode: mutators.game_mode[0], + game_map_upk: "swoosh_p", + }, + mutators: { + ball_max_speed_option: mutators.ball_max_speed_option[2], + ball_weight_option: mutators.ball_weight_option[6], + ball_bounciness_option: mutators.ball_bounciness_option[4], + ball_type_option: mutators.ball_type_option[7], + }, + }, + "Tactical Rumble": { + match: { + game_mode: mutators.game_mode[4], + }, + mutators: { + rumble_option: mutators.rumble_option[9], + }, + }, + "Gforce Frenzy": { + match: { + game_mode: mutators.game_mode[0], + }, + mutators: { + boost_amount_option: mutators.boost_amount_option[1], + boost_strength_option: mutators.boost_strength_option[3], + gravity_option: mutators.gravity_option[1], + }, + }, + "Spike Rush": { + match: { + game_mode: mutators.game_mode[0], + game_map_upk: "ThrowbackStadium_P", + }, + mutators: { + rumble_option: mutators.rumble_option[7], + respawn_time_option: mutators.respawn_time_option[2], + game_event_option: mutators.game_event_option[2], + }, + }, + "Spring Loaded": { + match: {}, + mutators: { + rumble_option: mutators.rumble_option[5], + }, + }, + "Spooky Cube": { + match: { + game_mode: mutators.game_mode[0], + }, + mutators: { + ball_max_speed_option: mutators.ball_max_speed_option[3], + ball_type_option: mutators.ball_type_option[8], + ball_weight_option: mutators.ball_weight_option[1], + ball_bounciness_option: mutators.ball_bounciness_option[2], + boost_amount_option: mutators.boost_amount_option[1], + }, + }, + "Beach Ball": { + match: { + game_mode: mutators.game_mode[0], + }, + mutators: { + ball_max_speed_option: mutators.ball_max_speed_option[2], + ball_type_option: mutators.ball_type_option[4], + ball_weight_option: mutators.ball_weight_option[5], + ball_size_option: mutators.ball_size_option[2], + ball_bounciness_option: mutators.ball_bounciness_option[2], + }, + }, + "Super Cube": { + match: { + game_mode: mutators.game_mode[0], + }, + mutators: { + ball_max_speed_option: mutators.ball_max_speed_option[3], + ball_type_option: mutators.ball_type_option[1], + ball_weight_option: mutators.ball_weight_option[1], + ball_bounciness_option: mutators.ball_bounciness_option[2], + boost_amount_option: mutators.boost_amount_option[1], + }, + }, + "Boomer Ball": { + match: { + game_mode: mutators.game_mode[0], + }, + mutators: { + boost_amount_option: mutators.boost_amount_option[1], + boost_strength_option: mutators.boost_strength_option[1], + ball_max_speed_option: mutators.ball_max_speed_option[3], + ball_bounciness_option: mutators.ball_bounciness_option[2], + ball_weight_option: mutators.ball_weight_option[3], + }, + }, +} diff --git a/frontend/src/components/MatchSettings/rlmutators.ts b/frontend/src/components/MatchSettings/rlmutators.ts index e1828fd..0c058ff 100644 --- a/frontend/src/components/MatchSettings/rlmutators.ts +++ b/frontend/src/components/MatchSettings/rlmutators.ts @@ -61,7 +61,7 @@ export const mutators: { [x: string]: string[] } = { "Super High", "Lowish", ], - boost_option: [ + boost_amount_option: [ "Default", "Unlimited", "Slow Recharge", diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte index 7977911..aecef2a 100644 --- a/frontend/src/components/Modal.svelte +++ b/frontend/src/components/Modal.svelte @@ -1,6 +1,12 @@ + + +
+
+ + + + +
+ + {#each paths as path, i} +
+
{path.repo ? `${path.repo} @ ${path.installPath}` : path.installPath}
+ +
+ {/each} +
+
+ + +
+ + +
+ + +
+ {#if selectedBotpackType === "custom"} + + {/if} +
+ + +
+
+
+ + diff --git a/frontend/src/components/Teams/Main.svelte b/frontend/src/components/Teams/Main.svelte index 9f5d2bc..eb202ca 100644 --- a/frontend/src/components/Teams/Main.svelte +++ b/frontend/src/components/Teams/Main.svelte @@ -4,11 +4,22 @@ let { bluePlayers = $bindable(), orangePlayers = $bindable(), - }: { bluePlayers: any[]; orangePlayers: any[] } = $props(); + selectedTeam = $bindable(null), + }: { bluePlayers: any[]; orangePlayers: any[], selectedTeam: 'blue' | 'orange' | null } = $props(); + + function toggleTeam(team: 'blue' | 'orange') { + if (selectedTeam === team) { + selectedTeam = null; + } else { + selectedTeam = team; + } + } + +
-
+
toggleTeam('blue')} class:selected={selectedTeam === 'blue'}>

Blue team

@@ -16,7 +27,7 @@
-
+
toggleTeam('orange')} class:selected={selectedTeam === 'orange'}>

Orange team

@@ -35,9 +46,10 @@ .teams { display: flex; gap: 1rem; + height: 100%; } .teams > .team { - flex: 1; + flex: 1 0 auto; /* Prevents the team from growing before its child */ padding: 0px 0; /* Nice transparent blur */ background-color: rgba(0, 0, 0, 0.7); @@ -45,6 +57,9 @@ backdrop-filter: blur(10px); display: flex; flex-direction: column; + border: 2px solid transparent; + border-radius: 0.6rem 0.6rem 0px 0px; + max-height: 100%; /* Ensures the team does not grow beyond its container */ } .teams > .team > header { border-radius: 0.4rem 0.4rem 0px 0px; @@ -53,13 +68,27 @@ display: flex; color: white; } + header { + border: 2px solid; + } header.blue { + border-color: #0054a6; background-color: #0054a6; } header.orange { + border-color: #f26522; background-color: #f26522; } .dimmed { color: #ffffffcc; } + .team.selected { + border: 2px solid; + } + .team.selected.blue { + border-color: #0054a6; + } + .team.selected.orange { + border-color: #f26522; + } diff --git a/frontend/src/components/Teams/TeamBotList.svelte b/frontend/src/components/Teams/TeamBotList.svelte index a5348cd..b4a72d5 100644 --- a/frontend/src/components/Teams/TeamBotList.svelte +++ b/frontend/src/components/Teams/TeamBotList.svelte @@ -34,8 +34,11 @@ onconsider={handleSort} onfinalize={handleSort} > + + {#each items as bot (bot.id)} -
+ +
e.stopPropagation()}> icon

{bot?.displayName}

@@ -70,6 +73,7 @@ flex-direction: column; gap: 0.5rem; min-height: 100%; + overflow-y: auto; } .bot { display: flex; diff --git a/frontend/src/pages/Home.svelte b/frontend/src/pages/Home.svelte index 4a05c0c..9859ce8 100644 --- a/frontend/src/pages/Home.svelte +++ b/frontend/src/pages/Home.svelte @@ -8,7 +8,6 @@ /** @import * from '../../bindings/gui' */ import toast from "svelte-5-french-toast"; import arenaImages from "../arena-images"; - import closeIcon from "../assets/close.svg"; import reloadIcon from "../assets/reload.svg"; import BotList from "../components/BotList.svelte"; // @ts-ignore @@ -19,26 +18,27 @@ import { BASE_PLAYERS } from "../base-players"; import { mapStore } from "../settings"; import { MAPS_STANDARD } from "../arena-names"; + import PathsViewer from "../components/PathsViewer.svelte"; const backgroundImage = arenaImages[Math.floor(Math.random() * arenaImages.length)]; - // const backgroundImage = arenaImages.find((x) => - // x.includes("Mannfield_Stormy"), - // ); - let paths = $state( + let paths: { tagName: string | null, repo: string | null, installPath: string }[] = $state( JSON.parse(window.localStorage.getItem("BOT_SEARCH_PATHS") || "[]"), ); let players: DraggablePlayer[] = $state([...BASE_PLAYERS]); + let selectedTeam = $state(null); let loadingPlayers = $state(false); let latestBotUpdateTime = null; + let showPathsViewer = $state(false); + async function updateBots() { loadingPlayers = true; let internalUpdateTime = new Date(); latestBotUpdateTime = internalUpdateTime; - const result = await App.GetBots(paths); + const result = await App.GetBots(paths.map((x) => x.installPath)); if (latestBotUpdateTime !== internalUpdateTime) { return; // if newer "search" already started, dont write old data } @@ -65,6 +65,10 @@ let bluePlayers: DraggablePlayer[] = $state([]); let orangePlayers: DraggablePlayer[] = $state([]); + let showHuman = $state(true); + $effect(() => { + showHuman = !(bluePlayers.some((x) => x.tags.includes("human")) || orangePlayers.some((x) => x.tags.includes("human"))); + }); let mode = $state(localStorage.getItem("MS_MODE") || "Soccer"); $effect(() => { @@ -153,6 +157,12 @@ }); } } + + let searchQuery = $state(""); + + function handleSearch(event: Event) { + searchQuery = (event.target as HTMLInputElement).value; + }
@@ -160,33 +170,7 @@

Bots

{#if loadingPlayers}

Searching...

@@ -196,12 +180,19 @@ > {/if}
- +
- +
-
+
+ + diff --git a/frontend/src/pages/RocketHost.svelte b/frontend/src/pages/RocketHost.svelte index b9a4451..654afd3 100644 --- a/frontend/src/pages/RocketHost.svelte +++ b/frontend/src/pages/RocketHost.svelte @@ -15,7 +15,7 @@ [name: string]: string[]; } = {}; for (let bot of bots) { - let fam = bot.family != "" ? bot.family : bot.name; + let fam = bot.family !== "" ? bot.family : bot.name; if (!families.hasOwnProperty(fam)) { families[fam] = []; } diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index c1ff950..ca517e6 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -1,7 +1,7 @@ import { writable } from 'svelte/store'; import { MAPS_STANDARD } from './arena-names'; -export const mapStore = writable(localStorage.getItem("MS_MAP") || MAPS_STANDARD.DFHStadium); +export const mapStore = writable(localStorage.getItem("MS_MAP") || MAPS_STANDARD["DFH Stadium"]); mapStore.subscribe(value => { localStorage.setItem("MS_MAP", value); }); diff --git a/github.go b/github.go new file mode 100644 index 0000000..91b6b86 --- /dev/null +++ b/github.go @@ -0,0 +1,162 @@ +package main + +import ( + "archive/tar" + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/ulikunitz/xz" +) + +type GhRelease struct { + URL string `json:"url"` + AssetsURL string `json:"assets_url"` + UploadURL string `json:"upload_url"` + HTMLURL string `json:"html_url"` + ID int `json:"id"` + Author GhAuthor `json:"author"` + NodeID string `json:"node_id"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Assets []GhAsset `json:"assets"` + TarballURL string `json:"tarball_url"` + ZipballURL string `json:"zipball_url"` + Body string `json:"body"` +} + +type GhAuthor struct { + Login string `json:"login"` + ID int `json:"id"` + NodeID string `json:"node_id"` + AvatarURL string `json:"avatar_url"` + GravatarID string `json:"gravatar_id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + FollowersURL string `json:"followers_url"` + FollowingURL string `json:"following_url"` + GistsURL string `json:"gists_url"` + StarredURL string `json:"starred_url"` + SubscriptionsURL string `json:"subscriptions_url"` + OrganizationsURL string `json:"organizations_url"` + ReposURL string `json:"repos_url"` + EventsURL string `json:"events_url"` + ReceivedEventsURL string `json:"received_events_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` +} + +type GhAsset struct { + URL string `json:"url"` + ID int `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Label string `json:"label"` + Uploader GhAuthor `json:"uploader"` + ContentType string `json:"content_type"` + State string `json:"state"` + Size int `json:"size"` + DownloadCount int `json:"download_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func ParseReleaseData(data []byte) (GhRelease, error) { + var release GhRelease + err := json.Unmarshal(data, &release) + if err != nil { + return GhRelease{}, err + } + + return release, nil +} + +// taken from https://medium.com/@skdomino/taring-untaring-files-in-go-6b07cf56bc07 +func extractTar(tr *tar.Reader, dst string) error { + for { + header, err := tr.Next() + + switch { + + // if no more files are found return + case err == io.EOF: + return nil + + // return any other error + case err != nil: + return err + + // if the header is nil, just skip it (not sure how this happens) + case header == nil: + continue + } + + // the target location where the dir/file should be created + target := filepath.Join(dst, header.Name) + + // the following switch could also be done using fi.Mode(), not sure if there + // a benefit of using one vs. the other. + // fi := header.FileInfo() + + // check the file type + switch header.Typeflag { + + // if its a dir and it doesn't exist create it + case tar.TypeDir: + if _, err := os.Stat(target); err != nil { + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + } + + // if it's a file create it + case tar.TypeReg: + f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) + if err != nil { + return err + } + + // copy over contents + if _, err := io.Copy(f, tr); err != nil { + return err + } + + // manually close here after each file operation; defering would cause each file close + // to wait until all operations have completed. + f.Close() + } + } +} + +func DownloadExtractArchive(url string, dest string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // body is a .tar.xz file + xzr, err := xz.NewReader(bytes.NewReader(body)) + if err != nil { + return err + } + + tr := tar.NewReader(xzr) + err = extractTar(tr, dest) + return err +} diff --git a/go.mod b/go.mod index cefaa34..9fce3b3 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/BurntSushi/toml v1.4.0 github.com/ncruces/zenity v0.10.14 github.com/swz-git/go-interface v0.0.0-20250223222446-f9cf3451531b + github.com/ulikunitz/xz v0.5.12 github.com/wailsapp/mimetype v1.4.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.9 ) diff --git a/go.sum b/go.sum index 7d51cd4..91d862c 100644 --- a/go.sum +++ b/go.sum @@ -115,6 +115,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swz-git/go-interface v0.0.0-20250223222446-f9cf3451531b h1:MeJIpQfaQR5ct+9k1E5TEhI0gBMgvBx9nNLZZhJF+MA= github.com/swz-git/go-interface v0.0.0-20250223222446-f9cf3451531b/go.mod h1:Fnu6IUjNzFw0QsRnxu5vTfdq3bCkxjg51oDYEaLIxJs= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= diff --git a/main.go b/main.go index 62ff343..c5fa64c 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ func main() { app := application.New(application.Options{ Name: "rlbotgui", Services: []application.Service{ - application.NewService(&App{}), + application.NewService(NewApp()), }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(assets), @@ -27,15 +27,14 @@ func main() { // Create application with options app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ Title: "RLBotGUI", - Width: 1024, - Height: 768, + Width: 1300, + Height: 870, MinWidth: 600, MinHeight: 400, // AssetServer: &assetserver.Options{ // Assets: assets, // }, BackgroundColour: application.NewRGBA(27, 38, 54, 1), - // OnStartup: app.startup, // Bind: []interface{}{ // app, // &HumanInfo{}, diff --git a/players.go b/players.go index a618ad4..06f4687 100644 --- a/players.go +++ b/players.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "sort" "github.com/BurntSushi/toml" "github.com/swz-git/go-interface/flat" @@ -166,7 +167,7 @@ func (a *App) GetBots(paths []string) []BotInfo { potentialConfigs := []string{} for _, path := range paths { - new, err := recursiveFileSearch(path, "bot.toml") + new, err := recursiveTomlSearch(path, "bot") if err != nil { println("WARN: failed to search path: " + path) continue @@ -188,17 +189,24 @@ func (a *App) GetBots(paths []string) []BotInfo { // make location path relative to parent of bot.toml conf.Settings.RootDir = filepath.Join(filepath.Dir(potentialConfigPath), conf.Settings.RootDir) + var logo_file string + if conf.Settings.LogoFile == "" { + logo_file = filepath.Join(conf.Settings.RootDir, "logo.png") + } else { + logo_file = filepath.Join(conf.Settings.RootDir, conf.Settings.LogoFile) + } + // Read logo file and convert it to data url so the frontend can use it - if conf.Settings.LogoFile != "" { - conf.Settings.LogoFile = filepath.Join(conf.Settings.RootDir, conf.Settings.LogoFile) - logo_data, err := os.ReadFile(conf.Settings.LogoFile) - if err != nil { + logo_data, err := os.ReadFile(logo_file) + if err != nil { + // only warn if the logo file was explicitly set + if conf.Settings.LogoFile != "" { println("WARN: failed to read logo file at " + conf.Settings.LogoFile) - } else { - mtype := mimetype.Detect(logo_data) - b64data := base64.StdEncoding.EncodeToString(logo_data) - conf.Settings.LogoFile = "data:" + mtype.String() + ";base64," + b64data } + } else { + mtype := mimetype.Detect(logo_data) + b64data := base64.StdEncoding.EncodeToString(logo_data) + conf.Settings.LogoFile = "data:" + mtype.String() + ";base64," + b64data } infos = append(infos, BotInfo{ @@ -207,5 +215,10 @@ func (a *App) GetBots(paths []string) []BotInfo { }) } + // sort infos by bot name + sort.Slice(infos, func(i, j int) bool { + return infos[i].Config.Settings.Name < infos[j].Config.Settings.Name + }) + return infos } diff --git a/rockethost.go b/rockethost.go index 6fe5b25..fc980c4 100644 --- a/rockethost.go +++ b/rockethost.go @@ -141,10 +141,9 @@ func (a *App) StartRHostMatch(settings RHostMatchSettings) (string, error) { }() // TODO: Save this in App struct - // TODO: Make dynamic, pull from env var? - conn, err := rlbot.Connect("127.0.0.1:23234") + conn, err := rlbot.Connect(a.rlbot_address) if err != nil { - return "", errors.New("Couldn't connect to RLBotServer") + return "", errors.New("Failed to connect to RLBotServer at " + a.rlbot_address) } var launcher flat.Launcher