Current section
Files
Jump to
Current section
Files
src/expresso@world.erl
-module(expresso@world).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/expresso/world.gleam").
-export([new/1, add_body/2, remove_body/2, update_body/3, get_body/2, bodies/1, with_iterations/2, with_restitution/2, with_substeps/2, with_spatial_strategy/2, query_radius/3, query_region/3, query_nearest/2, raycast/5, step/2]).
-export_type([spatial_strategy/0, collision_event/1, collision_pair/1, world/1]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" 2D physics world management with intelligent spatial acceleration.\n"
"\n"
" This module provides the `World` type that manages a collection of rigid bodies\n"
" and simulates physics each step using position-based dynamics.\n"
"\n"
" ## Features\n"
"\n"
" - **Dual spatial acceleration**: BVH for dynamic scenes, Grid for particles\n"
" - **Spatial queries**: Find bodies by radius, region, or nearest neighbor\n"
" - **Auto-selection**: Intelligently chooses BVH or Grid based on body distribution\n"
" - **Deterministic**: Same inputs always produce same outputs\n"
"\n"
" ## Quick Start\n"
"\n"
" ```gleam\n"
" import expresso/world\n"
" import expresso/body\n"
" import vec/vec2\n"
"\n"
" // Create a world with gravity\n"
" let world = world.new(gravity: vec2.Vec2(0.0, -9.8))\n"
"\n"
" // Add bodies\n"
" let world = world\n"
" |> world.add_body(body.new_circle(\"ball\", vec2.Vec2(0.0, 5.0), radius: 0.5))\n"
" |> world.add_body(body.new_box(\"platform\", vec2.Vec2(0.0, 0.0), 5.0, 0.5))\n"
"\n"
" // Configure solver\n"
" let world = world\n"
" |> world.with_iterations(10)\n"
" |> world.with_restitution(0.5)\n"
"\n"
" // Step physics each frame\n"
" let world = world.step(world, delta_time: 0.016)\n"
" ```\n"
"\n"
" ## Spatial Acceleration\n"
"\n"
" Choose the best strategy for your use case:\n"
"\n"
" - **UseBVH** - Best for dynamic scenes with moving objects (default)\n"
" - **UseGrid** - Best for uniform particle systems (1000s of particles)\n"
" - **AutoSelect** - Automatically picks BVH or Grid based on distribution\n"
"\n"
" ```gleam\n"
" // Force BVH for dynamic game\n"
" let world = world.with_spatial_strategy(world, world.UseBVH)\n"
"\n"
" // Force Grid for particle system\n"
" let world = world.with_spatial_strategy(world, world.UseGrid(cell_size: 2.0))\n"
" ```\n"
"\n"
" ## Spatial Queries\n"
"\n"
" Find bodies efficiently using spatial queries:\n"
"\n"
" ```gleam\n"
" // Find all bodies near a point (e.g., explosion radius)\n"
" let nearby = world.query_radius(world, center: pos, radius: 5.0)\n"
"\n"
" // Find all bodies in a rectangle (e.g., screen culling)\n"
" let visible = world.query_region(world, min: vec2.Vec2(-10.0, -10.0), \n"
" max: vec2.Vec2(10.0, 10.0))\n"
"\n"
" // Find nearest body (e.g., target acquisition)\n"
" let nearest = world.query_nearest(world, point: player_pos)\n"
" ```\n"
).
-type spatial_strategy() :: use_b_v_h | {use_grid, float()} | auto_select.
-type collision_event(NDE) :: {collision_started, NDE, NDE} |
{collision_ended, NDE, NDE} |
{trigger_entered, NDE, NDE} |
{trigger_exited, NDE, NDE}.
-type collision_pair(NDF) :: {collision_pair, NDF, NDF}.
-opaque world(NDG) :: {world,
gleam@dict:dict(NDG, expresso@body:body(NDG)),
vec@vec3:vec3(float()),
integer(),
float(),
integer(),
spatial_strategy(),
gleam@option:option(expresso@collision:spatial_index(NDG)),
list(collision_pair(NDG))}.
-file("src/expresso/world.gleam", 141).
?DOC(
" Create a new empty physics world\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let world = world.new(\n"
" gravity: vec3.Vec3(0.0, -9.8, 0.0), // Earth gravity pointing down\n"
" )\n"
" ```\n"
).
-spec new(vec@vec3:vec3(float())) -> world(any()).
new(Gravity) ->
{world, maps:new(), Gravity, 4, +0.0, 2, auto_select, none, []}.
-file("src/expresso/world.gleam", 159).
?DOC(" Add a body to the world\n").
-spec add_body(world(NDK), expresso@body:body(NDK)) -> world(NDK).
add_body(World, Body) ->
New_bodies = gleam@dict:insert(
erlang:element(2, World),
erlang:element(2, Body),
Body
),
New_spatial_index = case erlang:element(8, World) of
none ->
none;
{some, {b_v_h_index, Tree}} ->
Pos_3d = {vec3,
erlang:element(2, erlang:element(3, Body)),
erlang:element(3, erlang:element(3, Body)),
+0.0},
Updated_tree = spatial@bvh:insert(
Tree,
Pos_3d,
erlang:element(2, Body),
8
),
{some, {b_v_h_index, Updated_tree}};
{some, {grid_index, Grid}} ->
Pos_3d@1 = {vec3,
erlang:element(2, erlang:element(3, Body)),
erlang:element(3, erlang:element(3, Body)),
+0.0},
Updated_grid = spatial@grid:insert(
Grid,
Pos_3d@1,
erlang:element(2, Body)
),
{some, {grid_index, Updated_grid}}
end,
{world,
New_bodies,
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
New_spatial_index,
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 182).
?DOC(" Remove a body from the world\n").
-spec remove_body(world(NDO), NDO) -> world(NDO).
remove_body(World, Id) ->
New_bodies = gleam@dict:delete(erlang:element(2, World), Id),
New_spatial_index = case erlang:element(8, World) of
none ->
none;
{some, {b_v_h_index, Tree}} ->
case spatial@bvh:remove(Tree, fun(Body_id) -> Body_id =:= Id end) of
{ok, Updated_tree} ->
{some, {b_v_h_index, Updated_tree}};
{error, _} ->
none
end;
{some, {grid_index, _}} ->
none
end,
{world,
New_bodies,
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
New_spatial_index,
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 207).
?DOC(" Update a body in the world\n").
-spec update_body(
world(NDR),
NDR,
fun((expresso@body:body(NDR)) -> expresso@body:body(NDR))
) -> world(NDR).
update_body(World, Id, Update) ->
case gleam_stdlib:map_get(erlang:element(2, World), Id) of
{ok, Body} ->
Updated_body = Update(Body),
New_bodies = gleam@dict:insert(
erlang:element(2, World),
Id,
Updated_body
),
New_spatial_index = case erlang:element(8, World) of
none ->
none;
{some, {b_v_h_index, Tree}} ->
New_pos = {vec3,
erlang:element(2, erlang:element(3, Updated_body)),
erlang:element(3, erlang:element(3, Updated_body)),
+0.0},
case spatial@bvh:update(
Tree,
fun(Body_id) -> Body_id =:= Id end,
New_pos,
Id,
8
) of
{ok, Updated_tree} ->
{some, {b_v_h_index, Updated_tree}};
{error, _} ->
none
end;
{some, {grid_index, _}} ->
none
end,
{world,
New_bodies,
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
New_spatial_index,
erlang:element(9, World)};
{error, _} ->
World
end.
-file("src/expresso/world.gleam", 249).
?DOC(" Get a body from the world\n").
-spec get_body(world(NDW), NDW) -> {ok, expresso@body:body(NDW)} | {error, nil}.
get_body(World, Id) ->
gleam_stdlib:map_get(erlang:element(2, World), Id).
-file("src/expresso/world.gleam", 254).
?DOC(" Get all bodies in the world\n").
-spec bodies(world(NEB)) -> gleam@dict:dict(NEB, expresso@body:body(NEB)).
bodies(World) ->
erlang:element(2, World).
-file("src/expresso/world.gleam", 262).
?DOC(
" Set the number of solver iterations\n"
"\n"
" More iterations = more stable but slower.\n"
" 3-5 is typical, 10+ for very stable stacks.\n"
).
-spec with_iterations(world(NEG), integer()) -> world(NEG).
with_iterations(World, Iterations) ->
{world,
erlang:element(2, World),
erlang:element(3, World),
Iterations,
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
erlang:element(8, World),
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 270).
?DOC(
" Set the coefficient of restitution (bounciness)\n"
"\n"
" 0.0 = perfectly inelastic (no bounce)\n"
" 1.0 = perfectly elastic (full bounce)\n"
).
-spec with_restitution(world(NEJ), float()) -> world(NEJ).
with_restitution(World, Restitution) ->
{world,
erlang:element(2, World),
erlang:element(3, World),
erlang:element(4, World),
Restitution,
erlang:element(6, World),
erlang:element(7, World),
erlang:element(8, World),
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 289).
?DOC(
" Set the number of substeps per physics step\n"
"\n"
" Substeps divide each physics step into smaller timesteps,\n"
" re-detecting collisions between each substep. This fixes chain\n"
" collisions where forces need to propagate through multiple bodies.\n"
"\n"
" More substeps = more accurate but slower.\n"
" Typical values: 2-3 for games, 4+ for complex chains.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Handle chains of 10+ enemies pushing each other\n"
" let world = world.new(gravity: vec2.Vec2(0.0, 0.0))\n"
" |> world.with_substeps(3)\n"
" ```\n"
).
-spec with_substeps(world(NEM), integer()) -> world(NEM).
with_substeps(World, Substeps) ->
{world,
erlang:element(2, World),
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
Substeps,
erlang:element(7, World),
erlang:element(8, World),
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 307).
?DOC(
" Set the spatial partitioning strategy\n"
"\n"
" - `UseBVH`: Best for dynamic scenes with moving objects (default)\n"
" - `UseGrid(cell_size)`: Best for uniform distributions (particles, crowds)\n"
" - `AutoSelect`: Automatically choose based on body count\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Force BVH for dynamic game\n"
" let world = world.with_spatial_strategy(world, UseBVH)\n"
" \n"
" // Use grid for particle system\n"
" let world = world.with_spatial_strategy(world, UseGrid(cell_size: 2.0))\n"
" ```\n"
).
-spec with_spatial_strategy(world(NEP), spatial_strategy()) -> world(NEP).
with_spatial_strategy(World, Strategy) ->
{world,
erlang:element(2, World),
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
Strategy,
none,
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 322).
?DOC(
" Query bodies within a radius of a point\n"
"\n"
" Uses spatial acceleration when available.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let nearby = world.query_radius(world, center: vec3.Vec3(0.0, 0.0, 0.0), radius: 5.0)\n"
" ```\n"
).
-spec query_radius(world(NES), vec@vec3:vec3(float()), float()) -> list({NES,
expresso@body:body(NES)}).
query_radius(World, Center, Radius) ->
case erlang:element(8, World) of
none ->
_pipe = maps:to_list(erlang:element(2, World)),
expresso@collision:filter_by_radius(_pipe, Center, Radius);
{some, {b_v_h_index, Tree}} ->
_pipe@1 = spatial@bvh:query_radius(Tree, Center, Radius),
expresso@collision:extract_bodies_from_results(
_pipe@1,
erlang:element(2, World)
);
{some, {grid_index, Grid}} ->
_pipe@2 = spatial@grid:query_radius(Grid, Center, Radius),
expresso@collision:extract_bodies_from_results(
_pipe@2,
erlang:element(2, World)
)
end.
-file("src/expresso/world.gleam", 354).
?DOC(
" Query bodies within a rectangular region\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let bodies_in_region = world.query_region(\n"
" world,\n"
" min: vec3.Vec3(-10.0, -10.0, -10.0),\n"
" max: vec3.Vec3(10.0, 10.0, 10.0),\n"
" )\n"
" ```\n"
).
-spec query_region(world(NEX), vec@vec3:vec3(float()), vec@vec3:vec3(float())) -> list({NEX,
expresso@body:body(NEX)}).
query_region(World, Min, Max) ->
Region = spatial@collider:box(Min, Max),
case erlang:element(8, World) of
none ->
_pipe = maps:to_list(erlang:element(2, World)),
expresso@collision:filter_by_region(_pipe, Min, Max);
{some, {b_v_h_index, Tree}} ->
_pipe@1 = spatial@bvh:'query'(Tree, Region),
expresso@collision:extract_bodies_from_results(
_pipe@1,
erlang:element(2, World)
);
{some, {grid_index, Grid}} ->
_pipe@2 = spatial@grid:'query'(Grid, Region),
expresso@collision:extract_bodies_from_results(
_pipe@2,
erlang:element(2, World)
)
end.
-file("src/expresso/world.gleam", 387).
?DOC(
" Find the nearest body to a point\n"
"\n"
" ## Example\n"
" ```gleam\n"
" case world.query_nearest(world, point: vec3.Vec3(5.0, 5.0, 5.0)) {\n"
" Some(#(id, body, distance)) -> // Use nearest body\n"
" None -> // No bodies in world\n"
" }\n"
" ```\n"
).
-spec query_nearest(world(NFD), vec@vec3:vec3(float())) -> gleam@option:option({NFD,
expresso@body:body(NFD),
float()}).
query_nearest(World, Point) ->
Max_radius = 1000.0,
_pipe = query_radius(World, Point, Max_radius),
expresso@collision:find_nearest(_pipe, Point).
-file("src/expresso/world.gleam", 438).
?DOC(
" Cast a ray and find the first body it hits\n"
"\n"
" Returns detailed information about the hit including:\n"
" - The body that was hit\n"
" - The exact hit point\n"
" - The surface normal at the hit point\n"
" - The distance from the ray origin\n"
"\n"
" Optionally filter by collision layers using the layer_mask parameter.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Shoot a laser forward\n"
" case world.raycast(\n"
" world,\n"
" origin: player_pos,\n"
" direction: vec3.Vec3(1.0, 0.0, 0.0),\n"
" max_distance: 100.0,\n"
" layer_mask: option.None, // Hit all layers\n"
" ) {\n"
" Some(hit) -> {\n"
" // Hit something!\n"
" io.println(\"Hit \" <> hit.body.id <> \" at distance \" <> float.to_string(hit.distance))\n"
" }\n"
" None -> {\n"
" // No hit\n"
" }\n"
" }\n"
"\n"
" // Only hit enemies\n"
" case world.raycast(\n"
" world,\n"
" origin: player_pos,\n"
" direction: aim_direction,\n"
" max_distance: 50.0,\n"
" layer_mask: option.Some(body.layer_enemy),\n"
" ) {\n"
" Some(hit) -> damage_enemy(hit.body_id)\n"
" None -> {}\n"
" }\n"
" ```\n"
).
-spec raycast(
world(NFI),
vec@vec3:vec3(float()),
vec@vec3:vec3(float()),
float(),
gleam@option:option(integer())
) -> gleam@option:option(expresso@collision:raycast_hit(NFI)).
raycast(World, Origin, Direction, Max_distance, Layer_mask) ->
expresso@collision:raycast(
erlang:element(2, World),
erlang:element(8, World),
Origin,
Direction,
Max_distance,
Layer_mask
).
-file("src/expresso/world.gleam", 514).
?DOC(" Check if world has fast-moving CCD bodies that need extra substeps\n").
-spec has_fast_ccd_bodies(world(any()), float()) -> boolean().
has_fast_ccd_bodies(World, Delta_time) ->
_pipe = maps:to_list(erlang:element(2, World)),
gleam@list:any(
_pipe,
fun(Pair) ->
{_, Body_val} = Pair,
case erlang:element(13, Body_val) of
false ->
false;
true ->
Speed = vec@vec3f:length(erlang:element(4, Body_val)),
Distance_per_frame = Speed * Delta_time,
Bounding_radius = expresso@body:bounding_radius(Body_val),
Distance_per_frame > (Bounding_radius * 0.5)
end
end
).
-file("src/expresso/world.gleam", 534).
?DOC(" Apply gravity to all dynamic bodies\n").
-spec apply_gravity(world(NFW), float()) -> world(NFW).
apply_gravity(World, Delta_time) ->
New_bodies = gleam@dict:map_values(
erlang:element(2, World),
fun(_, Body) -> case erlang:element(9, Body) of
true ->
Body;
false ->
Gravity_velocity = vec@vec3f:scale(
erlang:element(3, World),
Delta_time
),
{body,
erlang:element(2, Body),
erlang:element(3, Body),
vec@vec3f:add(erlang:element(4, Body), Gravity_velocity),
erlang:element(5, Body),
erlang:element(6, Body),
erlang:element(7, Body),
erlang:element(8, Body),
erlang:element(9, Body),
erlang:element(10, Body),
erlang:element(11, Body),
erlang:element(12, Body),
erlang:element(13, Body),
erlang:element(14, Body),
erlang:element(15, Body),
erlang:element(16, Body)}
end end
),
{world,
New_bodies,
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
erlang:element(8, World),
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 591).
?DOC(" Integrate angular velocity into rotation using quaternions\n").
-spec integrate_angular_velocity(
quaternion:quaternion(),
vec@vec3:vec3(float()),
float()
) -> quaternion:quaternion().
integrate_angular_velocity(Rotation, Angular_velocity, Delta_time) ->
Speed_squared = ((erlang:element(2, Angular_velocity) * erlang:element(
2,
Angular_velocity
))
+ (erlang:element(3, Angular_velocity) * erlang:element(3, Angular_velocity)))
+ (erlang:element(4, Angular_velocity) * erlang:element(4, Angular_velocity)),
case Speed_squared < 0.000001 of
true ->
Rotation;
false ->
Speed = case gleam@float:square_root(Speed_squared) of
{ok, S} ->
S;
{error, _} ->
+0.0
end,
Angle = Speed * Delta_time,
Axis = {vec3, case Speed of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> erlang:element(2, Angular_velocity) / Gleam@denominator
end, case Speed of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator@1 -> erlang:element(3, Angular_velocity) / Gleam@denominator@1
end, case Speed of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator@2 -> erlang:element(4, Angular_velocity) / Gleam@denominator@2
end},
Delta_rotation = quaternion:from_axis_angle(Axis, Angle),
_pipe = quaternion:multiply(Rotation, Delta_rotation),
quaternion:normalize(_pipe)
end.
-file("src/expresso/world.gleam", 553).
?DOC(" Integrate velocities to update positions and rotations\n").
-spec integrate_velocities(world(NFZ), float()) -> world(NFZ).
integrate_velocities(World, Delta_time) ->
New_bodies = gleam@dict:map_values(
erlang:element(2, World),
fun(_, Body) ->
Displacement = vec@vec3f:scale(erlang:element(4, Body), Delta_time),
New_position = vec@vec3f:add(erlang:element(3, Body), Displacement),
Friction_factor = 1.0 - (erlang:element(10, Body) * Delta_time),
New_velocity = vec@vec3f:scale(
erlang:element(4, Body),
Friction_factor
),
New_rotation = integrate_angular_velocity(
erlang:element(5, Body),
erlang:element(6, Body),
Delta_time
),
Angular_damping = 0.98,
New_angular_velocity = vec@vec3f:scale(
erlang:element(6, Body),
Angular_damping
),
{body,
erlang:element(2, Body),
New_position,
New_velocity,
New_rotation,
New_angular_velocity,
erlang:element(7, Body),
erlang:element(8, Body),
erlang:element(9, Body),
erlang:element(10, Body),
erlang:element(11, Body),
erlang:element(12, Body),
erlang:element(13, Body),
erlang:element(14, Body),
erlang:element(15, Body),
erlang:element(16, Body)}
end
),
{world,
New_bodies,
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
erlang:element(8, World),
erlang:element(9, World)}.
-file("src/expresso/world.gleam", 724).
?DOC(" Auto-select the best spatial strategy based on body count and distribution\n").
-spec auto_select_strategy(
gleam@dict:dict(NGL, expresso@body:body(NGL)),
integer()
) -> spatial_strategy().
auto_select_strategy(Bodies, Body_count) ->
case Body_count of
N when N < 100 ->
use_b_v_h;
_ ->
{Is_uniform, Avg_radius} = expresso@collision:analyze_distribution(
Bodies
),
case Is_uniform of
true ->
{use_grid, Avg_radius * 4.0};
false ->
use_b_v_h
end
end.
-file("src/expresso/world.gleam", 742).
?DOC(
" Build a BVH spatial index from bodies (stores IDs for efficient updates)\n"
" Optimized: Single pass with dict.fold instead of dict.to_list + list.map\n"
).
-spec build_bvh_index(gleam@dict:dict(NGP, expresso@body:body(NGP))) -> expresso@collision:spatial_index(NGP).
build_bvh_index(Bodies) ->
Items = gleam@dict:fold(
Bodies,
[],
fun(Acc, Id, Body) ->
Pos_3d = {vec3,
erlang:element(2, erlang:element(3, Body)),
erlang:element(3, erlang:element(3, Body)),
+0.0},
[{Pos_3d, Id} | Acc]
end
),
Tree@1 = case spatial@bvh:from_items(Items, 8) of
{ok, Tree} -> Tree;
_assert_fail ->
erlang:error(#{gleam_error => let_assert,
message => <<"Expected body count to be always nonzero"/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"expresso/world"/utf8>>,
function => <<"build_bvh_index"/utf8>>,
line => 749,
value => _assert_fail,
start => 22764,
'end' => 22825,
pattern_start => 22775,
pattern_end => 22783})
end,
{b_v_h_index, Tree@1}.
-file("src/expresso/world.gleam", 756).
?DOC(" Build a Grid spatial index from bodies (stores IDs for efficient updates)\n").
-spec build_grid_index(gleam@dict:dict(NGU, expresso@body:body(NGU)), float()) -> expresso@collision:spatial_index(NGU).
build_grid_index(Bodies, Cell_size) ->
Bounds = expresso@collision:calculate_world_bounds(Bodies),
Grid_empty = spatial@grid:new(Cell_size, Bounds),
Grid_filled = gleam@dict:fold(
Bodies,
Grid_empty,
fun(G, Id, Body) ->
Pos_3d = {vec3,
erlang:element(2, erlang:element(3, Body)),
erlang:element(3, erlang:element(3, Body)),
+0.0},
spatial@grid:insert(G, Pos_3d, Id)
end
),
{grid_index, Grid_filled}.
-file("src/expresso/world.gleam", 700).
?DOC(" Build or rebuild the spatial index based on current bodies\n").
-spec rebuild_spatial_index(world(NGI)) -> world(NGI).
rebuild_spatial_index(World) ->
Body_count = maps:size(erlang:element(2, World)),
case Body_count of
0 ->
{world,
erlang:element(2, World),
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
none,
erlang:element(9, World)};
_ ->
Strategy = case erlang:element(7, World) of
use_b_v_h ->
use_b_v_h;
{use_grid, Cell_size} ->
{use_grid, Cell_size};
auto_select ->
auto_select_strategy(erlang:element(2, World), Body_count)
end,
Spatial_index = case Strategy of
use_b_v_h ->
build_bvh_index(erlang:element(2, World));
{use_grid, Cell_size@1} ->
build_grid_index(erlang:element(2, World), Cell_size@1);
auto_select ->
build_bvh_index(erlang:element(2, World))
end,
{world,
erlang:element(2, World),
erlang:element(3, World),
erlang:element(4, World),
erlang:element(5, World),
erlang:element(6, World),
erlang:element(7, World),
{some, Spatial_index},
erlang:element(9, World)}
end.
-file("src/expresso/world.gleam", 778).
?DOC(" Check if a collision pair exists in a list (order-independent)\n").
-spec contains_collision(list(collision_pair(NGZ)), NGZ, NGZ) -> boolean().
contains_collision(Collisions, A, B) ->
gleam@list:any(
Collisions,
fun(Pair) ->
((erlang:element(2, Pair) =:= A) andalso (erlang:element(3, Pair)
=:= B))
orelse ((erlang:element(2, Pair) =:= B) andalso (erlang:element(
3,
Pair
)
=:= A))
end
).
-file("src/expresso/world.gleam", 785).
?DOC(" Convert contacts to collision pairs\n").
-spec contacts_to_pairs(
list(expresso@collision:contact(NHC)),
gleam@dict:dict(NHC, expresso@body:body(NHC))
) -> {list(collision_pair(NHC)), list(collision_pair(NHC))}.
contacts_to_pairs(Contacts, Bodies) ->
gleam@list:fold(
Contacts,
{[], []},
fun(Acc, Contact) ->
{Regular, Triggers} = Acc,
case {gleam_stdlib:map_get(Bodies, erlang:element(2, Contact)),
gleam_stdlib:map_get(Bodies, erlang:element(3, Contact))} of
{{ok, Body_a}, {ok, Body_b}} ->
Pair = {collision_pair,
erlang:element(2, Contact),
erlang:element(3, Contact)},
case erlang:element(16, Body_a) orelse erlang:element(
16,
Body_b
) of
true ->
{Regular, [Pair | Triggers]};
false ->
{[Pair | Regular], Triggers}
end;
{_, _} ->
Acc
end
end
).
-file("src/expresso/world.gleam", 807).
?DOC(" Generate collision events by comparing current and previous collisions\n").
-spec generate_collision_events(
list(collision_pair(NHM)),
list(collision_pair(NHM)),
list(collision_pair(NHM)),
gleam@dict:dict(NHM, expresso@body:body(NHM))
) -> list(collision_event(NHM)).
generate_collision_events(Current_regular, Current_triggers, Previous, Bodies) ->
Started_events = gleam@list:filter_map(
Current_regular,
fun(Pair) ->
case contains_collision(
Previous,
erlang:element(2, Pair),
erlang:element(3, Pair)
) of
true ->
{error, nil};
false ->
{ok,
{collision_started,
erlang:element(2, Pair),
erlang:element(3, Pair)}}
end
end
),
Ended_events = gleam@list:filter_map(
Previous,
fun(Pair@1) ->
case contains_collision(
Current_regular,
erlang:element(2, Pair@1),
erlang:element(3, Pair@1)
) of
true ->
{error, nil};
false ->
{ok,
{collision_ended,
erlang:element(2, Pair@1),
erlang:element(3, Pair@1)}}
end
end
),
Trigger_events = gleam@list:filter_map(
Current_triggers,
fun(Pair@2) ->
case {gleam_stdlib:map_get(Bodies, erlang:element(2, Pair@2)),
gleam_stdlib:map_get(Bodies, erlang:element(3, Pair@2))} of
{{ok, Body_a}, {ok, Body_b}} ->
case {erlang:element(16, Body_a),
erlang:element(16, Body_b)} of
{true, false} ->
case contains_collision(
Previous,
erlang:element(2, Pair@2),
erlang:element(3, Pair@2)
) of
true ->
{error, nil};
false ->
{ok,
{trigger_entered,
erlang:element(3, Pair@2),
erlang:element(2, Pair@2)}}
end;
{false, true} ->
case contains_collision(
Previous,
erlang:element(2, Pair@2),
erlang:element(3, Pair@2)
) of
true ->
{error, nil};
false ->
{ok,
{trigger_entered,
erlang:element(2, Pair@2),
erlang:element(3, Pair@2)}}
end;
{_, _} ->
{error, nil}
end;
{_, _} ->
{error, nil}
end
end
),
Trigger_exit_events = gleam@list:filter_map(
Previous,
fun(Pair@3) ->
case {gleam_stdlib:map_get(Bodies, erlang:element(2, Pair@3)),
gleam_stdlib:map_get(Bodies, erlang:element(3, Pair@3)),
contains_collision(
Current_triggers,
erlang:element(2, Pair@3),
erlang:element(3, Pair@3)
)} of
{{ok, Body_a@1}, {ok, Body_b@1}, false} ->
case {erlang:element(16, Body_a@1),
erlang:element(16, Body_b@1)} of
{true, false} ->
{ok,
{trigger_exited,
erlang:element(3, Pair@3),
erlang:element(2, Pair@3)}};
{false, true} ->
{ok,
{trigger_exited,
erlang:element(2, Pair@3),
erlang:element(3, Pair@3)}};
{_, _} ->
{error, nil}
end;
{_, _, _} ->
{error, nil}
end
end
),
lists:append(
[Started_events, Ended_events, Trigger_events, Trigger_exit_events]
).
-file("src/expresso/world.gleam", 636).
?DOC(
" Detect collisions and resolve them\n"
" Returns updated world and collision events (only generated if generate_events is True)\n"
).
-spec detect_and_resolve_collisions(world(NGD), boolean()) -> {world(NGD),
list(collision_event(NGD))}.
detect_and_resolve_collisions(World, Generate_events) ->
World@1 = rebuild_spatial_index(World),
Contacts = case erlang:element(8, World@1) of
none ->
expresso@collision:detect_collisions(erlang:element(2, World@1));
{some, Spatial_index} ->
expresso@collision:detect_collisions_with_index(
erlang:element(2, World@1),
Spatial_index
)
end,
Bodies_after_position = expresso@internal@solver:solve_position_constraints(
erlang:element(2, World@1),
Contacts,
erlang:element(4, World@1)
),
Bodies_after_velocity = expresso@internal@solver:solve_velocity_constraints(
Bodies_after_position,
Contacts,
erlang:element(5, World@1)
),
{Current_regular, Current_triggers} = contacts_to_pairs(
Contacts,
erlang:element(2, World@1)
),
All_current = lists:append(Current_regular, Current_triggers),
Events = case Generate_events of
false ->
[];
true ->
generate_collision_events(
Current_regular,
Current_triggers,
erlang:element(9, World@1),
erlang:element(2, World@1)
)
end,
New_previous = case Generate_events of
true ->
All_current;
false ->
erlang:element(9, World@1)
end,
Updated_world = {world,
Bodies_after_velocity,
erlang:element(3, World@1),
erlang:element(4, World@1),
erlang:element(5, World@1),
erlang:element(6, World@1),
erlang:element(7, World@1),
erlang:element(8, World@1),
New_previous},
{Updated_world, Events}.
-file("src/expresso/world.gleam", 477).
?DOC(
" Simulate one physics timestep using substeps\n"
"\n"
" This is the main physics update function. Call it each game tick with delta_time.\n"
" Returns the updated world and a list of collision events that occurred.\n"
"\n"
" The timestep is divided into `world.substeps` smaller steps, with collision\n"
" detection happening between each substep. This fixes chain collisions where\n"
" forces need to propagate through multiple bodies (e.g., Enemy1 → Enemy2 → Enemy3).\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let #(world, events) = world.step(world, delta_time: 1.0 /. 60.0) // 60 FPS\n"
"\n"
" // Handle collision events\n"
" list.each(events, fn(event) {\n"
" case event {\n"
" CollisionStarted(a, b) -> io.println(\"Collision started!\")\n"
" TriggerEntered(body, trigger) -> io.println(\"Entered trigger zone!\")\n"
" _ -> Nil\n"
" }\n"
" })\n"
" ```\n"
).
-spec step(world(NFP), float()) -> {world(NFP), list(collision_event(NFP))}.
step(World, Delta_time) ->
Needs_ccd = has_fast_ccd_bodies(World, Delta_time),
Substeps = case Needs_ccd of
true ->
erlang:element(6, World) * 4;
false ->
erlang:element(6, World)
end,
Substep_dt = case erlang:float(Substeps) of
+0.0 -> +0.0;
-0.0 -> -0.0;
Gleam@denominator -> Delta_time / Gleam@denominator
end,
{Final_world, Events} = begin
_pipe = gleam@list:range(0, Substeps - 1),
gleam@list:fold(
_pipe,
{World, []},
fun(Acc, Substep_index) ->
{Current_world, _} = Acc,
Is_final_substep = Substep_index =:= (Substeps - 1),
Updated_world = begin
_pipe@1 = Current_world,
_pipe@2 = apply_gravity(_pipe@1, Substep_dt),
integrate_velocities(_pipe@2, Substep_dt)
end,
detect_and_resolve_collisions(Updated_world, Is_final_substep)
end
)
end,
{Final_world, Events}.