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}));
+ }
+ }
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}
+
+
+
+
+
+
+
+
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()}>
{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;
+ }
-
+
+
+
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