diff --git a/README.md b/README.md index d6c44f2f..7fe6e208 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [Athena Crisis](https://athenacrisis.com) is an Open Core video game developed by [Nakazawa Tech](https://nkzw.tech) and published by [Null](https://null.com). The source code in this repository is licensed under the [MIT License](./LICENSE.md) and can be used to improve Athena Crisis, build additional tools, study game development with JavaScript or create entirely new turn-based strategy games. -The single-player campaign, multiplayer, art, music, and content are not open source. You can try a demo at [athenacrisis.com](https://athenacrisis.com) and you can [wishlist or purchase Athena Crisis on Steam Early Access](https://store.steampowered.com/app/2456430/Athena_Crisis/) or [buy Athena Crisis directly](https://app.athenacrisis.com/checkout) to play the full game. +The single-player campaign, multiplayer, art, music, and content are not open source. You can try a demo at [athenacrisis.com](https://athenacrisis.com) and you can [wishlist or purchase Athena Crisis on Steam Early Access](https://store.steampowered.com/app/2456430/Athena_Crisis/) or [buy Athena Crisis directly](https://app.athenacrisis.com/checkout) to experience the full game. If you like Athena Crisis, [please consider a sponsorship to support its development](https://github.com/sponsors/cpojer). @@ -38,7 +38,11 @@ pnpm dev Visit [localhost:3003](http://localhost:3003/) to see the docs page. -## Packages +## Documentation & Playground + +Check out our [Athena Crisis Open Source Docs & Playground](https://athenacrisis.com/open-source) site. + +### Packages The codebase is split into multiple packages to enforce the separation of concerns. We suggest starting with these packages to get an end-to-end overview: @@ -59,17 +63,33 @@ These are secondary packages focused on specific domains: - `offline` → Offline splash screen for app (_client_). - `tests` → e2e tests. -## Documentation & Playground +# Contributing -Check out our [Athena Crisis Open Source Docs & Playground](https://athenacrisis.com/open-source) site. +We welcome contributions to Athena Crisis. Some feature development is funded via [Polar](https://polar.sh): [`nkzw-tech/athena-crisis` on Polar](https://polar.sh/nkzw-tech/athena-crisis). Here are some guidelines to get you started: + +- The style guide is enforced through tests and linting. Please run `pnpm test` to run all checks. If they pass, you are good to send a Pull Request. +- We suggest adding tests to Pull Requests. You can find many examples in the [`tests` folder](https://github.com/nkzw-tech/athena-crisis/tree/main/tests). +- Check out [The Perfect Development Environment](https://cpojer.net/posts/the-perfect-development-environment) and [Fastest Frontend Tooling](https://cpojer.net/posts/fastest-frontend-tooling-in-2022) for tips on how to optimize your environment setup. +- [Join the #tech channel on Discord](https://discord.gg/2VBCCep7Fk) if you run into issues. + +We greatly appreciate contributions in the following areas: + +- Bug fixes. +- AI improvements. +- New game features. +- Balancing improvements. +- Experimental technical explorations. +- Tests to cover untested functionality. +- Performance Improvements to core data structures. +- Separation of concerns into smaller libraries that can be published on npm and consumed by other projects. # Q&A ## 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, 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 experience [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? +## Why is Athena Crisis open source? [Nakazawa Tech](https://nkzw.tech) is an Open Core company. See [the "Athena Crisis is now Open Source" blog post](https://cpojer.net/posts/athena-crisis-open-source) for more information. @@ -85,7 +105,7 @@ To simplify dependency management with [`pnpm`](https://pnpm.io/), most of the i Why not!? At some point it became necessary to split the codebase into multiple packages to share code between the client and server. The first package was named `athena`, and it was hard to come up with meaningful names for the other packages. We decided to name them after Greek gods because it seemed cute. -Over time, many pieces are expected to be extracted into separate packages and published on npm under the `@nkzw` organization. Please send a Pull Request if you find code that you think should be extracted into a separate package. +Over time, many pieces will be extracted into separate packages and published on npm under the `@nkzw` organization. Please move it to a `packages` folder and send a Pull Request if you find code that should be extracted into a separate package. ## How do assets work in this codebase? @@ -97,25 +117,6 @@ Yes, you can. However, any content such as art, music, story, characters and the If you'd like to use content from Athena Crisis for commercial or non-commercial purposes, you must obtain a license from Nakazawa Tech KK by emailing license@nakazwa.dev. -# Contributing - -We welcome contributions to Athena Crisis. Some feature development is funded via [Polar](https://polar.sh): [`nkzw-tech/athena-crisis` on Polar](https://polar.sh/nkzw-tech/athena-crisis). Here are some guidelines to get you started: - -- The style guide is enforced through tests and linting. Please run `pnpm test` to run all checks. If they pass, you are good to send a Pull Request. -- Check out [The Perfect Development Environment](https://cpojer.net/posts/the-perfect-development-environment) and [Fastest Frontend Tooling](https://cpojer.net/posts/fastest-frontend-tooling-in-2022) for tips on how to optimize your environment setup. -- We suggest adding tests to Pull Requests. You can find many examples in the [`tests` folder](https://github.com/nkzw-tech/athena-crisis/tree/main/tests). - -We greatly appreciate contributions in the following areas: - -- Bug fixes. -- AI improvements. -- New game features. -- Balancing improvements. -- Experimental technical explorations. -- Tests to cover untested functionality. -- Performance Improvements to core data structures. -- Separation of concerns into smaller libraries that can be published on npm and consumed by other projects. - # More information Check out these links to learn more about the tech behind Athena Crisis: diff --git a/dionysus/BaseAI.tsx b/dionysus/BaseAI.tsx index d2ec98e5..9104d8a1 100644 --- a/dionysus/BaseAI.tsx +++ b/dionysus/BaseAI.tsx @@ -72,7 +72,7 @@ export default abstract class BaseAI { protected executeMove( map: MapData, action: MoveAction, - ): [MapData | null, boolean] { + ): [map: MapData | null, blocked: boolean] { const currentMap = this.execute(map, action); const state = this.gameState.at(-1); diff --git a/docs/content/examples/entities-example.tsx b/docs/content/examples/entities-example.tsx new file mode 100644 index 00000000..3d6513e6 --- /dev/null +++ b/docs/content/examples/entities-example.tsx @@ -0,0 +1,5 @@ +import UnitPreviews from '@deities/hera/ui/demo/UnitPreviews.tsx'; + +export default function EntitiesExample() { + return ; +} diff --git a/docs/content/examples/map-data-examples.tsx b/docs/content/examples/map-data-examples.tsx index ed691ca2..e5fb11d9 100644 --- a/docs/content/examples/map-data-examples.tsx +++ b/docs/content/examples/map-data-examples.tsx @@ -3,7 +3,7 @@ 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 InlineLink from '@deities/ui/InlineLink.tsx'; import { Fragment, useState } from 'react'; import PlaygroundGame from '../playground/PlaygroundGame.tsx'; @@ -41,7 +41,7 @@ export default function ExampleMap() { return ( - + rerender(render + 1)}>Reset State ); } diff --git a/docs/content/examples/map-editor.tsx b/docs/content/examples/map-editor.tsx index f1bbea4c..7d2d8aaf 100644 --- a/docs/content/examples/map-editor.tsx +++ b/docs/content/examples/map-editor.tsx @@ -16,15 +16,17 @@ const viewer = { export default function MapEditorExample() { return ( - {}} - fogStyle="soft" - setHasChanges={() => {}} - tiltStyle="on" - updateMap={() => {}} - user={viewer} - /> +
+ {}} + fogStyle="soft" + setHasChanges={() => {}} + tiltStyle="on" + updateMap={() => {}} + user={viewer} + /> +
); } diff --git a/docs/content/examples/portraits-example.tsx b/docs/content/examples/portraits-example.tsx new file mode 100644 index 00000000..2b464b55 --- /dev/null +++ b/docs/content/examples/portraits-example.tsx @@ -0,0 +1,26 @@ +import { preparePortraits } from '@deities/art/Sprites.tsx'; +import { + BazookaBear, + Flamethrower, + Jetpack, + Sniper, +} from '@deities/athena/info/Unit.tsx'; +import Portrait from '@deities/hera/character/Portrait.tsx'; +import Stack from '@deities/ui/Stack.tsx'; + +preparePortraits(); + +// [!region portraits] +const portraits = ( + + + + + + +); +// [!endregion portraits] + +export default function PortraitsExample() { + return portraits; +} diff --git a/docs/content/pages/core-concepts/actions.mdx b/docs/content/pages/core-concepts/actions.mdx index 1060a658..1f019b93 100644 --- a/docs/content/pages/core-concepts/actions.mdx +++ b/docs/content/pages/core-concepts/actions.mdx @@ -1 +1,167 @@ +import Image from '../../playground/Image.tsx'; + # Actions + +In the [Map Data](/core-concepts/map-data) section we learned about the core map data structure of Athena Crisis and how to query and update game state. In this section we'll discuss the formalized approach to update game state via _Actions_. + +Actions are the primary way to update game state. While game state might be mutated before a game starts or after it ends via code, players can only interact with the game via actions. Actions can be a move, attack, create unit or other game events. You can find the full list of actions that can be executed by users or scripts in [`Action.tsx`](https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/Action.tsx). Each Action produces an ActionResponse, which can be found in [`ActionResponse.tsx`](https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/ActionResponse.tsx). ActionResponses will then be applied to the game state to update it, and animated if they are shown to a player. + +**Here is a diagram of the action flow:** + +Actions + +## Defining a new Action + +Adding new actions is straightforward and TypeScript guides you through the process. First, add a new Action type to `Action.tsx`: + +```tsx +type SleepAction = Readonly<{ + type: 'Sleep'; + from: Vector; +}>; +``` + +You'll also need to add it to the `Action` type in the same file: + +```tsx +export type Action = + | ActivatePowerAction + | …… + | SleepAction; +``` + +If you are adding a new type of `ActionResponse`, you'll need to do the same in `ActionResponse.tsx`: + +```tsx +export type SleepActionResponse = Readonly<{ + type: 'Sleep'; + from: Vector; +}>; +``` + +You'll also need to add it to the `Action` type in the same file: + +```tsx +export type ActionResponse = + | ActivatePowerActionResponse + | …… + | SleepActionResponse; +``` + +After this, run `pnpm codegen` to generate all the encoded actions (for storage or network transmission), and the corresponding formatters for use in snapshot tests. + +## Implementing an Action + +After a new Action is defined, you can run TypeScript via `pnpm tsc` and it will guide you through each call site where the new action needs to be handled. This is a great way to get an overview of the whole system. Let's build a new "Sleep" action that puts a unit to sleep. In our case, it won't have any functionality, but could be made visible to the player with an animation. First, add the new Action in [`Action.tsx`](https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/Action.tsx): + +```tsx +function sleep(map: MapData, { from }: SleepAction) { + const unit = map.units.get(from); + return unit && map.isCurrentPlayer(unit) && !unit.isSleeping() + ? ({ from, type: 'Sleep' } as const) + : null; +} +``` + +Actions only return an `ActionResponse` if the action is valid. They do not mutate the game state, which happens via [`applyActionResponse`](https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/actions/applyActionResponse.tsx) by processing the `ActionResponse` and returning a new `MapData` object: + +```tsx +switch (type) { + … + case 'Sleep': { + const { from } = actionResponse; + const unit = map.units.get(from); + return unit + ? map.copy({ units: map.units.set(from, unit.sleep().complete()) }) + : map; + } +} +``` + +Next, TypeScript will tell us that we need to handle the visibility of the new ActionResponse in fog. Fog in Athena Crisis works by removing all information from each player that is not visible to them. When an Action is executed, it calls [`computeVisibleActions`](https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/lib/computeVisibleActions.tsx) on the ActionResponse once for each player. The Action we created is fairly minimal, so we only need to handle one case: Show the action if the source field (`from`) is visible to the player, or drop it if it isn't: + +```tsx +const VisibleActionModifiers = { + … + Sleep: { Source: true }, +} +``` + +There are more complex cases where it is harder to know if an action should be visible or hidden from that player, such as when a unit is moving or attacking, and the action affects more than just one field. `computeVisibleActions` can handle each case individually, and it can either return the same ActionResponse, drop it, return a modified version or even return multiple new ActionResponses. For example, a unit can be created from one Building but deployed on another field. The process looks like this: + +```tsx +CreateUnit: { + Both: true, + Source: true, + Target: ( + { from, to, unit }: CreateUnitActionResponse, + _: MapData, + activeMap: MapData, + ): HiddenMoveActionResponse => ({ + path: [from, to], + type: 'HiddenMove', + unit, + }), +} +``` + +If both fields or just the source are visible, the action is shown to the player unmodified. However, if only the target field is visible, the response is replaced with a `HiddenMove` ActionResponse. The player who is viewing the game won't be able to tell if the unit was just created or moved from another field in fog. + +For convenience, we'll also add an [`ActionMutator`](https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/action-mutators/ActionMutators.tsx). These are simple functions to avoid repetition when executing actions against game state, like is often the case in tests. + +``` +export const SleepAction = (from: Vector) => + ({ + from, + type: 'Sleep', + }) as const; +``` + +Now, if you are writing a test to simulate some game actions, you can use the mutator like this: + +```tsx +const response = executeGameActions(map, [ + MoveAction(from, to), + SleepAction(to), + EndTurnAction(), +]); +``` + +TypeScript may point you to a few more utility functions that need handling for your action, but once you are done we can move on to the UI layer. + +## Actions in the UI + +We are now ready to make our first change to the Athena Crisis game client. Most of the client code can be found in [`hera`](https://github.com/nkzw-tech/athena-crisis/tree/main/hera). + +For our new action, we need to first implement the handler for what happens when another player or the AI execute this action. This code lives in [`processActionResponse`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/action-response/processActionResponse.tsx) which is a wrapper around `applyActionResponse` to animate game state and apply the ActionResponse at the right time. The client side Sleep Action could look something like this: + +```tsx +export function sleepAction( + { optimisticAction, update }: Actions, + state: State, +): Promise { + const { map, selectedPosition, vision } = state; + if (selectedPosition) { + return update({ + map: applyActionResponse( + map, + vision, + optimisticAction(state, SleepUnitAction(selectedPosition)), + ), + position: selectedPosition, + ...resetBehavior(), + }); + } + return null; +} +``` + +Finally, we need to allow the player to execute the Sleep Action for a unit in the game. We could consider adding a button to the [`Menu`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/behavior/Menu.tsx) behavior that works similarly to other buttons and executes the action against the game state when clicked. We can reuse the same `sleepAction` that we defined above for the user initiated action as well. + +After following the above steps, Athena Crisis should now have a new "Sleep" feature for units! + +## Optimistic Updates + +In the above example we called `optimisticAction(state, SleepUnitAction(from))` to update the game state on the client optimistically. Due to the elegance of immutable data structures and the Athena Crisis architecture, we can apply an Action on the client while sending the same action to the server at the same time, executing it, and sending the (visible) ActionResponse to each other player. + +The architecture of the game ensures that the server always has the final say on the game state, and the client will be updated with the server's response. This is a powerful feature that allows the game to feel responsive and smooth, even on slow connections, and it also allows hiding secrets like hidden win conditions from players. diff --git a/docs/content/pages/core-concepts/ai.mdx b/docs/content/pages/core-concepts/ai.mdx new file mode 100644 index 00000000..d872661c --- /dev/null +++ b/docs/content/pages/core-concepts/ai.mdx @@ -0,0 +1,83 @@ +# AI Deep Dive + +The first Athena Crisis AI, [`DionysusAlpha`](https://github.com/nkzw-tech/athena-crisis/blob/main/dionysus/DionysusAlpha.tsx), was written from scratch to understand how to build a heuristics based AI for a turn-based strategy game. Instead of building a new AI from scratch, it was extended to handle almost all of the mechanics in Athena Crisis. The AI is roughly as good as an intermediate player at most types of maps, but map design has a significant impact on its performance. + +## Architecture + +- The [`BaseAI`](https://github.com/nkzw-tech/athena-crisis/blob/main/dionysus/BaseAI.tsx) class abstracts away interactions with game state such as executing actions and handling game over states. +- [`DionysusAlpha`](https://github.com/nkzw-tech/athena-crisis/blob/main/dionysus/DionysusAlpha.tsx) is stateless and does not plan ahead. After each action that it executes, it looks at the game state as if it's a completly different game, and makes decisions based on that. This is not fast or memory efficient, but makes the implementation less error-prone and easier to understand since any mutation to the game state can completely change the AI's possible options. For example, defeating a unit on a bridge may unblock a path that allows other units to act. +- [`skmeans`](https://github.com/solzimer/skmeans) is used for clustering locations on a map to identify targets. + +The AI executes actions in a loop until it exhausts all possible actions for all units. After each action, it starts from scratch. For example, after the AI finishes capturing a building, it will try to activate a power, finish capturing another building, […], attack, move or end its turn. Once the action generator returns `null`, the AI will yield to the next player or AI. Here is what that looks like in the code: + +```tsx +class DionysusAlpha extends BaseAI { + protected action(map: MapData): MapData | null { + return ( + this.activatePower(map) || + this.finishCapture(map) || + this.finishRescue(map) || + this.toggleLightning(map) || + this.rescue(map) || + this.attack(map) || + this.capture(map) || + this.fold(map) || + this.createBuilding(map) || + this.move(map) || + this.unfold(map) || + this.buySkills(map) || + this.createUnit(map) || + this.endTurn(map) + ); + } + … +} +``` + +:::info[Note] +There are some optimizations when it comes to attacking other units where the AI will actually execute all attack actions before moving on to other actions. It will also mark that it can no longer execute any attacks to avoid some computations. This is purely for performance reasons. +::: + +## Possible improvements + +There are many ways the AI's ability, performance and memory consumption could be improved: + +### Making the AI Smarter + +- Almost every heuristic and assumption the AI makes could be improved upon. +- Instead of taking one move at a time, the AI could plan its entire turn in advance and then execute on it. +- The AI could simulate various actions to determine the best outcome and only return the most promising list of actions. This works because `MapData` is immutable and the AI can simulate actions without changing the game state. + +### Making the AI faster and more memory efficient + +- Intelligent caching could be implemented to avoid recalculating the same information multiple times. +- [`getAvailableUnitActions`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/lib/getAvailableUnitActions.tsx) could likely be used to more intelligently determine the available actions for each unit. +- Various algorithms could likely be improved to reduce memory usage, especially around wasteful allocations that get discarded immediately. + +## Creating a new AI + +- Pick a good name, create a new file in the [`dionysus`](https://github.com/nkzw-tech/athena-crisis/blob/main/dionysus/DionysusAlpha.tsx) top-level folder. +- Define a new class that extends `BaseAI`. +- Add the class to the [`AIRegistry`](https://github.com/nkzw-tech/athena-crisis/blob/main/dionysus/AIRegistry.tsx), pick any unused number as an id for your AI. +- Implement the `action` function that returns either a `MapData` instance or `null`. +- Call `this.execute(…)` (or `this.executeMove()` for executing a move, considering fog) to record actions. + +We suggest starting with basic behaviors like ending the turn, attacking and capturing. Here are some of the behaviors you need to consider when implementing an AI: + +- The AI needs to understand all types of maps, terrain, and units. +- The optimal set of units need to be created at each turn. +- An AI should know when it's time to push and when it's time to defend. +- Units that are out of ammo or almost out of supplies need to be resupplied. They may need to retreat, or a decision can be made to block the opponent's path. +- The AI needs to make reasonable decisions when deciding which buildings to create (House, Repair Shop, Factory, etc.). +- Naval transport units need to understand which islands to travel to and how to transport units from one island to another. +- The AI must never crash or produce illegal game states. + +Check out [`AIBehavior.test.tsx`](https://github.com/nkzw-tech/athena-crisis/blob/main/tests/__tests__/AIBehavior.test.tsx) to get an idea for the type of behaviors an AI needs to handle. + +:::info[Note] +AI specific helpers can be found in [`dionysus/lib`](https://github.com/nkzw-tech/athena-crisis/tree/main/dionysus/lib). If you are building an AI from scratch, feel free to add more helpers, extract existing functionality from `DionysusAlpha` and submit them as a Pull Request separately. +::: + +### How to test your AI + +The [Map Editor](/playground/map-editor) can be used to set up scenarios and try different AI behaviors. You can choose your AI for one of the players in a dropdown in the "Setup" (shortcut: `t`) panel. Then set up a scenario in the editor, jump into a playtest and end your turn to see the AI behavior. diff --git a/docs/content/pages/core-concepts/map-data.mdx b/docs/content/pages/core-concepts/map-data.mdx index 29e157ec..3004a1ee 100644 --- a/docs/content/pages/core-concepts/map-data.mdx +++ b/docs/content/pages/core-concepts/map-data.mdx @@ -21,7 +21,7 @@ class MapData { } ``` -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. +Each map in the game is a grid defined by `size`. `map` is an Array of [`TileInfo`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/info/Tile.tsx) 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). @@ -119,6 +119,18 @@ 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). +## Buildings & Units + +We already learned about [`TileInfo`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/info/Tile.tsx) above. There are corresponding definitions for [`BuildingInfo`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/info/Building.tsx) and [`UnitInfo`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/info/Unit.tsx). Instances of these classes (also in the same files) contain all the necessary configuration and sprite information for buildings and units such as health, cost, or the assets to render. + +Maps contain instances of [`Building`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/map/Building.tsx) and [`Unit`](https://github.com/nkzw-tech/athena-crisis/blob/main/athena/map/Unit.tsx). They are immutable like `MapData`, and similarly have query and mutation functions: + +```tsx +Jeep.create(player1).load(Flamethrower.create(player1).transport()); // A Jeep loaded with a Flamethrower. + +Factory.create(player2).canBuildUnits(); // true +``` + :::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. -::: +:::import Tiles from '@deities/hera/Tiles.tsx'; diff --git a/docs/content/pages/core-concepts/overview.mdx b/docs/content/pages/core-concepts/overview.mdx index f0588eb9..80414b79 100644 --- a/docs/content/pages/core-concepts/overview.mdx +++ b/docs/content/pages/core-concepts/overview.mdx @@ -1,7 +1,10 @@ # Core Concepts – Overview -This section dives into the core concepts of the codebase to give you a high-level overview of how Athena Crisis works. The technical walkthroughs primarily cover these three top level folders in the repository: +This section dives into the core concepts of the codebase to give you a high-level overview of how Athena Crisis works. This overview is for you if you are interested in the data structures and algorithms used in Athena Crisis. The technical walkthroughs primarily cover these three top level folders in the repository: - [`athena`](https://github.com/nkzw-tech/athena-crisis/tree/main/athena) → Data structures and algorithms for manipulating _map_ state (_client/server_). - [`apollo`](https://github.com/nkzw-tech/athena-crisis/tree/main/apollo) → Data structures and algorithms for manipulating _game_ state (_client/server_). - [`hera`](https://github.com/nkzw-tech/athena-crisis/tree/main/hera) → Game engine and rendering (_client_). +- [`dionysus`](https://github.com/nkzw-tech/athena-crisis/tree/main/hera) → AI implementation (_client/server_). + +There are many tests covering almost all game related logic, with most end-to-end screenshot tests in the [`tests`](https://github.com/nkzw-tech/athena-crisis/tree/main/tests) top-level folder. They are worth checking out when studying the codebase. diff --git a/docs/content/pages/index.mdx b/docs/content/pages/index.mdx index d6fbeb44..9d408e13 100644 --- a/docs/content/pages/index.mdx +++ b/docs/content/pages/index.mdx @@ -2,24 +2,40 @@ layout: landing --- +import { css } from '@emotion/css'; 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 - Athena Crisis is a modern retro turn-based strategy game. + Athena Crisis is a modern retro turn-based strategy game built from scratch + with JavaScript, React and CSS. +
+ [Read the + announcement](https://cpojer.net/posts/athena-crisis-open-source) +
Getting Started - GitHub + Star on GitHub + + + Athena Crisis on Steam + Try a demo of Athena Crisis! import('../playground/PlaygroundDemoGame.tsx'))} /> diff --git a/docs/content/pages/playground/ai.mdx b/docs/content/pages/playground/ai.mdx new file mode 100644 index 00000000..357f456b --- /dev/null +++ b/docs/content/pages/playground/ai.mdx @@ -0,0 +1,3 @@ +# AI Playground + +The AI playground will be published shortly. diff --git a/docs/content/pages/ui/game-components.mdx b/docs/content/pages/ui/game-components.mdx new file mode 100644 index 00000000..7805af71 --- /dev/null +++ b/docs/content/pages/ui/game-components.mdx @@ -0,0 +1,43 @@ +import { lazy } from 'react'; +import ClientComponent from '../../playground/ClientComponent.tsx'; + +# Game Components + +The Athena Crisis renderer was written for fun in JavaScript and CSS from scratch without experience in building game engines. It then ended up being a real game. _Oops._ Anyway, it's been fun to push web technologies to the max and optimize every detail, so let's explore some of the core components! + +Most of the UI and game rendering can be found in the [`hera`](https://github.com/nkzw-tech/athena-crisis/tree/main/hera) top level folder. Athena Crisis renders almost the entire game using just plain React, DOM and CSS. Only the map tiles, fog and decorations are using a `canvas` Element. Even then, all three are rendered using different `canvas` elements that are laid on top of each other. The entry point to render a map is the [`Map`](https://github.com/nkzw-tech/athena-crisis/tree/main/hera/Map.tsx) component. + +## Tiles, Fog and Decorations + +Building a renderer with basic web technologies makes it easier to study how to build a game. It's not the most efficient approach, but it's a great way to learn. All the "static content" is rendered using `` through these components: + +- [`Tiles`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/Tiles.tsx) for rendering tiles. +- [`Decorators`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/Decorators.tsx) for decorations. +- [`Fog`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/Fog.tsx) for fog that overlays the map and blends into everything. + +_If you are a fan of web development, check out how the fog layer is implemented._ + +## Units & Buildings + +Units and Buildings are interactable entities. Both are making use of somewhat complex CSS like `mask-image`, `filter` and `transform` and are therefore slightly harder to replicate when building a game engine and renderer from scratch. In a way, CSS was used as if it was a shader language. Their implementation can be found here: + +- [`Unit`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/Unit.tsx) +- [`Building`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/Building.tsx) + +Here are some examples of units as they are rendered on a map in the game. You can click them to show the [`GameDialog`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/ui/GameDialog.tsx) as it is rendered in the game when right-clicking or long-pressing a field: + + import('../../examples/entities-example.tsx'))} +/> + +## Portraits + +Portraits are rendered using the [`Portrait`](https://github.com/nkzw-tech/athena-crisis/blob/main/hera/character/Portrait.tsx) component: + +```tsx +// [!include ~/examples/portraits-example.tsx:portraits] +``` + + import('../../examples/portraits-example.tsx'))} +/> diff --git a/docs/content/playground/ClientScope.tsx b/docs/content/playground/ClientScope.tsx index 17b850de..fba8bdb9 100644 --- a/docs/content/playground/ClientScope.tsx +++ b/docs/content/playground/ClientScope.tsx @@ -1,6 +1,11 @@ +import AudioPlayer from '@deities/ui/AudioPlayer.tsx'; +import setupGamePad from '@deities/ui/controls/setupGamePad.tsx'; +import setupKeyboard from '@deities/ui/controls/setupKeyboard.tsx'; +import { getScopedCSSDefinitions } from '@deities/ui/CSS.tsx'; import { initializeCSSVariables } from '@deities/ui/cssVar.tsx'; import { ScaleContext } from '@deities/ui/hooks/useScale.tsx'; -import { css, injectGlobal } from '@emotion/css'; +import { setDefaultPortalContainer } from '@deities/ui/Portal.tsx'; +import { css } from '@emotion/css'; import { init as initFbt, IntlVariations } from 'fbt'; initializeCSSVariables(); @@ -15,21 +20,44 @@ initFbt({ translations: {}, }); -injectGlobal(` -@font-face { - font-display: swap; - font-family: Athena; - src: url('/fonts/AthenaNova.woff2'); -} +const clientScopeStyle = css` + all: initial; -body { font-family: Athena, ui-sans-serif, system-ui, sans-serif; font-size: 20px; font-weight: normal; line-height: 1em; + outline: none; + touch-action: pan-x pan-y; + + img { + max-width: initial; + } + + svg { + display: initial; + } + + ${getScopedCSSDefinitions()} +`; + +if (!document.querySelector('body > div.portal')) { + const portal = document.createElement('div'); + portal.classList.add('portal'); + portal.classList.add(clientScopeStyle); + document.body.append(portal, document.body.childNodes[0]); + setDefaultPortalContainer(portal); } -`); +if (!document.querySelector('body > div.background')) { + const background = document.createElement('div'); + background.classList.add('background'); + document.body.insertBefore(background, document.body.childNodes[0]); +} + +AudioPlayer.pause(); +setupKeyboard(); +setupGamePad(); if (import.meta.env.DEV) { import('@deities/hera/ui/fps/Fps.tsx'); @@ -38,14 +66,7 @@ if (import.meta.env.DEV) { export default function ClientScope({ children }: { children: JSX.Element }) { return ( -
{children}
+
{children}
); } - -const style = css` - all: initial; - - font-family: Athena, ui-sans-serif, system-ui, sans-serif; - outline: none; -`; diff --git a/docs/content/playground/Image.tsx b/docs/content/playground/Image.tsx new file mode 100644 index 00000000..8e0a807e --- /dev/null +++ b/docs/content/playground/Image.tsx @@ -0,0 +1,27 @@ +type Props = React.DetailedHTMLProps< + React.ImgHTMLAttributes, + HTMLImageElement +>; + +export default function Image(props: Props) { + if (!props.src) { + return null; + } + + const parts = props.src.split('.'); + const extension = parts.pop(); + const image = ; + return ( + + + + {image} + + ); +} diff --git a/docs/content/playground/PlaygroundGame.tsx b/docs/content/playground/PlaygroundGame.tsx index 19947ead..77dbe4ba 100644 --- a/docs/content/playground/PlaygroundGame.tsx +++ b/docs/content/playground/PlaygroundGame.tsx @@ -6,15 +6,11 @@ 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', diff --git a/docs/content/public/actions.dark.png b/docs/content/public/actions.dark.png new file mode 100644 index 00000000..32d20eab Binary files /dev/null and b/docs/content/public/actions.dark.png differ diff --git a/docs/content/public/actions.png b/docs/content/public/actions.png new file mode 100644 index 00000000..38f2c698 Binary files /dev/null and b/docs/content/public/actions.png differ diff --git a/docs/content/styles.css b/docs/content/styles.css index e69de29b..8e4f5678 100644 --- a/docs/content/styles.css +++ b/docs/content/styles.css @@ -0,0 +1,42 @@ +@font-face { + font-display: swap; + font-family: Athena; + src: url('/fonts/AthenaNova.woff2'); +} + +:root { + --vocs-space_56: var(--vocs-space_24); +} + +html, +body { + tab-size: 2; +} + +#app { + position: relative; +} + +.LogoLink { + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +} + +div.vocs_HomePage_description { + text-wrap: balance; +} + +div.vocs_DocsLayout_gutterTop { + backdrop-filter: blur(2px); +} + +div.vocs_HomePage { + padding-top: 0; +} + +div[data-layout='minimal'] div#vocs-content, +div[data-layout='minimal'] article.vocs_Content { + max-width: initial; +} diff --git a/docs/vocs.config.tsx b/docs/vocs.config.tsx index a70893ff..1a782f96 100644 --- a/docs/vocs.config.tsx +++ b/docs/vocs.config.tsx @@ -73,15 +73,32 @@ export default defineConfig({ link: '/core-concepts/actions', text: 'Actions ', }, + { + link: '/core-concepts/ai', + text: 'AI Deep Dive', + }, ], text: 'Core Concepts', }, + { + items: [ + { + link: '/ui/game-components', + text: 'Game Components', + }, + ], + text: 'UI Components', + }, { items: [ { link: '/playground/map-editor', text: 'Map Editor', }, + { + link: '/playground/ai', + text: 'AI', + }, ], text: 'Playground', }, diff --git a/hera/editor/lib/ActionCard.tsx b/hera/editor/lib/ActionCard.tsx index c545dcd5..7eba27de 100644 --- a/hera/editor/lib/ActionCard.tsx +++ b/hera/editor/lib/ActionCard.tsx @@ -433,14 +433,16 @@ const headlineStyle = css` `; const textareaStyle = css` - background: none; - box-shadow: none; - line-height: 1.4em; - margin: 0; - padding: 0; - - &:focus { + html body & { + background: none; box-shadow: none; + line-height: 1.4em; + margin: 0; + padding: 0; + + &:focus { + box-shadow: none; + } } `; diff --git a/hera/ui/PlayerIcon.tsx b/hera/ui/PlayerIcon.tsx index b7475d3a..4dfce05d 100644 --- a/hera/ui/PlayerIcon.tsx +++ b/hera/ui/PlayerIcon.tsx @@ -35,9 +35,8 @@ export default function PlayerIcon({ onClick?: () => void; selected?: boolean; }) { - const Component = onClick ? 'a' : 'span'; return ( - {playerIconText(id)} - + ); } diff --git a/hera/ui/demo/UnitPreviews.tsx b/hera/ui/demo/UnitPreviews.tsx new file mode 100644 index 00000000..47c5aef2 --- /dev/null +++ b/hera/ui/demo/UnitPreviews.tsx @@ -0,0 +1,129 @@ +import { Plain } from '@deities/athena/info/Tile.tsx'; +import { mapUnitsWithContentRestriction } from '@deities/athena/info/Unit.tsx'; +import { Biome } from '@deities/athena/map/Biome.tsx'; +import { + AnimationConfig, + TileSize, +} from '@deities/athena/map/Configuration.tsx'; +import { PlayerIDs } from '@deities/athena/map/Player.tsx'; +import Unit from '@deities/athena/map/Unit.tsx'; +import vec from '@deities/athena/map/vec.tsx'; +import MapData from '@deities/athena/MapData.tsx'; +import randomEntry from '@deities/hephaestus/randomEntry.tsx'; +import { SquareButtonStyle } from '@deities/ui/Button.tsx'; +import useInput from '@deities/ui/controls/useInput.tsx'; +import Stack from '@deities/ui/Stack.tsx'; +import { css, cx } from '@emotion/css'; +import arrayShuffle from 'array-shuffle'; +import { MouseEvent, useCallback, useMemo, useState } from 'react'; +import { useSprites } from '../../hooks/useSprites.tsx'; +import toTransformOrigin from '../../lib/toTransformOrigin.tsx'; +import Tick from '../../Tick.tsx'; +import { MapInfoState } from '../../Types.tsx'; +import UnitTile from '../../Unit.tsx'; +import GameDialog from '../GameDialog.tsx'; + +const UnitPreview = ({ + onClick, + unit, +}: { + onClick?: (event: MouseEvent, unit: Unit) => void; + unit: Unit; +}) => ( +
onClick?.(event, unit)} + title={unit.info.name} + > +
+ +
+
+); + +const map = MapData.createMap({ + map: [1], + modifiers: [0], + teams: PlayerIDs.slice(1).map((id) => ({ + id, + name: '', + players: [{ funds: 0, id, name: `User-${id}`, teamId: id }], + })), +}); + +export default function UnitPreviews() { + const hasSprites = useSprites('all'); + + const [gameInfoState, setGameInfoState] = useState(null); + + useInput('cancel', () => setGameInfoState(null), 'top'); + + const showGameInfo = useCallback((event: MouseEvent, unit: Unit) => { + setGameInfoState({ + biome: map.config.biome, + origin: toTransformOrigin(event), + type: 'map-info', + unit, + vector: vec(1, 1), + }); + }, []); + + const unitPreviews = useMemo( + () => + arrayShuffle( + mapUnitsWithContentRestriction((unit) => unit, new Set()) + .filter( + (unit) => + unit.characterName !== 'Unknown' && + unit.characterDescription !== 'Unknown', + ) + .map((unit) => { + const player = randomEntry(PlayerIDs.slice(1)); + return ( + + showGameInfo(event, unit.create(player, { name: -1 })) + } + unit={unit.create(player)} + /> + ); + }), + ).slice(0, 5), + [showGameInfo], + ); + + return hasSprites ? ( + <> + + + {unitPreviews} + + + {gameInfoState && ( + setGameInfoState(null)} + state={ + { + currentViewer: gameInfoState.unit?.player || map.getPlayer(1).id, + factionNames: new Map(), + gameInfoState, + map, + } as const + } + /> + )} + + ) : null; +} + +const unitPreviewStyle = css` + cursor: pointer; +`; diff --git a/hera/ui/fps/FpsComponent.tsx b/hera/ui/fps/FpsComponent.tsx index 583344c1..ef002425 100644 --- a/hera/ui/fps/FpsComponent.tsx +++ b/hera/ui/fps/FpsComponent.tsx @@ -17,6 +17,7 @@ export default function Fps() { const style = css` bottom: 10px; + font-family: Athena, ui-sans-serif, system-ui, sans-serif; font-size: 12px; height: 12px; left: 0; diff --git a/tests/display.tsx b/tests/display.tsx index ad10602b..23a2b0bb 100644 --- a/tests/display.tsx +++ b/tests/display.tsx @@ -8,8 +8,8 @@ import { 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 initializeCSS from '@deities/ui/CSS.tsx'; import { applyVar } from '@deities/ui/cssVar.tsx'; import { css, cx, injectGlobal } from '@emotion/css'; import React, { useEffect, useMemo } from 'react'; @@ -23,6 +23,8 @@ declare global { var renderMap: (url: string) => void; } +initializeCSS(); + // Playwright does not like audio. AudioPlayer.pause(); diff --git a/ui/CSS.tsx b/ui/CSS.tsx index 42354851..fc9b8e84 100644 --- a/ui/CSS.tsx +++ b/ui/CSS.tsx @@ -5,125 +5,11 @@ import { applyVar, initializeCSSVariables } from './cssVar.tsx'; import getColor from './getColor.tsx'; import pixelBorder from './pixelBorder.tsx'; -initializeCSSVariables(); - -injectGlobal(` -* { - box-sizing: border-box; -} - -@media (prefers-color-scheme: dark) { - div.background { - filter: invert(1); - } -} - -html.dark div.background { - filter: invert(1); -} - -html { - -webkit-text-size-adjust: 100%; - height: 100vh; - height: -webkit-fill-available; - height: fill-available; -} - -body { - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; - background: ${applyVar('background-color')}; - color: ${applyVar('text-color')}; - font-family: Athena, ui-sans-serif, system-ui, sans-serif; - font-size: 20px; - font-weight: normal; - line-height: 1em; - margin: 0; - overscroll-behavior: none; - touch-action: pan-x pan-y; - user-select: none; -} - -::-webkit-scrollbar { - display: none; -} - -html[lang="ja_JP"] body, .locale-ja_JP { - font-family: Athena, MadouFutoMaru, ui-sans-serif, system-ui, sans-serif; - - & button, - & input, - & select, - & textarea { - font-family: Athena, MadouFutoMaru, ui-sans-serif, system-ui, sans-serif; - } -} - -html[lang="uk_UA"] body, html[lang="ru_RU"] body, .locale-uk_UA, .locale-ru_RU { - font-family: Athena, PressStart2P, ui-sans-serif, system-ui, sans-serif; - - & button, - & input, - & select, - & textarea { - font-family: Athena, PressStart2P, ui-sans-serif, system-ui, sans-serif; - } -} - -html[lang="ko_KR"] body, .locale-ko_KR { - font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; - - & button, - & input, - & select, - & textarea { - font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; - } -} - -html[lang="zh_CN"] body, .locale-zh_CN { - font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; - - & button, - & input, - & select, - & textarea { - font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; - } -} - -body .all-fonts { - font-family: Athena, PressStart2P, MadouFutoMaru, ui-sans-serif, system-ui, sans-serif; -} - +const scope = ` svg { vertical-align: middle; } -@media (orientation: portrait) { - body { - margin-top: env(safe-area-inset-top); - } -} - -div.background, div.background-absolute { - background-image: url('${Background}'); - bottom: 0; - image-rendering: pixelated; - left: -144px; - overflow: hidden; - pointer-events: none; - position: fixed; - right: -144px; - top: -144px; - transform: ${applyVar('perspective-transform')}; - zoom: ${applyVar('scale')}; -} - -div.background-absolute { - position: absolute; -} - table { border-collapse: collapse; border-spacing: 0; @@ -317,4 +203,135 @@ p { margin: 0; user-select: text; } -`); +`; + +const global = ` +* { + box-sizing: border-box; +} + +html.dark div.background { + filter: invert(1); +} + +html { + -webkit-text-size-adjust: 100%; + height: 100vh; + height: -webkit-fill-available; + height: fill-available; +} + +body { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + background: ${applyVar('background-color')}; + color: ${applyVar('text-color')}; + font-family: Athena, ui-sans-serif, system-ui, sans-serif; + font-size: 20px; + font-weight: normal; + line-height: 1em; + margin: 0; + overscroll-behavior: none; + touch-action: pan-x pan-y; + user-select: none; +} + +::-webkit-scrollbar { + display: none; +} + +html[lang="ja_JP"] body, .locale-ja_JP { + font-family: Athena, MadouFutoMaru, ui-sans-serif, system-ui, sans-serif; + + & button, + & input, + & select, + & textarea { + font-family: Athena, MadouFutoMaru, ui-sans-serif, system-ui, sans-serif; + } +} + +html[lang="uk_UA"] body, html[lang="ru_RU"] body, .locale-uk_UA, .locale-ru_RU { + font-family: Athena, PressStart2P, ui-sans-serif, system-ui, sans-serif; + + & button, + & input, + & select, + & textarea { + font-family: Athena, PressStart2P, ui-sans-serif, system-ui, sans-serif; + } +} + +html[lang="ko_KR"] body, .locale-ko_KR { + font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; + + & button, + & input, + & select, + & textarea { + font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; + } +} + +html[lang="zh_CN"] body, .locale-zh_CN { + font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; + + & button, + & input, + & select, + & textarea { + font-family: AthenaLatin, ui-sans-serif, system-ui, sans-serif; + } +} + +body .all-fonts { + font-family: Athena, PressStart2P, MadouFutoMaru, ui-sans-serif, system-ui, sans-serif; +} + +@media (orientation: portrait) { + body { + margin-top: env(safe-area-inset-top); + } +} + +div.background, div.background-absolute { + background-image: url('${Background}'); + bottom: 0; + image-rendering: pixelated; + left: -144px; + overflow: hidden; + pointer-events: none; + position: fixed; + right: -144px; + top: -144px; + transform: ${applyVar('perspective-transform')}; + zoom: ${applyVar('scale')}; +} + +div.background-absolute { + position: absolute; +} + +@media (prefers-color-scheme: dark) { + div.background { + filter: invert(1); + } +} + +${scope} +`; + +let initialized = false; +export default function initializeCSS() { + if (initialized) { + return; + } + + initialized = true; + initializeCSSVariables(); + injectGlobal(global); +} + +export function getScopedCSSDefinitions() { + return scope; +} diff --git a/ui/Portal.tsx b/ui/Portal.tsx index cf180500..75ed98a4 100644 --- a/ui/Portal.tsx +++ b/ui/Portal.tsx @@ -1,9 +1,15 @@ import { ReactNode } from 'react'; import { createPortal } from 'react-dom'; +let defaultContainer = document.body; + +export function setDefaultPortalContainer(container: HTMLElement) { + defaultContainer = container; +} + export default function Portal({ children, - container = document.body, + container = defaultContainer, }: { children: ReactNode; container?: HTMLElement; diff --git a/ui/TagInput.tsx b/ui/TagInput.tsx index 0fa26a71..ddff1897 100644 --- a/ui/TagInput.tsx +++ b/ui/TagInput.tsx @@ -77,16 +77,18 @@ const inputStyle = css` `; const resetStyle = css` - background: transparent; - border: none; - box-shadow: none; - display: inline-block; - margin: -3px 0 0 4px; - padding: 2px; - width: 100%; - - &:focus { + html body & { + background: transparent; + border: none; box-shadow: none; + display: inline-block; + margin: -3px 0 0 4px; + padding: 2px; + width: 100%; + + &:focus { + box-shadow: none; + } } `;