Skip to content

Reimplement snake using the ECS architecture #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions shell.nix
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ pkgs.mkShell {
coreutils
findutils
bash

# Extra buildtools
graphviz
scc

# Elixir
elixir_1_14
Expand All @@ -24,6 +27,11 @@ pkgs.mkShell {
glew # An OpenGL extension loading library for C/C++
pkg-config
gcc

# For Membrane
portaudio
libmad
ffmpeg_6-headless
] ++ lib.optionals stdenv.isDarwin [
darwin.apple_sdk.frameworks.Cocoa
];
Expand Down
6 changes: 5 additions & 1 deletion snake-ecs/.formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# Used by "mix format"
locals_without_parens = [member: 2, member: 3, name: 1, component: 1, component: 2, wants: 1]

[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: locals_without_parens,
import_deps: [:typed_struct]
]
4 changes: 4 additions & 0 deletions snake-ecs/.gradient_ignore.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[
# Ignore the spec error from using Membrane
"lib/engine/sounds.ex:1"
]
32 changes: 32 additions & 0 deletions snake-ecs/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
include modules/make/boilerplate.mk
include modules/make/elixir-build.mk

.PHONY: interactive-server is
## Shorthand for interactive-server
is: interactive-server
## Start Athena in interactive mode with:
## - Phoenix running on http://localhost:3000
interactive-server: deps
@${source}
iex -S mix phx.server

.PHONY: test
## Run test suite
test: deps
export ERL_FLAGS=-kernel start_pg true
mix test --cover

test-ci: deps
mix test

.PHONY: tc typecheck
tc: typecheck
typecheck:
mix gradient && mix dialyzer

.PHONY: test-watch
## Run test suite with code watcher
## Use --stale option (https://github.com/lpil/mix-test.watch#running-tests-of-modules-that-changed)
test-watch: deps
export ERL_FLAGS=-kernel start_pg true
mix test.watch --stale
5 changes: 5 additions & 0 deletions snake-ecs/lib/components/stats.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
use Engine.DSL.Component

defcomponent Stats do
member :hp, 100
end
60 changes: 60 additions & 0 deletions snake-ecs/lib/engine/DSL/component.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Engine.DSL.Component do
defmacro __using__(_options) do
quote do
use TypedStruct
import Engine.DSL.Component
end
end

defmacro defcomponent(name, do: block) do
quote do
defmodule Engine.ComponentTypes.unquote(name) do
typedstruct do
unquote(block)
end
end
end
end

defmacro member(name, default_value) when is_atom(default_value) do
quote do
field(unquote(name), atom(), default: unquote(default_value))
end
end

defmacro member(name, default_value) when is_boolean(default_value) do
quote do
field(unquote(name), boolean(), default: unquote(default_value))
end
end

defmacro member(name, default_value) when is_integer(default_value) do
quote do
field(unquote(name), integer(), default: unquote(default_value))
end
end

defmacro member(name, default_value) when is_float(default_value) do
quote do
field(unquote(name), float(), default: unquote(default_value))
end
end

defmacro member(name, default_value) when is_binary(default_value) do
quote do
field(unquote(name), String.t(), default: unquote(default_value))
end
end

defmacro member(name, default_value) when is_list(default_value) do
quote do
field(unquote(name), list(), default: unquote(default_value))
end
end

defmacro member(name, default_value) when is_map(default_value) do
quote do
field(unquote(name), %{}, default: unquote(default_value))
end
end
end
64 changes: 64 additions & 0 deletions snake-ecs/lib/engine/DSL/entity.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
defmodule Engine.DSL.Entity do
defmacro __using__(_options) do
quote do
import Engine.DSL.Entity
end
end

defmacro defentity(name, do: block) do
quote do
# Maybe it should Game.Entity instead of Engine.EntityTypes
defmodule Engine.EntityTypes.unquote(name) do
alias Engine.Entity

Module.register_attribute(__MODULE__, :components, accumulate: true, persist: true)
Module.register_attribute(__MODULE__, :entity_name, persist: true)

unquote(block)

defp __full_type(type) do
Module.concat(Engine.ComponentTypes, type)
end

def create do
components =
Enum.reduce(@components, %{}, fn {type, default_data}, acc ->
full_type = __full_type(type)
Map.put_new(acc, full_type, struct(full_type, default_data))
end)

%Entity.Data{
components: components
}
end
end
end
end

defmacro component(component_type) do
quote do
@components {unquote(component_type), %{}}
end
end

defmacro component(component_type, default_data) do
{:__aliases__, _, [type]} = component_type
full_type = Module.concat(Engine.ComponentTypes, type)

# Some person on the Internet[1] say this function is unsafe and can
# deadlock, and that the recommended function is actually ensure_loaded?
#
# [1]: https://github.com/elixirmoney/money/pull/131
if !Code.ensure_compiled(full_type) do
raise(CompileError,
description: "Component #{type} does not exist!",
file: __CALLER__.file,
line: __CALLER__.line
)
end

quote do
@components {unquote(component_type), unquote(default_data)}
end
end
end
146 changes: 146 additions & 0 deletions snake-ecs/lib/engine/DSL/system.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
defmodule Engine.DSL.System do
defmacro __using__(_options) do
quote do
import Engine.DSL.System
end
end

defmacro defsystem(name, do: block) do
quote do
defmodule RuntimeSystems.unquote(name) do
alias Engine.Entity

require Logger

Module.register_attribute(__MODULE__, :wants, accumulate: true, persist: true)
Module.register_attribute(__MODULE__, :system_name, persist: true)

defp __get_component_data(entity_pid, component_type, key) do
case GenServer.call(entity_pid, {:get_component, component_type}) do
%{} = component ->
get_in(component, [Access.key!(key)])

_ ->
Logger.warn(
"Could not fetch component #{inspect(component_type)} on #{inspect(entity_pid)}"
)

nil
end
end

defp __get_all_components(entity_pid) do
GenServer.call(entity_pid, :get_all_components)
end

defp __set_component_data(entity_pid, component_type, key, new_value) do
GenServer.call(entity_pid, {:set_component_data, component_type, key, new_value})
end

defp __add_component(entity_pid, component_type, default_data) do
GenServer.call(entity_pid, {:add_component, component_type, default_data})
end

defp __remove_component(entity_pid, component_type) do
GenServer.call(entity_pid, {:remove_component, component_type})
end

unquote(block)

def wants do
@wants
end

def name do
@system_name
end
end
end
end

defmacro log(item) do
quote do
Engine.Util.SystemLog.debug(unquote(item))
end
end

defmacro warn(item) do
quote do
Engine.Util.SystemLog.warn(unquote(item))
end
end

defmacro get_component_data(component_type, key) do
quote do
__get_component_data(var!(entity), unquote(component_type), unquote(key))
end
end

defmacro get_all_components() do
quote do
__get_all_components(var!(entity))
end
end

defmacro set_component_data(component_type, key, new_data) do
quote do
__set_component_data(var!(entity), unquote(component_type), unquote(key), unquote(new_data))
end
end

defmacro add_component(component_type) do
quote do
__add_component(var!(entity), unquote(component_type))
end
end

defmacro remove_component(component_type) do
quote do
__remove_component(var!(entity), unquote(component_type))
end
end

defmacro name(name) do
quote do
@system_name unquote(name)
end
end

defmacro wants(component_name) do
{:__aliases__, _, [type]} = component_name
full_type = Module.concat(ComponentTypes, type)

# Some person on the Internet[1] say this function is unsafe and can
# deadlock, and that the recommended function is actually ensure_loaded?
#
# [1]: https://github.com/elixirmoney/money/pull/131
if !Code.ensure_compiled(full_type) do
raise(CompileError,
description: "Component #{type} does not exist!",
file: __CALLER__.file,
line: __CALLER__.line
)
end

quote do
@wants unquote(component_name)
end
end

defmacro on_tick(do: block) do
quote do
alias Engine.Util.Performance

def __process_entity(var!(entity), var!(world_name), var!(frontend_pid), var!(delta_time)) do
unquote(block)
end

def __tick(entity_list, world_name, frontend_pid, delta_time)
when is_list(entity_list) and is_atom(world_name) do
Engine.Util.Performance.parallel_map(entity_list, fn ent ->
__process_entity(ent, world_name, frontend_pid, delta_time)
end)
end
end
end
end
30 changes: 30 additions & 0 deletions snake-ecs/lib/engine/action.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Engine.Action do
use TypedStruct

require Logger

alias __MODULE__

typedstruct do
field(:action_type, atom(), enforce: true)
field(:target_entity, pid(), enforce: true)
field(:payload, %{}, enforce: true)
end

def create(type, target, extra_data \\ %{}) do
%Action{
action_type: type,
target_entity: target,
payload: extra_data
}
end

def execute(%Action{} = action) do
if Process.alive?(action.target_entity) do
Logger.debug("Action enqueued from: #{inspect(action)}")
GenServer.call(action.target_entity, {:action_recv, action})
else
Logger.warn("Action was dropped because target PID was dead: #{inspect(action)}")
end
end
end
Loading