style updates
This commit is contained in:
parent
edbe63a665
commit
a758b7b99f
23 changed files with 554 additions and 325 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
dist/
|
||||
node_modules/
|
||||
.wrangler/
|
||||
*.*~
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
{{ macros::message(text="i'm a creator and artist. i make music, stories, video games and use any medium that seems interesting to me.", classes="right", avatar="/static/img/pfp64.webp") }}
|
||||
{{ macros::message(text="my two main projects are immersive sims set in a near-future scifi setting. eventually, i'll provide public information about these two games. i'm disabled and easily discouraged so my work is slow.", classes="right", avatar="/static/img/pfp64.webp") }}
|
||||
{{ macros::message(text="other things i like are <ul><li>cooking and baking</li><li>collaborative storytelling and roleplay</li><li>hunting under a full moon</li><li><i>lots</i> of music</li><li>disguising myself as a beautiful human to seduce anyone im interested in</li><li>tea and coffee</li><li>sleepovers</li><li>bringing others to my domain to lavish them with indescribable joy and pleasure</li><li>donuts</li></ul>", classes="right", avatar="/static/img/pfp64.webp") }}
|
||||
{{ macros::message(text="okay but when are you releasing a new game", reversed=false, classes="accent left", avatar="/static/img/anon.webp") }}
|
||||
{{ macros::message(text="okay but when are you releasing a new game", reversed=false, classes="accent left", avatar="/static/img/anon.png") }}
|
||||
{{ macros::message(text=" ", classes="right", avatar="/static/img/pfp64.webp") }}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
title = "45 Day Roguelike Postmortem"
|
||||
created_date = "2022-04-22"
|
||||
tags = ["programming", "game"]
|
||||
|
||||
|
|
|
@ -1,265 +1,11 @@
|
|||
{% extends "../../../layouts/post.html" %}
|
||||
|
||||
{% block article %}
|
||||
|
||||
## Preface
|
||||
I'm an indie game dev that has an interest in making tools that I think would improve my life as a game developer in the hopes that it can help others. I'm not an expert in any of the topics that this article covers. I have no formal education in game engine development or architecture, systems programming, or anything else. I went to university for Computer Science with a specialization in game design & development when I was in my early 20s and the only thing I learned is that there are better ways to spend tens of thousands of dollars.
|
||||
|
||||
Everything in this article is drawn directly from my experiences working in game development.
|
||||
|
||||
## Theseus' Shield
|
||||
In the context of game design and development, object oriented data modeling can feel intuitive. Players, NPCs, and Enemies can all derive from a common "Creature" class which handle things like health and damage. Weapons, armor, and consumables can derive from a common Item class which handle inventory management.
|
||||
|
||||
Thinking exclusively in terms of hierarchal, nested patterns can come at a cost: software brittleness and inflexible design. Let's use an `Item` class as an example.
|
||||
|
||||
```cs
|
||||
abstract class Item {
|
||||
public string Name { get; protected set; }
|
||||
}
|
||||
|
||||
abstract class Weapon: Item {
|
||||
public float Damage { get; protected set; }
|
||||
public float Range { get; protected set; }
|
||||
}
|
||||
|
||||
abstract class Armor: Item {
|
||||
public float Defense { get; protected set; }
|
||||
}
|
||||
|
||||
class Sword: Weapon {}
|
||||
class Bow: Weapon {}
|
||||
|
||||
class Helmet: Armor {}
|
||||
class Shield: Armor {}
|
||||
```
|
||||
|
||||
In the above scenario, we have [three layers of inheritance](https://wiki.c2.com/?MaxThreeLayersOfInheritance). We can imagine that `Item` handles behaviors like item management such as being added or removed from an inventory and highly generic item behavior. `Weapon` would then be responsible for an item which is capable of dealing damage at any range. We can expect that the specifics of its attack behavior would be implemented by a subclass. Similarly for `Armor`, this would handle generic damage interactions such as common mitigation or avoidance calculations while leaving specific implementation details to its subclasses.
|
||||
|
||||
Seasoned game devs, disciplined object-oriented programmers, and existing ECS developers can likely already see what comes next: a new requirement.
|
||||
|
||||
How do we add a Spiked Shield item -- one which derives the behaviors of `Weapon` and `Armor`? If we were using a language which supports multiple inheritance this could potentially be a nonissue: except that multiple inheritance is often [purposefully missing](https://en.wikipedia.org/wiki/Multiple_inheritance#The_diamond_problem) in many languages. We could decide that the item belongs more to one class than the other and just duplicate the missing behavior but these types of decisions often introduce unforeseen complexity: will the damage algorithm need to do a specific type check for `SpikedShield`? What about the equipment screen? And of course, what happens when we need to implement a damaging potion?
|
||||
|
||||
Perhaps the most appropriate solution for this case would be to forgo the inheritance pattern in favor of [composition](https://en.wikipedia.org/wiki/Composition_over_inheritance). In C#, this could be achieved with interfaces and default implementations.
|
||||
|
||||
```cs
|
||||
interface ICollectable {
|
||||
void Take();
|
||||
void Remove();
|
||||
}
|
||||
|
||||
interface IDamaging {
|
||||
// by default, we'll just pass the damage directly to
|
||||
// the receiving damage handler
|
||||
void ApplyDamage(IDamageable damageable, float value) {
|
||||
damageable.ReceiveDamage(this, value);
|
||||
}
|
||||
}
|
||||
|
||||
interface IDamageable {
|
||||
void ReceiveDamage(IDamaging source, float value);
|
||||
}
|
||||
|
||||
class SpikedShield: ICollectable, IDamaging, IDamageable {}
|
||||
```
|
||||
|
||||
We can use this approach to refactor our existing items and systems to derive/override only the behaviors they use. The respective systems for these interfaces now only need to check for the existence of these interfaces in order to act on them.
|
||||
|
||||
Engines like Unity and Godot use variations of the [Entity-Component (EC)](https://gameprogrammingpatterns.com/component.html) pattern (similar to but distinct from Entity-Component-System (ECS)). These patterns favor composition over inheritance by allowing developers to isolate behaviors into discrete components that can be applied to entities. In the Spiked Shield example, a developer could make a "Damage Source" and "Damage Target" component and add both to the item. In essence, this is the same as the interface-based approach.
|
||||
|
||||
Unfortunately, these patterns dont alleviate other issues that are much more difficult to solve.
|
||||
|
||||
## In the event of my demise
|
||||
Due to the nature of video games, important events may need to be handled at any moment. For example, a player may've dealt a fatal blow to a boss enemy on the same frame that they received fatal damage. In which order should this damage be processed? Depending on the handling order, this is likely the difference between clearing a potentially difficult boss battle and needing to do it again.
|
||||
|
||||
In my experience, this would likely be handled by a traditional event system where the order is difficult to predict. This isn't to make the claim that syncronous event systems are *unpredictable*, but ensuring that a given event will be handled in a way that is predictable to the designer/developer without additional abstractions is difficult.
|
||||
|
||||
It's often useful to explicitly define the processing order of certain interactions and events. To solve this problem, we could implement a priority [event queue](https://gameprogrammingpatterns.com/event-queue.html).
|
||||
|
||||
```cs
|
||||
public class DamageEventArgs : EventArgs {
|
||||
public readonly IDamaging Source;
|
||||
public readonly IDamageable Target;
|
||||
public readonly float Value;
|
||||
}
|
||||
|
||||
// this is more of a dispatcher, but that's
|
||||
// an unnecessary implementation detail.
|
||||
// in a real scenario where other systems would be reading
|
||||
// these events too, this would subscribe to a Mediator object
|
||||
class DamageEvent {
|
||||
// only one of these should exist and should be globally
|
||||
// accessible so we make it a singleton
|
||||
private static DamageEvent instance = new Instance();
|
||||
public static DamageEvent Instance => instance;
|
||||
|
||||
private PriorityQueue<DamageEventArgs, int> queue = new();
|
||||
|
||||
private void Raise(DamageEventArgs args) {
|
||||
// before enqueuing, determine the priority
|
||||
// based on the object dealing damage
|
||||
var priority = args.Source switch {
|
||||
Player => 2,
|
||||
Enemy => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
queue.Enqueue(args, priority);
|
||||
}
|
||||
|
||||
// we'll dispatch events with the tick rate
|
||||
// so they're all handled at the same time
|
||||
private void OnTick(float deltaTime) {
|
||||
// for simplicity we'll dequeue everything each frame
|
||||
// and pass it to the damage handler
|
||||
while(queue.TryDequeue(out var e, out int priority)) {
|
||||
e.Source.ApplyDamage(e.Target, e.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bossEnemy's damage should be processed after player's damage
|
||||
// if they're raised during the same frame
|
||||
DamageEvent.Raise(new DamageEventArgs(bossEnemy, player, 10));
|
||||
DamageEvent.Raise(new DamageEventArgs(player, bossEnemy, 10));
|
||||
|
||||
```
|
||||
|
||||
There's a lot of issues with this code that I'm going to pretend were deliberate decisions for brevity. The point of this example is to show that we can ingest events and sort them arbitrarily based on the requirements of the game. We've made a step in the right direction by identifying a need to explicitly order these events. Even if this implementation isn't ideal, it properly encodes the requirements of the design (ie. player damage should be processed before all other types).
|
||||
|
||||
There is still a timing issue with this approach however. Events can be raised at any point: before, during, or after `DamageEvent` queue has already done its work for the frame. If `bossEnemy` raises its damage event before `DamageEvent` processes its queue but `player` raises their event after, we still have the original issue.
|
||||
|
||||
Depending on the engine and implementation, there may be a few options for solving this. Rather than using `OnTick`, the damage can be handled in `OnLateTick` which runs after all `OnTick` systems have been processed (Unity's version of these methods are `Update` and `LateUpdate`). In Unity the `DamageEvent` singleton script could have its order explicitly modified in the settings or with the `DefaultExecutionOrder` attribute. In Godot, this would likely be resolved by moving the `DamageEvent` node lower in the tree since nodes are processed from top to bottom while resolving children first.
|
||||
|
||||
An alternative solution would be to identify the behavior which raises events and isolate it into its own singleton. Doing this would allow it to easily be ordered before the damage handling system. We'll revisit this idea later.
|
||||
|
||||
## Legion-oriented programming
|
||||
Managing a game state as it grows in size and complexity is *difficult*. It's not uncommon for a game to have dozens, hundreds, or thousands of active entities at a time. In many cases, it makes sense to decouple separate-but-related behaviors into their own systems to make them easier to manage.
|
||||
|
||||
One such separation would be game logic and UI. Rather than directly coupling the player's health to the UI representation of the player's health, it makes sense to have them communicate via some type of messaging system. An event seems like a natural fit.
|
||||
|
||||
```cs
|
||||
public class PlayerSpawnedArgs : EventArgs {
|
||||
public readonly Player Player;
|
||||
}
|
||||
|
||||
public class HealthChangedArgs : EventArgs {
|
||||
public readonly float PreviousValue;
|
||||
public readonly float CurrentValue;
|
||||
}
|
||||
|
||||
class Healthbar : UIElement {
|
||||
private float _currentHealth;
|
||||
// use getter/setter to automatically redraw
|
||||
// the UI on state changes
|
||||
private float currentHealth {
|
||||
get => _currentHealth;
|
||||
set {
|
||||
if (value != _currentHealth) {
|
||||
_currentHealth = value;
|
||||
Redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnInitialize() {
|
||||
// register to the player spawn event
|
||||
PlayerSpawnEvent.Register(OnPlayerSpawned);
|
||||
}
|
||||
|
||||
private void OnPlayerSpawned(object sender, PlayerSpawnedArgs args) {
|
||||
// subscribe specifically to that player's events
|
||||
args.Player.health.Register(OnHealthChanged);
|
||||
}
|
||||
|
||||
private void OnHealthChanged(object sender, HealthChangedArgs args) {
|
||||
currentHealth = args.CurrentValue;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will work for many cases but it's unfortunate it needs to manage subscriptions to two events in order to get the updates it needs. If we ever needed to add more than one player that renders a healthbar, this solution would no longer be sufficient. We could circumvent this by making health updates dispatched globally but that wold come at the cost of checking *every* health update just to find a player. This solution would be much more appealing, however, if every entity with health had a healthbar. One happy medium would be to create a more specific global event for player health updates. `Healthbar` can then simply register to that event without needing to concern itself with the particulars of spawning.
|
||||
|
||||
With all this event-driven programming comes another downside: events are opaque and difficult to debug. When an architecture relies heavily on events, it becomes increasingly important to have outstanding documentation. It's an unfortunate sacrifice to be made in exchange for the highly decoupled nature of events. Some game engines have made attempts to offer better insight into event connections but it's often only a mild remedy.
|
||||
|
||||
In order to better query the state of an event-driven application, [Martin Fowler's Event Sourcing post](https://martinfowler.com/eaaDev/EventSourcing.html) provides some additional respite. It's worth reading if you're working with many events in your game. Essentially, tracking the source of an event *in* the newly raised event allows subscribers to walk the chain of source events in reverse to determine additional context.
|
||||
|
||||
## Enter ECS
|
||||
These were just a few pain points of game architecture that I've come across when making games. I've tried different solutions each time to varying levels of success. While I don't think there exists a one-size-fits-all solution to every architectural decision in video games, I *do* believe that reframing how we think about our architectural goals can make some problems diminish or even disappear. This is especially helpful if the affected problems were persistent across multiple projects.
|
||||
|
||||
Entity-Component-System (ECS) is a data-oriented approach that has resolved many of the above issues for me. It comes with its own architectural challenges, especially since the pattern has been rapidly evolving due to its recent explosion of popularity.
|
||||
|
||||
There are many guides attempting to explain ECS in a simple terms. This can be a bit challenging since the approach may run counter to the fundamental understanding of game architecture for many new readers. Additionally, there are different types and implementations of ECS which sometimes pollute the overall message. [Sander Mertens](https://ajmmertens.medium.com/), the author of [FLECS](https://github.com/SanderMertens/flecs), has contributed a substantial amount to the development and education of ECS. Their [FAQ](https://github.com/SanderMertens/ecs-faq) is a valuable resource to have. I'm going to try to provide a high level explanation of ECS but I recommend looking to other resources if this doesn't make sense.
|
||||
|
||||
### Entities
|
||||
|
||||
An entity is an identifier for an object. It has no data, properties, or behaviors by itself. It is simply a marker for something that exists. In some implementations of ECS, this could be as simple as an unsigned integer. These identifiers are the primary way of fetching *Component* data.
|
||||
|
||||
### Components
|
||||
|
||||
Components hold data and belong to an entity. Some implementations have limitations on what kind of data can be housed but conceptually it can be anything. A player's level, their position in the world, their current health, and the current input state of a gamepad would all be stored in a component.
|
||||
|
||||
Components are often stored contiguously in memory (such as in an array). The entity is used to fetch data from that container.
|
||||
|
||||
|
||||
### An example
|
||||
Let's pause for a moment to consider the relationship between entities and components. If we were to focus purely on simplicity, we could implement these concepts in the following way.
|
||||
|
||||
|
||||
```cs
|
||||
// this is our component definition
|
||||
struct Health {
|
||||
public double Current;
|
||||
public double Max;
|
||||
}
|
||||
|
||||
Health[] health = new Health[100]; // this is our component container
|
||||
|
||||
int player = 0; // this is our entity
|
||||
double currentHealth = health[player].current; // accessing our component data
|
||||
Console.WriteLine($"The player's current health is {currentHealth}");
|
||||
```
|
||||
|
||||
|
||||
I would like to restate that the actual interface will depend on the library and the decisions its developers have made. Different implementations have different tradeoffs and limitations that may change the internal representation of an entity or component.
|
||||
|
||||
If we extend our example just a little bit to include multiple components, we would end up with multiple containers (arrays) too. This has an interesting implication in that it allows us to visualize our data in a more intuitive way: a table.
|
||||
|
||||
| Entity | Name | Health.Current | Health.Max |
|
||||
| ------ | -------- | -------------- | ---------- |
|
||||
| 0 | "Kain" | 100 | 100 |
|
||||
| 1 | "Raziel" | 5 | 75 |
|
||||
| 2 | "Janos" | 0 | 1000 |
|
||||
|
||||
Our entity is the row ID and each successive column is its associated component data. In cases where an entity does not have a component, we can think of its value as `NULL`.
|
||||
|
||||
| Entity | Name | Health.Current | Health.Max | Weapon.Name |
|
||||
| ------ | -------- | -------------- | ---------- | ------------- |
|
||||
| 0 | "Kain" | 100 | 100 | "Soul Reaver" |
|
||||
| 1 | "Raziel" | 5 | 75 | "Soul Reaver" |
|
||||
| 2 | "Janos" | 0 | 1000 | `NULL` |
|
||||
|
||||
|
||||
### Systems
|
||||
|
||||
A system represents a behavior. This is where game and application logic lives. Systems receive a list of entities and iterate over that list to perform work on their component data. They may also create and destroy entities or attach and remove components. If we want to write a system which applies movement to an entity, we could check for the existence of a `Position` and `Velocity` components.
|
||||
|
||||
```cs
|
||||
foreach(var entity of entities) {
|
||||
if (entity.hasComponent(Velocity) && entity.hasComponent(Position)) {
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Going further (Conditional components, Queries)
|
||||
Most ECS systems can query the state to refine the list of entities they receive to what is most pertinent. Some have scheduling and ordering systems for systems.
|
||||
|
||||
## Performance
|
||||
its fast
|
||||
|
||||
## Issues with ECS
|
||||
Mental modeling is hard. Complex queries are hard. ECS is hard.
|
||||
|
||||
## A Solution?
|
||||
something something game state as a database, expressive querying
|
||||
https://ajmmertens.medium.com/why-it-is-time-to-start-thinking-of-games-as-databases-e7971da33ac3
|
||||
|
@ -286,5 +32,5 @@ continued evolution of ecs which can broadly respond to specific use cases and n
|
|||
* how best to solve a problem via OOP is not the point of this post
|
||||
* any feedback on how to better demonstrate solutions to the example problems are ok though. i want to fairly represent the alternatives
|
||||
|
||||
|
||||
{% endblock article %}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
title = "A Query about Game State"
|
||||
#created_date = "2024-11-30"
|
||||
description = "An exploration into game architecture and how I would like to see it evolve."
|
||||
description = ""
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
title = "A Roxy Update"
|
||||
created_date = "2024-02-14"
|
||||
tags = ["programming", "roxy", "web"]
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
title = "Convenient Unity Attributes"
|
||||
created_date = "2021-12-09"
|
||||
tags = ["programming", "game", "unity"]
|
||||
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
{% extends "../../layouts/page.html" %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/blog.css">
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
<ul>
|
||||
<ul class="blog link-list">
|
||||
{% for post in blog
|
||||
| values
|
||||
| filter(attribute="created_date")
|
||||
| sort(attribute="created_date")
|
||||
| reverse %}
|
||||
<li>
|
||||
<a href="{{ post.path }}">{{ post.title }}</a>
|
||||
<small class="muted">{{ post.created_date | date(format="%b %e, %Y") }}</small>
|
||||
<div>
|
||||
<a href="{{ post.path }}">{{ post.title }}</a>
|
||||
<small class="muted">{{ post.created_date | date(format="%b %e, %Y") }}</small>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
title = "Making a Static Site Generator (and a New Site)"
|
||||
created_date = "2023-08-15"
|
||||
tags = ["roxy", "programming", "web"]
|
||||
|
|
297
content/blog/on-ecs/index.md
Normal file
297
content/blog/on-ecs/index.md
Normal file
|
@ -0,0 +1,297 @@
|
|||
{% extends "../../../layouts/post.html" %}
|
||||
|
||||
{% block article %}
|
||||
|
||||
## Theseus' Shield
|
||||
In the context of game design and development, object oriented data modeling can feel intuitive. Players, NPCs, and Enemies can all derive from a common "Creature" class which handle things like health and damage. Weapons, armor, and consumables can derive from a common Item class which handle inventory management.
|
||||
|
||||
Thinking exclusively in terms of hierarchal, nested patterns can come at a cost: software brittleness and inflexible design. Let's use an `Item` class as an example.
|
||||
|
||||
```cs
|
||||
abstract class Item {
|
||||
public string Name { get; protected set; }
|
||||
}
|
||||
|
||||
abstract class Weapon: Item {
|
||||
public float Damage { get; protected set; }
|
||||
public float Range { get; protected set; }
|
||||
}
|
||||
|
||||
abstract class Armor: Item {
|
||||
public float Defense { get; protected set; }
|
||||
}
|
||||
|
||||
class Sword: Weapon {}
|
||||
class Bow: Weapon {}
|
||||
|
||||
class Helmet: Armor {}
|
||||
class Shield: Armor {}
|
||||
```
|
||||
|
||||
In the above scenario, we have [three layers of inheritance](https://wiki.c2.com/?MaxThreeLayersOfInheritance). We can imagine that `Item` handles behaviors like item management such as being added or removed from an inventory and highly generic item behavior. `Weapon` would then be responsible for an item which is capable of dealing damage at any range. We can expect that the specifics of its attack behavior would be implemented by a subclass. Similarly for `Armor`, this would handle generic damage interactions such as common mitigation or avoidance calculations while leaving specific implementation details to its subclasses.
|
||||
|
||||
Seasoned game devs, disciplined object-oriented programmers, and existing ECS developers can likely already see what comes next: a new requirement.
|
||||
|
||||
How do we add a Spiked Shield item -- one which derives the behaviors of `Weapon` and `Armor`? If we were using a language which supports multiple inheritance this could potentially be a nonissue: except that multiple inheritance is often [purposefully missing](https://en.wikipedia.org/wiki/Multiple_inheritance#The_diamond_problem) in many languages. We could decide that the item belongs more to one class than the other and just duplicate the missing behavior but these types of decisions often introduce unforeseen complexity: will the damage algorithm need to do a specific type check for `SpikedShield`? What about the equipment screen? And of course, what happens when we need to implement a damaging potion?
|
||||
|
||||
Perhaps the most appropriate solution for this case would be to forgo the inheritance pattern in favor of [composition](https://en.wikipedia.org/wiki/Composition_over_inheritance). In C#, this could be achieved with interfaces and default implementations.
|
||||
|
||||
```cs
|
||||
interface ICollectable {
|
||||
void Take();
|
||||
void Remove();
|
||||
}
|
||||
|
||||
interface IDamaging {
|
||||
// by default, we'll just pass the damage directly to
|
||||
// the receiving damage handler
|
||||
void ApplyDamage(IDamageable damageable, float value) {
|
||||
damageable.ReceiveDamage(this, value);
|
||||
}
|
||||
}
|
||||
|
||||
interface IDamageable {
|
||||
void ReceiveDamage(IDamaging source, float value);
|
||||
}
|
||||
|
||||
class SpikedShield: ICollectable, IDamaging, IDamageable {}
|
||||
```
|
||||
|
||||
We can use this approach to refactor our existing items and systems to derive/override only the behaviors they use. The respective systems for these interfaces now only need to check for the existence of these interfaces in order to act on them.
|
||||
|
||||
Engines like Unity and Godot use variations of the [Entity-Component (EC)](https://gameprogrammingpatterns.com/component.html) pattern (similar to but distinct from Entity-Component-System (ECS)). These patterns favor composition over inheritance by allowing developers to isolate behaviors into discrete components that can be applied to entities. In the Spiked Shield example, a developer could make a "Damage Source" and "Damage Target" component and add both to the item. In essence, this is the same as the interface-based approach.
|
||||
|
||||
Unfortunately, these patterns dont alleviate other issues that are much more difficult to solve.
|
||||
|
||||
## In the event of my demise
|
||||
Due to the nature of video games, important events may need to be handled at any moment. For example, a player may've dealt a fatal blow to a boss enemy on the same frame that they received fatal damage. In which order should this damage be processed? Depending on the handling order, this is likely the difference between clearing a potentially difficult boss battle and needing to do it again.
|
||||
|
||||
In my experience, this would likely be handled by a traditional event system where the order is difficult to predict. This isn't to make the claim that syncronous event systems are *unpredictable*, but ensuring that a given event will be handled in a way that is predictable to the designer/developer without additional abstractions is difficult.
|
||||
|
||||
It's often useful to explicitly define the processing order of certain interactions and events. To solve this problem, we could implement a priority [event queue](https://gameprogrammingpatterns.com/event-queue.html).
|
||||
|
||||
```cs
|
||||
public class DamageEventArgs : EventArgs {
|
||||
public readonly IDamaging Source;
|
||||
public readonly IDamageable Target;
|
||||
public readonly float Value;
|
||||
}
|
||||
|
||||
// this is more of a dispatcher, but that's
|
||||
// an unnecessary implementation detail.
|
||||
// in a real scenario where other systems would be reading
|
||||
// these events too, this would subscribe to a Mediator object
|
||||
class DamageEvent {
|
||||
// only one of these should exist and should be globally
|
||||
// accessible so we make it a singleton
|
||||
private static DamageEvent instance = new Instance();
|
||||
public static DamageEvent Instance => instance;
|
||||
|
||||
private PriorityQueue<DamageEventArgs, int> queue = new();
|
||||
|
||||
private void Raise(DamageEventArgs args) {
|
||||
// before enqueuing, determine the priority
|
||||
// based on the object dealing damage
|
||||
var priority = args.Source switch {
|
||||
Player => 2,
|
||||
Enemy => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
queue.Enqueue(args, priority);
|
||||
}
|
||||
|
||||
// we'll dispatch events with the tick rate
|
||||
// so they're all handled at the same time
|
||||
private void OnTick(float deltaTime) {
|
||||
// for simplicity we'll dequeue everything each frame
|
||||
// and pass it to the damage handler
|
||||
while(queue.TryDequeue(out var e, out int priority)) {
|
||||
e.Source.ApplyDamage(e.Target, e.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bossEnemy's damage should be processed after player's damage
|
||||
// if they're raised during the same frame
|
||||
DamageEvent.Raise(new DamageEventArgs(bossEnemy, player, 10));
|
||||
DamageEvent.Raise(new DamageEventArgs(player, bossEnemy, 10));
|
||||
|
||||
```
|
||||
|
||||
There's a lot of issues with this code that I'm going to pretend were deliberate decisions for brevity. The point of this example is to show that we can ingest events and sort them arbitrarily based on the requirements of the game. We've made a step in the right direction by identifying a need to explicitly order these events. Even if this implementation isn't ideal, it properly encodes the requirements of the design (ie. player damage should be processed before all other types).
|
||||
|
||||
There is still a timing issue with this approach however. Events can be raised at any point: before, during, or after `DamageEvent` queue has already done its work for the frame. If `bossEnemy` raises its damage event before `DamageEvent` processes its queue but `player` raises their event after, we still have the original issue.
|
||||
|
||||
Depending on the engine and implementation, there may be a few options for solving this. Rather than using `OnTick`, the damage can be handled in `OnLateTick` which runs after all `OnTick` systems have been processed (Unity's version of these methods are `Update` and `LateUpdate`). In Unity the `DamageEvent` singleton script could have its order explicitly modified in the settings or with the `DefaultExecutionOrder` attribute. In Godot, this would likely be resolved by moving the `DamageEvent` node lower in the tree since nodes are processed from top to bottom while resolving children first.
|
||||
|
||||
An alternative solution would be to identify the behavior which raises events and isolate it into its own singleton. Doing this would allow it to easily be ordered before the damage handling system. We'll revisit this idea later.
|
||||
|
||||
## The Offspring would be proud
|
||||
Managing a game state as it grows in size and complexity is *difficult*. It's not uncommon for a game to have many thousands of active entities at a time. In many cases, it makes sense to decouple separate-but-related behaviors into their own systems to make them easier to manage.
|
||||
|
||||
One such separation would be game logic and UI. Rather than directly coupling the player's health to the UI representation of the player's health, it makes sense to have them communicate via some type of messaging system. An event seems like a natural fit.
|
||||
|
||||
```cs
|
||||
public class PlayerSpawnedArgs : EventArgs {
|
||||
public readonly Player Player;
|
||||
}
|
||||
|
||||
public class HealthChangedArgs : EventArgs {
|
||||
public readonly double PreviousValue;
|
||||
public readonly double CurrentValue;
|
||||
}
|
||||
|
||||
class Healthbar : UIElement {
|
||||
private float _currentHealth;
|
||||
// use getter/setter to automatically redraw
|
||||
// the UI on state changes
|
||||
private double currentHealth {
|
||||
get => _currentHealth;
|
||||
set {
|
||||
if (value != _currentHealth) {
|
||||
_currentHealth = value;
|
||||
Redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void OnInitialize() {
|
||||
// register to the player spawn event
|
||||
PlayerSpawnEvent.Register(OnPlayerSpawned);
|
||||
}
|
||||
|
||||
private void OnPlayerSpawned(object sender, PlayerSpawnedArgs args) {
|
||||
// subscribe specifically to that player's events
|
||||
args.Player.health.Register(OnHealthChanged);
|
||||
}
|
||||
|
||||
private void OnHealthChanged(object sender, HealthChangedArgs args) {
|
||||
currentHealth = args.CurrentValue;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will work for many cases but it's unfortunate it needs to manage subscriptions to two events in order to get the updates it needs. If we ever needed to add more than one player that renders a healthbar, this solution would no longer be sufficient. We could circumvent this by making health updates dispatched globally but that wold come at the cost of checking *every* health update just to find a player. This solution would be much more appealing, however, if every entity with health had a healthbar. One happy medium would be to create a more specific global event for player health updates. `Healthbar` can then simply register to that event without needing to concern itself with the particulars of spawning.
|
||||
|
||||
With all this event-driven programming comes another downside: events are opaque and difficult to debug. When an architecture relies heavily on events, it becomes increasingly important to have outstanding documentation. It's an unfortunate sacrifice to be made in exchange for the highly decoupled nature of events. Some game engines have made attempts to offer better insight into event connections but it's often only a mild remedy.
|
||||
|
||||
In order to better query the state of an event-driven application, [Martin Fowler's Event Sourcing post](https://martinfowler.com/eaaDev/EventSourcing.html) provides some additional respite. It's worth reading if you're working with many events in your game. Essentially, tracking the source of an event *in* the newly raised event allows subscribers to walk the chain of source events in reverse to determine additional context.
|
||||
|
||||
## Spreadsheet-oriented programming
|
||||
These were just a few pain points of game architecture that I've come across when making games. I've tried different solutions each time to varying levels of success. While I don't think there exists a one-size-fits-all solution to every architectural decision in video games, I *do* believe that reframing how we think about our architectural goals can make some problems diminish or even disappear. This is especially helpful if the affected problems are persistent in a given domain.
|
||||
|
||||
Entity-Component-System (ECS) is a data-oriented approach that has resolved many of the above issues for me. It comes with its own architectural challenges, especially since the pattern has been rapidly evolving due to its recent explosion of popularity.
|
||||
|
||||
There are many guides attempting to explain ECS in a simple terms. This can be a bit challenging since the approach may run counter to the fundamental understanding of game architecture for many uninitiated readers. Additionally, there are different types and implementations of ECS which sometimes pollute the overall message. [Sander Mertens](https://ajmmertens.medium.com/), the author of [FLECS](https://github.com/SanderMertens/flecs), has contributed a substantial amount to the development and education of ECS. Their [FAQ](https://github.com/SanderMertens/ecs-faq) is a valuable resource to have. I'm going to try to provide a high level explanation of ECS but I recommend looking to other resources if this doesn't make sense.
|
||||
|
||||
### Entities
|
||||
|
||||
An entity is an identifier for an object. It has no data, properties, or behaviors by itself. It is simply a marker for something that exists. In some implementations of ECS, this could be as simple as an unsigned integer. These identifiers are the primary way of fetching *Component* data.
|
||||
|
||||
### Components
|
||||
|
||||
Components hold data and belong to an entity. Some implementations have limitations on what kind of data can be housed but conceptually it can be anything. A player's level, their position in the world, their current health, and the current input state of a gamepad would all be stored in a component.
|
||||
|
||||
Components are often stored contiguously in memory (such as in an array). The entity is used to fetch data from that container.
|
||||
|
||||
|
||||
### An example
|
||||
Let's pause for a moment to consider the relationship between entities and components. If we were to focus purely on simplicity, we could implement these concepts in the following way.
|
||||
|
||||
|
||||
```cs
|
||||
// this is our component definition
|
||||
struct Health {
|
||||
public double Current;
|
||||
public double Max;
|
||||
}
|
||||
|
||||
Health[] health = new Health[100]; // this is our component container
|
||||
|
||||
int player = 0; // this is our entity
|
||||
double currentHealth = health[player].current; // accessing our component data
|
||||
Console.WriteLine($"The player's current health is {currentHealth}");
|
||||
```
|
||||
|
||||
<div class="callout info">
|
||||
<p class="header">Note</p>
|
||||
I would like to restate that the actual interface will depend on the library and the decisions its developers have made. Different implementations have different tradeoffs and limitations that may change the internal representation of an entity or component.
|
||||
</div>
|
||||
|
||||
If we extend our example just a little bit to include multiple components, we would end up with multiple containers (arrays) too. This has an interesting implication in that it allows us to visualize our data in a more intuitive way: a table.
|
||||
|
||||
| Entity | Name | Health.Current | Health.Max |
|
||||
| ------ | -------- | -------------- | ---------- |
|
||||
| 0 | "Kain" | 100 | 100 |
|
||||
| 1 | "Raziel" | 5 | 75 |
|
||||
| 2 | "Janos" | 0 | 1000 |
|
||||
|
||||
Our entity is the row ID and each successive column is its associated component data. In cases where an entity does not have a component, we can think of its value as `NULL`.
|
||||
|
||||
| Entity | Name | Health.Current | Health.Max | Weapon.Name |
|
||||
| ------ | -------- | -------------- | ---------- | ------------- |
|
||||
| 0 | "Kain" | 100 | 100 | "Soul Reaver (Physical)" |
|
||||
| 1 | "Raziel" | 5 | 75 | "Soul Reaver (Spectral)" |
|
||||
| 2 | "Janos" | 0 | 1000 | `NULL` |
|
||||
|
||||
Adding a new row is as simple as making a new entity. All of the columns except for the entity ID would be `NULL` because we haven't added any components.
|
||||
|
||||
| Entity | Name | Health.Current | Health.Max | Weapon.Name |
|
||||
| ------ | -------- | -------------- | ---------- | ------------- |
|
||||
| 0 | "Kain" | 100 | 100 | "Soul Reaver (Physical)" |
|
||||
| 1 | "Raziel" | 5 | 75 | "Soul Reaver (Spectral)" |
|
||||
| 2 | "Janos" | 0 | 1000 | `NULL` |
|
||||
| 3 | `NULL` | `NULL` | `NULL` | `NULL` |
|
||||
|
||||
|
||||
So to sum up, an entity is a row, a component is a column. Data lives in the cells where entities and components intersect.
|
||||
|
||||
### Systems
|
||||
|
||||
A system represents a behavior. This is where game and application logic lives. Systems receive a list of entities and iterate over that list to perform work on their component data. They may also create and destroy entities or attach and remove components. If we want to write a system which applies movement to an entity, we could check for the existence of a `Position` and `Velocity` components.
|
||||
|
||||
```cs
|
||||
private void ApplyMovementVelocity(Entity[] entities) {
|
||||
foreach(var entity of entities) {
|
||||
if (entity.HasComponent(velocity) && entity.HasComponent(position)) {
|
||||
position.x[entity] += velocity.x[entity]
|
||||
position.z[entity] += velocity.z[entity]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This system would likely be run every [physics tick](https://web.archive.org/web/20241219080529/https://www.gafferongames.com/post/fix_your_timestep/#:~:text=Free%20the%20physics) ([FixedUpdate](https://docs.unity3d.com/6000.0/Documentation/Manual/fixed-updates.html) in Unity or [_physics_process](https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html) in Godot).
|
||||
|
||||
### Queries
|
||||
|
||||
Checking for entities with only certain components is such a common pattern that most ECS engines have a separate concept for doing exactly this: queries. In these frameworks we may ask to only receive a list of entities that meet certain conditions. These conditions are typically limited to a simple check of whether or not an entity has a component.
|
||||
|
||||
Since the syntax for querying is highly dependent on the ECS library, I'll use Unity's as an example and try to recreate the above system.
|
||||
|
||||
```cs
|
||||
public void ApplyMovementVelocity(ref SystemState state) {
|
||||
foreach(var (transform, velocity) in SystemAPI.Query<RefRW<LocalTransform>, RefRO<Velocity>>()) {
|
||||
transform.position += velocity;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This seems like it has a lot going on but it's not that bad. `SystemAPI.Query` is doing most of the hard work. Using a few generics, we're able to declare which component types we'd like to query for: `LocalTransform` and `Velocity`. `RefRW` and `RefRO` describe how we'd like to access that data. `Ref` means it will be a reference (as opposed to a value) while `RW` means read-write and `RO` means read only. So a `RefRW<LocalTransform>` means we'll have read-write access to the `LocalTransform` reference.
|
||||
|
||||
One of the other nice things about `SystemAPI.Query` is that it returns an enumerable that we can use with `foreach`. Each enumerable item is a `Tuple` containing whatever we queried. In the example, we use tuple destructuring to access those components (ie. `var (transform, velocity)`).
|
||||
|
||||
Other than being more convenient, there are significant benefits to querying entities this way that will be covered in just a moment.
|
||||
|
||||
## Going further (Schedules, Tags)
|
||||
Most ECS systems can query the state to refine the list of entities they receive to what is most pertinent. Some have scheduling and ordering systems for systems.
|
||||
|
||||
## Performance
|
||||
its fast
|
||||
|
||||
## Issues with ECS
|
||||
Mental modeling is hard. Complex queries are hard. ECS is hard.
|
||||
|
||||
{% endblock article %}
|
||||
|
5
content/blog/on-ecs/index.toml
Normal file
5
content/blog/on-ecs/index.toml
Normal file
|
@ -0,0 +1,5 @@
|
|||
title = "On ECS"
|
||||
#created_date = "2024-11-30"
|
||||
description = "How ECS changed the way I makes games"
|
||||
tags = ["game-dev"]
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
title = "Simplifying Code with Components"
|
||||
created_date = "2021-12-05"
|
||||
tags = ["unity", "game", "programming"]
|
||||
|
||||
|
|
|
@ -28,6 +28,19 @@
|
|||
<a href="/static/img/kitsucafe-88x31.png">
|
||||
<img alt="a smaller banner for this site" width="88" height="31" src="/static/img/kitsucafe-88x31.png" />
|
||||
</a>
|
||||
<a href="/static/img/acab2.gif">
|
||||
<picture>
|
||||
<source
|
||||
srcset="/static/img/acab1.png"
|
||||
type="image/png"
|
||||
media="(prefers-reduced-motion)"
|
||||
/>
|
||||
<img alt="acab" width="88" height="31" src="/static/img/acab2.gif" />
|
||||
</picture>
|
||||
</a>
|
||||
<a href="/static/img/tmkf.png">
|
||||
<img alt="acab" width="88" height="31" src="/static/img/tmkf.png" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="primary column">
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
<footer class="box center-text">© Kitsune Cafe {{ now() | date(format="%Y") }}</footer>
|
||||
<footer class="box center-text"><span class="anti-copyright">©<span class="cross">/</span></span> Kitsune Cafe {{ now() | date(format="%Y") }}</footer>
|
||||
|
||||
|
|
|
@ -24,4 +24,4 @@
|
|||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -14,8 +14,13 @@
|
|||
{% if this.image %}
|
||||
<meta property="og:image" content="{{ this.image }}" />
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="/static/css/blog.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{{ this.title }} - {{ super() }}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
{% set date_format = "%d %B, %Y" %}
|
||||
<article>
|
||||
|
@ -34,4 +39,4 @@
|
|||
{% block article %}
|
||||
{% endblock %}
|
||||
</article>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
45
static/css/blog.css
Normal file
45
static/css/blog.css
Normal file
|
@ -0,0 +1,45 @@
|
|||
.blog.link-list li:not(:last-child) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 0.5rem;
|
||||
padding-left: 0.8rem;
|
||||
border-radius: var(--border-radius);
|
||||
border-left: 4px solid var(--border-color);
|
||||
}
|
||||
|
||||
.callout .header {
|
||||
font-weight: 600;
|
||||
font-variant: all-small-caps;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.callout.info {
|
||||
background-color: hsla(var(--cyan) var(--dark) / 0.25);
|
||||
border-color: hsla(var(--cyan) var(--light) / 0.5);
|
||||
}
|
||||
|
||||
.callout.info .header {
|
||||
--color: light-dark(
|
||||
hsl(var(--cyan) var(--darker)),
|
||||
hsl(var(--cyan) var(--light))
|
||||
);
|
||||
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
background-color: hsla(var(--yellow) var(--dark) / 0.25);
|
||||
border-color: hsla(var(--orange) var(--light) / 0.5);
|
||||
}
|
||||
|
||||
.callout.warning .header {
|
||||
--color: light-dark(
|
||||
hsl(var(--orange) var(--darker)),
|
||||
hsl(var(--yellow) var(--light))
|
||||
);
|
||||
|
||||
color: var(--color);
|
||||
}
|
||||
|
|
@ -1,68 +1,114 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
box-sizing: border-box;
|
||||
font-family: Verdana, Helvetica, Tahoma, sans-serif;
|
||||
font-family: Arimo, Verdana, Helvetica, "Trebuchet MS", Tahoma, Arial, sans-serif;
|
||||
|
||||
--border-radius: 4px;
|
||||
|
||||
--primary-color: #EA80FC;
|
||||
--secondary-color: #AA4FF6;
|
||||
--tertiary-color: #7461AD;
|
||||
--quaternary-color: #794A7F;
|
||||
--egyptian-blue: #EA80FC;
|
||||
--light-purple: #AA4FF6;
|
||||
--purple: #7461AD;
|
||||
--lilac-violet: #794A7F;
|
||||
|
||||
--primary-complement-color: #92fc80;
|
||||
--secondary-complement-color: #9aad61;
|
||||
--tertiary-complement-color: #9aad61;
|
||||
--quaternary-complement-color: #507f4a;
|
||||
--lightest: 90%;
|
||||
--lighter: 70%;
|
||||
--light: 58%;
|
||||
--base: 46%;
|
||||
--dark: 34%;
|
||||
--darker: 22%;
|
||||
|
||||
--primary-triad-1-color: #FCEA80;
|
||||
--secondary-triad-1-color: #F6AA4F;
|
||||
--tertiary-triad-1-color: #AD7461;
|
||||
--quaternary-triad-1-color: #7f794a;
|
||||
--violet: 255 40%;
|
||||
--magenta: 285 40%;
|
||||
--green: 75 31%;
|
||||
--yellow: 51 95%;
|
||||
--orange: 40 71%;
|
||||
--cyan: 195 41%;
|
||||
|
||||
--primary-triad-2-color: #80FCEA;
|
||||
--secondary-triad-2-color: #4FF6AA;
|
||||
--tertiary-triad-2-color: #61AD74;
|
||||
--quaternary-triad-2-color: #4a7f79;
|
||||
--grey: 200 6%;
|
||||
|
||||
--black: #181A1B;
|
||||
--dark-grey: #232323;
|
||||
--grey: #A9A9A9;
|
||||
--light-grey: #D3D3D3;
|
||||
--white: #EEEEEE;
|
||||
--black: 200 6% 10%;
|
||||
--white: 200 6% 94%;
|
||||
|
||||
--dark-violet: #391648;
|
||||
--light-violet: #ecc0ff;
|
||||
--foreground-color: light-dark(
|
||||
hsl(var(--black)),
|
||||
hsl(var(--white))
|
||||
);
|
||||
|
||||
--foreground-color: var(--white);
|
||||
--background-color: var(--black);
|
||||
--secondary-background-color: var(--dark-violet);
|
||||
--code-color: var(--primary-complement-color);
|
||||
--background-color: light-dark(
|
||||
hsl(var(--white)),
|
||||
hsl(var(--black))
|
||||
);
|
||||
|
||||
--border-color: var(--secondary-color);
|
||||
--header-color: var(--secondary-triad-1-color);
|
||||
--subheader-color: var(--primary-color);
|
||||
--secondary-background-color: light-dark(
|
||||
hsl(var(--violet) var(--light)),
|
||||
hsl(var(--violet) var(--dark))
|
||||
);
|
||||
|
||||
--link-color: var(--secondary-triad-2-color);
|
||||
--link-hover-color: var(--primary-triad-2-color);
|
||||
--link-active-color: var(--tertiary-triad-2-color);
|
||||
--link-visited-color: var(--quaternary-triad-2-color);
|
||||
--code-color: light-dark(
|
||||
hsl(var(--orange) var(--darker)),
|
||||
hsl(var(--yellow) var(--light))
|
||||
);
|
||||
|
||||
--list-marker-color: var(--secondary-triad-1-color);
|
||||
--muted-color: var(--grey);
|
||||
--border-color: light-dark(
|
||||
hsl(var(--violet) var(--dark)),
|
||||
hsl(var(--violet) var(--light))
|
||||
);
|
||||
|
||||
--header-color: light-dark(
|
||||
hsl(var(--orange) var(--dark)),
|
||||
hsl(var(--yellow) var(--base))
|
||||
);
|
||||
|
||||
--subheader-color: light-dark(
|
||||
hsl(var(--violet) var(--dark)),
|
||||
hsl(var(--magenta) var(--light))
|
||||
);
|
||||
|
||||
--link-color: light-dark(
|
||||
hsl(var(--cyan) var(--dark)),
|
||||
hsl(var(--cyan) var(--light))
|
||||
);
|
||||
|
||||
--link-hover-color: light-dark(
|
||||
hsl(var(--cyan) var(--base)),
|
||||
hsl(var(--cyan) var(--lighter))
|
||||
);
|
||||
|
||||
--link-active-color: light-dark(
|
||||
hsl(var(--cyan) var(--dark)),
|
||||
hsl(var(--cyan) var(--dark))
|
||||
);
|
||||
|
||||
--link-visited-color: light-dark(
|
||||
hsl(var(--cyan) var(--darker)),
|
||||
hsl(var(--cyan) var(--dark))
|
||||
);
|
||||
|
||||
--list-marker-color: light-dark(
|
||||
hsl(var(--orange) var(--base)),
|
||||
hsl(var(--yellow) var(--base))
|
||||
);
|
||||
|
||||
--muted-color: light-dark(
|
||||
hsl(var(--grey) var(--dark)),
|
||||
hsl(var(--grey) var(--base))
|
||||
);
|
||||
|
||||
--table-header-color: light-dark(
|
||||
hsl(var(--violet) var(--lighter)),
|
||||
hsl(var(--violet) var(--base))
|
||||
);
|
||||
|
||||
--table-row-color: var(--background-color);
|
||||
|
||||
--table-row-alt-color: light-dark(
|
||||
hsl(var(--grey) var(--lightest)),
|
||||
hsl(var(--grey) var(--darker))
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--foreground-color: var(--black);
|
||||
--background-color: var(--white);
|
||||
--secondary-background-color: var(--light-violet);
|
||||
--code-color: var(--tertiary-triad-1-color);
|
||||
|
||||
--border-color: var(--tertiary-color);
|
||||
--header-color: var(--tertiary-triad-1-color);
|
||||
--subheader-color: var(--tertiary-color);
|
||||
|
||||
--link-color: var(--quaternary-complement-color);
|
||||
--muted-color: var(--dark-grey);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.high-motion {
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,7 +141,7 @@ body {
|
|||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: Arial, Georgia, Helvetica, sans-serif;
|
||||
font-family: Palatino, Georgia, Tinos, Georgia, Garamond, "Times New Roman", Times, serif;
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
|
@ -122,8 +168,9 @@ li {
|
|||
li::before {
|
||||
content: '★';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -12px;
|
||||
font-size: 1.2rem;
|
||||
top: -4px;
|
||||
left: -10px;
|
||||
color: var(--list-marker-color);
|
||||
}
|
||||
|
||||
|
@ -132,12 +179,23 @@ img {
|
|||
}
|
||||
|
||||
code {
|
||||
--x: 0.25rem;
|
||||
margin-left: var(--x);
|
||||
margin-right: var(--x);
|
||||
font-weight: 500;
|
||||
color: var(--code-color);
|
||||
}
|
||||
|
||||
pre {
|
||||
--x: 0.5rem;
|
||||
--y: 1rem;
|
||||
|
||||
padding: var(--y) var(--x) var(--y) var(--x);
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: Inconsolata, Consolas, Cousine, "Courier New", Courier, monospace;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
@ -159,16 +217,46 @@ article header {
|
|||
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table thead {
|
||||
background-color: var(--tertiary-color);
|
||||
table td {
|
||||
padding: 0.25rem 0.25rem;
|
||||
}
|
||||
|
||||
table tr:nth-child(2n) {
|
||||
background-color: var(--dark-grey);
|
||||
@media only screen and (min-width: 740px) {
|
||||
table {
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
table thead th {
|
||||
font-weight: 400;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
|
||||
color: light-dark(
|
||||
hsl(var(--grey) var(--dark)),
|
||||
hsl(var(--grey) var(--light))
|
||||
);
|
||||
}
|
||||
|
||||
table td:first-child {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
table tbody tr:nth-child(odd) {
|
||||
background-color: var(--table-row-color);
|
||||
}
|
||||
|
||||
table tbody tr:nth-child(even) {
|
||||
background-color: var(--table-row-alt-color);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 740px) {
|
||||
nav.main-nav {
|
||||
|
@ -238,12 +326,14 @@ table tr:nth-child(2n) {
|
|||
.box {
|
||||
background-color: var(--background-color);
|
||||
padding: 1rem;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 740px) {
|
||||
.box {
|
||||
border: 1px solid var(--quaternary-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
margin: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
@ -255,11 +345,11 @@ table tr:nth-child(2n) {
|
|||
.box.has-moon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
top: 24px;
|
||||
right: 18px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
box-shadow: -4px 4px 0 1px var(--secondary-triad-1-color);
|
||||
box-shadow: -4px 4px 0 1px var(--list-marker-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
|
@ -337,3 +427,18 @@ table tr:nth-child(2n) {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-copyright {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.anti-copyright .cross {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
|
|
BIN
static/img/acab1.png
Normal file
BIN
static/img/acab1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 398 B |
BIN
static/img/acab2.gif
Normal file
BIN
static/img/acab2.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
BIN
static/img/anon.png
Normal file
BIN
static/img/anon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 907 B |
Binary file not shown.
Before Width: | Height: | Size: 9.2 KiB |
BIN
static/img/tmkf.png
Normal file
BIN
static/img/tmkf.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 780 B |
Loading…
Reference in a new issue