Compare commits

...
7 Commits
Author SHA1 Message Date
TrèsAbhiandCopilot 0817ba18bd feat: homescreen light mode
Co-authored-by: Copilot <[email protected]>
2026-04-26 20:06:59 -05:00
TrèsAbhi 121a80d079 feat: light theme 2026-04-26 19:59:56 -05:00
TrèsAbhiandCopilot 932c414b0c fix: implement tank list on client
Co-authored-by: Copilot <[email protected]>
2026-04-26 19:44:27 -05:00
TrèsAbhiandCopilot 3a38441d42 feat: theme switching
Co-authored-by: Copilot <[email protected]>
2026-04-26 19:43:10 -05:00
TrèsAbhi 3e039d34c2 feat: grid sizing 2026-04-26 19:31:16 -05:00
TrèsAbhiandCopilot 2bae132d18 feat: tankopedia list
Co-authored-by: Copilot <[email protected]>
2026-04-26 18:19:09 -05:00
TrèsAbhi bc0004574a feat: nation flags 2026-04-26 18:02:45 -05:00
19 changed files with 207 additions and 37 deletions

No files matched your search

+47 -4
View File
@@ -1,4 +1,6 @@
using BlitzKit.Game.Models;
using System.IO;
using System.Text.RegularExpressions;
using BlitzKit.Game.Models;
using CUE4Parse_Conversion.Textures;
using CUE4Parse.UE4.Assets.Exports.Texture;
using CUE4Parse.UE4.Objects.Engine;
@@ -9,7 +11,7 @@ using SkiaSharp;
namespace BlitzKit.Game.JSExport;
[JSExport]
public class GameInterface
public partial class GameInterface
{
private readonly BlitzFileProvider provider;
@@ -51,7 +53,7 @@ public class GameInterface
if (cTexture is null)
{
continue;
return [];
}
SKBitmap bitmap = cTexture.ToSkBitmap();
@@ -60,10 +62,51 @@ public class GameInterface
return bytes;
}
else
}
return [];
}
[GeneratedRegex(@"T_UI_Flag_(\w+)_S")]
private static partial Regex FlagNameRegex();
public byte[] Flag(string nation)
{
var flagsBase = "Blitz/Content/UI/Textures/Flag/TankCard/";
foreach (var path in provider.Files.Keys)
{
if (!path.StartsWith(flagsBase))
{
continue;
}
var fileName = Path.GetFileNameWithoutExtension(path);
var match = FlagNameRegex().Match(fileName);
if (
match.Success
&& match.Groups[1].Value.Equals(nation, StringComparison.CurrentCultureIgnoreCase)
)
{
if (provider.TryLoadPackageObject<UTexture2D>($"{path}.{fileName}", out var uTexture))
{
CTexture? cTexture = uTexture.Decode(ETexturePlatform.DesktopMobile);
if (cTexture is null)
{
return [];
}
SKBitmap bitmap = cTexture.ToSkBitmap();
SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, 80);
byte[] bytes = data.ToArray();
return bytes;
}
return [];
}
}
return [];
@@ -81,5 +81,7 @@ import "@fontsource-variable/roboto-flex";
--duration-brief: 200ms;
--duration-subtle: 2s;
--tank-card-min-width: 11rem;
}
</style>
@@ -21,6 +21,6 @@
}
&[data-light-text="true"] {
color: var(--gray-12);
color: var(--white-a12);
}
}
@@ -11,18 +11,31 @@ const ALPHA_ONLY_COLORS = ["black", "white"] as const;
type AlphaOnlyColor = (typeof ALPHA_ONLY_COLORS)[number];
const { color, alpha } = Astro.props;
const name = `${color}${
const nameLight = `${color}${alpha ? "A" : ""}`;
const nameDark = `${color}${
ALPHA_ONLY_COLORS.includes(color as AlphaOnlyColor) ? "" : "Dark"
}${alpha ? "A" : ""}`;
const scale = colors[name as keyof typeof colors];
const scaleLight = colors[nameLight as keyof typeof colors];
const scaleDark = colors[nameDark as keyof typeof colors];
let styles = "";
styles += ":root{";
styles += ":root.dark{";
for (let i = 0; i < 12; i++) {
const n = i + 1;
const value = scale[`${color}${alpha ? "A" : ""}${n}` as keyof typeof scale];
const value =
scaleDark[`${color}${alpha ? "A" : ""}${n}` as keyof typeof scaleDark];
styles += `--${color}-${alpha ? "a" : ""}${n}:${value};`;
}
styles += "}";
styles += ":root.light{";
for (let i = 0; i < 12; i++) {
const n = i + 1;
const value =
scaleLight[`${color}${alpha ? "A" : ""}${n}` as keyof typeof scaleLight];
styles += `--${color}-${alpha ? "a" : ""}${n}:${value};`;
}
@@ -21,13 +21,17 @@ const { locale } = Astro.props;
justify-content: center;
z-index: 1;
background-color: var(--gray-a2);
box-shadow: var(--shadow-none);
backdrop-filter: var(--blur-glass) brightness(10%);
backdrop-filter: var(--blur-glass);
box-shadow: var(--shadow-subtle);
background-color: var(--black-a9);
transition-duration: var(--duration-brief);
:root.light & {
background-color: var(--white-a9);
}
&[data-sticky="true"] {
transform: translateY(-100%);
box-shadow: var(--shadow-none);
@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
gap: var(--space-2);
overflow: hidden;
min-width: min-content;
@@ -10,6 +10,8 @@
.icon-wrapper {
position: relative;
width: 100%;
.flag {
position: absolute;
top: 0;
@@ -23,7 +25,7 @@
}
.icon {
width: 11rem;
min-width: var(--tank-card-min-width);
height: 7rem;
position: relative;
@@ -1,4 +1,3 @@
import type { PopularTank } from "@protos/blitzkit/popular_tanks";
import { api } from "../../core/api/dynamic";
import { useAwait } from "../../hooks/useAwait";
import { useGameStrings } from "../../hooks/useGameStrings";
@@ -7,7 +6,12 @@ import { LinkI18n } from "../LinkI18n";
import { Text } from "../Text";
import styles from "./index.module.css";
export function TankCard({ id }: PopularTank) {
interface TankCardProps {
id: string;
subtitle?: string;
}
export function TankCard({ id }: TankCardProps) {
const tank = useAwait(() => api.tank(id), `tank-${id}`);
const gameStrings = useGameStrings("TankEntity");
const locale = useLocale();
@@ -23,7 +27,7 @@ export function TankCard({ id }: PopularTank) {
<div
className={styles.flag}
style={{
backgroundImage: "url(https://i.imgur.com/QGUfXNo.png)",
backgroundImage: `url(/api/nations/${tank.tank!.nation}.webp)`,
}}
/>
@@ -0,0 +1,9 @@
.tank-cards {
display: grid;
gap: var(--space-5) var(--space-4);
grid-template-columns: repeat(
auto-fill,
minmax(var(--tank-card-min-width), 1fr)
);
justify-content: center;
}
@@ -0,0 +1,9 @@
import styles from "./index.module.css";
interface Props {
children: React.ReactNode;
}
export function TankCards({ children }: Props) {
return <div className={styles["tank-cards"]}>{children}</div>;
}
@@ -1,6 +1,6 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import type { Color } from "../../core/ui/color";
import type { ColorWithBW } from "../../core/ui/color";
import styles from "./index.module.css";
export interface TextProps extends ComponentProps<"span"> {
@@ -8,7 +8,7 @@ export interface TextProps extends ComponentProps<"span"> {
lowContrast?: boolean;
size?: "regular" | "minor" | "major";
align?: "left" | "center" | "right";
color?: Color;
color?: ColorWithBW;
}
export function Text({
@@ -22,10 +22,11 @@ export function Text({
...props
}: TextProps) {
const scale = color === "gray" && lowContrast ? "white" : color;
let power: string | number = 12;
const isBW = color === "black" || color === "white";
let power: string | number = isBW ? "a12" : 12;
if (lowContrast) {
power = color === "gray" ? "a9" : 11;
power = color === "gray" ? "a9" : isBW ? "a11" : 11;
}
return (
@@ -31,6 +31,10 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
backdrop-filter: var(--blur-tint);
:root.light & {
backdrop-filter: var(--blur-tint) invert(1) hue-rotate(180deg);
}
@media (width > 40rem) {
padding: var(--space-6) var(--space-5);
}
@@ -50,6 +54,10 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
width: 100%;
height: 100%;
:root.light & {
filter: invert(1) brightness(0);
}
mask-image: linear-gradient(
90deg,
transparent 25%,
@@ -43,6 +43,10 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
background-size: cover;
background-position: center;
:root.light & {
filter: brightness(1.25);
}
.gradient {
width: 100%;
height: 100%;
@@ -58,9 +62,17 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
backdrop-filter: blur(var(--space-6));
:root.light & {
backdrop-filter: blur(var(--space-6)) brightness(1.25);
}
.readable {
display: flex;
flex-direction: column;
.title {
color: red;
}
}
}
}
@@ -87,7 +99,7 @@ const toolStrings = strings.tools[tool.id as keyof typeof strings.tools];
<div class="info">
<div class="readable">
<Text weight="medium">{toolStrings.name}</Text>
<Text weight="medium" color="white">{toolStrings.name}</Text>
<Text size="minor" lowContrast>{toolStrings.slogan}</Text>
</div>
+3 -2
View File
@@ -3,14 +3,15 @@ import type { Strings } from "@blitzkit/i18n";
import { Avatars } from "@protos/blitzkit/avatars";
import { PopularTanks } from "@protos/blitzkit/popular_tanks";
import { Tank } from "@protos/blitzkit/tank";
import { TankList } from "@protos/blitzkit/tank_list";
import { AbstractAPI, Cache } from "./abstract";
const rejected = Promise.reject("Not implemented on client");
export class ClientAPI extends AbstractAPI {
@UnimplementedOnClient
@Cache()
tankList() {
return rejected;
return fetchPB("/api/tanks/list.pb", TankList);
}
@Cache()
+3
View File
@@ -1,7 +1,10 @@
import type * as colors from "@radix-ui/colors";
const ignoreEndings = ["A", "P3", "Dark", "DarkA", "DarkP3"] as const;
export type Color = Exclude<
keyof typeof colors,
`${string}${(typeof ignoreEndings)[number]}` | "__esModule"
>;
export type ColorWithBW = Color | "black" | "white";
@@ -16,7 +16,7 @@ interface Props {
const { accent, noNavbar, locale } = Astro.props;
---
<html lang={locale}>
<html lang={locale} class="dark">
<head>
<BaseMeta />
@@ -56,10 +56,23 @@ const { accent, noNavbar, locale } = Astro.props;
}
</style>
<script>
const root = document.documentElement;
const media = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme(e: MediaQueryList | MediaQueryListEvent) {
root.classList.toggle("dark", e.matches);
root.classList.toggle("light", !e.matches);
}
applyTheme(media);
media.addEventListener("change", applyTheme);
</script>
<slot name="head" />
</head>
<body class="dark">
<body>
{!noNavbar && <Navbar {locale} />}
<slot name="pre-app" />
@@ -0,0 +1,29 @@
import { TankCard } from "../../../components/TankCard";
import { TankCards } from "../../../components/TankCards";
import { api } from "../../../core/api/dynamic";
import { useAwait } from "../../../hooks/useAwait";
import { LocaleProvider } from "../../../hooks/useLocale";
interface PageProps {
locale: string;
}
export function Page({ locale, ...props }: PageProps) {
return (
<LocaleProvider locale={locale}>
<Content {...props} />
</LocaleProvider>
);
}
function Content() {
const tankList = useAwait(() => api.tankList(), "tank-list");
return (
<TankCards>
{tankList.list.slice(0, 32).map(({ id }) => (
<TankCard key={id} id={id} />
))}
</TankCards>
);
}
@@ -1,9 +1,11 @@
---
import "@radix-ui/colors/violet-dark.css";
import { Section } from "../../../components/Section";
import ToolBanner from "../../../components/ToolBanner.astro";
import { api } from "../../../core/api/dynamic";
import { tools } from "../../../core/tools/tools";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { Page } from "./_index";
export { getStaticPaths } from "../index.astro";
@@ -14,16 +16,11 @@ const tankList = await api.tankList();
<BaseLayout {locale} accent="purple">
<ToolBanner {locale} tool={tools.tanks} slot="pre-app" />
<h1>tanks</h1>
<Section>
<Page {locale} client:load />
</Section>
{
tankList.list.map(({ id, slug }) => (
<p>
<a href={`/tanks/${slug}`}>
<img height="128" width="128" src={`/api/tanks/${id}.webp`} />
{slug} ({id})
</a>
</p>
))
}
<!-- {
} -->
</BaseLayout>
@@ -0,0 +1,20 @@
import { Nation } from "@protos/game/proto/legacy/blitz_static_tank_component";
import type { APIContext, GetStaticPaths } from "astro";
import { game } from "../../../core/game/game";
export const getStaticPaths = (async () => {
return Object.values(Nation)
.filter(Number.isInteger)
.map((id) => ({ params: { id } }));
}) satisfies GetStaticPaths;
/**
* Even Noah can't save these animals.
*/
export async function GET({ params }: APIContext<never, { id: string }>) {
const enumName = Nation[Number(params.id)];
const nationName = enumName.replace("NATION_", "").toLowerCase();
const flag = game.flag(nationName);
return new Response(new Uint8Array(flag));
}