Compare commits
5 Commits
65660cc639
...
b1c10e44ec
| Author | SHA1 | Date |
|---|---|---|
|
|
b1c10e44ec | |
|
|
6b9a5ee2fa | |
|
|
479ebbf9b9 | |
|
|
b085e7ec6a | |
|
|
8cde9818f4 |
|
|
@ -10,6 +10,8 @@ typedef enum : uint8_t {
|
||||||
TYPE_EMPTY = 0,
|
TYPE_EMPTY = 0,
|
||||||
TYPE_CIRCLE = 1,
|
TYPE_CIRCLE = 1,
|
||||||
TYPE_PLANE = 2,
|
TYPE_PLANE = 2,
|
||||||
|
TYPE_AABB = 3,
|
||||||
|
TYPE_COUNT,
|
||||||
} body_type;
|
} body_type;
|
||||||
|
|
||||||
////////// Types
|
////////// Types
|
||||||
|
|
@ -28,6 +30,9 @@ typedef struct {
|
||||||
struct {
|
struct {
|
||||||
vec2 normal;
|
vec2 normal;
|
||||||
} plane;
|
} plane;
|
||||||
|
struct {
|
||||||
|
vec2 half; // half-extents along x and y
|
||||||
|
} aabb;
|
||||||
};
|
};
|
||||||
// Private
|
// Private
|
||||||
vec2 force;
|
vec2 force;
|
||||||
|
|
@ -36,6 +41,17 @@ typedef struct {
|
||||||
typedef size_t rigid_body_index;
|
typedef size_t rigid_body_index;
|
||||||
typedef void (*rigid_body_collision_callback_t)(rigid_body_index a, rigid_body_index b);
|
typedef void (*rigid_body_collision_callback_t)(rigid_body_index a, rigid_body_index b);
|
||||||
|
|
||||||
|
// `normal` points from body a toward body b; `overlap` is the penetration depth.
|
||||||
|
typedef struct {
|
||||||
|
bool hit;
|
||||||
|
vec2 normal;
|
||||||
|
float overlap;
|
||||||
|
} contact;
|
||||||
|
|
||||||
|
typedef contact (*contact_fn)(const rigid_body* a, const rigid_body* b);
|
||||||
|
|
||||||
|
constexpr float COLLISION_EPSILON = 1e-6f;
|
||||||
|
|
||||||
////////// Prototypes
|
////////// Prototypes
|
||||||
|
|
||||||
void rigid_body_resolve_collision(rigid_body_index rb);
|
void rigid_body_resolve_collision(rigid_body_index rb);
|
||||||
|
|
@ -49,18 +65,72 @@ static rigid_body* rigid_bodies = NULL;
|
||||||
static size_t rigid_bodies_cap = 0;
|
static size_t rigid_bodies_cap = 0;
|
||||||
static rigid_body_collision_callback_t rigid_body_collision_callback = NULL;
|
static rigid_body_collision_callback_t rigid_body_collision_callback = NULL;
|
||||||
|
|
||||||
|
static vec2 gravity = {0, 0};
|
||||||
|
|
||||||
|
// Queued during the solve and dispatched only after it finishes, so callbacks may
|
||||||
|
// free or create bodies (reallocating `rigid_bodies`) without invalidating the body
|
||||||
|
// pointers held mid-solve.
|
||||||
|
typedef struct {
|
||||||
|
rigid_body_index a;
|
||||||
|
rigid_body_index b;
|
||||||
|
} collision_event;
|
||||||
|
|
||||||
|
static collision_event* collision_events = NULL;
|
||||||
|
static size_t collision_events_count = 0;
|
||||||
|
static size_t collision_events_cap = 0;
|
||||||
|
|
||||||
////////// Functions
|
////////// Functions
|
||||||
|
|
||||||
inline static rigid_body* rb_get(rigid_body_index idx) {
|
inline static rigid_body* rb_get(rigid_body_index idx) {
|
||||||
return (rigid_bodies + (idx));
|
return (rigid_bodies + (idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void collision_event_push(rigid_body_index a, rigid_body_index b) {
|
||||||
|
if (collision_events_count == collision_events_cap) {
|
||||||
|
size_t new_cap = collision_events_cap ? collision_events_cap * 2 : 16;
|
||||||
|
collision_event* grown = realloc(collision_events, new_cap * sizeof(collision_event));
|
||||||
|
if (!grown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collision_events = grown;
|
||||||
|
collision_events_cap = new_cap;
|
||||||
|
}
|
||||||
|
collision_events[collision_events_count].a = a;
|
||||||
|
collision_events[collision_events_count].b = b;
|
||||||
|
collision_events_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rigid_body_dispatch_collisions() {
|
||||||
|
if (rigid_body_collision_callback) {
|
||||||
|
for (size_t i = 0; i < collision_events_count; i++) {
|
||||||
|
rigid_body_collision_callback(collision_events[i].a, collision_events[i].b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collision_events_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rigid_body_integrate(rigid_body* rb, float dt) {
|
||||||
|
if (!isinf(rb->mass)) {
|
||||||
|
rb->vel.x += (rb->force.x / rb->mass + gravity.x) * dt;
|
||||||
|
rb->vel.y += (rb->force.y / rb->mass + gravity.y) * dt;
|
||||||
|
|
||||||
|
rb->pos.x += rb->vel.x * dt;
|
||||||
|
rb->pos.y += rb->vel.y * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
rb->force.x = 0;
|
||||||
|
rb->force.y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
JS_EXPORT rigid_body* rigid_body_get(rigid_body_index idx) {
|
JS_EXPORT rigid_body* rigid_body_get(rigid_body_index idx) {
|
||||||
return rb_get(idx);
|
return rb_get(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t rigid_body_new(float x, float y, float vx, float vy, float mass) {
|
size_t rigid_body_new(float x, float y, float vx, float vy, float mass) {
|
||||||
rigid_body_index idx = rigid_body_find_empty();
|
rigid_body_index idx = rigid_body_find_empty();
|
||||||
|
if (idx == SIZE_MAX) {
|
||||||
|
return SIZE_MAX; // allocation failed
|
||||||
|
}
|
||||||
rigid_body* rb = rb_get(idx);
|
rigid_body* rb = rb_get(idx);
|
||||||
rb->type = TYPE_EMPTY;
|
rb->type = TYPE_EMPTY;
|
||||||
|
|
||||||
|
|
@ -80,6 +150,9 @@ size_t rigid_body_new(float x, float y, float vx, float vy, float mass) {
|
||||||
|
|
||||||
JS_EXPORT rigid_body_index rigid_body_new_circle(float x, float y, float vx, float vy, float mass, float radius) {
|
JS_EXPORT rigid_body_index rigid_body_new_circle(float x, float y, float vx, float vy, float mass, float radius) {
|
||||||
rigid_body_index idx = rigid_body_new(x, y, vx, vy, mass);
|
rigid_body_index idx = rigid_body_new(x, y, vx, vy, mass);
|
||||||
|
if (idx == SIZE_MAX) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
rigid_body* rb = rb_get(idx);
|
rigid_body* rb = rb_get(idx);
|
||||||
|
|
||||||
rb->type = TYPE_CIRCLE;
|
rb->type = TYPE_CIRCLE;
|
||||||
|
|
@ -90,6 +163,9 @@ JS_EXPORT rigid_body_index rigid_body_new_circle(float x, float y, float vx, flo
|
||||||
|
|
||||||
JS_EXPORT rigid_body_index rigid_body_new_plane(float x, float y, float nx, float ny) {
|
JS_EXPORT rigid_body_index rigid_body_new_plane(float x, float y, float nx, float ny) {
|
||||||
rigid_body_index idx = rigid_body_new(x, y, 0, 0, INFINITY);
|
rigid_body_index idx = rigid_body_new(x, y, 0, 0, INFINITY);
|
||||||
|
if (idx == SIZE_MAX) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
rigid_body* rb = rb_get(idx);
|
rigid_body* rb = rb_get(idx);
|
||||||
|
|
||||||
rb->type = TYPE_PLANE;
|
rb->type = TYPE_PLANE;
|
||||||
|
|
@ -98,24 +174,27 @@ JS_EXPORT rigid_body_index rigid_body_new_plane(float x, float y, float nx, floa
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JS_EXPORT rigid_body_index rigid_body_new_aabb(float x, float y, float vx, float vy, float mass, float hx, float hy) {
|
||||||
|
rigid_body_index idx = rigid_body_new(x, y, vx, vy, mass);
|
||||||
|
if (idx == SIZE_MAX) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
rigid_body* rb = rb_get(idx);
|
||||||
|
|
||||||
|
rb->type = TYPE_AABB;
|
||||||
|
rb->aabb.half = (vec2){hx, hy};
|
||||||
|
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
JS_EXPORT void rigid_body_free(rigid_body_index idx) {
|
JS_EXPORT void rigid_body_free(rigid_body_index idx) {
|
||||||
memset(rb_get(idx), 0, sizeof(rigid_body));
|
memset(rb_get(idx), 0, sizeof(rigid_body));
|
||||||
}
|
}
|
||||||
|
|
||||||
JS_EXPORT void rigid_body_update(rigid_body_index idx, float dt) {
|
JS_EXPORT void rigid_body_update(rigid_body_index idx, float dt) {
|
||||||
|
rigid_body_integrate(rb_get(idx), dt);
|
||||||
rigid_body_resolve_collision(idx);
|
rigid_body_resolve_collision(idx);
|
||||||
rigid_body* rb = rb_get(idx);
|
rigid_body_dispatch_collisions();
|
||||||
|
|
||||||
if (!isinf(rb->mass)) {
|
|
||||||
rb->vel.x += rb->force.x * dt / rb->mass;
|
|
||||||
rb->vel.y += rb->force.y * dt / rb->mass;
|
|
||||||
|
|
||||||
rb->pos.x += rb->vel.x * dt;
|
|
||||||
rb->pos.y += rb->vel.y * dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
rb->force.x = 0;
|
|
||||||
rb->force.y = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
JS_EXPORT void rigid_body_update_all(float dt) {
|
JS_EXPORT void rigid_body_update_all(float dt) {
|
||||||
|
|
@ -124,8 +203,18 @@ JS_EXPORT void rigid_body_update_all(float dt) {
|
||||||
if (current->type == TYPE_EMPTY) {
|
if (current->type == TYPE_EMPTY) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
rigid_body_update(idx, dt);
|
rigid_body_integrate(current, dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (rigid_body_index idx = 0; idx < rigid_bodies_cap; idx++) {
|
||||||
|
rigid_body* current = rb_get(idx);
|
||||||
|
if (current->type == TYPE_EMPTY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rigid_body_resolve_collision(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
rigid_body_dispatch_collisions();
|
||||||
}
|
}
|
||||||
|
|
||||||
JS_EXPORT void rigid_body_add_force(rigid_body_index idx, float fx, float fy) {
|
JS_EXPORT void rigid_body_add_force(rigid_body_index idx, float fx, float fy) {
|
||||||
|
|
@ -148,6 +237,11 @@ JS_EXPORT void rigid_body_set_collision_callback(rigid_body_collision_callback_t
|
||||||
rigid_body_collision_callback = callback;
|
rigid_body_collision_callback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JS_EXPORT void rigid_body_set_gravity(float gx, float gy) {
|
||||||
|
gravity.x = gx;
|
||||||
|
gravity.y = gy;
|
||||||
|
}
|
||||||
|
|
||||||
void rigid_body_resolve_collision(rigid_body_index idx) {
|
void rigid_body_resolve_collision(rigid_body_index idx) {
|
||||||
rigid_body* rb = rb_get(idx);
|
rigid_body* rb = rb_get(idx);
|
||||||
int is_static = isinf(rb->mass);
|
int is_static = isinf(rb->mass);
|
||||||
|
|
@ -159,79 +253,181 @@ void rigid_body_resolve_collision(rigid_body_index idx) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void rigid_body_handle_collision(rigid_body_index idx1, rigid_body_index idx2) {
|
////////// Contact generators
|
||||||
rigid_body* rb1 = rb_get(idx1);
|
|
||||||
rigid_body* rb2 = rb_get(idx2);
|
static contact contact_circle_circle(const rigid_body* a, const rigid_body* b) {
|
||||||
if (rb1->type == TYPE_CIRCLE && rb2->type == TYPE_CIRCLE) {
|
vec2 d = vec2_sub(b->pos, a->pos);
|
||||||
vec2 d = vec2_sub(rb2->pos, rb1->pos);
|
|
||||||
float distance = vec2_mag(d);
|
float distance = vec2_mag(d);
|
||||||
|
|
||||||
float overlap = rb1->circle.radius + rb2->circle.radius - distance;
|
float overlap = a->circle.radius + b->circle.radius - distance;
|
||||||
if (overlap < 0) {
|
if (overlap < 0) {
|
||||||
|
return (contact){.hit = false};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coincident centres have no separation axis; pick an arbitrary one.
|
||||||
|
vec2 n = (distance > COLLISION_EPSILON) ? vec2_div(d, distance) : (vec2){1.0f, 0.0f};
|
||||||
|
return (contact){.hit = true, .normal = n, .overlap = overlap};
|
||||||
|
}
|
||||||
|
|
||||||
|
static contact contact_plane_circle(const rigid_body* a, const rigid_body* b) {
|
||||||
|
// a: plane (static), b: circle.
|
||||||
|
float distance = point_to_line_dist(b->pos, a->pos, a->plane.normal) - b->circle.radius;
|
||||||
|
|
||||||
|
float overlap = -distance;
|
||||||
|
if (overlap < 0) {
|
||||||
|
return (contact){.hit = false};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (contact){.hit = true, .normal = a->plane.normal, .overlap = overlap};
|
||||||
|
}
|
||||||
|
|
||||||
|
static contact contact_aabb_circle(const rigid_body* a, const rigid_body* b) {
|
||||||
|
// a: box, b: circle.
|
||||||
|
vec2 half = a->aabb.half;
|
||||||
|
vec2 d = vec2_sub(b->pos, a->pos);
|
||||||
|
|
||||||
|
// Closest point on the box to the circle centre, expressed relative to the centre.
|
||||||
|
vec2 closest = {
|
||||||
|
fmaxf(-half.x, fminf(d.x, half.x)),
|
||||||
|
fmaxf(-half.y, fminf(d.y, half.y)),
|
||||||
|
};
|
||||||
|
vec2 diff = vec2_sub(d, closest);
|
||||||
|
float dist = vec2_mag(diff);
|
||||||
|
|
||||||
|
if (dist > COLLISION_EPSILON) {
|
||||||
|
float overlap = b->circle.radius - dist;
|
||||||
|
if (overlap < 0) {
|
||||||
|
return (contact){.hit = false};
|
||||||
|
}
|
||||||
|
// Normal points box -> circle.
|
||||||
|
return (contact){.hit = true, .normal = vec2_div(diff, dist), .overlap = overlap};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centre is inside the box; push out along the axis of least penetration.
|
||||||
|
float px = half.x - fabsf(d.x);
|
||||||
|
float py = half.y - fabsf(d.y);
|
||||||
|
if (px < py) {
|
||||||
|
vec2 n = {copysignf(1.0f, d.x), 0.0f};
|
||||||
|
return (contact){.hit = true, .normal = n, .overlap = px + b->circle.radius};
|
||||||
|
}
|
||||||
|
vec2 n = {0.0f, copysignf(1.0f, d.y)};
|
||||||
|
return (contact){.hit = true, .normal = n, .overlap = py + b->circle.radius};
|
||||||
|
}
|
||||||
|
|
||||||
|
static contact contact_aabb_plane(const rigid_body* a, const rigid_body* b) {
|
||||||
|
// a: box, b: plane (static).
|
||||||
|
vec2 normal = b->plane.normal;
|
||||||
|
|
||||||
|
// Projection of the box half-extents onto the plane normal.
|
||||||
|
float reach = a->aabb.half.x * fabsf(normal.x) + a->aabb.half.y * fabsf(normal.y);
|
||||||
|
float overlap = reach - point_to_line_dist(a->pos, b->pos, normal);
|
||||||
|
if (overlap < 0) {
|
||||||
|
return (contact){.hit = false};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal points box -> plane, so resolution pushes the box out along +plane.normal.
|
||||||
|
return (contact){.hit = true, .normal = vec2_mul(normal, -1.0f), .overlap = overlap};
|
||||||
|
}
|
||||||
|
|
||||||
|
static contact contact_aabb_aabb(const rigid_body* a, const rigid_body* b) {
|
||||||
|
vec2 d = vec2_sub(b->pos, a->pos);
|
||||||
|
|
||||||
|
float ox = (a->aabb.half.x + b->aabb.half.x) - fabsf(d.x);
|
||||||
|
float oy = (a->aabb.half.y + b->aabb.half.y) - fabsf(d.y);
|
||||||
|
if (ox < 0 || oy < 0) {
|
||||||
|
return (contact){.hit = false};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate along the axis of least penetration; normal points a -> b.
|
||||||
|
if (ox < oy) {
|
||||||
|
vec2 n = {copysignf(1.0f, d.x), 0.0f};
|
||||||
|
return (contact){.hit = true, .normal = n, .overlap = ox};
|
||||||
|
}
|
||||||
|
vec2 n = {0.0f, copysignf(1.0f, d.y)};
|
||||||
|
return (contact){.hit = true, .normal = n, .overlap = oy};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexed [a->type][b->type] in canonical order (a->type >= b->type); a NULL entry
|
||||||
|
// means the pair does not collide.
|
||||||
|
static contact_fn contact_table[TYPE_COUNT][TYPE_COUNT] = {
|
||||||
|
[TYPE_CIRCLE][TYPE_CIRCLE] = contact_circle_circle,
|
||||||
|
[TYPE_PLANE][TYPE_CIRCLE] = contact_plane_circle,
|
||||||
|
[TYPE_AABB][TYPE_CIRCLE] = contact_aabb_circle,
|
||||||
|
[TYPE_AABB][TYPE_PLANE] = contact_aabb_plane,
|
||||||
|
[TYPE_AABB][TYPE_AABB] = contact_aabb_aabb,
|
||||||
|
};
|
||||||
|
|
||||||
|
// `n` points from a toward b; infinite-mass bodies are treated as immovable.
|
||||||
|
static void resolve_contact(rigid_body* a, rigid_body* b, vec2 n, float overlap) {
|
||||||
|
float factor = (isinf(a->mass) || isinf(b->mass)) ? 1 : 0.5;
|
||||||
|
vec2 n_overlap = vec2_mul(n, overlap * factor);
|
||||||
|
|
||||||
|
if (!isinf(a->mass)) {
|
||||||
|
a->pos = vec2_sub(a->pos, n_overlap);
|
||||||
|
}
|
||||||
|
if (!isinf(b->mass)) {
|
||||||
|
b->pos = vec2_add(b->pos, n_overlap);
|
||||||
|
}
|
||||||
|
|
||||||
|
float van = vec2_dot(a->vel, n);
|
||||||
|
float vbn = vec2_dot(b->vel, n);
|
||||||
|
|
||||||
|
// Skip the impulse if the bodies are already separating along the normal.
|
||||||
|
if (van - vbn <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rigid_body_collision_callback) {
|
float van_new = van;
|
||||||
rigid_body_collision_callback(idx1, idx2);
|
float vbn_new = vbn;
|
||||||
}
|
|
||||||
|
|
||||||
vec2 n = vec2_normalize(d);
|
if (!isinf(a->mass) && !isinf(b->mass)) {
|
||||||
float factor = (isinf(rb1->mass) || isinf(rb2->mass)) ? 1 : 0.5;
|
// Elastic collision: each body weighted by the *other* body's mass.
|
||||||
vec2 n_overlap = vec2_mul(n, overlap * factor);
|
van_new = van - 2.0f * (van - vbn) * b->mass / (a->mass + b->mass);
|
||||||
|
vbn_new = vbn + 2.0f * (van - vbn) * a->mass / (a->mass + b->mass);
|
||||||
if (!isinf(rb1->mass)) {
|
} else if (isinf(a->mass)) {
|
||||||
rb1->pos = vec2_sub(rb1->pos, n_overlap);
|
vbn_new = -vbn;
|
||||||
}
|
} else if (isinf(b->mass)) {
|
||||||
if (!isinf(rb2->mass)) {
|
van_new = -van;
|
||||||
rb2->pos = vec2_add(rb2->pos, n_overlap);
|
|
||||||
}
|
|
||||||
|
|
||||||
float v1n = vec2_dot(rb1->vel, n);
|
|
||||||
float v2n = vec2_dot(rb2->vel, n);
|
|
||||||
|
|
||||||
float v1n_new = v1n;
|
|
||||||
float v2n_new = v2n;
|
|
||||||
|
|
||||||
if (!isinf(rb1->mass) && !isinf(rb2->mass)) {
|
|
||||||
v1n_new = v1n - 2.0f * (v1n - v2n) * rb1->mass / (rb1->mass + rb2->mass);
|
|
||||||
v2n_new = v2n + 2.0f * (v1n - v2n) * rb2->mass / (rb1->mass + rb2->mass);
|
|
||||||
} else if (isinf(rb1->mass)) {
|
|
||||||
v2n_new = -v2n;
|
|
||||||
} else if (isinf(rb2->mass)) {
|
|
||||||
v1n_new = -v1n;
|
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec2 v1t = vec2_sub(rb1->vel, vec2_mul(n, v1n));
|
vec2 vat = vec2_sub(a->vel, vec2_mul(n, van));
|
||||||
vec2 v2t = vec2_sub(rb2->vel, vec2_mul(n, v2n));
|
vec2 vbt = vec2_sub(b->vel, vec2_mul(n, vbn));
|
||||||
|
|
||||||
if (!isinf(rb1->mass)) {
|
if (!isinf(a->mass)) {
|
||||||
rb1->vel = vec2_add(v1t, vec2_mul(n, v1n_new));
|
a->vel = vec2_add(vat, vec2_mul(n, van_new));
|
||||||
}
|
}
|
||||||
if (!isinf(rb2->mass)) {
|
if (!isinf(b->mass)) {
|
||||||
rb2->vel = vec2_add(v2t, vec2_mul(n, v2n_new));
|
b->vel = vec2_add(vbt, vec2_mul(n, vbn_new));
|
||||||
}
|
}
|
||||||
} else if (rb1->type == TYPE_PLANE && rb2->type == TYPE_CIRCLE) {
|
}
|
||||||
float distance = point_to_line_dist(rb2->pos, rb1->pos, rb1->plane.normal) - rb2->circle.radius;
|
|
||||||
|
|
||||||
float overlap = -distance;
|
void rigid_body_handle_collision(rigid_body_index idx1, rigid_body_index idx2) {
|
||||||
if (overlap < 0) {
|
// The greater type enum becomes `a` so each pair needs only one table entry; ties
|
||||||
|
// keep the caller's order, preserving the argument order seen by the callback.
|
||||||
|
rigid_body_index ia = idx1;
|
||||||
|
rigid_body_index ib = idx2;
|
||||||
|
if (rb_get(idx2)->type > rb_get(idx1)->type) {
|
||||||
|
ia = idx2;
|
||||||
|
ib = idx1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rigid_body* a = rb_get(ia);
|
||||||
|
rigid_body* b = rb_get(ib);
|
||||||
|
|
||||||
|
contact_fn generate = contact_table[a->type][b->type];
|
||||||
|
if (!generate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rigid_body_collision_callback) {
|
contact c = generate(a, b);
|
||||||
rigid_body_collision_callback(idx1, idx2);
|
if (!c.hit) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec2 n = rb1->plane.normal;
|
collision_event_push(ia, ib);
|
||||||
vec2 n_overlap = vec2_mul(n, overlap);
|
resolve_contact(a, b, c.normal, c.overlap);
|
||||||
|
|
||||||
rb2->pos = vec2_add(rb2->pos, n_overlap);
|
|
||||||
rb2->vel = vec2_reflect(rb2->vel, n);
|
|
||||||
} else if (rb1->type == TYPE_CIRCLE && rb2->type == TYPE_PLANE) {
|
|
||||||
rigid_body_handle_collision(idx2, idx1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
float point_to_line_dist(vec2 p, vec2 line_point, vec2 normal) {
|
float point_to_line_dist(vec2 p, vec2 line_point, vec2 normal) {
|
||||||
|
|
@ -248,14 +444,23 @@ rigid_body_index rigid_body_find_empty() {
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
size_t new_cap = rigid_bodies_cap * 2;
|
size_t old_cap = rigid_bodies_cap;
|
||||||
rigid_bodies = realloc(rigid_bodies, new_cap * sizeof(rigid_body));
|
size_t new_cap = old_cap * 2;
|
||||||
memset(rigid_bodies + rigid_bodies_cap, 0, rigid_bodies_cap * sizeof(rigid_body));
|
rigid_body* grown = realloc(rigid_bodies, new_cap * sizeof(rigid_body));
|
||||||
|
if (!grown) {
|
||||||
|
return SIZE_MAX; // keep the old buffer intact; caller handles failure
|
||||||
|
}
|
||||||
|
rigid_bodies = grown;
|
||||||
|
memset(rigid_bodies + old_cap, 0, (new_cap - old_cap) * sizeof(rigid_body));
|
||||||
rigid_bodies_cap = new_cap;
|
rigid_bodies_cap = new_cap;
|
||||||
return rigid_body_find_empty();
|
return old_cap; // first slot of the freshly grown region
|
||||||
} else {
|
} else {
|
||||||
|
rigid_body* allocated = malloc(2 * sizeof(rigid_body));
|
||||||
|
if (!allocated) {
|
||||||
|
return SIZE_MAX;
|
||||||
|
}
|
||||||
|
rigid_bodies = allocated;
|
||||||
rigid_bodies_cap = 2;
|
rigid_bodies_cap = 2;
|
||||||
rigid_bodies = malloc(rigid_bodies_cap * sizeof(rigid_body));
|
|
||||||
memset(rigid_bodies, 0, rigid_bodies_cap * sizeof(rigid_body));
|
memset(rigid_bodies, 0, rigid_bodies_cap * sizeof(rigid_body));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import E from './engine.c';
|
||||||
namespace Physics {
|
namespace Physics {
|
||||||
const TYPE_CIRCLE = 1;
|
const TYPE_CIRCLE = 1;
|
||||||
const TYPE_PLANE = 2;
|
const TYPE_PLANE = 2;
|
||||||
|
const TYPE_AABB = 3;
|
||||||
|
|
||||||
export function newCircle(x: number, y: number, radius: number, mass: number = 1.0): number {
|
export function newCircle(x: number, y: number, radius: number, mass: number = 1.0): number {
|
||||||
const body = E.rigid_body_new_circle(x, y, 0, 0, mass, radius);
|
const body = E.rigid_body_new_circle(x, y, 0, 0, mass, radius);
|
||||||
|
|
@ -14,6 +15,11 @@ namespace Physics {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function newAABB(x: number, y: number, hx: number, hy: number, mass: number = 1.0): number {
|
||||||
|
const body = E.rigid_body_new_aabb(x, y, 0, 0, mass, hx, hy);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteBody(body: number) {
|
export function deleteBody(body: number) {
|
||||||
E.rigid_body_free(body);
|
E.rigid_body_free(body);
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +32,10 @@ namespace Physics {
|
||||||
E.rigid_body_add_global_force(fx, fy);
|
E.rigid_body_add_global_force(fx, fy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setGravity(gx: number, gy: number) {
|
||||||
|
E.rigid_body_set_gravity(gx, gy);
|
||||||
|
}
|
||||||
|
|
||||||
export function update(dt: number) {
|
export function update(dt: number) {
|
||||||
E.rigid_body_update_all(dt);
|
E.rigid_body_update_all(dt);
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +58,10 @@ namespace Physics {
|
||||||
const nx = E.data.getFloat32(ptr + 24, true);
|
const nx = E.data.getFloat32(ptr + 24, true);
|
||||||
const ny = E.data.getFloat32(ptr + 28, true);
|
const ny = E.data.getFloat32(ptr + 28, true);
|
||||||
return { id: ptr, type, x, y, vx, vy, mass, nx, ny };
|
return { id: ptr, type, x, y, vx, vy, mass, nx, ny };
|
||||||
|
} else if (type === TYPE_AABB) {
|
||||||
|
const hx = E.data.getFloat32(ptr + 24, true);
|
||||||
|
const hy = E.data.getFloat32(ptr + 28, true);
|
||||||
|
return { id: ptr, type, x, y, vx, vy, mass, hx, hy };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id: ptr, type, x, y, vx, vy, mass };
|
return { id: ptr, type, x, y, vx, vy, mass };
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ abstract class BaseEffect extends Component<{
|
||||||
condition: string | null; // keep effect while true; remove when it becomes false
|
condition: string | null; // keep effect while true; remove when it becomes false
|
||||||
stacking: 'stack' | 'unique' | 'replace';
|
stacking: 'stack' | 'unique' | 'replace';
|
||||||
tag: string | null; // discriminator for stacking; null = no stacking enforcement
|
tag: string | null; // discriminator for stacking; null = no stacking enforcement
|
||||||
|
active: boolean; // true while the delta is currently applied to the target stat
|
||||||
}> {
|
}> {
|
||||||
constructor(opts: {
|
constructor(opts: {
|
||||||
target?: Class<Component<any>> | string,
|
target?: Class<Component<any>> | string,
|
||||||
|
|
@ -43,6 +44,7 @@ abstract class BaseEffect extends Component<{
|
||||||
condition: opts.condition ?? null,
|
condition: opts.condition ?? null,
|
||||||
stacking: opts.stacking ?? 'stack',
|
stacking: opts.stacking ?? 'stack',
|
||||||
tag: opts.tag ?? null,
|
tag: opts.tag ?? null,
|
||||||
|
active: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (opts.gradual && opts.duration == null) {
|
if (opts.gradual && opts.duration == null) {
|
||||||
|
|
@ -62,8 +64,9 @@ abstract class BaseEffect extends Component<{
|
||||||
@tag(ComponentTag.Equippable)
|
@tag(ComponentTag.Equippable)
|
||||||
@component
|
@component
|
||||||
export class Effect extends BaseEffect {
|
export class Effect extends BaseEffect {
|
||||||
/** True while the effect's delta is applied to the target stat. */
|
/** True while the delta is applied. Backed by `state` so it survives serialize/clone. */
|
||||||
@variable('.') active: boolean = false;
|
@variable('.') get active(): boolean { return this.state.active; }
|
||||||
|
set active(v: boolean) { this.state.active = v; }
|
||||||
|
|
||||||
override onAdd(): void {
|
override onAdd(): void {
|
||||||
const { stacking, tag } = this.state;
|
const { stacking, tag } = this.state;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ export type SlotInput = SlotDefinition | SlotDefinition[];
|
||||||
|
|
||||||
@component
|
@component
|
||||||
export class Equipment extends Component<EquipmentState> {
|
export class Equipment extends Component<EquipmentState> {
|
||||||
#cachedVars: RPGVariables | null = null;
|
// TS `private`, not `#private`: clone/deserialize rehydrate via Object.create(), which omits #members.
|
||||||
|
private cachedVars: RPGVariables | null = null;
|
||||||
|
|
||||||
constructor(...slots: SlotInput[]) {
|
constructor(...slots: SlotInput[]) {
|
||||||
const record: Record<string, SlotRecord> = {};
|
const record: Record<string, SlotRecord> = {};
|
||||||
|
|
@ -55,13 +56,13 @@ export class Equipment extends Component<EquipmentState> {
|
||||||
super({ slots: record });
|
super({ slots: record });
|
||||||
}
|
}
|
||||||
|
|
||||||
#slot(slotName: string): SlotRecord | undefined {
|
private slot(slotName: string): SlotRecord | undefined {
|
||||||
return this.state.slots[slotName];
|
return this.state.slots[slotName];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ItemId in the named slot, or null if empty. */
|
/** ItemId in the named slot, or null if empty. */
|
||||||
getItemId(slotName: string): string | null {
|
getItemId(slotName: string): string | null {
|
||||||
return this.#slot(slotName)?.itemId ?? null;
|
return this.slot(slotName)?.itemId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getItem(slotName: string): Entity | undefined {
|
getItem(slotName: string): Entity | undefined {
|
||||||
|
|
@ -103,7 +104,7 @@ export class Equipment extends Component<EquipmentState> {
|
||||||
*/
|
*/
|
||||||
@action
|
@action
|
||||||
equip({ slotName, itemId }: { slotName: string; itemId: string }): boolean {
|
equip({ slotName, itemId }: { slotName: string; itemId: string }): boolean {
|
||||||
const slot = this.#slot(slotName);
|
const slot = this.slot(slotName);
|
||||||
if (!slot) return false;
|
if (!slot) return false;
|
||||||
|
|
||||||
const itemEntity = this.entity.world.getEntity(itemId);
|
const itemEntity = this.entity.world.getEntity(itemId);
|
||||||
|
|
@ -117,7 +118,7 @@ export class Equipment extends Component<EquipmentState> {
|
||||||
this.unequip(slotName);
|
this.unequip(slotName);
|
||||||
|
|
||||||
slot.itemId = itemId;
|
slot.itemId = itemId;
|
||||||
this.#cachedVars = null;
|
this.cachedVars = null;
|
||||||
|
|
||||||
let id = 0;
|
let id = 0;
|
||||||
for (const [key, component] of itemEntity) {
|
for (const [key, component] of itemEntity) {
|
||||||
|
|
@ -138,19 +139,19 @@ export class Equipment extends Component<EquipmentState> {
|
||||||
*/
|
*/
|
||||||
@action
|
@action
|
||||||
unequip(slotName: string): boolean {
|
unequip(slotName: string): boolean {
|
||||||
const slot = this.#slot(slotName);
|
const slot = this.slot(slotName);
|
||||||
if (!slot || slot.itemId === null) return false;
|
if (!slot || slot.itemId === null) return false;
|
||||||
|
|
||||||
const itemId = slot.itemId;
|
const itemId = slot.itemId;
|
||||||
this.#removeEffects(slot);
|
this.removeEffects(slot);
|
||||||
slot.itemId = null;
|
slot.itemId = null;
|
||||||
this.#cachedVars = null;
|
this.cachedVars = null;
|
||||||
|
|
||||||
this.emit('unequip', { slotName, itemId });
|
this.emit('unequip', { slotName, itemId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
#removeEffects(slot: SlotRecord): void {
|
private removeEffects(slot: SlotRecord): void {
|
||||||
for (const key of slot.appliedEffectKeys) {
|
for (const key of slot.appliedEffectKeys) {
|
||||||
this.entity.remove(key);
|
this.entity.remove(key);
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +159,7 @@ export class Equipment extends Component<EquipmentState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
override getVariables(): RPGVariables {
|
override getVariables(): RPGVariables {
|
||||||
if (this.#cachedVars) return this.#cachedVars;
|
if (this.cachedVars) return this.cachedVars;
|
||||||
|
|
||||||
const result: RPGVariables = {};
|
const result: RPGVariables = {};
|
||||||
for (const { slotName, itemId } of Object.values(this.state.slots)) {
|
for (const { slotName, itemId } of Object.values(this.state.slots)) {
|
||||||
|
|
@ -166,7 +167,7 @@ export class Equipment extends Component<EquipmentState> {
|
||||||
result[slotName] = itemId;
|
result[slotName] = itemId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.#cachedVars = result;
|
this.cachedVars = result;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export class Experience extends Component<{
|
||||||
/** Progress toward the next level as a 0–1 fraction. `1` at max level. */
|
/** Progress toward the next level as a 0–1 fraction. `1` at max level. */
|
||||||
@variable get progress(): number {
|
@variable get progress(): number {
|
||||||
const needed = xpForStep(this.state.spec, this.state.level);
|
const needed = xpForStep(this.state.spec, this.state.level);
|
||||||
if (needed === null) return 1;
|
if (needed === null || needed <= 0) return 1; // avoid /0 → NaN at a degenerate threshold
|
||||||
return Math.min(this.xpInLevel / needed, 1);
|
return Math.min(this.xpInLevel / needed, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +68,7 @@ export class Experience extends Component<{
|
||||||
while (true) {
|
while (true) {
|
||||||
const needed = xpForStep(this.state.spec, this.state.level);
|
const needed = xpForStep(this.state.spec, this.state.level);
|
||||||
if (needed === null) break;
|
if (needed === null) break;
|
||||||
|
if (needed <= 0) break; // a 0 threshold would never be reached → infinite loop
|
||||||
if (this.xpInLevel < needed) break;
|
if (this.xpInLevel < needed) break;
|
||||||
this.state.xpAtLevel += needed;
|
this.state.xpAtLevel += needed;
|
||||||
const prev = this.state.level++;
|
const prev = this.state.level++;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ function buildInventoryState(input?: number | InventorySlotInput[]): InventorySt
|
||||||
|
|
||||||
@component
|
@component
|
||||||
export class Inventory extends Component<InventoryState> {
|
export class Inventory extends Component<InventoryState> {
|
||||||
#cachedVars: RPGVariables | null = null;
|
// TS `private`, not `#private`: clone/deserialize rehydrate via Object.create(), which omits #members.
|
||||||
|
private cachedVars: RPGVariables | null = null;
|
||||||
|
|
||||||
/** Infinite inventory — grows on demand, no slot cap. */
|
/** Infinite inventory — grows on demand, no slot cap. */
|
||||||
constructor();
|
constructor();
|
||||||
|
|
@ -49,25 +50,25 @@ export class Inventory extends Component<InventoryState> {
|
||||||
super(buildInventoryState(input));
|
super(buildInventoryState(input));
|
||||||
}
|
}
|
||||||
|
|
||||||
#slot(slotId: SlotId): SlotRecord | undefined {
|
private slot(slotId: SlotId): SlotRecord | undefined {
|
||||||
return this.state.slots[slotId];
|
return this.state.slots[slotId];
|
||||||
}
|
}
|
||||||
|
|
||||||
#capFor(slot: SlotRecord, itemId: string): number {
|
private capFor(slot: SlotRecord, itemId: string): number {
|
||||||
const limitCap = slot.limit ?? Infinity;
|
const limitCap = slot.limit ?? Infinity;
|
||||||
const stackable = this.entity.world.getEntity(itemId)?.get(Stackable);
|
const stackable = this.entity.world.getEntity(itemId)?.get(Stackable);
|
||||||
const stackCap = stackable ? stackable.maxStack : 1;
|
const stackCap = stackable ? stackable.maxStack : 1;
|
||||||
return Math.min(limitCap, stackCap);
|
return Math.min(limitCap, stackCap);
|
||||||
}
|
}
|
||||||
|
|
||||||
#roomFor(slot: SlotRecord, itemId: string): number {
|
private roomFor(slot: SlotRecord, itemId: string): number {
|
||||||
if (slot.contents !== null && slot.contents.itemId !== itemId) return 0;
|
if (slot.contents !== null && slot.contents.itemId !== itemId) return 0;
|
||||||
return this.#capFor(slot, itemId) - (slot.contents?.amount ?? 0);
|
return this.capFor(slot, itemId) - (slot.contents?.amount ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
add(arg: { itemId: string; amount: number; slotId?: SlotId } | Entity): boolean {
|
add(arg: { itemId: string; amount: number; slotId?: SlotId } | Entity): boolean {
|
||||||
this.#cachedVars = null;
|
this.cachedVars = null;
|
||||||
const { itemId, amount = 1, slotId } = (arg instanceof Entity) ? { itemId: arg.id } : arg;
|
const { itemId, amount = 1, slotId } = (arg instanceof Entity) ? { itemId: arg.id } : arg;
|
||||||
if (amount < 0) return false;
|
if (amount < 0) return false;
|
||||||
if (amount === 0) return true;
|
if (amount === 0) return true;
|
||||||
|
|
@ -79,9 +80,9 @@ export class Inventory extends Component<InventoryState> {
|
||||||
|
|
||||||
// ── Direct slot ───────────────────────────────────────────────────────
|
// ── Direct slot ───────────────────────────────────────────────────────
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.#slot(slotId);
|
const slot = this.slot(slotId);
|
||||||
if (!slot) return false;
|
if (!slot) return false;
|
||||||
if (this.#roomFor(slot, itemId) < amount) return false;
|
if (this.roomFor(slot, itemId) < amount) return false;
|
||||||
slot.contents = { itemId, amount: (slot.contents?.amount ?? 0) + amount };
|
slot.contents = { itemId, amount: (slot.contents?.amount ?? 0) + amount };
|
||||||
this.emit('add', { itemId, amount, slotIds: [slotId] });
|
this.emit('add', { itemId, amount, slotIds: [slotId] });
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -92,7 +93,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
let canFit = 0;
|
let canFit = 0;
|
||||||
for (const slot of Object.values(this.state.slots)) {
|
for (const slot of Object.values(this.state.slots)) {
|
||||||
if (slot.contents === null || slot.contents.itemId === itemId)
|
if (slot.contents === null || slot.contents.itemId === itemId)
|
||||||
canFit += this.#roomFor(slot, itemId);
|
canFit += this.roomFor(slot, itemId);
|
||||||
}
|
}
|
||||||
if (canFit < amount) return false;
|
if (canFit < amount) return false;
|
||||||
|
|
||||||
|
|
@ -100,7 +101,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
const slotIds: SlotId[] = [];
|
const slotIds: SlotId[] = [];
|
||||||
for (const slot of Object.values(this.state.slots)) {
|
for (const slot of Object.values(this.state.slots)) {
|
||||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
const take = Math.min(this.roomFor(slot, itemId), remaining);
|
||||||
slot.contents!.amount += take;
|
slot.contents!.amount += take;
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(slot.slotId);
|
slotIds.push(slot.slotId);
|
||||||
|
|
@ -108,7 +109,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
}
|
}
|
||||||
for (const slot of Object.values(this.state.slots)) {
|
for (const slot of Object.values(this.state.slots)) {
|
||||||
if (slot.contents === null && remaining > 0) {
|
if (slot.contents === null && remaining > 0) {
|
||||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
const take = Math.min(this.capFor(slot, itemId), remaining);
|
||||||
slot.contents = { itemId, amount: take };
|
slot.contents = { itemId, amount: take };
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(slot.slotId);
|
slotIds.push(slot.slotId);
|
||||||
|
|
@ -124,7 +125,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
|
|
||||||
for (const slot of Object.values(this.state.slots)) {
|
for (const slot of Object.values(this.state.slots)) {
|
||||||
if (slot.contents?.itemId === itemId && remaining > 0) {
|
if (slot.contents?.itemId === itemId && remaining > 0) {
|
||||||
const take = Math.min(this.#roomFor(slot, itemId), remaining);
|
const take = Math.min(this.roomFor(slot, itemId), remaining);
|
||||||
if (take > 0) {
|
if (take > 0) {
|
||||||
slot.contents!.amount += take;
|
slot.contents!.amount += take;
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
|
|
@ -134,7 +135,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
}
|
}
|
||||||
for (const slot of Object.values(this.state.slots)) {
|
for (const slot of Object.values(this.state.slots)) {
|
||||||
if (slot.contents === null && remaining > 0) {
|
if (slot.contents === null && remaining > 0) {
|
||||||
const take = Math.min(this.#capFor(slot, itemId), remaining);
|
const take = Math.min(this.capFor(slot, itemId), remaining);
|
||||||
slot.contents = { itemId, amount: take };
|
slot.contents = { itemId, amount: take };
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(slot.slotId);
|
slotIds.push(slot.slotId);
|
||||||
|
|
@ -143,7 +144,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
while (remaining > 0) {
|
while (remaining > 0) {
|
||||||
const newSlot: SlotRecord = { slotId: this.state.nextSlotId++, limit: undefined, contents: null };
|
const newSlot: SlotRecord = { slotId: this.state.nextSlotId++, limit: undefined, contents: null };
|
||||||
this.state.slots[newSlot.slotId] = newSlot;
|
this.state.slots[newSlot.slotId] = newSlot;
|
||||||
const take = Math.min(this.#capFor(newSlot, itemId), remaining);
|
const take = Math.min(this.capFor(newSlot, itemId), remaining);
|
||||||
newSlot.contents = { itemId, amount: take };
|
newSlot.contents = { itemId, amount: take };
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
slotIds.push(newSlot.slotId);
|
slotIds.push(newSlot.slotId);
|
||||||
|
|
@ -155,12 +156,12 @@ export class Inventory extends Component<InventoryState> {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
remove({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean {
|
remove({ itemId, amount, slotId }: { itemId: string; amount: number; slotId?: SlotId }): boolean {
|
||||||
this.#cachedVars = null;
|
this.cachedVars = null;
|
||||||
if (amount < 0) return false;
|
if (amount < 0) return false;
|
||||||
if (amount === 0) return true;
|
if (amount === 0) return true;
|
||||||
|
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.#slot(slotId);
|
const slot = this.slot(slotId);
|
||||||
if (!slot || slot.contents?.itemId !== itemId) return false;
|
if (!slot || slot.contents?.itemId !== itemId) return false;
|
||||||
if (slot.contents.amount < amount) return false;
|
if (slot.contents.amount < amount) return false;
|
||||||
slot.contents.amount -= amount;
|
slot.contents.amount -= amount;
|
||||||
|
|
@ -194,7 +195,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
*/
|
*/
|
||||||
@action
|
@action
|
||||||
equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string } | Entity): boolean {
|
equip(arg: string | { itemId?: string; slotId?: SlotId; slotName?: string } | Entity): boolean {
|
||||||
const resolved = this.#resolveItem(arg);
|
const resolved = this.resolveItem(arg);
|
||||||
if (!resolved) return false;
|
if (!resolved) return false;
|
||||||
const { itemId, slotId } = resolved;
|
const { itemId, slotId } = resolved;
|
||||||
const slotName = typeof arg === 'object' && 'slotName' in arg ? arg.slotName : undefined;
|
const slotName = typeof arg === 'object' && 'slotName' in arg ? arg.slotName : undefined;
|
||||||
|
|
@ -234,7 +235,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
*/
|
*/
|
||||||
@action
|
@action
|
||||||
use(arg?: string | { itemId?: string; slotId?: SlotId }): boolean {
|
use(arg?: string | { itemId?: string; slotId?: SlotId }): boolean {
|
||||||
const resolved = this.#resolveItem(arg);
|
const resolved = this.resolveItem(arg);
|
||||||
if (!resolved) return false;
|
if (!resolved) return false;
|
||||||
const { itemId, slotId } = resolved;
|
const { itemId, slotId } = resolved;
|
||||||
|
|
||||||
|
|
@ -262,7 +263,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve an item reference, deriving `itemId` from slot contents if only `slotId` is given. */
|
/** Resolve an item reference, deriving `itemId` from slot contents if only `slotId` is given. */
|
||||||
#resolveItem(arg?: string | { itemId?: string; slotId?: SlotId } | Entity): { itemId: string; slotId?: SlotId } | null {
|
private resolveItem(arg?: string | { itemId?: string; slotId?: SlotId } | Entity): { itemId: string; slotId?: SlotId } | null {
|
||||||
if (!arg) return null;
|
if (!arg) return null;
|
||||||
if (typeof arg === 'string') return { itemId: arg };
|
if (typeof arg === 'string') return { itemId: arg };
|
||||||
if (arg instanceof Entity) return { itemId: arg.id };
|
if (arg instanceof Entity) return { itemId: arg.id };
|
||||||
|
|
@ -278,12 +279,12 @@ export class Inventory extends Component<InventoryState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
getSlotContents(slotId: SlotId): { itemId: string; amount: number } | null {
|
getSlotContents(slotId: SlotId): { itemId: string; amount: number } | null {
|
||||||
return this.#slot(slotId)?.contents ?? null;
|
return this.slot(slotId)?.contents ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAmount(itemId: string, slotId?: SlotId): number {
|
getAmount(itemId: string, slotId?: SlotId): number {
|
||||||
if (slotId !== undefined) {
|
if (slotId !== undefined) {
|
||||||
const slot = this.#slot(slotId);
|
const slot = this.slot(slotId);
|
||||||
return slot?.contents?.itemId === itemId ? slot.contents.amount : 0;
|
return slot?.contents?.itemId === itemId ? slot.contents.amount : 0;
|
||||||
}
|
}
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
@ -305,7 +306,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
override getVariables(): RPGVariables {
|
override getVariables(): RPGVariables {
|
||||||
if (this.#cachedVars) return this.#cachedVars;
|
if (this.cachedVars) return this.cachedVars;
|
||||||
const result: RPGVariables = {};
|
const result: RPGVariables = {};
|
||||||
for (const [itemId, amount] of this.getItems()) {
|
for (const [itemId, amount] of this.getItems()) {
|
||||||
result[itemId] = amount;
|
result[itemId] = amount;
|
||||||
|
|
@ -316,7 +317,7 @@ export class Inventory extends Component<InventoryState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.#cachedVars = result;
|
this.cachedVars = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ interface QuestLogState {
|
||||||
|
|
||||||
@component
|
@component
|
||||||
export class QuestLog extends Component<QuestLogState> {
|
export class QuestLog extends Component<QuestLogState> {
|
||||||
#cachedVars: RPGVariables | null = null;
|
// TS `private`, not `#private`: clone/deserialize rehydrate via Object.create(), which omits #members.
|
||||||
|
private cachedVars: RPGVariables | null = null;
|
||||||
|
|
||||||
constructor(quests: Quest[] = []) {
|
constructor(quests: Quest[] = []) {
|
||||||
const questsRecord: Record<string, Quest> = {};
|
const questsRecord: Record<string, Quest> = {};
|
||||||
|
|
@ -43,9 +44,9 @@ export class QuestLog extends Component<QuestLogState> {
|
||||||
this.state.runtimeStates[quest.id] = { status: 'inactive', stageIndex: 0 };
|
this.state.runtimeStates[quest.id] = { status: 'inactive', stageIndex: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
#invalidate() { this.#cachedVars = null; }
|
private invalidate() { this.cachedVars = null; }
|
||||||
|
|
||||||
#transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean {
|
private transition(op: string, questId: string, from: QuestStatus, to: QuestStatus, event: string): boolean {
|
||||||
const runtimeState = this.state.runtimeStates[questId];
|
const runtimeState = this.state.runtimeStates[questId];
|
||||||
if (!runtimeState) {
|
if (!runtimeState) {
|
||||||
console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`);
|
console.warn(`[QuestLog] ${op}: quest '${questId}' is not registered`);
|
||||||
|
|
@ -57,25 +58,25 @@ export class QuestLog extends Component<QuestLogState> {
|
||||||
}
|
}
|
||||||
runtimeState.status = to;
|
runtimeState.status = to;
|
||||||
if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0;
|
if (to === 'active' || to === 'inactive') runtimeState.stageIndex = 0;
|
||||||
this.#invalidate();
|
this.invalidate();
|
||||||
this.emit(event, { questId });
|
this.emit(event, { questId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(questId: string): boolean {
|
start(questId: string): boolean {
|
||||||
return this.#transition('start', questId, 'inactive', 'active', 'started');
|
return this.transition('start', questId, 'inactive', 'active', 'started');
|
||||||
}
|
}
|
||||||
|
|
||||||
complete(questId: string): boolean {
|
complete(questId: string): boolean {
|
||||||
return this.#transition('complete', questId, 'active', 'completed', 'completed');
|
return this.transition('complete', questId, 'active', 'completed', 'completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(questId: string): boolean {
|
fail(questId: string): boolean {
|
||||||
return this.#transition('fail', questId, 'active', 'failed', 'failed');
|
return this.transition('fail', questId, 'active', 'failed', 'failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
abandon(questId: string): boolean {
|
abandon(questId: string): boolean {
|
||||||
return this.#transition('abandon', questId, 'active', 'inactive', 'abandoned');
|
return this.transition('abandon', questId, 'active', 'inactive', 'abandoned');
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(questId: string): QuestRuntimeState | undefined {
|
getState(questId: string): QuestRuntimeState | undefined {
|
||||||
|
|
@ -125,7 +126,7 @@ export class QuestLog extends Component<QuestLogState> {
|
||||||
const quest = this.state.quests[questId];
|
const quest = this.state.quests[questId];
|
||||||
const runtimeState = this.state.runtimeStates[questId];
|
const runtimeState = this.state.runtimeStates[questId];
|
||||||
if (!quest || !runtimeState) return;
|
if (!quest || !runtimeState) return;
|
||||||
this.#invalidate();
|
this.invalidate();
|
||||||
if (runtimeState.stageIndex + 1 < quest.stages.length) {
|
if (runtimeState.stageIndex + 1 < quest.stages.length) {
|
||||||
runtimeState.stageIndex++;
|
runtimeState.stageIndex++;
|
||||||
this.emit('stage', { questId, index: runtimeState.stageIndex, stage: quest.stages[runtimeState.stageIndex] });
|
this.emit('stage', { questId, index: runtimeState.stageIndex, stage: quest.stages[runtimeState.stageIndex] });
|
||||||
|
|
@ -150,13 +151,13 @@ export class QuestLog extends Component<QuestLogState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
override getVariables(): RPGVariables {
|
override getVariables(): RPGVariables {
|
||||||
if (this.#cachedVars) return this.#cachedVars;
|
if (this.cachedVars) return this.cachedVars;
|
||||||
const result: RPGVariables = {};
|
const result: RPGVariables = {};
|
||||||
for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) {
|
for (const [questId, runtimeState] of Object.entries(this.state.runtimeStates)) {
|
||||||
result[`${questId}.status`] = runtimeState.status;
|
result[`${questId}.status`] = runtimeState.status;
|
||||||
result[`${questId}.stage`] = runtimeState.stageIndex;
|
result[`${questId}.stage`] = runtimeState.stageIndex;
|
||||||
}
|
}
|
||||||
this.#cachedVars = result;
|
this.cachedVars = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,19 @@ function deserializeComponent(data: ComponentData): Component<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedVersion = data.version ?? 0;
|
const savedVersion = data.version ?? 0;
|
||||||
|
if (savedVersion > meta.version) {
|
||||||
|
throw new Error(
|
||||||
|
`Component '${data.name}' was saved at version ${savedVersion} but this build only knows ` +
|
||||||
|
`version ${meta.version}. The save is from a newer version of the game and cannot be loaded.`
|
||||||
|
);
|
||||||
|
}
|
||||||
const state = savedVersion < meta.version
|
const state = savedVersion < meta.version
|
||||||
? migrateState(data.name, data.state as Record<string, unknown>, savedVersion)
|
? migrateState(data.name, data.state as Record<string, unknown>, savedVersion)
|
||||||
: data.state;
|
: data.state;
|
||||||
|
|
||||||
// Bypass constructor: create a bare instance and restore state directly.
|
// Bypass the constructor (state holds all persistent data). Components must NOT use ES #private
|
||||||
// Safe because constructors must only call super(state) — all initialization
|
// members — Object.create() omits them, so any access throws; use TS `private`. Re-inserted via
|
||||||
// logic goes in onAdd(), which entity.add() calls after this.
|
// entity.restore() (not add()), so onAdd side effects are not re-applied on load.
|
||||||
const instance = Object.create(meta.ctor.prototype) as Component<any>;
|
const instance = Object.create(meta.ctor.prototype) as Component<any>;
|
||||||
(instance as unknown as { state: unknown }).state = state;
|
(instance as unknown as { state: unknown }).state = state;
|
||||||
return instance;
|
return instance;
|
||||||
|
|
@ -87,9 +93,9 @@ function deserializeEntity(data: EntityData, world: World): Entity {
|
||||||
for (const componentData of data.components) {
|
for (const componentData of data.components) {
|
||||||
const component = deserializeComponent(componentData);
|
const component = deserializeComponent(componentData);
|
||||||
if (componentData.key === null) {
|
if (componentData.key === null) {
|
||||||
entity.add(component);
|
entity.restore(component);
|
||||||
} else {
|
} else {
|
||||||
entity.add(component, componentData.key);
|
entity.restore(component, componentData.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return entity;
|
return entity;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ export abstract class Component<TState = Record<string, unknown>> {
|
||||||
onAdd(): void { }
|
onAdd(): void { }
|
||||||
onRemove(): void { }
|
onRemove(): void { }
|
||||||
|
|
||||||
|
/** Called instead of {@link onAdd} on clone/deserialize, when `state` is already consistent.
|
||||||
|
* Override to rebuild runtime-only fields; never re-apply state-mutating side effects here. */
|
||||||
|
onRestore(): void { }
|
||||||
|
|
||||||
getVariables(): RPGVariables {
|
getVariables(): RPGVariables {
|
||||||
const meta = (this.constructor as Function)[Symbol.metadata];
|
const meta = (this.constructor as Function)[Symbol.metadata];
|
||||||
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
const keys = meta?.[VARIABLE_KEYS] as Map<string | symbol, string> | undefined;
|
||||||
|
|
@ -128,6 +132,19 @@ export class Entity {
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-insert a pre-built component on clone/deserialize: fires `onRestore()`, not `onAdd()`,
|
||||||
|
* so state-mutating side effects (e.g. an Effect's delta) are not re-applied. @internal */
|
||||||
|
restore<T extends Component<any>>(component: T, k?: string): T {
|
||||||
|
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||||
|
if (component == null) throw new Error(`Component must be an instance of Component`);
|
||||||
|
const key = k ?? Symbol();
|
||||||
|
component.entity = this;
|
||||||
|
component.key = key;
|
||||||
|
this.#components.set(key, component);
|
||||||
|
component.onRestore();
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
clone<T extends Component<any>>(component: T, key: string): T {
|
clone<T extends Component<any>>(component: T, key: string): T {
|
||||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||||
const clone = Object.create(component.constructor.prototype) as T;
|
const clone = Object.create(component.constructor.prototype) as T;
|
||||||
|
|
@ -224,12 +241,14 @@ export class Entity {
|
||||||
|
|
||||||
removeAll<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): void {
|
removeAll<T extends Component<any>>(ctor: Class<T>, filter?: ComponentFilter<T>): void {
|
||||||
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
if (this.#destroyed) throw new Error('Entity has been destroyed');
|
||||||
|
// Collect first, then remove, so onRemove() side effects can't disturb iteration.
|
||||||
|
const keys: (string | symbol)[] = [];
|
||||||
for (const [k, c] of this.#components) {
|
for (const [k, c] of this.#components) {
|
||||||
if (!(c instanceof ctor)) continue;
|
if (!(c instanceof ctor)) continue;
|
||||||
if (typeof filter === 'function' && !filter(c)) continue;
|
if (typeof filter === 'function' && !filter(c)) continue;
|
||||||
|
keys.push(k);
|
||||||
this.#removeByKey(k); return;
|
|
||||||
}
|
}
|
||||||
|
for (const k of keys) this.#removeByKey(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
#removeByKey(key: string | symbol): void {
|
#removeByKey(key: string | symbol): void {
|
||||||
|
|
@ -282,13 +301,17 @@ export class Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class World {
|
export class World {
|
||||||
|
/** Set to true to log every event emit via console.debug. Off by default (it is a hot path). */
|
||||||
|
static logEvents = false;
|
||||||
|
|
||||||
/** World-level variables, accessible in conditions via the $. prefix */
|
/** World-level variables, accessible in conditions via the $. prefix */
|
||||||
readonly globals: RPGVariables = {};
|
readonly globals: RPGVariables = {};
|
||||||
|
|
||||||
readonly #entities = new Map<string, Entity>();
|
readonly #entities = new Map<string, Entity>();
|
||||||
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
readonly #handlers = new Map<string, Set<EntityEventHandler>>();
|
||||||
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
readonly #globalHandlers = new Map<string, Set<WorldEventHandler>>();
|
||||||
readonly #onceWrappers = new Map<Function, Function>();
|
/** Maps a handler-map key (`entityId\0event` or global `event`) → (original handler → once-wrapper). */
|
||||||
|
readonly #onceWrappers = new Map<string, Map<Function, Function>>();
|
||||||
readonly #systems: System[] = [];
|
readonly #systems: System[] = [];
|
||||||
#entityCounter = 0;
|
#entityCounter = 0;
|
||||||
|
|
||||||
|
|
@ -325,8 +348,13 @@ export class World {
|
||||||
destroyEntity(entity: Entity): void {
|
destroyEntity(entity: Entity): void {
|
||||||
entity._destroy();
|
entity._destroy();
|
||||||
this.#entities.delete(entity.id);
|
this.#entities.delete(entity.id);
|
||||||
|
const prefix = `${entity.id}\0`;
|
||||||
for (const key of this.#handlers.keys()) {
|
for (const key of this.#handlers.keys()) {
|
||||||
if (key.startsWith(`${entity.id}\0`)) this.#handlers.delete(key);
|
if (key.startsWith(prefix)) this.#handlers.delete(key);
|
||||||
|
}
|
||||||
|
// release pending once-wrappers for this entity (else they leak)
|
||||||
|
for (const key of this.#onceWrappers.keys()) {
|
||||||
|
if (key.startsWith(prefix)) this.#onceWrappers.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -338,10 +366,13 @@ export class World {
|
||||||
*/
|
*/
|
||||||
cloneEntity(source: Entity, newId?: string): Entity {
|
cloneEntity(source: Entity, newId?: string): Entity {
|
||||||
const target = this.createEntity(newId);
|
const target = this.createEntity(newId);
|
||||||
for (const [key, component] of source) {
|
for (const [, component] of source) {
|
||||||
|
// COMPONENT_KEY (real key), not Component.key — the latter returns the class name for
|
||||||
|
// symbol keys, which would collapse anonymous components of the same type together.
|
||||||
|
const realKey = component[COMPONENT_KEY];
|
||||||
const clone = Object.create(component.constructor.prototype) as Component<any>;
|
const clone = Object.create(component.constructor.prototype) as Component<any>;
|
||||||
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
(clone as unknown as { state: unknown }).state = structuredClone(component.state);
|
||||||
target.add(clone, key);
|
target.restore(clone, typeof realKey === 'symbol' ? undefined : realKey);
|
||||||
}
|
}
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
@ -398,16 +429,20 @@ export class World {
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(entityId: string, event: string, data?: unknown): void {
|
emit(entityId: string, event: string, data?: unknown): void {
|
||||||
console.debug(`Emitting event ${event} for entity ${entityId}, data:`, data);
|
if (World.logEvents) console.debug(`Emitting event ${event} for entity ${entityId}, data:`, data);
|
||||||
const entity = this.getEntity(entityId);
|
const entity = this.getEntity(entityId);
|
||||||
if (!entity) return;
|
if (!entity) return;
|
||||||
this.#handlers.get(`${entityId}\0${event}`)?.forEach(h => h({ target: entity, data }));
|
// Snapshot handler sets so mutation during dispatch (off/once/destroy) is safe.
|
||||||
this.#globalHandlers.get(event)?.forEach(h => h({ target: entity, data }));
|
const local = this.#handlers.get(`${entityId}\0${event}`);
|
||||||
|
if (local) for (const h of [...local]) h({ target: entity, data });
|
||||||
|
const global = this.#globalHandlers.get(event);
|
||||||
|
if (global) for (const h of [...global]) h({ target: entity, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
emitGlobal(event: string, data?: unknown): void {
|
emitGlobal(event: string, data?: unknown): void {
|
||||||
console.debug(`Emitting global event ${event}, data:`, data);
|
if (World.logEvents) console.debug(`Emitting global event ${event}, data:`, data);
|
||||||
this.#globalHandlers.get(event)?.forEach(h => h({ target: this, data }));
|
const global = this.#globalHandlers.get(event);
|
||||||
|
if (global) for (const h of [...global]) h({ target: this, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
on<T>(event: string, handler: WorldEventHandler<T>): () => void;
|
on<T>(event: string, handler: WorldEventHandler<T>): () => void;
|
||||||
|
|
@ -423,13 +458,12 @@ export class World {
|
||||||
off(entityId: string, event: string, handler: EntityEventHandler): void;
|
off(entityId: string, event: string, handler: EntityEventHandler): void;
|
||||||
off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void {
|
off(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler): void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
const handler = (this.#onceWrappers.get(arg3!) ?? arg3!) as EntityEventHandler;
|
const mapKey = `${arg1}\0${arg2}`;
|
||||||
this.#handlers.get(`${arg1}\0${arg2}`)?.delete(handler);
|
const handler = (this.#takeOnceWrapper(mapKey, arg3!) ?? arg3!) as EntityEventHandler;
|
||||||
this.#onceWrappers.delete(arg3!);
|
this.#handlers.get(mapKey)?.delete(handler);
|
||||||
} else {
|
} else {
|
||||||
const handler = (this.#onceWrappers.get(arg2) ?? arg2) as WorldEventHandler;
|
const handler = (this.#takeOnceWrapper(arg1, arg2) ?? arg2) as WorldEventHandler;
|
||||||
this.#globalHandlers.get(arg1)?.delete(handler);
|
this.#globalHandlers.get(arg1)?.delete(handler);
|
||||||
this.#onceWrappers.delete(arg2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,17 +471,42 @@ export class World {
|
||||||
once<T>(entityId: string, event: string, handler: EntityEventHandler<T>): () => void;
|
once<T>(entityId: string, event: string, handler: EntityEventHandler<T>): () => void;
|
||||||
once<T>(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler<T>): () => void {
|
once<T>(arg1: string, arg2: WorldEventHandler | string, arg3?: EntityEventHandler<T>): () => void {
|
||||||
if (typeof arg2 === 'string') {
|
if (typeof arg2 === 'string') {
|
||||||
|
const mapKey = `${arg1}\0${arg2}`;
|
||||||
const original = arg3!;
|
const original = arg3!;
|
||||||
const wrapped: EntityEventHandler<T> = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
const wrapped: EntityEventHandler<T> = data => { this.#deleteOnceWrapper(mapKey, original); unsub(); original(data); };
|
||||||
this.#onceWrappers.set(original, wrapped);
|
this.#setOnceWrapper(mapKey, original, wrapped);
|
||||||
const unsub = this.on(arg1, arg2, wrapped);
|
const unsub = this.on(arg1, arg2, wrapped);
|
||||||
return () => { this.#onceWrappers.delete(original); unsub(); };
|
return () => { this.#deleteOnceWrapper(mapKey, original); unsub(); };
|
||||||
}
|
}
|
||||||
|
const mapKey = arg1;
|
||||||
const original = arg2;
|
const original = arg2;
|
||||||
const wrapped: WorldEventHandler = data => { this.#onceWrappers.delete(original); unsub(); original(data); };
|
const wrapped: WorldEventHandler = data => { this.#deleteOnceWrapper(mapKey, original); unsub(); original(data); };
|
||||||
this.#onceWrappers.set(original, wrapped);
|
this.#setOnceWrapper(mapKey, original, wrapped);
|
||||||
const unsub = this.on(arg1, wrapped);
|
const unsub = this.on(arg1, wrapped);
|
||||||
return () => { this.#onceWrappers.delete(original); unsub(); };
|
return () => { this.#deleteOnceWrapper(mapKey, original); unsub(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped per handler-map key so the same handler can be once()'d on multiple events without colliding.
|
||||||
|
#setOnceWrapper(mapKey: string, original: Function, wrapped: Function): void {
|
||||||
|
let inner = this.#onceWrappers.get(mapKey);
|
||||||
|
if (!inner) { inner = new Map(); this.#onceWrappers.set(mapKey, inner); }
|
||||||
|
inner.set(original, wrapped);
|
||||||
|
}
|
||||||
|
#deleteOnceWrapper(mapKey: string, original: Function): void {
|
||||||
|
const inner = this.#onceWrappers.get(mapKey);
|
||||||
|
if (!inner) return;
|
||||||
|
inner.delete(original);
|
||||||
|
if (inner.size === 0) this.#onceWrappers.delete(mapKey);
|
||||||
|
}
|
||||||
|
#takeOnceWrapper(mapKey: string, original: Function): Function | undefined {
|
||||||
|
const inner = this.#onceWrappers.get(mapKey);
|
||||||
|
if (!inner) return undefined;
|
||||||
|
const wrapped = inner.get(original);
|
||||||
|
if (wrapped) {
|
||||||
|
inner.delete(original);
|
||||||
|
if (inner.size === 0) this.#onceWrappers.delete(mapKey);
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
#addHandler<T extends WorldEventHandler<any> | EntityEventHandler<any>>(
|
#addHandler<T extends WorldEventHandler<any> | EntityEventHandler<any>>(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface HitEvent {
|
||||||
export class CombatSystem extends System {
|
export class CombatSystem extends System {
|
||||||
override update(world: World) {
|
override update(world: World) {
|
||||||
let random: Random | undefined;
|
let random: Random | undefined;
|
||||||
|
const rng = () => (random ??= getWorldRandom(world));
|
||||||
|
|
||||||
for (const [target] of world.query(Attacked)) {
|
for (const [target] of world.query(Attacked)) {
|
||||||
const healths = target.getAll(Health).sort((a, b) => b.priority - a.priority);
|
const healths = target.getAll(Health).sort((a, b) => b.priority - a.priority);
|
||||||
|
|
@ -25,9 +26,7 @@ export class CombatSystem extends System {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let damageSum = 0;
|
|
||||||
const hitEvents: HitEvent[] = [];
|
const hitEvents: HitEvent[] = [];
|
||||||
let lastHit: HitEvent | null = null;
|
|
||||||
|
|
||||||
for (const attack of target.getAll(Attacked)) {
|
for (const attack of target.getAll(Attacked)) {
|
||||||
const { attackerId, sourceId } = attack.state;
|
const { attackerId, sourceId } = attack.state;
|
||||||
|
|
@ -45,51 +44,47 @@ export class CombatSystem extends System {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const damage = source.get(Damage);
|
const damages = source.getAll(Damage);
|
||||||
if (!damage) {
|
if (damages.length === 0) {
|
||||||
console.warn(`[CombatSystem] No Damage on source ${source.id}`);
|
console.warn(`[CombatSystem] No Damage on source ${source.id}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Crit rolls once per attack; guard so a value < 1 can't reduce damage.
|
||||||
|
let critMult = 1;
|
||||||
|
const crit = source.get(Crit);
|
||||||
|
if (crit) {
|
||||||
|
const roll = rng().use(r => r.nextFloat());
|
||||||
|
if (roll < crit.state.chance) critMult = Math.max(1, crit.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each Damage component contributes its own typed hit (multi-type weapons).
|
||||||
|
for (const damage of damages) {
|
||||||
const { damageType, minDamage = 0, variance = 0 } = damage.state;
|
const { damageType, minDamage = 0, variance = 0 } = damage.state;
|
||||||
let damageAmount = damage.value;
|
let damageAmount = damage.value;
|
||||||
|
|
||||||
|
// symmetric variance: [-variance, +variance]
|
||||||
if (variance > 0) {
|
if (variance > 0) {
|
||||||
if (!random) {
|
damageAmount += rng().use(r => r.nextInt(-variance, variance + 1));
|
||||||
random = getWorldRandom(world);
|
|
||||||
}
|
|
||||||
const variedDamage = random.use(r => r.nextInt(-variance, variance + 1));
|
|
||||||
|
|
||||||
if (variedDamage > 0) {
|
|
||||||
damageAmount += variedDamage;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const crit = source.get(Crit);
|
damageAmount *= critMult;
|
||||||
|
|
||||||
if (crit) {
|
// sum all matching-type + general defenses, so stacked armor accumulates
|
||||||
const { chance } = crit.state;
|
let defense = 0;
|
||||||
if (!random) {
|
for (const d of target.getAll(Defense)) {
|
||||||
random = getWorldRandom(world);
|
if (d.state.damageType === damageType || d.state.damageType == null) {
|
||||||
}
|
defense += d.value;
|
||||||
const roll = random.use(r => r.nextFloat());
|
|
||||||
if (roll < chance) {
|
|
||||||
damageAmount *= crit.value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
damageAmount -= defense;
|
||||||
const typeDefense = target.get(Defense, (c) => c.state.damageType === damageType);
|
|
||||||
if (typeDefense) {
|
|
||||||
damageAmount -= typeDefense.value;
|
|
||||||
}
|
|
||||||
const generalDefense = target.get(Defense, (c) => c.state.damageType == null);
|
|
||||||
if (generalDefense) {
|
|
||||||
damageAmount -= generalDefense.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
damageAmount = Math.max(0, minDamage, damageAmount);
|
damageAmount = Math.max(0, minDamage, damageAmount);
|
||||||
|
|
||||||
// Apply on-hit effects from source onto target
|
hitEvents.push({ attackerId, sourceId, amount: damageAmount, damageType });
|
||||||
|
}
|
||||||
|
|
||||||
|
// on-hit effects from source → target, once per attack
|
||||||
for (const component of source.getAll(EffectTemplate)) {
|
for (const component of source.getAll(EffectTemplate)) {
|
||||||
const s = component.state;
|
const s = component.state;
|
||||||
target.add(
|
target.add(
|
||||||
|
|
@ -108,19 +103,16 @@ export class CombatSystem extends System {
|
||||||
`__hit_${source.id}_${component.key}_${hitEffectCounter++}`,
|
`__hit_${source.id}_${component.key}_${hitEffectCounter++}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
damageSum += damageAmount;
|
|
||||||
lastHit = { attackerId, sourceId, amount: damageAmount, damageType };
|
|
||||||
hitEvents.push(lastHit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (damageSum === 0) continue;
|
if (hitEvents.length === 0) continue;
|
||||||
|
|
||||||
let totalBefore = 0;
|
let totalBefore = 0;
|
||||||
for (const pool of healths) totalBefore += pool.value;
|
for (const pool of healths) totalBefore += pool.value;
|
||||||
|
|
||||||
for (const hit of hitEvents) {
|
for (const hit of hitEvents) {
|
||||||
let remaining = hit.amount;
|
let remaining = hit.amount;
|
||||||
|
let applied = 0;
|
||||||
|
|
||||||
if (hit.damageType) {
|
if (hit.damageType) {
|
||||||
for (const pool of healths) {
|
for (const pool of healths) {
|
||||||
|
|
@ -130,6 +122,7 @@ export class CombatSystem extends System {
|
||||||
const take = Math.min(pool.value, remaining);
|
const take = Math.min(pool.value, remaining);
|
||||||
pool.update(-take);
|
pool.update(-take);
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
|
applied += take;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,10 +134,15 @@ export class CombatSystem extends System {
|
||||||
const take = Math.min(pool.value, remaining);
|
const take = Math.min(pool.value, remaining);
|
||||||
pool.update(-take);
|
pool.update(-take);
|
||||||
remaining -= take;
|
remaining -= take;
|
||||||
|
applied += take;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hit.amount = applied; // report damage actually dealt, not pre-mitigation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastHit = hitEvents[hitEvents.length - 1] ?? null;
|
||||||
|
|
||||||
for (const info of hitEvents) {
|
for (const info of hitEvents) {
|
||||||
target.emit('Combat.hit', info);
|
target.emit('Combat.hit', info);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ export function migrateState(
|
||||||
`[registry] No migration for '${name}' from version ${current} to ${meta.version}. ` +
|
`[registry] No migration for '${name}' from version ${current} to ${meta.version}. ` +
|
||||||
`Register one with registerMigration('${name}', ${current}, ...).`
|
`Register one with registerMigration('${name}', ${current}, ...).`
|
||||||
);
|
);
|
||||||
|
if (entry.toVersion <= current) throw new Error(
|
||||||
|
`[registry] Migration for '${name}' from version ${current} does not advance the version ` +
|
||||||
|
`(toVersion=${entry.toVersion}); this would loop forever. Migrations must increase the version.`
|
||||||
|
);
|
||||||
s = entry.fn(s);
|
s = entry.fn(s);
|
||||||
current = entry.toVersion;
|
current = entry.toVersion;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@ const setup = (): State => {
|
||||||
Physics.newPlane(0, 0, 0, 1);
|
Physics.newPlane(0, 0, 0, 1);
|
||||||
Physics.newPlane(canvas.width, 0, -1, 0);
|
Physics.newPlane(canvas.width, 0, -1, 0);
|
||||||
|
|
||||||
|
Physics.setGravity(0, 100);
|
||||||
Physics.setCollisionCallback((a, b) => onCollision(state, a, b));
|
Physics.setCollisionCallback((a, b) => onCollision(state, a, b));
|
||||||
|
|
||||||
update();
|
update();
|
||||||
|
|
@ -137,10 +138,7 @@ const setup = (): State => {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frame = (dt: number, state: State) => {
|
const frame = (_dt: number, state: State) => {
|
||||||
Physics.addGlobalForce(0, 100);
|
|
||||||
Physics.update(dt);
|
|
||||||
|
|
||||||
const { ctx, canvas } = state;
|
const { ctx, canvas } = state;
|
||||||
ctx.fillStyle = `#111`;
|
ctx.fillStyle = `#111`;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ const RenameInput = ({ value, onSubmit, onCancel, className }: RenameInputProps)
|
||||||
|
|
||||||
interface StoryItemProps {
|
interface StoryItemProps {
|
||||||
story: Story;
|
story: Story;
|
||||||
|
world: World;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onRename: (newTitle: string) => void;
|
onRename: (newTitle: string) => void;
|
||||||
|
|
@ -55,7 +56,7 @@ interface StoryItemProps {
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate, onExport }: StoryItemProps) => {
|
const StoryItem = ({ story, world, active, onSelect, onRename, onDelete, onDuplicate, onExport }: StoryItemProps) => {
|
||||||
const isEditing = useBool(false);
|
const isEditing = useBool(false);
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
|
|
||||||
|
|
@ -78,7 +79,10 @@ const StoryItem = ({ story, active, onSelect, onRename, onDelete, onDuplicate, o
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
onDblClick={isEditing.setTrue}
|
onDblClick={isEditing.setTrue}
|
||||||
>
|
>
|
||||||
{Prompt.substituteVars(appState, story.title)}
|
{Prompt.substituteVars({
|
||||||
|
...appState,
|
||||||
|
currentWorld: world,
|
||||||
|
}, story.title)}
|
||||||
</button>
|
</button>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<button class={styles.actionButton} onClick={isEditing.setTrue} title="Rename">
|
<button class={styles.actionButton} onClick={isEditing.setTrue} title="Rename">
|
||||||
|
|
@ -179,6 +183,7 @@ const WorldItem = ({
|
||||||
{world.stories.map(story => (
|
{world.stories.map(story => (
|
||||||
<StoryItem
|
<StoryItem
|
||||||
key={story.id}
|
key={story.id}
|
||||||
|
world={world}
|
||||||
story={story}
|
story={story}
|
||||||
active={activeStoryId === story.id && activeWorldId === world.id}
|
active={activeStoryId === story.id && activeWorldId === world.id}
|
||||||
onSelect={() => onSelectStory(story.id)}
|
onSelect={() => onSelectStory(story.id)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
// Regression tests for the engine fixes (rehydration, loop guards, event cleanup, combat).
|
||||||
|
// Each test below fails against the pre-fix engine.
|
||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { World, Component, COMPONENT_KEY } from '@common/rpg/core/world';
|
||||||
|
import { Serialization } from '@common/rpg/core/serialization';
|
||||||
|
import { component, registerMigration, migrateState } from '@common/rpg/utils/decorators';
|
||||||
|
import { resolveVariables } from '@common/rpg/utils/variables';
|
||||||
|
import { Stat, Health } from '@common/rpg/components/stat';
|
||||||
|
import { Effect } from '@common/rpg/components/effect';
|
||||||
|
import { Inventory } from '@common/rpg/components/inventory';
|
||||||
|
import { Equipment } from '@common/rpg/components/equipment';
|
||||||
|
import { QuestLog } from '@common/rpg/components/questLog';
|
||||||
|
import { Experience } from '@common/rpg/components/experience';
|
||||||
|
import { Attacked, Damage, Defense, Crit } from '@common/rpg/components/combat';
|
||||||
|
import { CombatSystem } from '@common/rpg/systems/combat';
|
||||||
|
import { EffectSystem } from '@common/rpg/systems/effect';
|
||||||
|
|
||||||
|
const roundtrip = (w: World) => Serialization.deserialize(Serialization.serialize(w)) as World;
|
||||||
|
|
||||||
|
function combatWorld() {
|
||||||
|
const w = new World();
|
||||||
|
w.addSystem(new CombatSystem());
|
||||||
|
w.addSystem(new EffectSystem());
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fix: rehydration (#private + onAdd double-apply)', () => {
|
||||||
|
it('deserialized Inventory/Equipment/QuestLog do not throw on use', () => {
|
||||||
|
const w = new World();
|
||||||
|
w.createEntity('sword');
|
||||||
|
const p = w.createEntity('player');
|
||||||
|
p.add(new Inventory(5));
|
||||||
|
p.add(new Equipment('weapon'));
|
||||||
|
p.add(new QuestLog());
|
||||||
|
|
||||||
|
const p2 = roundtrip(w).getEntity('player')!;
|
||||||
|
expect(() => p2.get(Inventory)!.add({ itemId: 'sword', amount: 1 })).not.toThrow();
|
||||||
|
expect(() => p2.get(Inventory)!.getVariables()).not.toThrow();
|
||||||
|
expect(() => p2.get(Equipment)!.getVariables()).not.toThrow();
|
||||||
|
expect(() => p2.get(Equipment)!.getItemId('weapon')).not.toThrow();
|
||||||
|
expect(() => p2.get(QuestLog)!.getVariables()).not.toThrow();
|
||||||
|
expect(() => resolveVariables(p2)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cloned Inventory does not throw on use', () => {
|
||||||
|
const w = new World();
|
||||||
|
w.createEntity('potion');
|
||||||
|
const a = w.createEntity('a');
|
||||||
|
a.add(new Inventory(3));
|
||||||
|
const b = w.cloneEntity(a, 'b');
|
||||||
|
expect(() => b.get(Inventory)!.add({ itemId: 'potion', amount: 1 })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deserialization does NOT double-apply an active Effect delta', () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.createEntity('e');
|
||||||
|
e.add(new Stat({ value: 100 }), 'hp');
|
||||||
|
e.add(new Effect({ targetKey: 'hp', delta: -10 })); // permanent modifier
|
||||||
|
expect(e.get(Stat, 'hp')!.value).toBe(90);
|
||||||
|
|
||||||
|
const e2 = roundtrip(w).getEntity('e')!;
|
||||||
|
expect(e2.get(Stat, 'hp')!.value).toBe(90); // not 80
|
||||||
|
expect(e2.getAll(Effect).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cloneEntity does NOT double-apply an active Effect delta', () => {
|
||||||
|
const w = new World();
|
||||||
|
const src = w.createEntity('src');
|
||||||
|
src.add(new Stat({ value: 100 }), 'hp');
|
||||||
|
src.add(new Effect({ targetKey: 'hp', delta: -10 }));
|
||||||
|
const clone = w.cloneEntity(src, 'clone');
|
||||||
|
expect(clone.get(Stat, 'hp')!.value).toBe(90); // not 80
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a deserialized active Effect still reverses its delta when removed', () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.createEntity('e');
|
||||||
|
e.add(new Stat({ value: 100 }), 'hp');
|
||||||
|
e.add(new Effect({ targetKey: 'hp', delta: -10 }), 'fx');
|
||||||
|
|
||||||
|
const e2 = roundtrip(w).getEntity('e')!;
|
||||||
|
e2.remove('fx');
|
||||||
|
expect(e2.get(Stat, 'hp')!.value).toBe(100); // active survived in state → reversal works
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fix: Entity.removeAll removes ALL matches', () => {
|
||||||
|
it('removes every matching component, not just the first', () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.createEntity();
|
||||||
|
e.add(new Stat({ value: 1 }));
|
||||||
|
e.add(new Stat({ value: 2 }));
|
||||||
|
e.add(new Stat({ value: 3 }));
|
||||||
|
e.removeAll(Stat);
|
||||||
|
expect(e.getAll(Stat).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the filter and removes all that match it', () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.createEntity();
|
||||||
|
e.add(new Stat({ value: 5 }));
|
||||||
|
e.add(new Stat({ value: 5 }));
|
||||||
|
e.add(new Stat({ value: 9 }));
|
||||||
|
e.removeAll(Stat, s => s.value === 5);
|
||||||
|
expect(e.getAll(Stat).map(s => s.value)).toEqual([9]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fix: cloneEntity preserves anonymous (symbol) keys', () => {
|
||||||
|
it('does not collapse two anonymous components of the same type', () => {
|
||||||
|
const w = new World();
|
||||||
|
const src = w.createEntity('src');
|
||||||
|
src.add(new Stat({ value: 7 }));
|
||||||
|
src.add(new Stat({ value: 8 }));
|
||||||
|
const clone = w.cloneEntity(src, 'clone');
|
||||||
|
const stats = clone.getAll(Stat);
|
||||||
|
expect(stats.length).toBe(2);
|
||||||
|
expect(stats.map(s => s.value).sort()).toEqual([7, 8]);
|
||||||
|
for (const [, c] of clone) expect(typeof c[COMPONENT_KEY]).toBe('symbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves string keys on clone', () => {
|
||||||
|
const w = new World();
|
||||||
|
const src = w.createEntity('src');
|
||||||
|
src.add(new Stat({ value: 42 }), 'str');
|
||||||
|
const clone = w.cloneEntity(src, 'clone');
|
||||||
|
expect(clone.get(Stat, 'str')!.value).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fix: Experience never infinite-loops on degenerate thresholds', () => {
|
||||||
|
it('award terminates when a geometric threshold floors to 0', () => {
|
||||||
|
const w = new World();
|
||||||
|
const xp = w.createEntity('p').add(new Experience({ base: 10, factor: 0.5 }));
|
||||||
|
xp.award(25); // would hang pre-fix
|
||||||
|
expect(xp.level).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(Number.isNaN(xp.progress)).toBeFalse();
|
||||||
|
expect(xp.progress).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fix: migration guards', () => {
|
||||||
|
@component({ name: 'MigTestFixes', version: 2 })
|
||||||
|
class MigTestFixes extends Component<{ x: number }> {
|
||||||
|
constructor() { super({ x: 0 }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('throws (does not loop) on a non-advancing migration', () => {
|
||||||
|
registerMigration('MigTestFixes', 0, 0, s => s); // toVersion does not advance
|
||||||
|
expect(() => migrateState('MigTestFixes', { x: 0 }, 0)).toThrow(/advance|loop/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a save from a newer engine version', () => {
|
||||||
|
const w = new World();
|
||||||
|
w.createEntity('e').add(new Stat({ value: 5 })); // Stat is version 0
|
||||||
|
const data = JSON.parse(Serialization.serialize(w));
|
||||||
|
data.entities[0].components[0].version = 99; // pretend it came from the future
|
||||||
|
expect(() => Serialization.deserialize(JSON.stringify(data))).toThrow(/newer version/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fix: once() bookkeeping is scoped per event', () => {
|
||||||
|
it('off() on one event does not leave the other event armed/disarmed wrongly', () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.createEntity('e');
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const handler = ({ data }: { data?: unknown }) => calls.push(data);
|
||||||
|
e.once('a', handler);
|
||||||
|
e.once('b', handler); // same fn, different event
|
||||||
|
e.off('a', handler); // must remove ONLY the 'a' registration
|
||||||
|
e.emit('a', 1); // disarmed → no call
|
||||||
|
e.emit('b', 2); // still armed → fires once
|
||||||
|
expect(calls).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the same handler fires once for each event it was registered on', () => {
|
||||||
|
const w = new World();
|
||||||
|
const e = w.createEntity('e');
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const handler = ({ data }: { data?: unknown }) => calls.push(data);
|
||||||
|
e.once('a', handler);
|
||||||
|
e.once('b', handler);
|
||||||
|
e.emit('a', 1);
|
||||||
|
e.emit('b', 2);
|
||||||
|
e.emit('a', 3); // already consumed
|
||||||
|
e.emit('b', 4); // already consumed
|
||||||
|
expect(calls).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fix: combat correctness', () => {
|
||||||
|
it('stacks multiple defenses of the same type', () => {
|
||||||
|
const w = combatWorld();
|
||||||
|
w.createEntity('sword').add(new Damage({ value: 20, damageType: 'physical' }));
|
||||||
|
w.createEntity('a');
|
||||||
|
const t = w.createEntity('t');
|
||||||
|
t.add(new Health({ value: 100, min: 0 }));
|
||||||
|
t.add(new Defense({ value: 5, damageType: 'physical' }));
|
||||||
|
t.add(new Defense({ value: 3, damageType: 'physical' }));
|
||||||
|
t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||||
|
w.update(1);
|
||||||
|
expect(t.get(Health)!.value).toBe(88); // 20 - (5 + 3)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies every Damage component on a multi-type weapon', () => {
|
||||||
|
const w = combatWorld();
|
||||||
|
const sword = w.createEntity('sword');
|
||||||
|
sword.add(new Damage({ value: 10, damageType: 'physical' }));
|
||||||
|
sword.add(new Damage({ value: 5, damageType: 'fire' }));
|
||||||
|
w.createEntity('a');
|
||||||
|
const t = w.createEntity('t');
|
||||||
|
t.add(new Health({ value: 100, min: 0 }));
|
||||||
|
const types: (string | undefined)[] = [];
|
||||||
|
t.on('Combat.hit', ({ data }) => types.push((data as any).damageType));
|
||||||
|
t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||||
|
w.update(1);
|
||||||
|
expect(t.get(Health)!.value).toBe(85); // 10 + 5
|
||||||
|
expect(types.sort()).toEqual(['fire', 'physical']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Combat.hit reports the damage actually applied, not the pre-mitigation amount", () => {
|
||||||
|
const w = combatWorld();
|
||||||
|
w.createEntity('sword').add(new Damage({ value: 100, damageType: 'physical' }));
|
||||||
|
w.createEntity('a');
|
||||||
|
const t = w.createEntity('t');
|
||||||
|
t.add(new Health({ value: 30, min: 0 }));
|
||||||
|
let reported = -1;
|
||||||
|
t.on('Combat.hit', ({ data }) => { reported = (data as any).amount; });
|
||||||
|
t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||||
|
w.update(1);
|
||||||
|
expect(reported).toBe(30); // not 100
|
||||||
|
expect(t.get(Health)!.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a crit value below 1 never reduces damage', () => {
|
||||||
|
const w = combatWorld();
|
||||||
|
const sword = w.createEntity('sword');
|
||||||
|
sword.add(new Damage({ value: 20, damageType: 'physical' }));
|
||||||
|
sword.add(new Crit({ value: 0.5, chance: 1 })); // always "crit", but multiplier < 1
|
||||||
|
w.createEntity('a');
|
||||||
|
const t = w.createEntity('t');
|
||||||
|
t.add(new Health({ value: 100, min: 0 }));
|
||||||
|
t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||||
|
w.update(1);
|
||||||
|
expect(t.get(Health)!.value).toBe(80); // 20, not 10
|
||||||
|
});
|
||||||
|
|
||||||
|
it('damage variance can roll below the base value (symmetric)', () => {
|
||||||
|
const w = combatWorld();
|
||||||
|
const sword = w.createEntity('sword');
|
||||||
|
sword.add(new Damage({ value: 20, damageType: 'physical', variance: 5 }));
|
||||||
|
w.createEntity('a');
|
||||||
|
const t = w.createEntity('t');
|
||||||
|
t.add(new Health({ value: 1_000_000, min: 0 }));
|
||||||
|
let min = Infinity, max = -Infinity;
|
||||||
|
t.on('Combat.hit', ({ data }) => {
|
||||||
|
const a = (data as any).amount as number;
|
||||||
|
min = Math.min(min, a); max = Math.max(max, a);
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
t.add(new Attacked({ attackerId: 'a', sourceId: 'sword' }));
|
||||||
|
w.update(1);
|
||||||
|
}
|
||||||
|
expect(min).toBeLessThan(20); // pre-fix: variance only ever added
|
||||||
|
expect(min).toBeGreaterThanOrEqual(15); // within [-variance, +variance]
|
||||||
|
expect(max).toBeLessThanOrEqual(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue