Compare commits
5 Commits
f3e7c7b43b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 48118efeeb | |||
| 79d4e76090 | |||
| ea39be0510 | |||
| 7bac55ca43 | |||
| 435da61405 |
+12
-2
@@ -1,10 +1,20 @@
|
|||||||
# horsebot
|
# horsebot
|
||||||
|
|
||||||
Discord bot serving horse game data.
|
Website and Discord bot serving horse game data.
|
||||||
|
|
||||||
Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations.
|
Production instance is named Zenno Rob Roy, because she has read all about Umamusume and is always happy to share her knowledge and give recommendations.
|
||||||
|
|
||||||
## Running
|
The `-mdb` flag is mandatory for both website and Discord bot modes.
|
||||||
|
It provides data for both the horsebot API and the discord bot.
|
||||||
|
|
||||||
|
## Running the Website
|
||||||
|
|
||||||
|
If the `-public` flag is provided, its value gives the directory to serve at `/`.
|
||||||
|
The `-img` flag gives a separate directory into which images are synced from GameTora; obtain permission from Gertas prior to using it.
|
||||||
|
The images are served on `/img/`.
|
||||||
|
|
||||||
|
## Running the Discord Bot
|
||||||
|
|
||||||
|
Provide the `-token` flag pointing to a file containing the token.
|
||||||
The bot always uses the Gateway API.
|
The bot always uses the Gateway API.
|
||||||
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.
|
If the `-http` argument is provided, it will also use the HTTP API, and `-key` must also be provided.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func main() {
|
|||||||
addr string
|
addr string
|
||||||
mdbf string
|
mdbf string
|
||||||
public string
|
public string
|
||||||
|
img string
|
||||||
// discord
|
// discord
|
||||||
tokenFile string
|
tokenFile string
|
||||||
apiRoute string
|
apiRoute string
|
||||||
@@ -47,6 +48,7 @@ func main() {
|
|||||||
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
|
flag.StringVar(&addr, "http", ":13669", "`address` to bind HTTP server")
|
||||||
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
|
flag.StringVar(&mdbf, "mdb", "", "`path` to master.mdb")
|
||||||
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
|
flag.StringVar(&public, "public", "", "`dir`ectory containing the website to serve")
|
||||||
|
flag.StringVar(&img, "img", "", "`dir`ectory to save and serve images")
|
||||||
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
|
flag.StringVar(&tokenFile, "token", "", "`file` containing the Discord bot token")
|
||||||
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
|
flag.StringVar(&apiRoute, "route", "/interactions/callback", "`path` to serve Discord HTTP API calls")
|
||||||
flag.StringVar(&pubkey, "key", "", "Discord public key")
|
flag.StringVar(&pubkey, "key", "", "Discord public key")
|
||||||
@@ -86,6 +88,17 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if img != "" {
|
||||||
|
// Ensure the img directory is in fact a directory
|
||||||
|
// before we start trying to fill it out.
|
||||||
|
err := os.MkdirAll(img, 0777)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("img", slog.Any("err", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
go saveImgs(ctx, db, img)
|
||||||
|
}
|
||||||
|
|
||||||
var client *bot.Client
|
var client *bot.Client
|
||||||
if tokenFile != "" {
|
if tokenFile != "" {
|
||||||
skills, err := mdb.Skills(ctx, db)
|
skills, err := mdb.Skills(ctx, db)
|
||||||
@@ -128,6 +141,9 @@ func main() {
|
|||||||
if public != "" {
|
if public != "" {
|
||||||
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
|
mux.Handle("GET /", httpmiddle.Compress(5)(http.FileServerFS(os.DirFS(public))))
|
||||||
}
|
}
|
||||||
|
if img != "" {
|
||||||
|
mux.Handle("GET /img/", http.StripPrefix("/img", http.FileServerFS(os.DirFS(img))))
|
||||||
|
}
|
||||||
mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary)))
|
mux.Handle("GET /api/global/affinity", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinitySummary)))
|
||||||
mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
|
mux.Handle("GET /api/global/affinity-detail", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.AffinityDetail)))
|
||||||
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters)))
|
mux.Handle("GET /api/global/character", httpmiddle.Compress(5)(dataroute(ctx, db, mdb.Characters)))
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/png"
|
"image/png"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,31 +20,7 @@ import (
|
|||||||
"zombiezen.com/go/sqlite/sqlitex"
|
"zombiezen.com/go/sqlite/sqlitex"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func saveImgs(ctx context.Context, db *sqlitex.Pool, out string) {
|
||||||
var (
|
|
||||||
mdb string
|
|
||||||
out string
|
|
||||||
)
|
|
||||||
flag.StringVar(&mdb, "mdb", "", "`path` to master.mdb")
|
|
||||||
flag.StringVar(&out, "o", "", "output `dir`ectory")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
stop()
|
|
||||||
}()
|
|
||||||
|
|
||||||
db, err := sqlitex.NewPool(mdb, sqlitex.PoolOptions{Flags: sqlite.OpenReadOnly})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("mdb", slog.Any("err", err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(out, 0777); err != nil {
|
|
||||||
slog.Error("output", slog.Any("err", err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
skill, err := skillIconIDs(ctx, db)
|
skill, err := skillIconIDs(ctx, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("skills", slog.Any("err", err))
|
slog.Error("skills", slog.Any("err", err))
|
||||||
@@ -71,6 +45,49 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load scans all results of sql and appends them to r.
|
||||||
|
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
|
||||||
|
conn, err := db.Take(ctx)
|
||||||
|
defer db.Put(conn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stmt, err := conn.Prepare(sql)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
ok, err := stmt.Step()
|
||||||
|
if err != nil {
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r = append(r, row(stmt))
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
||||||
|
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
|
||||||
|
return s.ColumnInt32(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
||||||
|
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
|
||||||
|
return s.ColumnInt32(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
||||||
|
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
|
||||||
|
return s.ColumnInt32(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var pngenc = &png.Encoder{CompressionLevel: png.BestCompression}
|
var pngenc = &png.Encoder{CompressionLevel: png.BestCompression}
|
||||||
|
|
||||||
func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) {
|
func idouts(ctx context.Context, g *errgroup.Group, tick *time.Ticker, ids []int32, p, urlf string, sizes ...image.Point) {
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# toradl
|
|
||||||
|
|
||||||
Download and process icons from GameTora.
|
|
||||||
Obtain permission from Gertas before using this program.
|
|
||||||
|
|
||||||
toradl pulls the list of all skill icon IDs, character IDs, and rating IDs from the MDB
|
|
||||||
Then, for each that has no previous output, it downloads the corresponding icon from GameTora.
|
|
||||||
Each type is resized to various sizes and saved as PNG and AVIF.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"zombiezen.com/go/sqlite"
|
|
||||||
"zombiezen.com/go/sqlite/sqlitex"
|
|
||||||
)
|
|
||||||
|
|
||||||
// load scans all results of sql and appends them to r.
|
|
||||||
func load[T any](ctx context.Context, db *sqlitex.Pool, r []T, sql string, row func(*sqlite.Stmt) T) ([]T, error) {
|
|
||||||
conn, err := db.Take(ctx)
|
|
||||||
defer db.Put(conn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stmt, err := conn.Prepare(sql)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
ok, err := stmt.Step()
|
|
||||||
if err != nil {
|
|
||||||
return r, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
r = append(r, row(stmt))
|
|
||||||
}
|
|
||||||
return r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillIconIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
|
||||||
return load(ctx, db, nil, "SELECT DISTINCT icon_id FROM skill_data", func(s *sqlite.Stmt) int32 {
|
|
||||||
return s.ColumnInt32(0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func charaIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
|
||||||
return load(ctx, db, nil, "SELECT id FROM chara_data", func(s *sqlite.Stmt) int32 {
|
|
||||||
return s.ColumnInt32(0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func rankIDs(ctx context.Context, db *sqlitex.Pool) ([]int32, error) {
|
|
||||||
return load(ctx, db, nil, "SELECT id-1 FROM single_mode_rank", func(s *sqlite.Stmt) int32 {
|
|
||||||
return s.ColumnInt32(0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,9 @@ This file is my notes from exploring the database.
|
|||||||
- 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale)
|
- 119 is scenario full titles (e.g. The Beginning: URA Finale), 120 is scenario descriptions, 237 is scenario names (e.g. URA Finale)
|
||||||
- 130 is epithet names, 131 is epithet descriptions
|
- 130 is epithet names, 131 is epithet descriptions
|
||||||
|
|
||||||
|
in the decomp, text data categories are named in `public enum MasterString.Category`.
|
||||||
|
some categories seem to be missing, though.
|
||||||
|
|
||||||
# succession factor (sparks)
|
# succession factor (sparks)
|
||||||
|
|
||||||
tables are succession_factor and succession_factor_effect
|
tables are succession_factor and succession_factor_effect
|
||||||
@@ -317,6 +320,37 @@ so, it doesn't seem like there's a particular flag that identifies maiden races,
|
|||||||
- card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows
|
- card_talent_hint_upgrade has costs to raise hint levels, but it's actually universal, only six rows
|
||||||
- single_mode_route_race is career goals (not only races)
|
- single_mode_route_race is career goals (not only races)
|
||||||
- available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2)
|
- available_skill_set has starting skills including those unlocked by potential level given under need_rank (0 for pl1, 2 for pl2)
|
||||||
|
- single_mode_special_chara is scenario links
|
||||||
|
|
||||||
|
# gl
|
||||||
|
|
||||||
|
single_mode_live_song_list has the song list for lives ha ha.
|
||||||
|
this is defined from the perspective of bonuses they grant when you have them, not the shop per se.
|
||||||
|
- text_data category for song names is 210, look up by live_id; for post-concert effect text, category 208 indexed by song list id
|
||||||
|
- command_id and live_id are always equal
|
||||||
|
- level is 1 for all songs except the two instances of girls' legend u
|
||||||
|
- master_bonus_content_text_id corresponds to text_data categories 207 for pickup effect text and 209 for song name
|
||||||
|
- live_bonus_type is 1 for specialty prio, 2 for chain frequency, 3 for fb
|
||||||
|
- girls' legend u is defined twice as ids 22 and 24
|
||||||
|
|
||||||
|
costs are in single_mode_live_square.
|
||||||
|
- square_type is 1 for stat up, 2 for skill up, 3 for energy up, 4 for live
|
||||||
|
- perf_type_n is token type: 1 for dance, 2 for passion, 3 for vocals, 4 for visuals, 5 for composure
|
||||||
|
- perf_value_n is number of tokens
|
||||||
|
|
||||||
|
immediate bonuses are in single_mode_live_master_bonus.
|
||||||
|
- master_bonus_type is 1 for stat up, 2 for skill hint, 3 for energy up, 4 for song
|
||||||
|
- master_bonus_type_value is 1 for type 1, live id for type 4, 0 otherwise
|
||||||
|
- master_bonus_gain_type_n is 1 for stat, 2 for skill hint, 3 for energy, 5 for permanent training bonus
|
||||||
|
+ 1 for stat: then master_bonus_gain_value_n_1 is 1-5 for stat id or 6 for sp, n_2 is 1 for fixed or 2 for random (not actually in the game), n_3 is lower bound on gain, n_4 is 0 for fixed or upper bound for random
|
||||||
|
+ 2 for skill hint: then gain_value_n_1 is 3(*), n_2 is skill tag, n_3 is hint level
|
||||||
|
+ 3 for energy: then gain_value_n_1 is amount
|
||||||
|
+ 5 for permanent training bonus: then gain_value_n_1 is stat id (6 for sp), n_2 is amount
|
||||||
|
(*) MasterSingleModeLiveMasterBonus.SingleModeLiveMasterBonus.GainSkillHintTypeEnum defines values of Random = 1 and SkillId = 2, but they are unused in the db.
|
||||||
|
|
||||||
|
there is some ostensibly unused data.
|
||||||
|
- master bonus ids 12001-12206 grant random amounts of stats
|
||||||
|
- id 20000 corresponds to "Group Lesson" "Skill hint appropriate for aptitude"
|
||||||
|
|
||||||
# lobby conversations!!!
|
# lobby conversations!!!
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ SELECT
|
|||||||
FROM single_mode_scenario sc
|
FROM single_mode_scenario sc
|
||||||
JOIN scenario_name n ON sc.id = n.id
|
JOIN scenario_name n ON sc.id = n.id
|
||||||
JOIN scenario_title t ON sc.id = t.id
|
JOIN scenario_title t ON sc.id = t.id
|
||||||
ORDER BY sc.id
|
ORDER BY sc.sort_id
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||||
|
|
||||||
|
const sizes = [256, 64, 32, 16] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chara_id: number | null | undefined;
|
||||||
|
size: (typeof sizes)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chara_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
|
||||||
|
|
||||||
|
const icon = $derived(chara_id ?? 0);
|
||||||
|
const avifs = $derived(
|
||||||
|
sizes
|
||||||
|
.filter((sz) => sz >= size)
|
||||||
|
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.avif ${sz / size}x`)
|
||||||
|
.join(', '),
|
||||||
|
);
|
||||||
|
const pngs = $derived(
|
||||||
|
sizes
|
||||||
|
.filter((sz) => sz >= size)
|
||||||
|
.map((sz) => `/img/chara/${icon}/${sz}x${sz}.png ${sz / size}x`)
|
||||||
|
.join(', '),
|
||||||
|
);
|
||||||
|
const src = $derived(icon !== 0 ? `/img/chara/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if icon !== 0}
|
||||||
|
<picture {...rest}>
|
||||||
|
<source srcset={avifs} type="image/avif" />
|
||||||
|
<source srcset={pngs} type="image/png" />
|
||||||
|
<img {src} alt={`Icon for character ID ${chara_id}`} class="inline" width={size} height={size} loading="lazy" />
|
||||||
|
</picture>
|
||||||
|
{:else}
|
||||||
|
<div class={`w-[${size}px] h-[${size}px]`}></div>
|
||||||
|
{/if}
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Character } from '$lib/data/character';
|
import type { Character } from '$lib/data/character';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
import Pick from './Pick.svelte';
|
import Pick from './Pick.svelte';
|
||||||
|
import CharaIcon from './CharaIcon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
characters: Character[] | null;
|
characters: Character[] | null;
|
||||||
value: Character | undefined;
|
value: Character | undefined;
|
||||||
class?: ClassValue | null;
|
class?: ClassValue | null;
|
||||||
option?: Snippet<[Character]>;
|
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, characters, value = $bindable(), class: className, option, required = false }: Props = $props();
|
let { id, characters, value = $bindable(), class: className, required = false }: Props = $props();
|
||||||
|
|
||||||
const charas = $derived(characters ?? []);
|
const charas = $derived(characters ?? []);
|
||||||
|
|
||||||
const key = (v: Character) => ({ id: v.chara_id, name: v.name });
|
const key = (v: Character) => ({ id: v.chara_id, name: v.name });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Pick {id} items={charas} {key} bind:value {option} class={className} {required} />
|
<Pick {id} items={charas} {key} bind:value class={className} {required}>
|
||||||
|
{#snippet option(c: Character)}
|
||||||
|
<span>
|
||||||
|
<CharaIcon chara_id={c.chara_id} size={32} />
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Pick>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
type Ability,
|
type Ability,
|
||||||
type Skill,
|
type Skill,
|
||||||
} from './data/skill';
|
} from './data/skill';
|
||||||
|
import SkillIcon from './SkillIcon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
skill: Skill | null | undefined;
|
skill: Skill | null | undefined;
|
||||||
@@ -63,9 +64,13 @@
|
|||||||
|
|
||||||
<span class="group relative inline-block hyphens-none">
|
<span class="group relative inline-block hyphens-none">
|
||||||
<span
|
<span
|
||||||
class="skill-tip invisible absolute bottom-4 -left-1/2 flex w-80 flex-col rounded-lg border px-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
|
class="skill-tip invisible absolute bottom-4 -left-1/2 flex min-w-80 flex-col rounded-lg border px-2 pt-2 text-center opacity-0 shadow-lg transition-all group-hover:visible group-hover:bottom-6 group-hover:opacity-100"
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
>
|
>
|
||||||
|
<span class="block text-xl">
|
||||||
|
<SkillIcon icon_id={skill?.icon_id} size={32} class="inline" />
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
<span class="block border-b py-1 hyphens-auto">
|
<span class="block border-b py-1 hyphens-auto">
|
||||||
{s.description}
|
{s.description}
|
||||||
</span>
|
</span>
|
||||||
@@ -92,5 +97,6 @@
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
|
<SkillIcon icon_id={skill?.icon_id} size={16} class="inline max-h-lh align-text-bottom" />
|
||||||
<span class={[spanClass, 'cursor-pointer']}>{s.name}</span>
|
<span class={[spanClass, 'cursor-pointer']}>{s.name}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||||
|
|
||||||
|
const sizes = [64, 32, 16] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon_id: number | null | undefined;
|
||||||
|
size: (typeof sizes)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon_id, size, ...rest }: Props & SvelteHTMLElements['picture'] = $props();
|
||||||
|
|
||||||
|
const icon = $derived(icon_id ?? 0);
|
||||||
|
const avifs = $derived(
|
||||||
|
sizes
|
||||||
|
.filter((sz) => sz >= size)
|
||||||
|
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.avif ${sz / size}x`)
|
||||||
|
.join(', '),
|
||||||
|
);
|
||||||
|
const pngs = $derived(
|
||||||
|
sizes
|
||||||
|
.filter((sz) => sz >= size)
|
||||||
|
.map((sz) => `/img/skill/${icon}/${sz}x${sz}.png ${sz / size}x`)
|
||||||
|
.join(', '),
|
||||||
|
);
|
||||||
|
const src = $derived(icon !== 0 ? `/img/skill/${icon}/${sizes[0]}x${sizes[0]}.png` : `/img/skill/10011`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if icon !== 0}
|
||||||
|
<picture {...rest}>
|
||||||
|
<source srcset={avifs} type="image/avif" />
|
||||||
|
<source srcset={pngs} type="image/png" />
|
||||||
|
<img {src} alt={`Skill icon ID ${icon_id}`} class="inline" width={size} height={size} loading="lazy" />
|
||||||
|
</picture>
|
||||||
|
{:else}
|
||||||
|
<div class={`w-[${size}px] h-[${size}px]`}></div>
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as skill from '$lib/data/skill';
|
import * as skill from '$lib/data/skill';
|
||||||
|
import SkillIcon from '$lib/SkillIcon.svelte';
|
||||||
|
|
||||||
let rare = $state(false);
|
let rare = $state(false);
|
||||||
let unique = $state(false);
|
let unique = $state(false);
|
||||||
@@ -94,7 +95,10 @@
|
|||||||
<div class="grid max-w-7xl grid-cols-1 gap-2 md:grid-cols-3">
|
<div class="grid max-w-7xl grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
{#each skills as s (s.skill_id)}
|
{#each skills as s (s.skill_id)}
|
||||||
<div class="rounded-md border px-2 pt-1 pb-2 text-center shadow-sm transition-shadow hover:shadow-md">
|
<div class="rounded-md border px-2 pt-1 pb-2 text-center shadow-sm transition-shadow hover:shadow-md">
|
||||||
<span class="block text-xl">{s.name}</span>
|
<span class="block text-xl">
|
||||||
|
<SkillIcon icon_id={s?.icon_id} size={32} />
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
<span class="block italic">{s.description}</span>
|
<span class="block italic">{s.description}</span>
|
||||||
{#each s.activations as act, i (i)}
|
{#each s.activations as act, i (i)}
|
||||||
<span class="my-2 block rounded-md border">
|
<span class="my-2 block rounded-md border">
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:13669',
|
target: 'http://localhost:13669',
|
||||||
},
|
},
|
||||||
|
'/img': {
|
||||||
|
target: 'http://localhost:13669',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user