├── .formatter.exs ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── brains ├── common.exs ├── cone.brain.exs ├── cylinder.brain.exs ├── frowny.brain.exs ├── smiley.brain.exs ├── ufo.brain.exs └── walker.brain.exs ├── lib ├── DSL │ ├── component.ex │ ├── entity.ex │ └── system.ex ├── action │ ├── action.ex │ └── action_types.ex ├── component │ └── definitions │ │ ├── action_list.ex │ │ ├── active_battle.ex │ │ ├── actor_name.ex │ │ ├── animation_mod.ex │ │ ├── demo_stats.ex │ │ ├── enemy.ex │ │ ├── grid_position.ex │ │ ├── npc_brain.ex │ │ ├── sprite.ex │ │ ├── status.ex │ │ └── targetable.ex ├── elixir_rpg.ex ├── entity │ ├── data.ex │ ├── definitions │ │ ├── cat.ex │ │ ├── enemy │ │ │ ├── cone.ex │ │ │ ├── cylinder.ex │ │ │ ├── frowny.ex │ │ │ ├── smiley.ex │ │ │ ├── ufo.ex │ │ │ └── walker.ex │ │ ├── guy.ex │ │ └── soda.ex │ ├── entity.ex │ └── entity_store.ex ├── status_effects.ex ├── system │ └── definitions │ │ ├── active_battle.ex │ │ ├── animate_mods.ex │ │ ├── casting.ex │ │ ├── clear_state.ex │ │ ├── combat.ex │ │ ├── drawing.ex │ │ ├── npc_brain.ex │ │ ├── player_input.ex │ │ ├── reaper.ex │ │ ├── special_sprite.ex │ │ └── status_effect.ex ├── util │ ├── mod_util.ex │ ├── perf_util.ex │ └── system_log.ex └── world │ ├── data.ex │ ├── input.ex │ ├── input_server.ex │ ├── world.ex │ └── world_clock.ex ├── mix.exs ├── mix.lock └── test └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | locals_without_parens = [member: 2, member: 3, name: 1, component: 1, component: 2, wants: 1] 3 | 4 | [ 5 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 6 | locals_without_parens: locals_without_parens, 7 | import_deps: [:typed_struct] 8 | ] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | elixir_rpg-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 23.3.1 2 | elixir 1.11.4-otp-23 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Digit (@doawoo) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elixir RPG ECS Experiment 2 | 3 | Welcome to the ECS/Engine portion of the code seen in my ElixirConf 2021 Talk "Game Programming Patterns In Elixir?" 4 | 5 | This code is very much: hacky, prototype quality, and probably full of bad ideas. 6 | 7 | But if it gives you any cool ideas that are useful in your own projects, then I'm glad! :) 8 | 9 | ## The Talk 10 | 11 | [![ElixirConf 2021 Youtube Link](https://img.youtube.com/vi/gQb58bqwDOc/0.jpg)](https://www.youtube.com/watch?v=gQb58bqwDOc) 12 | -------------------------------------------------------------------------------- /brains/common.exs: -------------------------------------------------------------------------------- 1 | ## COMMON SCRIPT CODE 2 | 3 | self = get_components.(entity) 4 | player_characters = EntityStore.get_entities_with([ActionList], world) 5 | enemies = EntityStore.get_entities_with([Enemy], world) 6 | 7 | ## END COMMON CODE 8 | -------------------------------------------------------------------------------- /brains/cone.brain.exs: -------------------------------------------------------------------------------- 1 | #! import_common 2 | 3 | #### 4 | # Cone enemy brain script 5 | #### 6 | 7 | # Select a totally random player character in the party 8 | if player_characters != [] do 9 | random_pc = Enum.random(player_characters) 10 | 11 | # Attack them with non-pierce physical damange 12 | dmg = 5 13 | atk_action = ActionTypes.physical_damage(random_pc, dmg, false) 14 | 15 | # Execute attack action 16 | Action.execute(atk_action) 17 | end -------------------------------------------------------------------------------- /brains/cylinder.brain.exs: -------------------------------------------------------------------------------- 1 | #! import_common 2 | 3 | #### 4 | # Cone enemy brain script 5 | #### 6 | 7 | # Select a totally random player character in the party 8 | if player_characters != [] do 9 | random_pc = Enum.random(player_characters) 10 | 11 | # Attack them with non-pierce physical damange 12 | dmg = 8 13 | atk_action = ActionTypes.physical_damage(random_pc, dmg, false) 14 | 15 | # Execute attack action 16 | Action.execute(atk_action) 17 | end -------------------------------------------------------------------------------- /brains/frowny.brain.exs: -------------------------------------------------------------------------------- 1 | #! import_common 2 | 3 | #### 4 | # Frown balloon enemy brain script 5 | # Picks a random play character and applies a debuff to them 6 | #### 7 | 8 | if enemies != [] && player_characters != [] do 9 | required_mp = 20 10 | current_mp = Entity.get_component(entity, DemoStats).mp 11 | 12 | if Entity.get_component(entity, DemoStats).mp >= required_mp do 13 | casting_delay = 3.5 14 | target = Enum.random(player_characters) 15 | effect = Enum.random([:burn, :shock]) 16 | 17 | if target do 18 | bad_action = ActionTypes.give_status(target, effect) 19 | Entity.set_component_data(entity, DemoStats, :casting, true) 20 | Entity.set_component_data(entity, DemoStats, :casting_data, bad_action) 21 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 22 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /brains/smiley.brain.exs: -------------------------------------------------------------------------------- 1 | #! import_common 2 | 3 | #### 4 | # Smile balloon enemy brain script 5 | # Heals a random npc who does not have full health 6 | #### 7 | 8 | if enemies != [] do 9 | required_mp = 10 10 | current_mp = Entity.get_component(entity, DemoStats).mp 11 | 12 | if Entity.get_component(entity, DemoStats).mp >= required_mp do 13 | casting_delay = 2.0 14 | heal_target = Enum.find(enemies, fn ent -> 15 | current_hp = Entity.get_component(ent, DemoStats).hp 16 | max_hp = Entity.get_component(ent, DemoStats).max_hp 17 | current_hp < max_hp 18 | end) 19 | 20 | if heal_target do 21 | heal_action = ActionTypes.heal(heal_target, 15) 22 | 23 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 24 | Entity.set_component_data(entity, DemoStats, :casting, true) 25 | Entity.set_component_data(entity, DemoStats, :casting_data, heal_action) 26 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 27 | 28 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 29 | end 30 | end 31 | end -------------------------------------------------------------------------------- /brains/ufo.brain.exs: -------------------------------------------------------------------------------- 1 | #! import_common 2 | 3 | #### 4 | # UFO Brain 5 | # The most complex brain of them all. 6 | # 7 | # When there are no allies on the feild: 8 | # Spawn a set (2) 9 | # 10 | # When there are allies on the field: 11 | # If they are all ok (above 1/2 health): 12 | # Attack the PC with the most HP 13 | # If they are all not ok (below 1/2 health): 14 | # Cast SHOCK on all PC 15 | #### 16 | 17 | enemy_sets = [ 18 | {Frowny, Smiley}, 19 | {Walker, Walker}, 20 | {Cone, Cone}, 21 | {Cone, Cylinder}, 22 | {Cylinder, Cylinder}, 23 | {Cone, Walker} 24 | ] 25 | 26 | if length(enemies) > 1 do 27 | IO.inspect("ATTACK RANDOM PC") 28 | enemies_are_ok? = Enum.all?(enemies, fn ent -> 29 | current_hp = Entity.get_component(ent, DemoStats).hp 30 | max_hp = Entity.get_component(ent, DemoStats).max_hp 31 | current_hp < (max_hp / 2) 32 | end) 33 | 34 | if enemies_are_ok? do 35 | # find PC with most health 36 | target = Enum.sort_by(player_characters, fn ent -> 37 | Entity.get_component(ent, DemoStats).hp 38 | end) |> List.first() 39 | 40 | # attack them 41 | dmg = 10 42 | atk_action = ActionTypes.physical_damage(target, dmg, false) 43 | Action.execute(atk_action) 44 | else 45 | # Cast shock on all players 46 | Enum.each(player_characters, fn ent -> 47 | shock_action = ActionTypes.give_status(ent, :shock) 48 | current_mp = Entity.get_component(entity, DemoStats).mp 49 | casting_delay = 2.5 50 | 51 | IO.inspect("SHOCK PC") 52 | 53 | Entity.set_component_data(entity, DemoStats, :casting, true) 54 | Entity.set_component_data(entity, DemoStats, :casting_data, shock_action) 55 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 56 | end) 57 | end 58 | else 59 | # Spawn some friends! 60 | IO.inspect("SPAWN FRIENDS") 61 | casting_delay = 1.5 62 | spawn_set = Enum.random(enemy_sets) 63 | spawn_action = ActionTypes.spawn(entity, spawn_set) 64 | Entity.set_component_data(entity, DemoStats, :casting, true) 65 | Entity.set_component_data(entity, DemoStats, :casting_data, spawn_action) 66 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 67 | end -------------------------------------------------------------------------------- /brains/walker.brain.exs: -------------------------------------------------------------------------------- 1 | #! import_common 2 | 3 | #### 4 | # Walker enemy brain script 5 | #### 6 | 7 | # Select a totally random player character in the party 8 | if player_characters != [] do 9 | random_pc = Enum.random(player_characters) 10 | 11 | # Attack them with non-pierce physical damange 12 | dmg = 2 13 | atk_action = ActionTypes.physical_damage(random_pc, dmg, false) 14 | 15 | # Execute attack action 16 | Action.execute(atk_action) 17 | end -------------------------------------------------------------------------------- /lib/DSL/component.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.DSL.Component do 2 | defmacro __using__(_options) do 3 | quote do 4 | use TypedStruct 5 | import ElixirRPG.DSL.Component 6 | end 7 | end 8 | 9 | defmacro defcomponent(name, do: block) do 10 | quote do 11 | defmodule ElixirRPG.ComponentTypes.unquote(name) do 12 | typedstruct do 13 | unquote(block) 14 | end 15 | end 16 | end 17 | end 18 | 19 | defmacro member(name, default_value) when is_atom(default_value) do 20 | quote do 21 | field(unquote(name), atom(), default: unquote(default_value)) 22 | end 23 | end 24 | 25 | defmacro member(name, default_value) when is_boolean(default_value) do 26 | quote do 27 | field(unquote(name), boolean(), default: unquote(default_value)) 28 | end 29 | end 30 | 31 | defmacro member(name, default_value) when is_integer(default_value) do 32 | quote do 33 | field(unquote(name), integer(), default: unquote(default_value)) 34 | end 35 | end 36 | 37 | defmacro member(name, default_value) when is_float(default_value) do 38 | quote do 39 | field(unquote(name), float(), default: unquote(default_value)) 40 | end 41 | end 42 | 43 | defmacro member(name, default_value) when is_binary(default_value) do 44 | quote do 45 | field(unquote(name), String.t(), default: unquote(default_value)) 46 | end 47 | end 48 | 49 | defmacro member(name, default_value) when is_list(default_value) do 50 | quote do 51 | field(unquote(name), list(), default: unquote(default_value)) 52 | end 53 | end 54 | 55 | defmacro member(name, default_value) when is_map(default_value) do 56 | quote do 57 | field(unquote(name), %{}, default: unquote(default_value)) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/DSL/entity.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.DSL.Entity do 2 | defmacro __using__(_options) do 3 | quote do 4 | import ElixirRPG.DSL.Entity 5 | end 6 | end 7 | 8 | defmacro defentity(name, do: block) do 9 | quote do 10 | defmodule ElixirRPG.EntityTypes.unquote(name) do 11 | alias ElixirRPG.Entity 12 | 13 | Module.register_attribute(__MODULE__, :components, accumulate: true, persist: true) 14 | Module.register_attribute(__MODULE__, :entity_name, persist: true) 15 | 16 | unquote(block) 17 | 18 | defp __build_component(type, default_data) do 19 | full_type = Module.concat(ElixirRPG.ComponentTypes, type) 20 | struct(full_type, default_data) 21 | end 22 | 23 | def create do 24 | components = 25 | Enum.reduce(@components, %{}, fn {type, default_data}, acc -> 26 | comp = __build_component(type, default_data) 27 | Map.put_new(acc, type, comp) 28 | end) 29 | 30 | %Entity.Data{ 31 | components: components 32 | } 33 | end 34 | end 35 | end 36 | end 37 | 38 | defmacro component(component_type) do 39 | quote do 40 | @components {unquote(component_type), %{}} 41 | end 42 | end 43 | 44 | defmacro component(component_type, default_data) do 45 | {:__aliases__, _, [type]} = component_type 46 | full_type = Module.concat(ElixirRPG.ComponentTypes, type) 47 | 48 | if !Code.ensure_compiled?(full_type) do 49 | raise(CompileError, 50 | description: "Component #{type} does not exist!", 51 | file: __CALLER__.file, 52 | line: __CALLER__.line 53 | ) 54 | end 55 | 56 | quote do 57 | @components {unquote(component_type), unquote(default_data)} 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/DSL/system.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.DSL.System do 2 | defmacro __using__(_options) do 3 | quote do 4 | import ElixirRPG.DSL.System 5 | end 6 | end 7 | 8 | defmacro defsystem(name, do: block) do 9 | quote do 10 | defmodule ElixirRPG.RuntimeSystems.unquote(name) do 11 | alias ElixirRPG.Entity 12 | 13 | require Logger 14 | 15 | Module.register_attribute(__MODULE__, :wants, accumulate: true, persist: true) 16 | Module.register_attribute(__MODULE__, :system_name, persist: true) 17 | 18 | defp __get_component_data(entity_pid, component_type, key) do 19 | case GenServer.call(entity_pid, {:get_component, component_type}) do 20 | %{} = component -> 21 | get_in(component, [Access.key!(key)]) 22 | 23 | _ -> 24 | Logger.warn( 25 | "Could not fetch component #{inspect(component_type)} on #{inspect(entity_pid)}" 26 | ) 27 | 28 | nil 29 | end 30 | end 31 | 32 | defp __get_all_components(entity_pid) do 33 | GenServer.call(entity_pid, :get_all_components) 34 | end 35 | 36 | defp __set_component_data(entity_pid, component_type, key, new_value) do 37 | GenServer.call(entity_pid, {:set_component_data, component_type, key, new_value}) 38 | end 39 | 40 | defp __add_component(entity_pid, component_type, default_data) do 41 | GenServer.call(entity_pid, {:add_component, component_type, default_data}) 42 | end 43 | 44 | defp __remove_component(entity_pid, component_type) do 45 | GenServer.call(entity_pid, {:remove_component, component_type}) 46 | end 47 | 48 | unquote(block) 49 | 50 | def wants do 51 | @wants 52 | end 53 | 54 | def name do 55 | @system_name 56 | end 57 | end 58 | end 59 | end 60 | 61 | defmacro log(item) do 62 | quote do 63 | ElixirRPG.Util.SystemLog.debug(unquote(item)) 64 | end 65 | end 66 | 67 | defmacro warn(item) do 68 | quote do 69 | ElixirRPG.Util.SystemLog.warn(unquote(item)) 70 | end 71 | end 72 | 73 | defmacro get_component_data(component_type, key) do 74 | quote do 75 | __get_component_data(var!(entity), unquote(component_type), unquote(key)) 76 | end 77 | end 78 | 79 | defmacro get_all_components() do 80 | quote do 81 | __get_all_components(var!(entity)) 82 | end 83 | end 84 | 85 | defmacro set_component_data(component_type, key, new_data) do 86 | quote do 87 | __set_component_data(var!(entity), unquote(component_type), unquote(key), unquote(new_data)) 88 | end 89 | end 90 | 91 | defmacro add_component(component_type) do 92 | quote do 93 | __add_component(var!(entity), unquote(component_type)) 94 | end 95 | end 96 | 97 | defmacro remove_component(component_type) do 98 | quote do 99 | __remove_component(var!(entity), unquote(component_type)) 100 | end 101 | end 102 | 103 | defmacro name(name) do 104 | quote do 105 | @system_name unquote(name) 106 | end 107 | end 108 | 109 | defmacro wants(component_name) do 110 | {:__aliases__, _, [type]} = component_name 111 | full_type = Module.concat(ElixirRPG.ComponentTypes, type) 112 | 113 | if !Code.ensure_compiled?(full_type) do 114 | raise(CompileError, 115 | description: "Component #{type} does not exist!", 116 | file: __CALLER__.file, 117 | line: __CALLER__.line 118 | ) 119 | end 120 | 121 | quote do 122 | @wants unquote(component_name) 123 | end 124 | end 125 | 126 | defmacro on_tick(do: block) do 127 | quote do 128 | alias ElixirRPG.Util.PerfUtil 129 | 130 | def __process_entity(var!(entity), var!(world_name), var!(frontend_pid), var!(delta_time)) do 131 | unquote(block) 132 | end 133 | 134 | def __tick(entity_list, world_name, frontend_pid, delta_time) 135 | when is_list(entity_list) and is_atom(world_name) do 136 | PerfUtil.parallel_map(entity_list, fn ent -> 137 | __process_entity(ent, world_name, frontend_pid, delta_time) 138 | end) 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/action/action.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Action do 2 | use TypedStruct 3 | 4 | require Logger 5 | 6 | alias __MODULE__ 7 | 8 | typedstruct do 9 | field :action_type, atom(), enforce: true 10 | field :target_entity, pid(), enforce: true 11 | field :payload, %{}, enforce: true 12 | end 13 | 14 | def make_action(type, target, extra_data \\ %{}) do 15 | %Action{ 16 | action_type: type, 17 | target_entity: target, 18 | payload: extra_data 19 | } 20 | end 21 | 22 | def execute(%Action{} = action) do 23 | if Process.alive?(action.target_entity) do 24 | Logger.debug("Action enqueued from: #{inspect(action)}") 25 | GenServer.call(action.target_entity, {:action_recv, action}) 26 | else 27 | Logger.warn("Action was dropped because target PID was dead: #{inspect(action)}") 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/action/action_types.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Action.ActionTypes do 2 | alias ElixirRPG.Action 3 | 4 | # Damange Types 5 | def physical_damage(target, power, pierce? \\ false) do 6 | Action.make_action(:dmg_phys, target, %{power: power, pierce?: pierce?}) 7 | end 8 | 9 | def magic_damage(target, power, element \\ :no_element) do 10 | Action.make_action(:dmg_magic, target, %{power: power, element: element}) 11 | end 12 | 13 | # Restore stats 14 | def heal(target, amount) do 15 | Action.make_action(:healing, target, %{amount: amount}) 16 | end 17 | 18 | def restore_mp(target, amount) do 19 | Action.make_action(:restore_mp, target, %{amount: amount}) 20 | end 21 | 22 | # Inflict a status 23 | def give_status(target, effect) do 24 | Action.make_action(:give_status, target, %{effect: effect}) 25 | end 26 | 27 | # Spawn a pair of enemies 28 | def spawn(entity, spawn_set) do 29 | Action.make_action(:spawn, entity, %{set: spawn_set}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/component/definitions/action_list.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent ActionList do 4 | member :actions, [] 5 | member :can_act, false 6 | member :take_player_input, false 7 | end 8 | -------------------------------------------------------------------------------- /lib/component/definitions/active_battle.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent ActiveBattle do 4 | member :ready, false 5 | member :frozen, false 6 | member :atb_value, 0.0 7 | member :multiplier, 1.0 8 | end 9 | -------------------------------------------------------------------------------- /lib/component/definitions/actor_name.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent ActorName do 4 | member :name, "???" 5 | end 6 | -------------------------------------------------------------------------------- /lib/component/definitions/animation_mod.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent AnimationMod do 4 | member :active_mods, [] 5 | end 6 | -------------------------------------------------------------------------------- /lib/component/definitions/demo_stats.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent DemoStats do 4 | member :max_hp, 100 5 | member :hp, 100 6 | 7 | member :max_mp, 100 8 | member :mp, 15 9 | 10 | member :speed, 15 11 | 12 | member :defense, 5 13 | 14 | member :attack_power, 5 15 | 16 | member :just_took_damage, false 17 | 18 | member :casting, false 19 | member :casting_data, nil 20 | member :casting_delay, 0.0 21 | 22 | member :dead, false 23 | end 24 | -------------------------------------------------------------------------------- /lib/component/definitions/enemy.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent Enemy do 4 | member :is_enemy, true 5 | end 6 | -------------------------------------------------------------------------------- /lib/component/definitions/grid_position.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent GridPosition do 4 | member :index, 0 5 | end 6 | -------------------------------------------------------------------------------- /lib/component/definitions/npc_brain.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent NPCBrain do 4 | member :brain_name, "flan" 5 | member :cached_src, "" 6 | end 7 | -------------------------------------------------------------------------------- /lib/component/definitions/sprite.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent Sprite do 4 | member :sprite_name, "UNDEFINED_SPRITE" 5 | member :base_sprite_dir, "" 6 | member :sprite_override, "" 7 | member :override_delay, 0.0 8 | member :full_image, false 9 | end 10 | -------------------------------------------------------------------------------- /lib/component/definitions/status.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent Status do 4 | @type status :: {atom(), number(), number()} 5 | member :status_list, [] 6 | member :to_be_added, [] 7 | end 8 | -------------------------------------------------------------------------------- /lib/component/definitions/targetable.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Component 2 | 3 | defcomponent Targetable do 4 | member :actions_enabled, [] 5 | member :active, false 6 | end 7 | -------------------------------------------------------------------------------- /lib/elixir_rpg.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG do 2 | alias ElixirRPG.World 3 | alias ElixirRPG.World.Input 4 | alias ElixirRPG.RuntimeSystems 5 | 6 | require Logger 7 | 8 | def start(world_name, front_end_pid) do 9 | name = String.to_atom(world_name) 10 | 11 | # First create a world 12 | {:ok, the_world} = World.start_link(name, front_end_pid) 13 | {:ok, input_server} = World.InputServer.start_link(name) 14 | 15 | Logger.info("Booted World at PID: #{inspect(the_world)}") 16 | Logger.info("Booted InputServer at PID: #{inspect(input_server)}") 17 | 18 | # Pause it for now 19 | World.pause(the_world) 20 | 21 | # Now add systems 22 | systems = 23 | [ 24 | RuntimeSystems.ReaperSystem, 25 | RuntimeSystems.StatusEffectSystem, 26 | RuntimeSystems.ActiveBattleSystem, 27 | RuntimeSystems.CastingSystem, 28 | RuntimeSystems.PlayerInput, 29 | RuntimeSystems.NPCBrainSystem, 30 | RuntimeSystems.CombatSystem, 31 | RuntimeSystems.SpecialSpriteSystem, 32 | RuntimeSystems.DrawingSystem, 33 | RuntimeSystems.AnimateModSystem, 34 | RuntimeSystems.ClearStateSystem, 35 | ] 36 | |> Enum.reverse() 37 | 38 | Enum.each(systems, fn s -> World.add_system(the_world, s) end) 39 | 40 | the_world 41 | end 42 | 43 | def get_pending_input(world) do 44 | World.InputServer.peek_input(world) 45 | end 46 | 47 | def clear_input(world, from) do 48 | World.InputServer.clear_input(world, from) 49 | end 50 | 51 | def do_input(world, from, type, parameters) do 52 | input = %Input{ 53 | input_type: type, 54 | from_entity: from, 55 | input_paramters: parameters 56 | } 57 | 58 | World.InputServer.push_input(world, input) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/entity/data.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Entity.Data do 2 | use TypedStruct 3 | 4 | typedstruct do 5 | field :world_name, atom(), default: :global 6 | field :components, map(), default: %{} 7 | field :action_queue, Qex.t(), default: nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/entity/definitions/cat.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Cat do 4 | component ActorName, %{name: "Cat"} 5 | component Sprite, %{sprite_name: "char/cat/normal.png", base_sprite_dir: "char/cat"} 6 | component GridPosition, %{index: 7} 7 | 8 | component ActionList, %{actions: [{:intent, :attack}, {:intent, :burn}, :shock]} 9 | 10 | component DemoStats, %{ 11 | hp: 50, 12 | max_hp: 50, 13 | mp: 30, 14 | max_mp: 30, 15 | speed: 12, 16 | attack_power: 30 17 | } 18 | 19 | component Status 20 | 21 | component ActiveBattle 22 | component AnimationMod 23 | 24 | component Targetable, %{actions_enabled: [:coffee, :black_tea, :green_tea]} 25 | end 26 | -------------------------------------------------------------------------------- /lib/entity/definitions/enemy/cone.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Cone do 4 | component DemoStats, %{ 5 | max_hp: 45, 6 | hp: 45, 7 | max_mp: 183, 8 | mp: 183, 9 | speed: 5 10 | } 11 | 12 | component Status 13 | 14 | component Sprite, %{sprite_name: "enemy/cone.png", full_image: true} 15 | 16 | component ActorName, %{name: "Cone"} 17 | 18 | component NPCBrain, %{brain_name: "cone"} 19 | component ActiveBattle 20 | 21 | component AnimationMod 22 | 23 | component Targetable, %{actions_enabled: [:shock, :burn, :attack]} 24 | 25 | component Enemy 26 | end 27 | -------------------------------------------------------------------------------- /lib/entity/definitions/enemy/cylinder.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Cylinder do 4 | component DemoStats, %{ 5 | max_hp: 65, 6 | hp: 65, 7 | max_mp: 183, 8 | mp: 183, 9 | speed: 5 10 | } 11 | 12 | component Status 13 | 14 | component Sprite, %{sprite_name: "enemy/cylinder.png", full_image: true} 15 | 16 | component ActorName, %{name: "Cylinder"} 17 | 18 | component NPCBrain, %{brain_name: "cylinder"} 19 | component ActiveBattle 20 | 21 | component AnimationMod 22 | 23 | component Targetable, %{actions_enabled: [:shock, :burn, :attack]} 24 | 25 | component Enemy 26 | end 27 | -------------------------------------------------------------------------------- /lib/entity/definitions/enemy/frowny.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Frowny do 4 | component DemoStats, %{ 5 | max_hp: 20, 6 | hp: 20, 7 | max_mp: 200, 8 | mp: 200, 9 | speed: 9 10 | } 11 | 12 | component Status 13 | 14 | component Sprite, %{sprite_name: "enemy/frowny.png", full_image: true} 15 | 16 | component ActorName, %{name: "Frowny"} 17 | 18 | component NPCBrain, %{brain_name: "frowny"} 19 | component ActiveBattle 20 | 21 | component AnimationMod 22 | 23 | component Targetable, %{actions_enabled: [:shock, :burn, :attack]} 24 | 25 | component Enemy 26 | end 27 | -------------------------------------------------------------------------------- /lib/entity/definitions/enemy/smiley.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Smiley do 4 | component DemoStats, %{ 5 | max_hp: 20, 6 | hp: 20, 7 | max_mp: 200, 8 | mp: 200, 9 | speed: 10 10 | } 11 | 12 | component Status 13 | 14 | component Sprite, %{sprite_name: "enemy/smiley.png", full_image: true} 15 | 16 | component ActorName, %{name: "Smiley"} 17 | 18 | component NPCBrain, %{brain_name: "smiley"} 19 | component ActiveBattle 20 | 21 | component AnimationMod 22 | 23 | component Targetable, %{actions_enabled: [:shock, :burn, :attack]} 24 | 25 | component Enemy 26 | end 27 | -------------------------------------------------------------------------------- /lib/entity/definitions/enemy/ufo.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity UFO do 4 | component DemoStats, %{ 5 | max_hp: 175, 6 | hp: 175, 7 | max_mp: 400, 8 | mp: 400, 9 | speed: 10 10 | } 11 | 12 | component Status 13 | 14 | component Sprite, %{sprite_name: "enemy/ufo.png", full_image: true} 15 | 16 | component ActorName, %{name: "UFO"} 17 | 18 | component NPCBrain, %{brain_name: "ufo"} 19 | component ActiveBattle 20 | 21 | component AnimationMod 22 | 23 | component Targetable, %{actions_enabled: [:shock, :burn, :attack]} 24 | 25 | component Enemy 26 | end 27 | -------------------------------------------------------------------------------- /lib/entity/definitions/enemy/walker.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Walker do 4 | component DemoStats, %{ 5 | max_hp: 10, 6 | hp: 10, 7 | max_mp: 20, 8 | mp: 20, 9 | speed: 40 10 | } 11 | 12 | component Status 13 | 14 | component Sprite, %{sprite_name: "enemy/walker.png", full_image: true} 15 | 16 | component ActorName, %{name: "Walker"} 17 | 18 | component NPCBrain, %{brain_name: "walker"} 19 | component ActiveBattle 20 | 21 | component AnimationMod 22 | 23 | component Targetable, %{actions_enabled: [:shock, :burn, :attack]} 24 | 25 | component Enemy 26 | end 27 | -------------------------------------------------------------------------------- /lib/entity/definitions/guy.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity Guy do 4 | component ActorName, %{name: "Guy"} 5 | component Sprite, %{sprite_name: "char/guy/normal.png", base_sprite_dir: "char/guy"} 6 | component GridPosition, %{index: 8} 7 | 8 | component ActionList, %{actions: [:dance]} 9 | 10 | component DemoStats, %{ 11 | hp: 10, 12 | max_hp: 10, 13 | mp: 5, 14 | max_mp: 5, 15 | speed: 15, 16 | attack_power: 2 17 | } 18 | 19 | component Status 20 | 21 | component ActiveBattle 22 | component AnimationMod 23 | 24 | component Targetable, %{actions_enabled: [:coffee, :black_tea, :green_tea]} 25 | end 26 | -------------------------------------------------------------------------------- /lib/entity/definitions/soda.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.Entity 2 | 3 | defentity SodaBot do 4 | component ActorName, %{name: "SodaBot"} 5 | component Sprite, %{sprite_name: "char/soda/normal.png", base_sprite_dir: "char/soda"} 6 | component GridPosition, %{index: 9} 7 | 8 | component ActionList, %{ 9 | actions: [{:intent, :coffee}, {:intent, :green_tea}, {:intent, :black_tea}] 10 | } 11 | 12 | component DemoStats, %{ 13 | hp: 25, 14 | max_hp: 25, 15 | mp: 50, 16 | max_mp: 50, 17 | speed: 7, 18 | attack_power: 10 19 | } 20 | 21 | component Status 22 | 23 | component ActiveBattle 24 | component AnimationMod 25 | 26 | component Targetable, %{actions_enabled: [:coffee, :black_tea, :green_tea]} 27 | end 28 | -------------------------------------------------------------------------------- /lib/entity/entity.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Entity do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | alias ElixirRPG.Action 7 | alias ElixirRPG.Entity 8 | alias ElixirRPG.Entity.EntityStore 9 | 10 | def create_entity(type, world_name) when is_atom(type) do 11 | full_type = Module.concat(ElixirRPG.EntityTypes, type) 12 | data = full_type.create() 13 | data = %Entity.Data{data | world_name: world_name, action_queue: Qex.new()} 14 | start_link(data) 15 | end 16 | 17 | def pop_action(entity) do 18 | GenServer.call(entity, :pop_action) 19 | end 20 | 21 | def get_component(entity, type) do 22 | GenServer.call(entity, {:get_component, type}) 23 | end 24 | 25 | def set_component_data(entity, type, key, value) do 26 | GenServer.call(entity, {:set_component_data, type, key, value}) 27 | end 28 | 29 | def start_link(%Entity.Data{} = entity_data) do 30 | GenServer.start_link(__MODULE__, data: entity_data) 31 | end 32 | 33 | @impl GenServer 34 | def init(data: entity_data) do 35 | Enum.each(entity_data.components, fn {k, _} -> 36 | register_with_component_group(k, entity_data.world_name) 37 | end) 38 | 39 | {:ok, entity_data} 40 | end 41 | 42 | # Calls for component manipulation 43 | 44 | @impl GenServer 45 | def handle_call({:add_component, type, data}, _from, entity_data) 46 | when is_atom(type) and is_map(data) do 47 | full_type = Module.concat(ElixirRPG.ComponentTypes, type) 48 | 49 | if Map.has_key?(entity_data.components, type) do 50 | {:reply, :error, entity_data} 51 | else 52 | new_component = struct(full_type, data) 53 | 54 | register_with_component_group(type, entity_data.world_name) 55 | 56 | {:reply, :ok, 57 | %Entity.Data{ 58 | entity_data 59 | | components: Map.put_new(entity_data.components, type, new_component) 60 | }} 61 | end 62 | end 63 | 64 | def handle_call({:remove_component, type}, _from, entity_data) when is_atom(type) do 65 | unregister_with_component_group(type, entity_data.world_name) 66 | 67 | {:reply, :ok, 68 | %Entity.Data{entity_data | components: Map.delete(entity_data.components, type)}} 69 | end 70 | 71 | def handle_call({:get_component, type}, _from, entity_data) when is_atom(type) do 72 | {:reply, Map.get(entity_data.components, type), entity_data} 73 | end 74 | 75 | def handle_call(:get_all_components, _from, entity_data) do 76 | {:reply, entity_data.components, entity_data} 77 | end 78 | 79 | def handle_call({:set_world_name, world_name}, _from, entity_data) when is_pid(world_name) do 80 | {:reply, :ok, %Entity.Data{entity_data | world_name: world_name}} 81 | end 82 | 83 | def handle_call({:set_component_data, type, key, value}, _from, entity_data) do 84 | case Map.get(entity_data.components, type) do 85 | %{} = component -> 86 | updated_component = %{component | key => value} 87 | 88 | {:reply, :ok, 89 | %Entity.Data{ 90 | entity_data 91 | | components: %{entity_data.components | type => updated_component} 92 | }} 93 | 94 | _ -> 95 | {:reply, :error, entity_data} 96 | end 97 | end 98 | 99 | # Calls for action queue management 100 | 101 | def handle_call({:action_recv, %Action{} = action}, _from, entity_data) do 102 | {:reply, :ok, 103 | %Entity.Data{entity_data | action_queue: Qex.push(entity_data.action_queue, action)}} 104 | end 105 | 106 | def handle_call(:pop_action, _from, entity_data) do 107 | case Qex.pop(entity_data.action_queue) do 108 | {:empty, _} -> {:reply, :empty, entity_data} 109 | {{:value, action}, rest} -> {:reply, action, %Entity.Data{entity_data | action_queue: rest}} 110 | end 111 | end 112 | 113 | def handle_call(:destroy, _from, state) do 114 | {:stop, :normal, :ok, state} 115 | end 116 | 117 | # Catch-all Call because sometimes it helps :D 118 | 119 | def handle_call(unknown_message, from, entity_data) do 120 | Logger.warn( 121 | "#{inspect(self())} got an unknown message from #{inspect(from)}: #{ 122 | inspect(unknown_message) 123 | }" 124 | ) 125 | 126 | {:reply, :ok, entity_data} 127 | end 128 | 129 | # Private helper functions 130 | 131 | defp register_with_component_group(type, world_name) do 132 | EntityStore.add_entity_to_group(type, world_name, self()) 133 | end 134 | 135 | defp unregister_with_component_group(type, world_name) do 136 | EntityStore.remove_entity_from_group(type, world_name, self()) 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/entity/entity_store.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Entity.EntityStore do 2 | def add_entity_to_group(group, world_name, entity) when is_atom(group) and is_pid(entity) do 3 | full_name = Module.concat(world_name, group) 4 | :pg2.create(full_name) 5 | :pg2.join(full_name, entity) 6 | end 7 | 8 | def remove_entity_from_group(group, world_name, entity) 9 | when is_atom(group) and is_pid(entity) do 10 | full_name = Module.concat(world_name, group) 11 | :pg2.leave(full_name, entity) 12 | end 13 | 14 | def get_entities_with([single_want_list], world_name) do 15 | full_name = Module.concat(world_name, single_want_list) 16 | 17 | case :pg2.get_members(full_name) do 18 | {:error, _} -> [] 19 | result -> result 20 | end 21 | end 22 | 23 | def get_entities_with(want_list, world_name) when is_list(want_list) do 24 | all_wants = 25 | Enum.map(want_list, fn want -> 26 | full_name = Module.concat(world_name, want) 27 | 28 | case :pg2.get_members(full_name) do 29 | {:error, _} -> [] 30 | result -> result 31 | end 32 | |> :sets.from_list() 33 | end) 34 | 35 | :sets.intersection(all_wants) |> :sets.to_list() 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/status_effects.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.StatusEffects do 2 | use TypedStruct 3 | alias ElixirRPG.Entity 4 | alias ElixirRPG.RuntimeSystems.StatusEffectSystem 5 | 6 | alias __MODULE__ 7 | 8 | typedstruct do 9 | field :interval, number(), enforce: true, default: -1 10 | field :max_duration, number(), enforce: true, default: -1 11 | field :on_inflict, (pid() -> :ok), enforce: true, default: nil 12 | field :on_applied, (pid() -> :ok), enforce: true, default: nil 13 | field :on_removed, (pid() -> :ok), enforce: true, default: nil 14 | end 15 | 16 | def poison do 17 | %StatusEffects{ 18 | interval: 5.0, 19 | max_duration: -1.0, 20 | on_inflict: fn entity -> 21 | inflict_dmg = 3.0 22 | curr_hp = Entity.get_component(entity, DemoStats).hp 23 | Entity.set_component_data(entity, DemoStats, :hp, curr_hp - inflict_dmg) 24 | end, 25 | on_applied: nil, 26 | on_removed: nil 27 | } 28 | end 29 | 30 | def burn do 31 | %StatusEffects{ 32 | interval: 1.0, 33 | max_duration: 10.0, 34 | on_inflict: fn entity -> 35 | inflict_dmg = 2.0 36 | curr_hp = Entity.get_component(entity, DemoStats).hp 37 | Entity.set_component_data(entity, DemoStats, :hp, curr_hp - inflict_dmg) 38 | end, 39 | on_applied: nil, 40 | on_removed: nil 41 | } 42 | end 43 | 44 | @doc """ 45 | Freezes the ATB guage of the entity for 3.5 sec 46 | """ 47 | def shock do 48 | %StatusEffects{ 49 | interval: -1.0, 50 | max_duration: 3.5, 51 | on_inflict: nil, 52 | on_applied: fn entity -> 53 | Entity.set_component_data(entity, ActiveBattle, :frozen, true) 54 | 55 | inflict_dmg = 8.0 56 | curr_hp = Entity.get_component(entity, DemoStats).hp 57 | Entity.set_component_data(entity, DemoStats, :hp, curr_hp - inflict_dmg) 58 | end, 59 | on_removed: fn entity -> 60 | Entity.set_component_data(entity, ActiveBattle, :frozen, false) 61 | end 62 | } 63 | end 64 | 65 | @doc """ 66 | Boosts the speed of the ATB bar by 50% 67 | """ 68 | def coffee_up do 69 | %StatusEffects{ 70 | interval: -1.0, 71 | max_duration: 5.0, 72 | on_inflict: nil, 73 | on_applied: fn entity -> 74 | Entity.set_component_data(entity, ActiveBattle, :multiplier, 1.5) 75 | end, 76 | on_removed: fn entity -> 77 | StatusEffectSystem.add_status_to_entity(entity, :coffee_down) 78 | end 79 | } 80 | end 81 | 82 | @doc """ 83 | Lessens the speed of the ATB bar by 20% 84 | """ 85 | def coffee_down do 86 | %StatusEffects{ 87 | interval: -1.0, 88 | max_duration: 5.0, 89 | on_inflict: nil, 90 | on_applied: fn entity -> 91 | Entity.set_component_data(entity, ActiveBattle, :multiplier, 0.5) 92 | end, 93 | on_removed: fn entity -> 94 | Entity.set_component_data(entity, ActiveBattle, :multiplier, 1.0) 95 | end 96 | } 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/system/definitions/active_battle.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem ActiveBattleSystem do 4 | # Simple system that bumps the ATB value on any entity that has one 5 | # Caps at and sets :ready to true when equal to 1.0 6 | 7 | require Logger 8 | 9 | name "ATBSystem" 10 | 11 | wants ActorName 12 | wants DemoStats 13 | wants ActiveBattle 14 | 15 | on_tick do 16 | _ = world_name 17 | _ = frontend_pid 18 | 19 | speed_stat = get_component_data(DemoStats, :speed) 20 | current_atb = get_component_data(ActiveBattle, :atb_value) 21 | frozen = get_component_data(ActiveBattle, :frozen) 22 | multiplier = get_component_data(ActiveBattle, :multiplier) 23 | casting = get_component_data(DemoStats, :casting) 24 | 25 | if current_atb < 1.0 && !frozen && !casting do 26 | new_atb_value = current_atb + speed_stat * multiplier / 100 * delta_time 27 | 28 | if new_atb_value >= 1.0 do 29 | set_component_data(ActiveBattle, :atb_value, 1.0) 30 | set_component_data(ActiveBattle, :ready, true) 31 | else 32 | set_component_data(ActiveBattle, :atb_value, new_atb_value) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/system/definitions/animate_mods.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem AnimateModSystem do 4 | require Logger 5 | 6 | alias ElixirRPG.Entity 7 | 8 | name "AnimateModSystem" 9 | 10 | wants AnimationMod 11 | 12 | on_tick do 13 | _ = delta_time 14 | _ = frontend_pid 15 | _ = world_name 16 | current_anims = get_component_data(AnimationMod, :active_mods) 17 | 18 | updated_values = 19 | Enum.map(current_anims, fn {anim, tick_count} -> 20 | {anim, tick_count - 1} 21 | end) 22 | |> Enum.filter(fn {_, tick_count} -> tick_count > 0 end) 23 | 24 | set_component_data(AnimationMod, :active_mods, updated_values) 25 | end 26 | 27 | def add_animation(entity, animation_class, len \\ 5) do 28 | anim_data = Entity.get_component(entity, AnimationMod) 29 | new_anim = {animation_class, len} 30 | new_data = [new_anim | anim_data.active_mods] 31 | Entity.set_component_data(entity, AnimationMod, :active_mods, new_data) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/system/definitions/casting.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem CastingSystem do 4 | require Logger 5 | 6 | alias ElixirRPG.RuntimeSystems.AnimateModSystem 7 | alias ElixirRPG.Action 8 | 9 | name "ClearSpecialStateSystem" 10 | 11 | wants DemoStats 12 | 13 | on_tick do 14 | _ = world_name 15 | _ = frontend_pid 16 | 17 | data = get_all_components() 18 | stats = data[DemoStats] 19 | 20 | if stats.casting do 21 | new_time = stats.casting_delay - delta_time 22 | 23 | if new_time <= 0 do 24 | Action.execute(stats.casting_data) 25 | 26 | set_component_data(DemoStats, :casting, false) 27 | set_component_data(DemoStats, :casting_data, nil) 28 | set_component_data(DemoStats, :casting_delay, 0.0) 29 | else 30 | set_component_data(DemoStats, :casting_delay, new_time) 31 | 32 | AnimateModSystem.add_animation( 33 | entity, 34 | "animate__shakeY animate__infinite", 35 | 1.0 36 | ) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/system/definitions/clear_state.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem ClearStateSystem do 4 | require Logger 5 | 6 | name "ClearSpecialStateSystem" 7 | 8 | wants DemoStats 9 | wants Sprite 10 | 11 | on_tick do 12 | _ = delta_time 13 | _ = world_name 14 | _ = frontend_pid 15 | 16 | data = get_all_components() 17 | 18 | if data[Sprite].override_delay > 0 do 19 | new_time = max(0, data[Sprite].override_delay - delta_time) 20 | set_component_data(Sprite, :override_delay, new_time) 21 | end 22 | 23 | set_component_data(DemoStats, :just_took_damage, false) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/system/definitions/combat.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem CombatSystem do 4 | require Logger 5 | 6 | alias ElixirRPG.ComponentTypes 7 | alias ElixirRPG.Entity 8 | alias ElixirRPG.Action 9 | 10 | alias ElixirRPG.RuntimeSystems.StatusEffectSystem 11 | alias ElixirRPG.RuntimeSystems.AnimateModSystem 12 | 13 | name "CombatSystem" 14 | 15 | wants ActorName 16 | wants DemoStats 17 | wants AnimationMod 18 | 19 | on_tick do 20 | _ = frontend_pid 21 | _ = delta_time 22 | # name = get_component_data(ActorName, :name) 23 | 24 | # pop actions from the queue and process them until we run out 25 | first_action = Entity.pop_action(entity) 26 | process_action(entity, first_action, world_name) 27 | end 28 | 29 | defp process_action(_entity_pid, :empty, _world_name), do: :ok 30 | 31 | defp process_action(entity_pid, %Action{action_type: :dmg_phys} = action, _world_name) do 32 | case Entity.get_component(entity_pid, DemoStats) do 33 | %ComponentTypes.DemoStats{} = stats -> 34 | dmg_delt = action.payload.power 35 | new_hp = stats.hp - dmg_delt 36 | 37 | if new_hp <= 0 do 38 | Entity.set_component_data(entity_pid, DemoStats, :hp, 0) 39 | Entity.set_component_data(entity_pid, DemoStats, :dead, true) 40 | 41 | AnimateModSystem.add_animation( 42 | entity_pid, 43 | "animate__rotateOut animate__faster" 44 | ) 45 | else 46 | Entity.set_component_data(entity_pid, DemoStats, :hp, new_hp) 47 | Entity.set_component_data(entity_pid, DemoStats, :just_took_damage, true) 48 | 49 | AnimateModSystem.add_animation( 50 | entity_pid, 51 | "animate__jello animate__faster" 52 | ) 53 | end 54 | 55 | _ -> 56 | nil 57 | end 58 | end 59 | 60 | defp process_action(entity, %Action{action_type: :healing, payload: payload}, _world_name) do 61 | current_stats = Entity.get_component(entity, DemoStats) 62 | new_hp = min(current_stats.max_hp, current_stats.hp + payload.amount) 63 | Entity.set_component_data(entity, DemoStats, :hp, new_hp) 64 | end 65 | 66 | defp process_action(entity, %Action{action_type: :restore_mp, payload: payload}, _world_name) do 67 | current_stats = Entity.get_component(entity, DemoStats) 68 | new_hp = min(current_stats.max_mp, current_stats.mp + payload.amount) 69 | Entity.set_component_data(entity, DemoStats, :mp, new_hp) 70 | end 71 | 72 | defp process_action(entity, %Action{action_type: :give_status, payload: %{effect: effect}}, _world_name) do 73 | StatusEffectSystem.add_status_to_entity(entity, effect) 74 | end 75 | 76 | defp process_action(_entity, %Action{action_type: :spawn, payload: %{set: {spawn1, spawn2}}}, world_name) do 77 | world = Process.whereis(world_name) 78 | ElixirRPG.World.add_entity(world, spawn1); 79 | ElixirRPG.World.add_entity(world, spawn2); 80 | end 81 | 82 | defp process_action(entity_pid, %Action{} = unknown_action, _world_name) do 83 | Logger.warn("Unknown action: #{inspect(entity_pid)} #{inspect(unknown_action)}") 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/system/definitions/drawing.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem DrawingSystem do 4 | require Logger 5 | 6 | name "DrawingSystem" 7 | 8 | wants ActorName 9 | wants Sprite 10 | 11 | on_tick do 12 | _ = delta_time 13 | _ = world_name 14 | data = get_all_components() 15 | send(frontend_pid, {:_push_drawable, entity, data}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/system/definitions/npc_brain.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem NPCBrainSystem do 4 | name "EnemyBrainSystem" 5 | 6 | wants ActorName 7 | wants ActiveBattle 8 | wants NPCBrain 9 | 10 | @brain_location __ENV__.file |> Path.dirname() |> Path.join("../../../brains") 11 | @common_keyword "#! import_common" 12 | @common_code_path Path.join(@brain_location, "common.exs") 13 | @common_code File.read!(@common_code_path) 14 | 15 | def __mix_recompile__? do 16 | :erlang.md5(@common_code) != File.read!(@common_code_path) |> :erlang.md5() 17 | end 18 | 19 | on_tick do 20 | _ = frontend_pid 21 | _ = delta_time 22 | name = get_component_data(ActorName, :name) 23 | code = get_component_data(NPCBrain, :cached_src) 24 | brain_name = get_component_data(NPCBrain, :brain_name) 25 | can_act? = get_component_data(ActiveBattle, :ready) 26 | 27 | if can_act? do 28 | # Consume ATB gauge 29 | set_component_data(ActiveBattle, :ready, false) 30 | set_component_data(ActiveBattle, :atb_value, 0.0) 31 | 32 | # If we cached this brain already don't load it again 33 | src = 34 | if code == "" do 35 | load_script_file(entity, "/#{brain_name}.brain.exs") 36 | else 37 | code 38 | end 39 | 40 | Code.eval_string( 41 | src, 42 | [ 43 | entity: entity, 44 | world: world_name, 45 | get_components: &script_binding_get_components/1 46 | ], 47 | custom_script_env() 48 | ) 49 | 50 | log("Entity NPC #{name} consumed ATB and is going to act!") 51 | end 52 | end 53 | 54 | defp load_script_file(entity, file) do 55 | code = 56 | File.read!(Path.join(@brain_location, file)) 57 | |> String.replace(@common_keyword, @common_code) 58 | 59 | warn("Going to load code file: #{file} into component cache") 60 | set_component_data(NPCBrain, :cached_src, code) 61 | code 62 | end 63 | 64 | ### Script Binding Functions 65 | 66 | defp script_binding_get_components(entity) do 67 | get_all_components() 68 | end 69 | 70 | defp custom_script_env do 71 | alias ElixirRPG.Action 72 | alias ElixirRPG.Action.ActionTypes 73 | alias ElixirRPG.Entity.EntityStore 74 | alias ElixirRPG.Entity 75 | 76 | # Make the linter be quiet 77 | _ = Action 78 | _ = ActionTypes 79 | _ = EntityStore 80 | _ = Entity 81 | 82 | __ENV__ 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/system/definitions/player_input.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem PlayerInput do 4 | require Logger 5 | 6 | alias ElixirRPG.Entity 7 | alias ElixirRPG.Action 8 | alias ElixirRPG.Action.ActionTypes 9 | alias ElixirRPG.World.Input 10 | 11 | name "PlayerInputSystem" 12 | 13 | wants ActorName 14 | wants DemoStats 15 | wants ActiveBattle 16 | wants ActionList 17 | 18 | on_tick do 19 | _ = delta_time 20 | _ = frontend_pid 21 | 22 | can_move? = get_component_data(ActiveBattle, :ready) 23 | set_component_data(ActionList, :can_act, can_move?) 24 | 25 | # Are we able to move, is there input pending, and is it for us? 26 | with true <- can_move?, 27 | %{} = input_map <- ElixirRPG.get_pending_input(world_name), 28 | true <- Map.has_key?(input_map, entity) do 29 | input = input_map[entity] 30 | ElixirRPG.clear_input(world_name, entity) 31 | Logger.info("Consume input from #{inspect(entity)} -- #{inspect(input)}") 32 | process_input(input, world_name, entity) 33 | else 34 | _ -> :ok 35 | end 36 | end 37 | 38 | defp process_input(%Input{} = input, world_name, entity) do 39 | action_list = Entity.get_component(entity, ActionList).actions |> Enum.map(&get_action/1) 40 | action = input.input_paramters.action_name |> String.to_existing_atom() 41 | 42 | if Enum.member?(action_list, action) do 43 | Entity.set_component_data(entity, ActiveBattle, :ready, false) 44 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 45 | 46 | case action do 47 | :dance -> do_dance(entity) 48 | :shock -> do_shock(entity, world_name) 49 | :burn -> do_burn(entity, input.input_paramters.target) 50 | :attack -> do_attack(entity, input.input_paramters.target) 51 | :coffee -> do_coffee_cast(entity, input.input_paramters.target) 52 | :green_tea -> do_green_tea_cast(entity, input.input_paramters.target) 53 | :black_tea -> do_black_tea_cast(entity, input.input_paramters.target) 54 | end 55 | end 56 | end 57 | 58 | defp do_dance(entity) do 59 | ElixirRPG.RuntimeSystems.AnimateModSystem.add_animation(entity, "animate__tada", 15.0) 60 | ElixirRPG.RuntimeSystems.SpecialSpriteSystem.set_sprite_override(entity, "dance.gif", 2.05) 61 | end 62 | 63 | defp do_attack(entity, target) do 64 | attacker_stats = Entity.get_component(entity, DemoStats) 65 | atk_action = ActionTypes.physical_damage(target, attacker_stats.attack_power, false) 66 | 67 | Action.execute(atk_action) 68 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 69 | end 70 | 71 | defp do_shock(entity, world_name) do 72 | required_mp = 12 73 | current_mp = Entity.get_component(entity, DemoStats).mp 74 | 75 | if Entity.get_component(entity, DemoStats).mp >= required_mp do 76 | enemies = ElixirRPG.Entity.EntityStore.get_entities_with([Enemy], world_name) 77 | Enum.each(enemies, fn e -> 78 | ActionTypes.give_status(e, :shock) |> Action.execute() 79 | end) 80 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 81 | end 82 | end 83 | 84 | defp do_burn(entity, target) do 85 | required_mp = 12 86 | current_mp = Entity.get_component(entity, DemoStats).mp 87 | 88 | if Entity.get_component(entity, DemoStats).mp >= required_mp do 89 | casting_delay = 2.0 90 | burn_action = ActionTypes.give_status(target, :burn) 91 | 92 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 93 | Entity.set_component_data(entity, DemoStats, :casting, true) 94 | Entity.set_component_data(entity, DemoStats, :casting_data, burn_action) 95 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 96 | 97 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 98 | end 99 | end 100 | 101 | defp do_coffee_cast(entity, target) do 102 | required_mp = 7 103 | current_mp = Entity.get_component(entity, DemoStats).mp 104 | 105 | if Entity.get_component(entity, DemoStats).mp >= required_mp do 106 | casting_delay = 2.0 107 | coffee_action = ActionTypes.give_status(target, :coffee_up) 108 | 109 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 110 | Entity.set_component_data(entity, DemoStats, :casting, true) 111 | Entity.set_component_data(entity, DemoStats, :casting_data, coffee_action) 112 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 113 | 114 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 115 | end 116 | end 117 | 118 | defp do_green_tea_cast(entity, target) do 119 | required_mp = 15 120 | current_mp = Entity.get_component(entity, DemoStats).mp 121 | 122 | if current_mp >= required_mp do 123 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 124 | 125 | casting_delay = 3.0 126 | mp_action = ActionTypes.restore_mp(target, 15) 127 | Entity.set_component_data(entity, DemoStats, :casting, true) 128 | Entity.set_component_data(entity, DemoStats, :casting_data, mp_action) 129 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 130 | 131 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 132 | end 133 | end 134 | 135 | defp do_black_tea_cast(entity, target) do 136 | required_mp = 15 137 | current_mp = Entity.get_component(entity, DemoStats).mp 138 | 139 | if current_mp >= required_mp do 140 | Entity.set_component_data(entity, ActiveBattle, :atb_value, 0.0) 141 | 142 | casting_delay = 3.0 143 | heal_action = ActionTypes.heal(target, 15) 144 | Entity.set_component_data(entity, DemoStats, :casting, true) 145 | Entity.set_component_data(entity, DemoStats, :casting_data, heal_action) 146 | Entity.set_component_data(entity, DemoStats, :casting_delay, casting_delay) 147 | 148 | Entity.set_component_data(entity, DemoStats, :mp, current_mp - required_mp) 149 | end 150 | end 151 | 152 | defp get_action({:intent, a}), do: a 153 | defp get_action(a), do: a 154 | end 155 | -------------------------------------------------------------------------------- /lib/system/definitions/reaper.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem ReaperSystem do 4 | require Logger 5 | 6 | name "ReaperSystem" 7 | 8 | wants DemoStats 9 | 10 | on_tick do 11 | _ = delta_time 12 | _ = frontend_pid 13 | 14 | dead? = get_component_data(DemoStats, :dead) 15 | hp_neg? = get_component_data(DemoStats, :hp) <= 0 16 | 17 | if dead? || hp_neg? do 18 | get_all_components() 19 | |> Map.keys() 20 | |> Enum.each(fn comp -> 21 | ElixirRPG.Entity.EntityStore.remove_entity_from_group(comp, world_name, entity) 22 | end) 23 | 24 | ElixirRPG.Entity.EntityStore.add_entity_to_group(:__dead__, world_name, entity) 25 | GenServer.call(entity, :destroy) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/system/definitions/special_sprite.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem SpecialSpriteSystem do 4 | require Logger 5 | 6 | @sprite_override_ready "ready.png" 7 | @sprite_override_low_hp "hurt.png" 8 | @sprite_override_low_hp_ready "low_ready.png" 9 | @sprite_override_casting "cast.png" 10 | 11 | name "SpecialSprites" 12 | 13 | wants ActorName 14 | wants ActiveBattle 15 | wants DemoStats 16 | wants Sprite 17 | 18 | def set_sprite_override(entity, image_name, len \\ 0) 19 | when is_binary(image_name) and is_number(len) do 20 | set_component_data(Sprite, :sprite_override, image_name) 21 | set_component_data(Sprite, :override_delay, len) 22 | end 23 | 24 | on_tick do 25 | _ = delta_time 26 | _ = world_name 27 | _ = frontend_pid 28 | 29 | data = get_all_components() 30 | 31 | # Conditions we care about 32 | ready = data[ActiveBattle].ready 33 | low_health = data[DemoStats].hp <= data[DemoStats].max_hp / 2 34 | casting = data[DemoStats].casting 35 | 36 | # Only override if we have an override directory AND aren't displaying an override for a delay period 37 | if data[Sprite].base_sprite_dir != "" && data[Sprite].override_delay <= 0 do 38 | cond do 39 | casting -> set_sprite_override(entity, @sprite_override_casting, 0.5) 40 | ready && low_health -> set_sprite_override(entity, @sprite_override_low_hp_ready) 41 | ready -> set_sprite_override(entity, @sprite_override_ready) 42 | low_health -> set_sprite_override(entity, @sprite_override_low_hp) 43 | true -> set_sprite_override(entity, "") 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/system/definitions/status_effect.ex: -------------------------------------------------------------------------------- 1 | use ElixirRPG.DSL.System 2 | 3 | defsystem StatusEffectSystem do 4 | name "StatusEffectSystem" 5 | 6 | wants Status 7 | wants ActiveBattle 8 | wants DemoStats 9 | 10 | alias ElixirRPG.Entity 11 | 12 | on_tick do 13 | _ = world_name 14 | _ = frontend_pid 15 | 16 | to_be_added = get_component_data(Status, :to_be_added) 17 | current_status_list = to_be_added ++ get_component_data(Status, :status_list) 18 | 19 | set_component_data(Status, :to_be_added, []) 20 | 21 | Enum.each(to_be_added, fn {type, _, _} -> 22 | effect_data = apply(Module.concat(ElixirRPG, StatusEffects), type, []) 23 | if effect_data.on_applied do 24 | effect_data.on_applied.(entity) 25 | end 26 | end) 27 | 28 | new_status_list = 29 | current_status_list 30 | |> Enum.map(fn {type, time_applied, total_time} -> 31 | {type, time_applied + delta_time, total_time + delta_time} 32 | end) 33 | |> Enum.map(fn {type, time_applied, total_time} -> apply_status(entity, type, time_applied, total_time) end) 34 | |> Enum.filter(fn {type, _, _} -> type != :__expired__ end) 35 | 36 | set_component_data(Status, :status_list, new_status_list) 37 | end 38 | 39 | def add_status_to_entity(entity, effect_type) when is_pid(entity) and is_atom(effect_type) do 40 | comp_data = Entity.get_component(entity, Status) 41 | new_list = [{effect_type, 0.0, 0.0} | comp_data.to_be_added] 42 | 43 | Entity.set_component_data(entity, Status, :to_be_added, new_list) 44 | end 45 | 46 | defp apply_status(entity, type, time_applied, total_time) do 47 | do_apply_status(entity, time_applied, type, total_time) 48 | end 49 | 50 | defp do_apply_status(entity, time_applied, type, total_time) do 51 | effect_data = apply(Module.concat(ElixirRPG, StatusEffects), type, []) 52 | 53 | time_applied = if time_applied >= effect_data.interval do 54 | if effect_data.on_inflict != nil do 55 | effect_data.on_inflict.(entity) 56 | end 57 | 58 | 0.0 59 | else 60 | time_applied 61 | end 62 | 63 | if total_time >= effect_data.max_duration do 64 | if effect_data.on_removed != nil do 65 | effect_data.on_removed.(entity) 66 | end 67 | 68 | {:__expired__, 0.0, total_time} 69 | else 70 | {type, time_applied, total_time} 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/util/mod_util.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Util.ModUtil do 2 | def get_entity_types do 3 | {:ok, mods} = :application.get_key(:elixir_rpg, :modules) 4 | 5 | mods 6 | |> Enum.filter(fn mod -> 7 | parts = Module.split(mod) 8 | match?(["ElixirRPG", "EntityTypes" | _], parts) 9 | end) 10 | end 11 | 12 | def get_component_types do 13 | {:ok, mods} = :application.get_key(:elixir_rpg, :modules) 14 | 15 | mods 16 | |> Enum.filter(fn mod -> 17 | parts = Module.split(mod) 18 | match?(["ElixirRPG", "ComponentTypes" | _], parts) 19 | end) 20 | end 21 | 22 | def get_system_types do 23 | {:ok, mods} = :application.get_key(:elixir_rpg, :modules) 24 | 25 | mods 26 | |> Enum.filter(fn mod -> 27 | parts = Module.split(mod) 28 | match?(["ElixirRPG", "RuntimeSystems" | _], parts) 29 | end) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/util/perf_util.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Util.PerfUtil do 2 | def parallel_map(collection, func) do 3 | collection 4 | |> Enum.map(&(Task.async(fn -> func.(&1) end))) 5 | |> Enum.each(&Task.await/1) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/util/system_log.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.Util.SystemLog do 2 | require Logger 3 | 4 | def debug(item) do 5 | if System.get_env("DEBUG") != nil do 6 | Logger.debug("#{inspect(item)}") 7 | end 8 | end 9 | 10 | def warn(item) do 11 | Logger.warn("#{inspect(item)}") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/world/data.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.World.Data do 2 | use TypedStruct 3 | 4 | typedstruct do 5 | field(:name, atom(), default: :global) 6 | 7 | field(:clock, pid(), default: nil) 8 | field(:playing, boolean(), default: false) 9 | field(:target_tick_rate, integer(), default: 0) 10 | field(:last_tick, integer(), default: 0) 11 | 12 | field(:frontend, pid(), default: nil) 13 | 14 | field(:systems, list(), default: []) 15 | 16 | field(:pending_input, World.Input.t(), default: nil) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/world/input.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.World.Input do 2 | use TypedStruct 3 | 4 | typedstruct do 5 | field(:from_entity, pid(), enforce: true) 6 | field(:input_type, atom(), enforce: true) 7 | field(:input_paramters, map(), enforce: true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/world/input_server.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.World.InputServer do 2 | alias ElixirRPG.World.Input 3 | use GenServer 4 | 5 | require Logger 6 | 7 | # Client Frontend Functions 8 | 9 | def start_link(world) when is_atom(world) do 10 | GenServer.start_link(__MODULE__, [], name: full_name(world)) 11 | end 12 | 13 | def peek_input(world) do 14 | GenServer.call(full_name(world), :peek_input) 15 | end 16 | 17 | def push_input(world, %Input{} = input) do 18 | GenServer.cast(full_name(world), {:push_input, input}) 19 | end 20 | 21 | def clear_input(world, source_pid) do 22 | GenServer.cast(full_name(world), {:clear_input, source_pid}) 23 | end 24 | 25 | defp full_name(world_name), do: Module.concat(world_name, :input_server) 26 | 27 | # Input Callbacks 28 | 29 | @impl GenServer 30 | def init(_) do 31 | {:ok, %{}} 32 | end 33 | 34 | @impl GenServer 35 | def handle_call(:peek_input, _from, state) do 36 | {:reply, state, state} 37 | end 38 | 39 | def handle_call(msg, _from, state) do 40 | Logger.warn("Unknown call message: #{msg}") 41 | {:reply, state, state} 42 | end 43 | 44 | @impl GenServer 45 | def handle_cast({:push_input, %Input{} = input}, state) do 46 | {:noreply, Map.put(state, input.from_entity, input)} 47 | end 48 | 49 | def handle_cast({:clear_input, source_pid}, state) do 50 | {:noreply, Map.delete(state, source_pid)} 51 | end 52 | 53 | def handle_cast(msg, state) do 54 | Logger.warn("Unknown cast message: #{msg}") 55 | {:noreply, state} 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/world/world.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.World do 2 | use GenServer 3 | 4 | alias ElixirRPG.World 5 | alias ElixirRPG.Entity 6 | alias ElixirRPG.Entity.EntityStore 7 | 8 | require Logger 9 | 10 | @initial_state %World.Data{target_tick_rate: 15, last_tick: nil} 11 | 12 | def start_link(name, live_view_frontend \\ nil) when is_atom(name) do 13 | GenServer.start_link(__MODULE__, [args: {name, live_view_frontend}], name: name) 14 | end 15 | 16 | def add_system(world, system) when is_pid(world) and is_atom(system) do 17 | GenServer.cast(world, {:add_system, system}) 18 | end 19 | 20 | def add_entity(world, type) when is_pid(world) and is_atom(type) do 21 | GenServer.cast(world, {:add_entity, type}) 22 | end 23 | 24 | def pause(world) when is_pid(world) do 25 | GenServer.cast(world, :pause) 26 | end 27 | 28 | def resume(world) when is_pid(world) do 29 | GenServer.cast(world, :resume) 30 | end 31 | 32 | @impl GenServer 33 | def init(args: args) do 34 | state = @initial_state 35 | 36 | {world_name, liveview_pid} = args 37 | 38 | clock_ref = World.Clock.start_tick(state.target_tick_rate, self()) 39 | 40 | curr_time = :os.system_time(:millisecond) 41 | 42 | {:ok, 43 | %World.Data{ 44 | state 45 | | name: world_name, 46 | frontend: liveview_pid, 47 | clock: clock_ref, 48 | last_tick: curr_time 49 | }} 50 | end 51 | 52 | @impl GenServer 53 | def handle_cast(:pause, current_state) do 54 | Logger.info("PAUSE WORLD: #{current_state.name}") 55 | {:noreply, %{current_state | playing: false}} 56 | end 57 | 58 | def handle_cast(:resume, current_state) do 59 | Logger.info("RESUME WORLD: #{current_state.name}") 60 | {:noreply, %{current_state | playing: true}} 61 | end 62 | 63 | def handle_cast({:add_system, system}, current_state) do 64 | {:noreply, %World.Data{current_state | systems: [system | current_state.systems]}} 65 | end 66 | 67 | def handle_cast({:remove_entity, entity}, current_state) do 68 | Process.exit(entity, 0) 69 | {:noreply, current_state} 70 | end 71 | 72 | def handle_cast({:add_entity, entity_type}, current_state) do 73 | Entity.create_entity(entity_type, current_state.name) 74 | {:noreply, current_state} 75 | end 76 | 77 | @impl GenServer 78 | def handle_call(message, from, current_state) do 79 | Logger.warn("Unkown message type #{inspect(message)}, from #{inspect(from)}") 80 | {:reply, :ok, current_state} 81 | end 82 | 83 | @impl GenServer 84 | def handle_info(:tick, current_state) do 85 | curr_time = :os.system_time(:millisecond) 86 | last_tick_time = current_state.last_tick 87 | delta_time = (curr_time - last_tick_time) / 1000 88 | 89 | if current_state.playing do 90 | Enum.each(current_state.systems, fn system -> 91 | ents = 92 | system.wants() 93 | |> EntityStore.get_entities_with(current_state.name) 94 | 95 | system.__tick(ents, current_state.name, current_state.frontend, delta_time) 96 | end) 97 | end 98 | 99 | update_frontend_world_state(current_state) 100 | flush_frontend_backbuffer(current_state) 101 | 102 | {:noreply, %World.Data{current_state | last_tick: curr_time}} 103 | end 104 | 105 | def update_frontend_world_state(state) do 106 | send( 107 | state.frontend, 108 | {:_force_update, 109 | fn socket -> 110 | Phoenix.LiveView.assign(socket, :world_data, state) 111 | end} 112 | ) 113 | end 114 | 115 | def flush_frontend_backbuffer(state) do 116 | send(state.frontend, :_flush_backbuffer) 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/world/world_clock.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.World.Clock do 2 | def start_tick(target_ticks_per_sec, target_pid) 3 | when is_pid(target_pid) and is_integer(target_ticks_per_sec) do 4 | ms = trunc(1000 / target_ticks_per_sec) 5 | {:ok, timer_ref} = :timer.send_interval(ms, target_pid, :tick) 6 | timer_ref 7 | end 8 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ElixirRPG.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :elixir_rpg, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger] 18 | ] 19 | end 20 | 21 | # Run "mix help deps" to learn about dependencies. 22 | defp deps do 23 | [ 24 | {:typed_struct, "~> 0.2.1"}, 25 | {:phoenix_live_view, "~> 0.16.3"}, 26 | {:qex, "~> 0.5"} 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "mime": {:hex, :mime, "2.0.1", "0de4c81303fe07806ebc2494d5321ce8fb4df106e34dd5f9d787b637ebadc256", [:mix], [], "hexpm", "7a86b920d2aedce5fb6280ac8261ac1a739ae6c1a1ad38f5eadf910063008942"}, 3 | "phoenix": {:hex, :phoenix, "1.5.12", "75fddb14c720388eea93d33886166a690416a7ff8633fbd93f364355b6fe1166", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8f0ae6734fcc18bbaa646c161e2febc46fb899eae43f82679b92530983324113"}, 4 | "phoenix_html": {:hex, :phoenix_html, "3.0.3", "32812d70841c7e975e01edb591989b2b002b69797db1005b8d0adc1fe717be30", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e8152ae9e8c60705659761edb8d8c4bb7e29130a9b0803ec1854fe137ec62dde"}, 5 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.16.3", "f6f597c74cfc8b00919eb717852b9b750fc326f815ef6b8d6ae503c7d9d09871", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "42bac6b82fd182f10b0b3b39d57aaf2fbe5eab423f8216afeeea9c6b1bd14554"}, 6 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 7 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 8 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 9 | "qex": {:hex, :qex, "0.5.0", "5a3a9becf67d4006377c4c247ffdaaa8ae5b3634a0caadb788dc24d6125068f4", [:mix], [], "hexpm", "4ad6f6421163cd8204509a119a5c9813cbb969cfb8d802a9dc49b968bffbac2a"}, 10 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 11 | "typed_struct": {:hex, :typed_struct, "0.2.1", "e1993414c371f09ff25231393b6430bd89d780e2a499ae3b2d2b00852f593d97", [:mix], [], "hexpm", "8f5218c35ec38262f627b2c522542f1eae41f625f92649c0af701a6fab2e11b3"}, 12 | } 13 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------