diff --git a/.eslintrc.cjs b/.eslintrc.cjs index be6fd4f5..e7d8c9b1 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -72,6 +72,7 @@ module.exports = { './ares/ares.tsx', './artemis/prisma/seed.tsx', './codegen/**', + './docs/vocs.config.tsx', './electron/**', './infra/**', './scripts/**', diff --git a/README.md b/README.md index 46adb8a9..b9c7f251 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The codebase is split into multiple packages to enforce the separation of concer - `apollo` → Data structures and algorithms for manipulating _game_ state (_client/server_). - `hera` → Game engine and rendering (_client_). - `ui` → Design system (_client_). -- `deimos` → Landing page (_client_). +- `docs` → Docs & Playground (_client_). These are secondary packages focused on specific domains: @@ -67,7 +67,7 @@ Check out our [Athena Crisis Open Source Docs & Playground](https://athenacrisis ## What is open source and what isn't? -About 75% of all non-content related Athena Crisis code – **almost 100,000 lines** – is open source, including the core data structures, algorithms, game engine, rendering, AI, landing page and the map editor. Backend implementations such as user management, databases, APIs, realtime spectating, server configuration, and app wrappers for Steam or app stores are not open source. We aim to open source more of the game over time, but the content will remain the intellectual property of Nakazawa Tech KK and therefore not be open source. You can buy and enjoy [Athena Crisis on Steam Early Access](https://store.steampowered.com/app/2456430/Athena_Crisis/) or [buy it on athenacrisis.com](https://app.athenacrisis.com/checkout). +About 75% of all non-content related Athena Crisis code – **almost 100,000 lines** – is open source, including the core data structures, algorithms, game engine, rendering, AI, and the map editor. Backend implementations such as user management, databases, APIs, realtime spectating, server configuration, and app wrappers for Steam or app stores are not open source. We aim to open source more of the game over time, but the content will remain the intellectual property of Nakazawa Tech KK and therefore not be open source. You can buy and enjoy [Athena Crisis on Steam Early Access](https://store.steampowered.com/app/2456430/Athena_Crisis/) or [buy it on athenacrisis.com](https://app.athenacrisis.com/checkout). ## Why did you open source Athena Crisis? diff --git a/ares/package.json b/ares/package.json index 0915e9d1..3e0f508b 100644 --- a/ares/package.json +++ b/ares/package.json @@ -36,7 +36,6 @@ "react": "19.0.0-canary-fd0da3eef-20240404", "react-dom": "19.0.0-canary-fd0da3eef-20240404", "react-error-boundary": "^4.0.13", - "react-fps": "^1.0.6", "react-relay": "^16.2.0", "react-router-dom": "^6.23.1", "relay-runtime": "^16.2.0", diff --git a/docs/content/examples/map-data-examples.tsx b/docs/content/examples/map-data-examples.tsx new file mode 100644 index 00000000..ed691ca2 --- /dev/null +++ b/docs/content/examples/map-data-examples.tsx @@ -0,0 +1,47 @@ +import { Mountain } from '@deities/athena/info/Tile.tsx'; +import { Flamethrower, Infantry } from '@deities/athena/info/Unit.tsx'; +import withModifiers from '@deities/athena/lib/withModifiers.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import Button from '@deities/ui/Button.tsx'; +import { Fragment, useState } from 'react'; +import PlaygroundGame from '../playground/PlaygroundGame.tsx'; + +// [!region mapA] +const mapA = withModifiers( + MapData.createMap({ + map: [1, 1, 1, 1, Mountain.id, 1, 1, 1, 1], + size: { height: 3, width: 3 }, + teams: [ + { + id: 1, + name: '', + players: [{ funds: 500, id: 1, userId: '1' }], + }, + { + id: 2, + name: '', + players: [{ funds: 500, id: 2, name: 'Bot' }], + }, + ], + }), +); +// [!endregion mapA] + +// [!region mapB] +const mapB = mapA.copy({ + units: mapA.units + .set(vec(2, 1), Flamethrower.create(1)) + .set(vec(3, 3), Infantry.create(2)), +}); +// [!endregion mapB] + +export default function ExampleMap() { + const [render, rerender] = useState(0); + return ( + + + + + ); +} diff --git a/docs/content/examples/map-editor.tsx b/docs/content/examples/map-editor.tsx new file mode 100644 index 00000000..f1bbea4c --- /dev/null +++ b/docs/content/examples/map-editor.tsx @@ -0,0 +1,30 @@ +import { Sniper } from '@deities/athena/info/Unit.tsx'; +import MapEditor from '@deities/hera/editor/MapEditor.tsx'; + +const viewer = { + access: 'User', + character: { + unitId: Sniper.id, + variant: 0, + }, + displayName: 'Maxima', + factionName: 'Atlas', + id: 'Demo-User-12', + skills: [], + username: 'demo-maxima', +} as const; + +export default function MapEditorExample() { + return ( + {}} + fogStyle="soft" + setHasChanges={() => {}} + tiltStyle="on" + updateMap={() => {}} + user={viewer} + /> + ); +} diff --git a/docs/content/pages/core-concepts/actions.mdx b/docs/content/pages/core-concepts/actions.mdx new file mode 100644 index 00000000..1060a658 --- /dev/null +++ b/docs/content/pages/core-concepts/actions.mdx @@ -0,0 +1 @@ +# Actions diff --git a/docs/content/pages/core-concepts/map-data.mdx b/docs/content/pages/core-concepts/map-data.mdx index edc4ac81..29e157ec 100644 --- a/docs/content/pages/core-concepts/map-data.mdx +++ b/docs/content/pages/core-concepts/map-data.mdx @@ -1,3 +1,124 @@ +import { lazy } from 'react'; +import ClientComponent from '../../playground/ClientComponent.tsx'; + # The `MapData` Class -[`MapData`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/MapData.tsx) +The [`MapData`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/MapData.tsx) class is at the core of each map and game state. As outlined in section about [Immutable Data Structures](/core-concepts/immutable-data-structures), it is immutable. Any change to map state returns a new instance of `MapData`. Here is the data that it holds: + +```tsx +class MapData { + map: TileMap, + modifiers: ModifierMap, + decorators: DecoratorMap, + config: MapConfig, + size: SizeVector, + currentPlayer: PlayerID, + round: number, + active: PlayerIDs, + teams: Teams, + buildings: ImmutableMap, + units: ImmutableMap, +} +``` + +Each map in the game is a grid defined by `size`. `map` is an Array of tile ids (In-game they are referred to as "fields"), with `modifiers` being the corresponding Array to identify the specific sprite variant for rendering. For example, if a tile in a specific location is a Street, with Street tiles above and below, but not to the right and left, the modifier will store information that it should render a vertical Street sprite. `map` is critical for gameplay and behaviors, but `modifiers` is only used for rendering. Modifiers are only stored on each map to avoid recalculating them frequently. In tests, you can use `withModifiers(map)` to generate the correct modifiers automatically. + +:::info[Note] +In Athena Crisis, the look of a tile on the screen depends on its adjacent tiles. There is always only one way to render a tile based on its neighbors. The map editor first checks if a tile can be placed via [`canPlaceTile`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib/canPlaceTile.tsx), and then recalculates the modifiers for the field and its neighbors via [`getModifier`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib/getModifier.tsx). +::: + +While `map` and `modifiers` are dense arrays, `decorators`, `buildings` and `units` are sparse. This is efficient because each game map has a tile and modifier for each field, but usually only few decorations, buildings and units. + +## Fun with Maps + +There are helper functions to serialize and deserialize map state from plain JavaScript values. Let's take a look at how to create an instance of `MapData` as is often seen in tests: + +```tsx +// [!include ~/examples/map-data-examples.tsx:mapA] +``` + +This creates a map with a 3x3 grid of plain fields (id `1`) and a Mountain in the center. The map has two teams with one human player and one bot. Now, let's add some units to this map: + +```tsx +// [!include ~/examples/map-data-examples.tsx:mapB] +``` + +We said "add some units", but in reality we created a completely new map with the units added. If we render this map, we see a Flamethrower on one side, and an Infantry on the other: + + import('../../examples/map-data-examples.tsx'))} +/> + +## Vectors & Positions + +In the above example we made use of a [`vec`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/map/vec.tsx) function. `vec` is a convenience function to create [`Vector`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/map/Vector.tsx) instances, which are 2d coordinates. Vectors cannot be created directly, and are always accessed via `vec`. Instances are cached for the duration of the session, and the same instance is returned for the same coordinates: + +```tsx +console.log(vec(3, 15) === vec(3, 15)); // true +``` + +Not only is this more memory efficient, but it also allows using them as keys in a `Map` or `Set`: + +```tsx +// Doesn't work: +const set = new Set([new Vector(1, 2), new Vector(3, 4)]); + +set.has(new Vector(1, 2)); // false + +// Works: +const set = new Set([vec(1, 2), vec(3, 4)]); + +set.has(vec(1, 2)); // true +``` + +Vectors have a number of convenience methods to navigate a grid. Here are some of the most useful ones: + +```tsx +vec(1, 3).down(); // Vector { x: 1, y: 4 } + +vec(2, 2).adjacent(); // up, right, down, left + +vec(2, 2).expand(); // self, up, right, down, left + +vec(5, 5).distance(vec(1, 1)); // 8, Manhattan distance +``` + +## Map State Queries + +Since most data structures are immutable, it's common to access data fields directly. For example, to find all opponent's of the current player you can do: + +```tsx +const opposingUnits = map.units.filter((unit) => + map.isOpponent(unit, map.currentPlayer), +); +``` + +This example will return a new `ImmutableMap` of all units. `MapData` contains many helper methods to query map state. For example, `map.isOpponent` checks if two players or entities are opponents, `map.isTeam(unit, player)` checks if they are the same team. To check if a unit matches a player, you can use `map.matchesPlayer(unit, player). These checks are necessary because a game can have multiple teams each consisting of one or more players. + +Since it's inconvenient to calculate the index of a tile in the `map` array, you can use `map.getTileInfo(vector)` to receive the tile structure for a specific field: + +```tsx +map.getTileInfo(vec(2, 2)).id === Mountain.id; // true +``` + +`MapData` has a few methods that return a new map state, for example `map.recover(playerID)` which returns a new map with all `completed` and `moved` states removed from units. This is used to reset the state for a player when a user ends their turn. + +There is a large number of query functions available in [`athena/lib`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib) that can be used to access map state or produce new ones. These functions are used widely in game logic and the AI. + +## Updating Map State + +Let's say we want to set the health of each opposing unit to `1`: + +```tsx +const units = map.units.map((unit) => + map.isOpponent(unit, map.currentPlayer) ? unit.setHealth(1) : unit, +); + +const newMap = map.copy({ units }); +``` + +That's it! After a mutation to map state it can be shared with other players, like for example when an action is taken on the server or the state can be stored in a database using `JSON.stringify(newMap)`. In the next section we'll discuss the formalized approach to update game state via [Actions](/core-concepts/actions). + +:::info[Note] +Many data structures in Athena Crisis are technically mutable as they are basic JavaScript Arrays, Sets or Maps. This is usually for performance reasons. Their immutability is enforced via TypeScript types. +::: diff --git a/docs/content/pages/index.mdx b/docs/content/pages/index.mdx index 0f228c08..d6fbeb44 100644 --- a/docs/content/pages/index.mdx +++ b/docs/content/pages/index.mdx @@ -2,13 +2,15 @@ layout: landing --- +import { lazy } from 'react'; import { HomePage } from 'vocs/components'; +import ClientComponent from '../playground/ClientComponent.tsx'; - Athena Crisis logo + Athena Crisis logo Open Source Docs & Playground - This is a description of my documentation website. + Athena Crisis is a modern retro turn-based strategy game. @@ -18,4 +20,7 @@ import { HomePage } from 'vocs/components'; GitHub + import('../playground/PlaygroundDemoGame.tsx'))} + /> diff --git a/docs/content/pages/playground/map-editor.mdx b/docs/content/pages/playground/map-editor.mdx new file mode 100644 index 00000000..7bc8a445 --- /dev/null +++ b/docs/content/pages/playground/map-editor.mdx @@ -0,0 +1,10 @@ +--- +layout: minimal +--- + +import { lazy } from 'react'; +import ClientComponent from '../../playground/ClientComponent.tsx'; + +# Map Editor + + import('../../examples/map-editor.tsx'))} /> diff --git a/docs/content/playground/ClientComponent.tsx b/docs/content/playground/ClientComponent.tsx new file mode 100644 index 00000000..6c339d4e --- /dev/null +++ b/docs/content/playground/ClientComponent.tsx @@ -0,0 +1,31 @@ +import Spinner from '@deities/ui/Spinner.tsx'; +import Stack from '@deities/ui/Stack.tsx'; +import { Suspense, useEffect, useState } from 'react'; + +export default function ClientComponent({ + module: Module, +}: { + module: () => JSX.Element; +}) { + const [element, setElement] = useState(null); + + useEffect(() => { + import('./ClientScope.tsx').then(({ default: ClientScope }) => + setElement( + + + + + } + > + + + , + ), + ); + }, [Module]); + + return element; +} diff --git a/docs/content/playground/ClientScope.tsx b/docs/content/playground/ClientScope.tsx new file mode 100644 index 00000000..17b850de --- /dev/null +++ b/docs/content/playground/ClientScope.tsx @@ -0,0 +1,51 @@ +import { initializeCSSVariables } from '@deities/ui/cssVar.tsx'; +import { ScaleContext } from '@deities/ui/hooks/useScale.tsx'; +import { css, injectGlobal } from '@emotion/css'; +import { init as initFbt, IntlVariations } from 'fbt'; + +initializeCSSVariables(); + +initFbt({ + hooks: { + getViewerContext: () => ({ + GENDER: IntlVariations.GENDER_UNKNOWN, + locale: 'en_US', + }), + }, + translations: {}, +}); + +injectGlobal(` +@font-face { + font-display: swap; + font-family: Athena; + src: url('/fonts/AthenaNova.woff2'); +} + +body { + font-family: Athena, ui-sans-serif, system-ui, sans-serif; + font-size: 20px; + font-weight: normal; + line-height: 1em; +} + +`); + +if (import.meta.env.DEV) { + import('@deities/hera/ui/fps/Fps.tsx'); +} + +export default function ClientScope({ children }: { children: JSX.Element }) { + return ( + +
{children}
+
+ ); +} + +const style = css` + all: initial; + + font-family: Athena, ui-sans-serif, system-ui, sans-serif; + outline: none; +`; diff --git a/docs/content/playground/PlaygroundDemoGame.tsx b/docs/content/playground/PlaygroundDemoGame.tsx new file mode 100644 index 00000000..01231511 --- /dev/null +++ b/docs/content/playground/PlaygroundDemoGame.tsx @@ -0,0 +1,37 @@ +import convertBiome from '@deities/athena/lib/convertBiome.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import demo1, { + metadata as metadata1, +} from '@deities/hermes/map-fixtures/demo-1.tsx'; +import demo2, { + metadata as metadata2, +} from '@deities/hermes/map-fixtures/demo-2.tsx'; +import randomEntry from '../../../hephaestus/randomEntry.tsx'; +import PlaygroundGame from './PlaygroundGame.tsx'; + +const biome = randomEntry([ + Biome.Grassland, + Biome.Desert, + Biome.Snow, + Biome.Swamp, + Biome.Volcano, +]); + +const [map, metadata] = randomEntry([ + [demo1, metadata1], + [demo1, metadata1], + [demo1, metadata1], + [demo2, metadata2], +]); +const currentDemoMap = convertBiome( + map.copy({ + config: map.config.copy({ + fog: randomEntry([true, false, false, false, false]), + }), + }), + biome, +); + +export default function PlaygroundDemoGame() { + return ; +} diff --git a/docs/content/playground/PlaygroundGame.tsx b/docs/content/playground/PlaygroundGame.tsx new file mode 100644 index 00000000..19947ead --- /dev/null +++ b/docs/content/playground/PlaygroundGame.tsx @@ -0,0 +1,81 @@ +import { MapMetadata } from '@deities/apollo/MapMetadata.tsx'; +import { prepareSprites } from '@deities/art/Sprites.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import GameMap from '@deities/hera/GameMap.tsx'; +import useClientGame from '@deities/hera/hooks/useClientGame.tsx'; +import useClientGameAction from '@deities/hera/hooks/useClientGameAction.tsx'; +import GameActions from '@deities/hera/ui/GameActions.tsx'; +import MapInfo from '@deities/hera/ui/MapInfo.tsx'; +import setupGamePad from '@deities/ui/controls/setupGamePad.tsx'; +import setupKeyboard from '@deities/ui/controls/setupKeyboard.tsx'; +import useScale from '@deities/ui/hooks/useScale.tsx'; +import { useInView } from 'framer-motion'; +import { useRef } from 'react'; + +prepareSprites(); +setupGamePad(); +setupKeyboard(); + +const startAction = { + type: 'Start', +} as const; + +export default function PlaygroundGame({ + map, + metadata, +}: { + map: MapData; + metadata?: MapMetadata; +}) { + const userId = 'User-Demo'; + const [game, setGame] = useClientGame( + map, + userId, + metadata?.effects || new Map(), + startAction, + ); + + const onAction = useClientGameAction(game, setGame); + const zoom = useScale(); + const ref = useRef(null); + const isInView = useInView(ref, { margin: '-20% 0px 6% 0px' }); + + return ( +
+ + {(props, actions) => { + const hide = + !isInView || props.lastActionResponse?.type === 'GameEnd'; + + return ( + <> + + + + ); + }} + +
+ ); +} diff --git a/docs/content/public/FiraCode.woff2 b/docs/content/public/FiraCode.woff2 deleted file mode 100644 index c856e7be..00000000 Binary files a/docs/content/public/FiraCode.woff2 and /dev/null differ diff --git a/docs/content/public/apple-touch-icon.png b/docs/content/public/apple-touch-icon.png index 82fad44d..e7aaf7b7 100644 Binary files a/docs/content/public/apple-touch-icon.png and b/docs/content/public/apple-touch-icon.png differ diff --git a/docs/content/public/favicon.ico b/docs/content/public/favicon.ico index 99600ec6..3a042245 100644 Binary files a/docs/content/public/favicon.ico and b/docs/content/public/favicon.ico differ diff --git a/docs/content/public/favicon.png b/docs/content/public/favicon.png index 98fb82c4..a6128bc0 100644 Binary files a/docs/content/public/favicon.png and b/docs/content/public/favicon.png differ diff --git a/docs/content/public/fonts/AthenaNova.woff2 b/docs/content/public/fonts/AthenaNova.woff2 new file mode 100644 index 00000000..b00db90d Binary files /dev/null and b/docs/content/public/fonts/AthenaNova.woff2 differ diff --git a/docs/package.json b/docs/package.json index 6e7ba269..d10602b6 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,11 +15,21 @@ "preview": "vocs preview" }, "dependencies": { + "@deities/apollo": "workspace:*", + "@deities/art": "workspace:*", + "@deities/athena": "workspace:*", + "@deities/hera": "workspace:*", + "@deities/hermes": "workspace:*", + "@deities/ui": "workspace:*", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/css": "^11.11.2", "@types/react": "^18.3.1", "dunkel-theme": "^1.7.1", + "fbt": "^1.0.2", + "framer-motion": "^11.1.9", "licht-theme": "^1.7.1", "react": "19.0.0-canary-fd0da3eef-20240404", "react-dom": "19.0.0-canary-fd0da3eef-20240404", - "vocs": "^1.0.0-alpha.49" + "vocs": "1.0.0-alpha.51" } } diff --git a/docs/vocs.config.tsx b/docs/vocs.config.tsx index a1cf4766..a70893ff 100644 --- a/docs/vocs.config.tsx +++ b/docs/vocs.config.tsx @@ -1,6 +1,10 @@ import { readFileSync } from 'node:fs'; +import babelPluginEmotion from '@emotion/babel-plugin'; +import react from '@vitejs/plugin-react'; import React from 'react'; import { defineConfig } from 'vocs'; +import babelFbtPlugins from '../infra/babelFbtPlugins.tsx'; +import resolver from '../infra/resolver.tsx'; const Licht = JSON.parse( readFileSync('./node_modules/licht-theme/licht.json', 'utf8'), @@ -11,6 +15,7 @@ const Dunkel = JSON.parse( export default defineConfig({ basePath: '/open-source', + baseUrl: 'https://athenacrisis.com/open-source', description: 'Open Source Docs & Playground', editLink: { pattern: @@ -25,6 +30,12 @@ export default defineConfig({ head: ( <> + ), iconUrl: '/favicon.png', @@ -58,9 +69,22 @@ export default defineConfig({ link: '/core-concepts/map-data', text: 'MapData ', }, + { + link: '/core-concepts/actions', + text: 'Actions ', + }, ], text: 'Core Concepts', }, + { + items: [ + { + link: '/playground/map-editor', + text: 'Map Editor', + }, + ], + text: 'Playground', + }, ], socials: [ { @@ -80,4 +104,39 @@ export default defineConfig({ accentColor: '#c3217f', }, title: 'Athena Crisis', + topNav: [ + { link: '/core-concepts/overview', match: '/core-concepts', text: 'Docs' }, + { + link: 'https://store.steampowered.com/app/2456430/Athena_Crisis/', + text: 'AC on Steam', + }, + { + items: [ + { + link: 'https://nkzw.tech', + text: 'Nakazawa Tech', + }, + { + link: 'https://null.com', + text: 'Null', + }, + ], + text: 'More', + }, + ], + vite: { + define: { + 'process.env.IS_LANDING_PAGE': `1`, + }, + plugins: [ + react({ + babel: { + plugins: [...babelFbtPlugins, babelPluginEmotion], + }, + }), + ], + resolve: { + alias: [resolver], + }, + }, }); diff --git a/hera/package.json b/hera/package.json index 43c6fa70..5f02745e 100644 --- a/hera/package.json +++ b/hera/package.json @@ -26,7 +26,8 @@ "fbt": "^1.0.2", "framer-motion": "^11.1.9", "react": "19.0.0-canary-fd0da3eef-20240404", - "react-dom": "19.0.0-canary-fd0da3eef-20240404" + "react-dom": "19.0.0-canary-fd0da3eef-20240404", + "react-fps": "^1.0.6" }, "devDependencies": { "@types/react": "^18.3.1", diff --git a/hera/ui/fps/Fps.tsx b/hera/ui/fps/Fps.tsx new file mode 100644 index 00000000..b316f3d2 --- /dev/null +++ b/hera/ui/fps/Fps.tsx @@ -0,0 +1,11 @@ +import { createRoot } from 'react-dom/client'; +import { HideContext } from '../../hooks/useHide.tsx'; +import Fps from './FpsComponent.tsx'; + +const root = document.createElement('div'); +createRoot(root).render( + + + , +); +document.body.append(root); diff --git a/hera/ui/fps/FpsComponent.tsx b/hera/ui/fps/FpsComponent.tsx new file mode 100644 index 00000000..583344c1 --- /dev/null +++ b/hera/ui/fps/FpsComponent.tsx @@ -0,0 +1,33 @@ +import parseInteger from '@deities/hephaestus/parseInteger.tsx'; +import Box from '@deities/ui/Box.tsx'; +import { css, cx } from '@emotion/css'; +import { useFps } from 'react-fps'; +import useHide from '../../hooks/useHide.tsx'; +import maybeFade from '../../ui/lib/maybeFade.tsx'; + +export default function Fps() { + const hidden = useHide(); + const { avgFps, currentFps } = useFps(20); + return currentFps != null ? ( + + {currentFps} fps/{parseInteger(avgFps)} avg + + ) : null; +} + +const style = css` + bottom: 10px; + font-size: 12px; + height: 12px; + left: 0; + line-height: 12px; + margin: 0 auto; + min-height: auto; + padding: 0 8px; + pointer-events: none; + position: fixed; + right: 0; + white-space: nowrap; + width: 124px; + z-index: 1000; +`; diff --git a/infra/babelFbtPlugins.tsx b/infra/babelFbtPlugins.tsx new file mode 100644 index 00000000..652de190 --- /dev/null +++ b/infra/babelFbtPlugins.tsx @@ -0,0 +1,30 @@ +import { join } from 'node:path'; +import babelPluginFbt from 'babel-plugin-fbt'; +import babelPluginFbtImport from 'babel-plugin-fbt-import'; +import babelPluginFbtRuntime from 'babel-plugin-fbt-runtime'; +import isOpenSource from './isOpenSource.tsx'; +import root from './root.ts'; + +const enumManifest = (() => { + try { + return require('../ares/.enum_manifest.json'); + } catch { + if (!isOpenSource()) { + throw new Error('babelFbtPlugins: Missing enum manifest.'); + } + } + return {}; +})(); + +export default [ + babelPluginFbtImport, + [ + babelPluginFbt, + { + extraOptions: { __self: true }, + fbtCommonPath: join(root, 'i18n/Common.cjs'), + fbtEnumManifest: enumManifest, + }, + ], + babelPluginFbtRuntime, +]; diff --git a/infra/resolver.tsx b/infra/resolver.tsx index 7469e8ce..90bb391a 100644 --- a/infra/resolver.tsx +++ b/infra/resolver.tsx @@ -1,7 +1,6 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; - -const root = process.cwd(); +import root from './root.ts'; const mappings = new Map([ ['athena-crisis:audio', join(root, 'ui/Audio')], @@ -10,7 +9,7 @@ const mappings = new Map([ ] as const); export default { - customResolver(id: string, from: string | undefined) { + customResolver(id: string) { if ( id === 'athena-crisis:audio' || id === 'athena-crisis:asset-variants' || diff --git a/infra/root.ts b/infra/root.ts new file mode 100644 index 00000000..c06ec1b5 --- /dev/null +++ b/infra/root.ts @@ -0,0 +1,4 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export default dirname(dirname(fileURLToPath(import.meta.url))); diff --git a/package.json b/package.json index 30ca8d2e..a886c643 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,17 @@ "pnpm": ">=9.0.1" }, "pnpm": { + "peerDependencyRules": { + "allowAny": [ + "@aws-sdk/client-sso-oidc", + "react", + "react-dom", + "utf-8-validate" + ], + "ignoreMissing": [ + "@fbtjs/default-collection-transform" + ] + }, "neverBuiltDependencies": [ "canvas" ], @@ -95,6 +106,7 @@ "rollup@^2.0.0": "^4.16.4" }, "patchedDependencies": { + "@remix-run/router@1.16.1": "patches/@remix-run__router@1.16.1.patch", "eslint-plugin-import@2.29.1": "patches/eslint-plugin-import@2.29.1.patch", "fbt@1.0.2": "patches/fbt@1.0.2.patch", "graphql-helix@1.13.0": "patches/graphql-helix@1.13.0.patch", diff --git a/patches/@remix-run__router@1.16.1.patch b/patches/@remix-run__router@1.16.1.patch new file mode 100644 index 00000000..2b334b09 --- /dev/null +++ b/patches/@remix-run__router@1.16.1.patch @@ -0,0 +1,22 @@ +diff --git a/dist/router.cjs.js b/dist/router.cjs.js +index 7c5abbb2f2c314912921298c6101e1fe0e0bebbf..4c0c94b3e9675a6bdeb95fb27364aa73a320a281 100644 +--- a/dist/router.cjs.js ++++ b/dist/router.cjs.js +@@ -3562,7 +3562,7 @@ function createStaticHandler(routes, opts) { + let results = await callDataStrategy("action", request, [actionMatch], matches, isRouteRequest, requestContext, unstable_dataStrategy); + result = results[0]; + if (request.signal.aborted) { +- throwStaticHandlerAbortedError(request, isRouteRequest, future); ++ //throwStaticHandlerAbortedError(request, isRouteRequest, future); + } + } + if (isRedirectResult(result)) { +@@ -3677,7 +3677,7 @@ function createStaticHandler(routes, opts) { + } + let results = await callDataStrategy("loader", request, matchesToLoad, matches, isRouteRequest, requestContext, unstable_dataStrategy); + if (request.signal.aborted) { +- throwStaticHandlerAbortedError(request, isRouteRequest, future); ++ //throwStaticHandlerAbortedError(request, isRouteRequest, future); + } + + // Process and commit output from loaders diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bff20cbc..5b1515cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ overrides: rollup@^2.0.0: ^4.16.4 patchedDependencies: + '@remix-run/router@1.16.1': + hash: f5klonf5edxua46cqfhk7ivlia + path: patches/@remix-run__router@1.16.1.patch eslint-plugin-import@2.29.1: hash: gqi3hqavyg4p4tnm2gmk4olmpe path: patches/eslint-plugin-import@2.29.1.patch @@ -181,9 +184,6 @@ importers: react-error-boundary: specifier: ^4.0.13 version: 4.0.13(react@19.0.0-canary-fd0da3eef-20240404) - react-fps: - specifier: ^1.0.6 - version: 1.0.6(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404) react-relay: specifier: ^16.2.0 version: 16.2.0(react@19.0.0-canary-fd0da3eef-20240404) @@ -569,12 +569,42 @@ importers: docs: dependencies: + '@deities/apollo': + specifier: workspace:* + version: link:../apollo + '@deities/art': + specifier: workspace:* + version: link:../art + '@deities/athena': + specifier: workspace:* + version: link:../athena + '@deities/hera': + specifier: workspace:* + version: link:../hera + '@deities/hermes': + specifier: workspace:* + version: link:../hermes + '@deities/ui': + specifier: workspace:* + version: link:../ui + '@emotion/babel-plugin': + specifier: ^11.11.0 + version: 11.11.0 + '@emotion/css': + specifier: ^11.11.2 + version: 11.11.2 '@types/react': specifier: ^18.3.1 version: 18.3.1 dunkel-theme: specifier: ^1.7.1 version: 1.7.1 + fbt: + specifier: ^1.0.2 + version: 1.0.2(patch_hash=nsmo6pdao5zymwjciznmexrgoy)(babel-plugin-fbt-runtime@1.0.0(babel-plugin-fbt@1.0.0))(babel-plugin-fbt@1.0.0)(react@19.0.0-canary-fd0da3eef-20240404) + framer-motion: + specifier: ^11.1.9 + version: 11.1.9(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404) licht-theme: specifier: ^1.7.1 version: 1.7.1 @@ -585,8 +615,8 @@ importers: specifier: 19.0.0-canary-fd0da3eef-20240404 version: 19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404) vocs: - specifier: 1.0.0-alpha.49 - version: 1.0.0-alpha.49(@types/node@20.12.11)(@types/react-dom@18.3.0)(@types/react@18.3.1)(babel-plugin-macros@3.1.0)(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404)(rollup@4.17.2)(terser@5.31.0)(ts-node@10.9.2(@swc/core@1.5.5)(@types/node@20.12.11)(typescript@5.4.5))(typescript@5.4.5) + specifier: 1.0.0-alpha.51 + version: 1.0.0-alpha.51(@types/node@20.12.11)(@types/react-dom@18.3.0)(@types/react@18.3.1)(babel-plugin-macros@3.1.0)(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404)(rollup@4.17.2)(terser@5.31.0)(ts-node@10.9.2(@swc/core@1.5.5)(@types/node@20.12.11)(typescript@5.4.5))(typescript@5.4.5) eslint-plugin: {} @@ -657,6 +687,9 @@ importers: react-dom: specifier: 19.0.0-canary-fd0da3eef-20240404 version: 19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404) + react-fps: + specifier: ^1.0.6 + version: 1.0.6(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404) devDependencies: '@types/react': specifier: ^18.3.1 @@ -726,6 +759,9 @@ importers: '@deities/ares': specifier: workspace:* version: link:../ares + '@deities/art': + specifier: workspace:* + version: link:../art '@deities/athena': specifier: workspace:* version: link:../athena @@ -735,9 +771,21 @@ importers: '@deities/hephaestus': specifier: workspace:* version: link:../hephaestus + '@deities/hera': + specifier: workspace:* + version: link:../hera '@deities/hermes': specifier: workspace:* version: link:../hermes + '@deities/ui': + specifier: workspace:* + version: link:../ui + '@emotion/babel-plugin': + specifier: ^11.11.0 + version: 11.11.0 + '@emotion/css': + specifier: ^11.11.2 + version: 11.11.2 '@nkzw/immutable-map': specifier: ^1.2.2 version: 1.2.2 @@ -750,15 +798,33 @@ importers: '@types/object-inspect': specifier: ^1.13.0 version: 1.13.0 + '@types/react': + specifier: ^18.3.1 + version: 18.3.1 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 chalk: specifier: ^5.3.0 version: 5.3.0 + fbt: + specifier: ^1.0.2 + version: 1.0.2(patch_hash=nsmo6pdao5zymwjciznmexrgoy)(babel-plugin-fbt-runtime@1.0.0(babel-plugin-fbt@1.0.0))(babel-plugin-fbt@1.0.0)(react@19.0.0-canary-fd0da3eef-20240404) jest-image-snapshot: specifier: ^6.4.0 version: 6.4.0 playwright: specifier: ^1.44.0 version: 1.44.0 + react: + specifier: 19.0.0-canary-fd0da3eef-20240404 + version: 19.0.0-canary-fd0da3eef-20240404 + react-dom: + specifier: 19.0.0-canary-fd0da3eef-20240404 + version: 19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@19.0.0-canary-fd0da3eef-20240404) strip-ansi: specifier: ^7.1.0 version: 7.1.0 @@ -7568,8 +7634,8 @@ packages: jsdom: optional: true - vocs@1.0.0-alpha.49: - resolution: {integrity: sha512-scdiioB5jdbFfko20/H6QuZ5btVmmArLSswYnjQEP7Qlb/wQhiz9W8PiCzj2vjnXdX0Eyl8Q5YAJPab9ETBNKA==} + vocs@1.0.0-alpha.51: + resolution: {integrity: sha512-OrRmVMo7m3QGQ9KvhQLhKIj4/FKxsXwol04gdLVIHR2Fr5awMApeLxkFnxcy30bht3LYxe5dw/ZWMBpB1Atm9Q==} hasBin: true peerDependencies: react: ^18.2.0 @@ -10213,7 +10279,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@remix-run/router@1.16.1': {} + '@remix-run/router@1.16.1(patch_hash=f5klonf5edxua46cqfhk7ivlia)': {} '@rollup/plugin-babel@5.3.1(@babel/core@7.24.5)(@types/babel__core@7.20.5)(rollup@4.17.2)': dependencies: @@ -14986,14 +15052,14 @@ snapshots: react-router-dom@6.23.1(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404): dependencies: - '@remix-run/router': 1.16.1 + '@remix-run/router': 1.16.1(patch_hash=f5klonf5edxua46cqfhk7ivlia) react: 19.0.0-canary-fd0da3eef-20240404 react-dom: 19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404) react-router: 6.23.1(react@19.0.0-canary-fd0da3eef-20240404) react-router@6.23.1(react@19.0.0-canary-fd0da3eef-20240404): dependencies: - '@remix-run/router': 1.16.1 + '@remix-run/router': 1.16.1(patch_hash=f5klonf5edxua46cqfhk7ivlia) react: 19.0.0-canary-fd0da3eef-20240404 react-side-effect@2.1.2(react@19.0.0-canary-fd0da3eef-20240404): @@ -16228,7 +16294,7 @@ snapshots: - supports-color - terser - vocs@1.0.0-alpha.49(@types/node@20.12.11)(@types/react-dom@18.3.0)(@types/react@18.3.1)(babel-plugin-macros@3.1.0)(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404)(rollup@4.17.2)(terser@5.31.0)(ts-node@10.9.2(@swc/core@1.5.5)(@types/node@20.12.11)(typescript@5.4.5))(typescript@5.4.5): + vocs@1.0.0-alpha.51(@types/node@20.12.11)(@types/react-dom@18.3.0)(@types/react@18.3.1)(babel-plugin-macros@3.1.0)(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404)(rollup@4.17.2)(terser@5.31.0)(ts-node@10.9.2(@swc/core@1.5.5)(@types/node@20.12.11)(typescript@5.4.5))(typescript@5.4.5): dependencies: '@floating-ui/react': 0.26.13(react-dom@19.0.0-canary-fd0da3eef-20240404(react@19.0.0-canary-fd0da3eef-20240404))(react@19.0.0-canary-fd0da3eef-20240404) '@hono/node-server': 1.11.1 diff --git a/tests/display.html b/tests/display.html new file mode 100644 index 00000000..5956b5e8 --- /dev/null +++ b/tests/display.html @@ -0,0 +1,28 @@ + + + + + Athena Crisis + + + + +
+ + + + diff --git a/tests/display.tsx b/tests/display.tsx new file mode 100644 index 00000000..0f8d8ec0 --- /dev/null +++ b/tests/display.tsx @@ -0,0 +1,144 @@ +import decodeGameActionResponse from '@deities/apollo/lib/decodeGameActionResponse.tsx'; +import { + InstantAnimationConfig, + TileSize, +} from '@deities/athena/map/Configuration.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import NullBehavior from '@deities/hera/behavior/NullBehavior.tsx'; +import GameMap from '@deities/hera/GameMap.tsx'; +import '@deities/ui/CSS.tsx'; +import AudioPlayer from '@deities/ui/AudioPlayer.tsx'; +import { applyVar } from '@deities/ui/cssVar.tsx'; +import { css, cx, injectGlobal } from '@emotion/css'; +import React, { useEffect, useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ErrorBoundary } from 'react-error-boundary'; + +declare global { + // eslint-disable-next-line no-var + var MapHasRendered: Record; + // eslint-disable-next-line no-var + var renderMap: (url: string) => void; +} + +// Playwright does not like audio. +AudioPlayer.pause(); + +const root = createRoot(document.getElementById('app')!); +window.renderMap = (url: string) => { + window.MapHasRendered = Object.create(null); + root.render(); +}; + +const ErrorComponent = ({ error }: { error: Error }) => ( + <> + {Object.keys(window.MapHasRendered).map((key) => { + window.MapHasRendered[key] = true; + return ( +
+ {error.message} +
+ ); + })} + +); + +const DisplayMap = ({ url: initialURL }: { url: string }) => { + const url = new URL(initialURL); + const maps = url.searchParams.getAll('map[]'); + const viewers = url.searchParams.getAll('viewer[]'); + const gameActionResponses = url.searchParams.getAll('gameActionResponse[]'); + const eventEmitters = useMemo( + () => maps.map(() => new EventTarget()), + [maps], + ); + + // Initialize global state for listeners. + gameActionResponses?.forEach((_, index) => { + window.MapHasRendered[index] = false; + }); + + useEffect(() => { + if (gameActionResponses?.length) { + gameActionResponses.forEach((gameActionResponse, index) => { + eventEmitters[index].dispatchEvent( + new CustomEvent('action', { + detail: decodeGameActionResponse(JSON.parse(gameActionResponse)), + }), + ); + eventEmitters[index].addEventListener('actionsProcessed', () => + setTimeout(() => { + window.MapHasRendered[index] = true; + }, 300), + ); + }); + } + }, [eventEmitters, gameActionResponses, initialURL]); + + return ( + + {maps.map((mapData, index) => { + const map = MapData.fromJSON(mapData); + if (!map) { + return ( +
+ Could not render Map {index} +
+ ); + } + return ( +
+
+ +
+
+ ); + })} +
+ ); +}; + +injectGlobal(` + body { + line-height: 1px; + } +`); + +const redStyle = css` + color: ${applyVar('error-color')}; + font-weight: bold; +`; + +const inlineStyle = css` + display: inline-block; +`; + +const wrapperStyle = css` + padding-top: ${TileSize}px; +`; + +document.body.style.background = '#fff'; +document.body.style.margin = '0px'; +document.body.style.padding = '0px'; + +renderMap(window.location.href); diff --git a/tests/package.json b/tests/package.json index c1ab2db3..45607881 100644 --- a/tests/package.json +++ b/tests/package.json @@ -12,17 +12,28 @@ "devDependencies": { "@deities/apollo": "workspace:*", "@deities/ares": "workspace:*", + "@deities/art": "workspace:*", "@deities/athena": "workspace:*", "@deities/dionysus": "workspace:*", "@deities/hephaestus": "workspace:*", + "@deities/hera": "workspace:*", "@deities/hermes": "workspace:*", + "@deities/ui": "workspace:*", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/css": "^11.11.2", "@nkzw/immutable-map": "^1.2.2", "@playwright/browser-chromium": "^1.44.0", "@types/jest-image-snapshot": "^6.4.0", "@types/object-inspect": "^1.13.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", "chalk": "^5.3.0", + "fbt": "^1.0.2", "jest-image-snapshot": "^6.4.0", "playwright": "^1.44.0", + "react": "19.0.0-canary-fd0da3eef-20240404", + "react-dom": "19.0.0-canary-fd0da3eef-20240404", + "react-error-boundary": "^4.0.13", "strip-ansi": "^7.1.0", "term-img": "^6.0.0" } diff --git a/tests/vite.config.ts b/tests/vite.config.ts new file mode 100644 index 00000000..2800d136 --- /dev/null +++ b/tests/vite.config.ts @@ -0,0 +1,26 @@ +import babelPluginEmotion from '@emotion/babel-plugin'; +import react from '@vitejs/plugin-react'; +import babelFbtPlugins from '../infra/babelFbtPlugins.tsx'; +import resolver from '../infra/resolver.tsx'; + +const root = process.cwd(); + +export default { + define: { + 'process.env.IS_LANDING_PAGE': `0`, + }, + plugins: [ + react({ + babel: { + plugins: [...babelFbtPlugins, babelPluginEmotion], + }, + }), + ], + resolve: { + alias: [resolver], + }, + root, + server: { + host: true, + }, +}; diff --git a/tests/viteServer.tsx b/tests/viteServer.tsx index fbd85da0..35f05e97 100644 --- a/tests/viteServer.tsx +++ b/tests/viteServer.tsx @@ -1,4 +1,3 @@ -import { resolve } from 'node:path'; import startServer from '../infra/startServer.tsx'; const APP_PORT = 3001; @@ -8,7 +7,7 @@ export async function setup() { const server = await startServer({ name: 'Tests', port: APP_PORT, - root: resolve(import.meta.dirname, '../deimos'), + root: import.meta.dirname, silent: true, }); diff --git a/ui/Browser.tsx b/ui/Browser.tsx index 3fb3d59c..a2256d4f 100644 --- a/ui/Browser.tsx +++ b/ui/Browser.tsx @@ -3,19 +3,28 @@ declare global { var safari: Record string }>; } -export const isIPhone = /iphone/i.test(navigator.userAgent); +const maybeWindow = + typeof window === 'undefined' + ? { + HTMLElement: false, + navigator: { userAgent: '' }, + safari: null, + } + : window; + +export const isIPhone = /iphone/i.test(maybeWindow.navigator.userAgent); export const isIOS = - !!window.navigator.userAgent.match(/i(?:pad|phone)/i) || + !!maybeWindow.navigator.userAgent.match(/i(?:pad|phone)/i) || (/(macintosh|macintel|macppc|mac68k|macos)/i.test(navigator.userAgent) && navigator.maxTouchPoints > 0); export const isSafari = - /constructor/i.test(window.HTMLElement as unknown as string) || + /constructor/i.test(maybeWindow.HTMLElement as unknown as string) || ((pushNotification) => pushNotification.toString() === '[object SafariRemoteNotification]')( - !window['safari'] || - (typeof safari !== 'undefined' && window['safari'].pushNotification), + !maybeWindow['safari'] || + (typeof safari !== 'undefined' && maybeWindow['safari'].pushNotification), ) || isIOS; diff --git a/ui/CSS.tsx b/ui/CSS.tsx index 84fcb064..42354851 100644 --- a/ui/CSS.tsx +++ b/ui/CSS.tsx @@ -1,146 +1,23 @@ -import { TileSize } from '@deities/athena/map/Configuration.tsx'; import { injectGlobal } from '@emotion/css'; import Background from './assets/Background.png'; import Breakpoints from './Breakpoints.tsx'; -import { isFirefox, isSafari } from './Browser.tsx'; -import cssVar, { applyVar } from './cssVar.tsx'; +import { applyVar, initializeCSSVariables } from './cssVar.tsx'; import getColor from './getColor.tsx'; -import { getScale } from './hooks/useScale.tsx'; import pixelBorder from './pixelBorder.tsx'; -const transform = cssVar( - 'perspective-transform', - isFirefox - ? '' - : (isSafari ? `translateZ(-25px) ` : '') + - `perspective(calc(125px - ${applyVar('perspective-height')} / 20 * 10px)) rotateX(calc(2deg - ${applyVar('perspective-height')} / 20 * 1deg))`, -); +initializeCSSVariables(); injectGlobal(` * { box-sizing: border-box; } -:root { - ${cssVar('background-color-active', '#a7c2f5')} - ${cssVar('background-color-bright', '#ffffff')} - ${cssVar('background-color-dark', 'rgba(40, 40, 40, 1)')} - ${cssVar('background-color-light', 'rgba(255, 255, 255, 0.8)')} - ${cssVar('background-color-light9', 'rgba(255, 255, 255, 0.9)')} - ${cssVar('background-color', '#f2f2f2')} - ${cssVar('border-color-light', 'rgba(0, 0, 0, 0.2)')} - ${cssVar('border-color', 'rgba(0, 0, 0, 0.7)')} - - ${cssVar('highlight-color', '#3999d4')} - ${cssVar('text-color-active-light', '#fff')} - ${cssVar('text-color-bright', '#fff')} - ${cssVar('text-color-active', '#111')} - ${cssVar('text-color-inactive', '#838383')} - ${cssVar('text-color-light', '#a2a2a2')} - ${cssVar('text-color', '#111')} - - // Player Colors - ${cssVar('color-black', '10, 10, 10')} - ${cssVar('color-blue', '60, 157, 255')} - ${cssVar('color-cyan', '33, 195, 155')} - ${cssVar('color-gray', '132, 132, 132')} - ${cssVar('color-green', '94, 163, 24')} - ${cssVar('color-neutral', '179, 160, 124')} - ${cssVar('color-orange', '255, 158, 60')} - ${cssVar('color-pink', '195, 33, 127')} - ${cssVar('color-purple', '157, 60, 255')} - ${cssVar('color-red', '195, 46, 33')} - - ${cssVar('color-red-orange', 'rgb(225, 102, 46.5)')} - ${cssVar('color-orange-green', 'rgb(134.5, 160.5, 42)')} - - // Common Variables - ${cssVar('error-color', '#c4362e')} - ${cssVar('inset-z', 0)} - ${cssVar('inset', '0px')} - ${cssVar('mouse-position-left', '0px')} - ${cssVar('mouse-position-right', 'auto')} - ${cssVar('scale', getScale(TileSize))} - ${cssVar('transform-origin', 'center center')} - ${cssVar('ui-scale', 1)} - ${cssVar('perspective-height', 0)} - ${transform} -} - @media (prefers-color-scheme: dark) { - :root { - ${cssVar('background-color-active', '#596884')} - ${cssVar('background-color-bright', '#121212')} - ${cssVar('background-color-dark', '#d7d7d7')} - ${cssVar('background-color-light', 'rgba(50, 50, 50, 0.8)')} - ${cssVar('background-color-light9', 'rgba(50, 50, 50, 0.9)')} - ${cssVar('background-color', '#28282b')} - - ${cssVar('border-color-light', 'rgba(255, 255, 255, 0.2)')} - ${cssVar('border-color', 'rgba(255, 255, 255, 0.7)')} - - ${cssVar('highlight-color', '#5e9fff')} - ${cssVar('text-color-active-light', '#555')} - ${cssVar('text-color-bright', '#fff')} - ${cssVar('text-color-active', '#f5f5f5')} - ${cssVar('text-color-inactive', '#999')} - ${cssVar('text-color-light', '#a7a7a7')} - ${cssVar('text-color', '#f5f5f5')} - - // Player Colors - ${cssVar('color-black', '10, 10, 10')} - ${cssVar('color-blue', '60, 157, 235')} - ${cssVar('color-cyan', '45, 197, 146')} - ${cssVar('color-gray', '132, 132, 132')} - ${cssVar('color-green', '142, 169, 53')} - ${cssVar('color-neutral', '204, 193, 155')} - ${cssVar('color-orange', '216, 154, 78')} - ${cssVar('color-pink', '255, 60, 200')} - ${cssVar('color-purple', '157, 80, 255')} - ${cssVar('color-red', '255, 76, 60')} - ${cssVar('color-red-orange', 'rgb(230, 125, 76)')} - ${cssVar('color-orange-green', 'rgb(153, 182, 47)')} - } - div.background { filter: invert(1); } } -html.dark { - ${cssVar('background-color-active', '#596884')} - ${cssVar('background-color-bright', '#121212')} - ${cssVar('background-color-dark', '#d7d7d7')} - ${cssVar('background-color-light', 'rgba(50, 50, 50, 0.8)')} - ${cssVar('background-color-light9', 'rgba(50, 50, 50, 0.9)')} - ${cssVar('background-color', '#28282b')} - - ${cssVar('border-color-light', 'rgba(255, 255, 255, 0.2)')} - ${cssVar('border-color', 'rgba(255, 255, 255, 0.7)')} - - ${cssVar('highlight-color', '#5e9fff')} - ${cssVar('text-color-active-light', '#555')} - ${cssVar('text-color-bright', '#fff')} - ${cssVar('text-color-active', '#f5f5f5')} - ${cssVar('text-color-inactive', '#999')} - ${cssVar('text-color-light', '#a7a7a7')} - ${cssVar('text-color', '#f5f5f5')} - - // Player Colors - ${cssVar('color-black', '10, 10, 10')} - ${cssVar('color-blue', '60, 157, 235')} - ${cssVar('color-cyan', '45, 197, 146')} - ${cssVar('color-gray', '132, 132, 132')} - ${cssVar('color-green', '142, 169, 53')} - ${cssVar('color-neutral', '204, 193, 155')} - ${cssVar('color-orange', '216, 154, 78')} - ${cssVar('color-pink', '255, 60, 200')} - ${cssVar('color-purple', '157, 80, 255')} - ${cssVar('color-red', '255, 76, 60')} - ${cssVar('color-red-orange', 'rgb(230, 125, 76)')} - ${cssVar('color-orange-green', 'rgb(153, 182, 47)')} -} - html.dark div.background { filter: invert(1); } @@ -440,8 +317,4 @@ p { margin: 0; user-select: text; } - -.sentry-error-embed-wrapper { - z-index: 20000 !important; -} `); diff --git a/ui/Dialog.tsx b/ui/Dialog.tsx index 3c29b390..900631e6 100644 --- a/ui/Dialog.tsx +++ b/ui/Dialog.tsx @@ -162,14 +162,14 @@ const backgroundStyle = css` background-color: ${applyVar('background-color-light')}; inset: 0; position: fixed; - z-index: 2; + z-index: 29; `; const wrapperStyle = css` inset: 0; pointer-events: none; position: fixed; - z-index: 3; + z-index: 30; `; const containerStyle = css` @@ -187,7 +187,7 @@ const containerStyle = css` transform: ${isSafari ? '' : `scale(${applyVar('ui-scale')})`}; transition: transform 300ms ease; width: 93vw; - z-index: calc(${applyVar('inset-z')} + 3); + z-index: calc(${applyVar('inset-z')} + 30); `; const largeStyle = css` diff --git a/ui/cssVar.tsx b/ui/cssVar.tsx index 766740a6..48c02719 100644 --- a/ui/cssVar.tsx +++ b/ui/cssVar.tsx @@ -1,3 +1,8 @@ +import { TileSize } from '@deities/athena/map/Configuration.tsx'; +import { injectGlobal } from '@emotion/css'; +import { isFirefox, isSafari } from './Browser.tsx'; +import { getScale } from './hooks/useScale.tsx'; + type GlobalCSSVariableName = // Map | 'animation-duration-30' @@ -77,8 +82,9 @@ export class CSSVariables { } const variables = new CSSVariables('a'); +const cssVar = variables.set.bind(variables); -export default variables.set.bind(variables); +export default cssVar; export const applyVar = variables.apply.bind(variables); export function insetStyle(inset: number | string) { @@ -89,3 +95,111 @@ export function insetStyle(inset: number | string) { [variables.set('inset-z')]: inset ? 1 : 0, }; } + +const transform = cssVar( + 'perspective-transform', + isFirefox + ? '' + : (isSafari ? `translateZ(-25px) ` : '') + + `perspective(calc(125px - ${applyVar('perspective-height')} / 20 * 10px)) rotateX(calc(2deg - ${applyVar('perspective-height')} / 20 * 1deg))`, +); + +const darkMode = ` +${cssVar('background-color-active', '#596884')} +${cssVar('background-color-bright', '#121212')} +${cssVar('background-color-dark', '#d7d7d7')} +${cssVar('background-color-light', 'rgba(50, 50, 50, 0.8)')} +${cssVar('background-color-light9', 'rgba(50, 50, 50, 0.9)')} +${cssVar('background-color', '#28282b')} + +${cssVar('border-color-light', 'rgba(255, 255, 255, 0.2)')} +${cssVar('border-color', 'rgba(255, 255, 255, 0.7)')} + +${cssVar('highlight-color', '#5e9fff')} +${cssVar('text-color-active-light', '#555')} +${cssVar('text-color-bright', '#fff')} +${cssVar('text-color-active', '#f5f5f5')} +${cssVar('text-color-inactive', '#999')} +${cssVar('text-color-light', '#a7a7a7')} +${cssVar('text-color', '#f5f5f5')} + +// Player Colors +${cssVar('color-black', '10, 10, 10')} +${cssVar('color-blue', '60, 157, 235')} +${cssVar('color-cyan', '45, 197, 146')} +${cssVar('color-gray', '132, 132, 132')} +${cssVar('color-green', '142, 169, 53')} +${cssVar('color-neutral', '204, 193, 155')} +${cssVar('color-orange', '216, 154, 78')} +${cssVar('color-pink', '255, 60, 200')} +${cssVar('color-purple', '157, 80, 255')} +${cssVar('color-red', '255, 76, 60')} +${cssVar('color-red-orange', 'rgb(230, 125, 76)')} +${cssVar('color-orange-green', 'rgb(153, 182, 47)')} +`; + +let initialized = false; +export function initializeCSSVariables() { + if (initialized) { + return; + } + + initialized = true; + injectGlobal(` +:root { + ${cssVar('background-color-active', '#a7c2f5')} + ${cssVar('background-color-bright', '#ffffff')} + ${cssVar('background-color-dark', 'rgba(40, 40, 40, 1)')} + ${cssVar('background-color-light', 'rgba(255, 255, 255, 0.8)')} + ${cssVar('background-color-light9', 'rgba(255, 255, 255, 0.9)')} + ${cssVar('background-color', '#f2f2f2')} + ${cssVar('border-color-light', 'rgba(0, 0, 0, 0.2)')} + ${cssVar('border-color', 'rgba(0, 0, 0, 0.7)')} + + ${cssVar('highlight-color', '#3999d4')} + ${cssVar('text-color-active-light', '#fff')} + ${cssVar('text-color-bright', '#fff')} + ${cssVar('text-color-active', '#111')} + ${cssVar('text-color-inactive', '#838383')} + ${cssVar('text-color-light', '#a2a2a2')} + ${cssVar('text-color', '#111')} + + // Player Colors + ${cssVar('color-black', '10, 10, 10')} + ${cssVar('color-blue', '60, 157, 255')} + ${cssVar('color-cyan', '33, 195, 155')} + ${cssVar('color-gray', '132, 132, 132')} + ${cssVar('color-green', '94, 163, 24')} + ${cssVar('color-neutral', '179, 160, 124')} + ${cssVar('color-orange', '255, 158, 60')} + ${cssVar('color-pink', '195, 33, 127')} + ${cssVar('color-purple', '157, 60, 255')} + ${cssVar('color-red', '195, 46, 33')} + + ${cssVar('color-red-orange', 'rgb(225, 102, 46.5)')} + ${cssVar('color-orange-green', 'rgb(134.5, 160.5, 42)')} + + // Common Variables + ${cssVar('error-color', '#c4362e')} + ${cssVar('inset-z', 0)} + ${cssVar('inset', '0px')} + ${cssVar('mouse-position-left', '0px')} + ${cssVar('mouse-position-right', 'auto')} + ${cssVar('scale', getScale(TileSize))} + ${cssVar('transform-origin', 'center center')} + ${cssVar('ui-scale', 1)} + ${cssVar('perspective-height', 0)} + ${transform} +} + +@media (prefers-color-scheme: dark) { + :root { + ${darkMode} + } +} + +html.dark { + ${darkMode} +} +`); +} diff --git a/ui/hooks/useScale.tsx b/ui/hooks/useScale.tsx index 4ae09573..6ff87daf 100644 --- a/ui/hooks/useScale.tsx +++ b/ui/hooks/useScale.tsx @@ -9,11 +9,15 @@ import { import { isIOS } from '../Browser.tsx'; import cssVar, { applyVar } from '../cssVar.tsx'; -const div = document.createElement('div'); +let div: HTMLDivElement | null = null; const CACHE = new Map(); export const MAX_SCALE = 3; export const getScale = (tileSize: number) => { + if (!div) { + div = document.createElement('div'); + } + const size = isIOS ? Math.max(window.screen.width, window.screen.height) : Math.max(window.innerWidth, window.innerHeight);