11 Commits

Author SHA1 Message Date
66a7b9e7e6 Fix downward dash not damaging enemies and add post-dash invincibility
Some checks failed
CI / build (pull_request) Successful in 32s
Deploy / deploy (push) Failing after 1m17s
Stomping was guarded by the invincibility check, so during a downward
dash the player could never deal stomp damage. Move the invincibility
guard to only protect against taking damage, not dealing it.

Extend dash invincibility by PLAYER_DASH_INV_GRACE (0.15s) past the
dash duration so the player is briefly protected after landing.

Closes #15
2026-03-16 20:20:05 +00:00
tas
59f76d6aa7 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m17s
2026-03-16 20:15:19 +00:00
tas
29c620a9e8 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m17s
2026-03-16 20:04:51 +00:00
27dc726839 Add hit markers and metal explosion for turrets
Some checks failed
CI / build (pull_request) Successful in 31s
Deploy / deploy (push) Failing after 1m19s
Turrets now emit orange-white spark particles when taking non-lethal
damage, giving clear visual feedback on hits. On death, turrets get a
dedicated metal explosion effect (shrapnel, hot sparks, flash, smoke)
instead of the generic death puff, with stronger screen shake.

Closes #14
2026-03-16 20:01:25 +00:00
tas
477c299d9f revert 84a257f9b9
Some checks failed
Deploy / deploy (push) Failing after 1m17s
revert Update .gitea/workflows/deploy.yaml
2026-03-16 19:47:27 +00:00
f65e8dd9ea Improve enemy AI cliff detection, add level-bottom kill, and charger knockback
Some checks failed
Deploy / deploy (push) Failing after 1m14s
Enemies that fall past the bottom of the level are now instantly destroyed,
preventing them from accumulating off-screen. Cliff detection uses a
speed-scaled lookahead so faster enemies reverse earlier. Charger deals
double damage during charge and knockback scales with the attacker's speed.
2026-03-16 19:45:12 +00:00
tas
84a257f9b9 Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m12s
2026-03-16 15:10:17 +00:00
tas
f7c498d7ad Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m14s
2026-03-16 15:07:57 +00:00
7080d7fefc Document tea CLI for remote git server operations
Some checks failed
CI / build (pull_request) Successful in 50s
Deploy / deploy (push) Failing after 1m16s
2026-03-16 14:58:53 +00:00
tas
69614f058c Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 2m47s
2026-03-16 13:29:08 +00:00
tas
cc582e1f0e Update .gitea/workflows/deploy.yaml
Some checks failed
Deploy / deploy (push) Failing after 1m14s
2026-03-16 13:26:28 +00:00
8 changed files with 252 additions and 69 deletions

View File

@@ -30,16 +30,6 @@ jobs:
CREDS="${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}" CREDS="${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}"
echo "=== debug: test v2 endpoint ==="
curl -sk https://git.kimchi/v2/ || true
echo ""
echo "=== debug: test v2 auth ==="
curl -sk -u "$CREDS" https://git.kimchi/v2/ || true
echo ""
echo "=== debug: test token endpoint ==="
curl -sk -u "$CREDS" "https://git.kimchi/v2/token?scope=repository:tas/major_tom:push,pull&service=container_registry" || true
echo ""
echo "=== buildah push tag ===" echo "=== buildah push tag ==="
buildah push --tls-verify=false --creds "$CREDS" "$IMAGE_TAG" buildah push --tls-verify=false --creds "$CREDS" "$IMAGE_TAG"
@@ -48,15 +38,13 @@ jobs:
echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV" echo "IMAGE_TAG=$IMAGE_TAG" >> "$GITHUB_ENV"
- name: Deploy to k3s - name: Restart deployment on k3s
run: | run: |
mkdir -p ~/.kube mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config chmod 600 ~/.kube/config
kubectl delete pod -l app=${{ env.DEPLOYMENT }} -n ${{ env.NAMESPACE }}
kubectl set image deployment/${{ env.DEPLOYMENT }} \ POD_NAME=$(kubectl get pods -l app=jnr-web -n jnr-web -o jsonpath='{.items[0].metadata.name}')
${{ env.DEPLOYMENT }}="${{ env.IMAGE_TAG }}" \ echo "Deleting running pod" "$POD_NAME"
-n ${{ env.NAMESPACE }}
kubectl rollout status deployment/${{ env.DEPLOYMENT }} \
-n ${{ env.NAMESPACE }} --timeout=60s

View File

@@ -256,6 +256,23 @@ incremental progress.
- Draw layers: BG → entities → FG → particles → HUD - Draw layers: BG → entities → FG → particles → HUD
- Camera transforms world coords to screen coords - Camera transforms world coords to screen coords
## Remote Git Server
The remote git server is a Gitea instance. Use the **`tea` CLI** (not `gh`) for all remote
operations: pull requests, issues, releases, and repository management.
```bash
tea pr create --title "..." --description "..." # Create a pull request
tea pr list # List open PRs
tea issue list # List issues
tea issue create --title "..." --description "..."
tea pr merge <number> # Merge a PR
```
Do NOT use `gh` (GitHub CLI) — it will not work with this remote.
After creating a pull request, always check out the `main` branch (`git checkout main`).
## Commit Messages ## Commit Messages
- Imperative mood, concise - Imperative mood, concise
- No co-authored-by or AI attribution - No co-authored-by or AI attribution

View File

@@ -530,6 +530,124 @@ void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir) {
particle_emit(&dust); particle_emit(&dust);
} }
void particle_emit_hit_sparks(Vec2 pos) {
/* Bright orange-white sparks — fast, short-lived, spray outward */
ParticleBurst sparks = {
.origin = pos,
.count = 8,
.speed_min = 60.0f,
.speed_max = 180.0f,
.life_min = 0.08f,
.life_max = 0.2f,
.size_min = 1.0f,
.size_max = 2.0f,
.spread = (float)M_PI,
.direction = 0,
.drag = 3.0f,
.gravity_scale = 0.4f,
.color = {255, 200, 100, 255}, /* orange-white */
.color_vary = true,
};
particle_emit(&sparks);
/* Brief white flash at impact point */
ParticleBurst flash = {
.origin = pos,
.count = 3,
.speed_min = 5.0f,
.speed_max = 15.0f,
.life_min = 0.03f,
.life_max = 0.06f,
.size_min = 2.0f,
.size_max = 3.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 8.0f,
.gravity_scale = 0.0f,
.color = {255, 255, 230, 255}, /* bright white */
.color_vary = false,
};
particle_emit(&flash);
}
void particle_emit_metal_explosion(Vec2 pos) {
/* Metal shrapnel — fast grey/silver chunks flying outward */
ParticleBurst shrapnel = {
.origin = pos,
.count = 16,
.speed_min = 60.0f,
.speed_max = 200.0f,
.life_min = 0.2f,
.life_max = 0.6f,
.size_min = 1.5f,
.size_max = 3.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 2.0f,
.gravity_scale = 0.5f,
.color = {180, 180, 190, 255}, /* silver-grey */
.color_vary = true,
};
particle_emit(&shrapnel);
/* Hot orange sparks — electrical/mechanical innards */
ParticleBurst sparks = {
.origin = pos,
.count = 10,
.speed_min = 40.0f,
.speed_max = 150.0f,
.life_min = 0.15f,
.life_max = 0.35f,
.size_min = 1.0f,
.size_max = 2.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 2.5f,
.gravity_scale = 0.3f,
.color = {255, 160, 40, 255}, /* hot orange */
.color_vary = true,
};
particle_emit(&sparks);
/* Bright white flash at center — brief pop */
ParticleBurst flash = {
.origin = pos,
.count = 5,
.speed_min = 5.0f,
.speed_max = 20.0f,
.life_min = 0.04f,
.life_max = 0.08f,
.size_min = 3.0f,
.size_max = 5.0f,
.spread = (float)M_PI,
.direction = 0,
.drag = 8.0f,
.gravity_scale = 0.0f,
.color = {255, 240, 200, 255}, /* bright flash */
.color_vary = false,
};
particle_emit(&flash);
/* Smoke cloud — slower, lingers */
ParticleBurst smoke = {
.origin = pos,
.count = 8,
.speed_min = 15.0f,
.speed_max = 50.0f,
.life_min = 0.3f,
.life_max = 0.7f,
.size_min = 2.5f,
.size_max = 4.5f,
.spread = (float)M_PI,
.direction = 0,
.drag = 3.5f,
.gravity_scale = -0.1f, /* floats up */
.color = {120, 120, 130, 180}, /* dark smoke */
.color_vary = true,
};
particle_emit(&smoke);
}
/* Spawn a single dust mote with the given visual properties. */ /* Spawn a single dust mote with the given visual properties. */
static void spawn_dust_mote(Vec2 pos, Vec2 vel, static void spawn_dust_mote(Vec2 pos, Vec2 vel,
float life_min, float life_max, float life_min, float life_max,

View File

@@ -89,6 +89,12 @@ void particle_emit_muzzle_flash(Vec2 pos, Vec2 shoot_dir);
/* Wall slide dust (small puffs while scraping against a wall) */ /* Wall slide dust (small puffs while scraping against a wall) */
void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir); void particle_emit_wall_slide_dust(Vec2 pos, int wall_dir);
/* Metal hit sparks (turret/machine takes non-lethal damage) */
void particle_emit_hit_sparks(Vec2 pos);
/* Metal explosion (turret/machine death — shrapnel + flash) */
void particle_emit_metal_explosion(Vec2 pos);
/* Ambient atmosphere dust (call each frame for Mars Surface levels). /* Ambient atmosphere dust (call each frame for Mars Surface levels).
* Spawns subtle dust motes around the camera viewport that drift with wind. * Spawns subtle dust motes around the camera viewport that drift with wind.
* cam_pos = camera top-left world position, vp = viewport size in pixels. */ * cam_pos = camera top-left world position, vp = viewport size in pixels. */

View File

@@ -5,9 +5,46 @@
#include "engine/renderer.h" #include "engine/renderer.h"
#include "engine/particle.h" #include "engine/particle.h"
#include "engine/audio.h" #include "engine/audio.h"
#include "config.h"
#include <stdlib.h> #include <stdlib.h>
#include <math.h> #include <math.h>
/* ── Shared helpers ───────────────────────────────── */
/* Kill enemy if it fell past the bottom of the level. */
static bool enemy_check_level_bottom(Entity *self, const Tilemap *map,
EntityManager *em) {
float level_bottom = (float)(map->height * TILE_SIZE);
if (self->body.pos.y > level_bottom) {
self->flags |= ENTITY_DEAD;
self->health = 0;
entity_destroy(em, self);
return true;
}
return false;
}
/* Check for cliff ahead of a ground enemy. Returns true if cliff detected.
* Uses a wider lookahead that scales with speed for fast-moving enemies. */
static bool enemy_detect_cliff(const Body *body, float patrol_dir,
float speed, const Tilemap *map) {
if (!body->on_ground) return false;
/* Scale lookahead with speed: at least 4px, up to speed/10 */
float lookahead = speed * 0.1f;
if (lookahead < 4.0f) lookahead = 4.0f;
float check_x = (patrol_dir > 0) ?
body->pos.x + body->size.x + lookahead :
body->pos.x - lookahead;
float check_y = body->pos.y + body->size.y + 4.0f;
int tx = world_to_tile(check_x);
int ty = world_to_tile(check_y);
return !tilemap_is_solid(map, tx, ty);
}
/* ════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════
* GRUNT - ground patrol enemy * GRUNT - ground patrol enemy
* ════════════════════════════════════════════════════ */ * ════════════════════════════════════════════════════ */
@@ -20,6 +57,9 @@ static void grunt_update(Entity *self, float dt, const Tilemap *map) {
Body *body = &self->body; Body *body = &self->body;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_grunt_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_grunt_death); animation_set(&self->anim, &anim_grunt_death);
@@ -48,19 +88,10 @@ static void grunt_update(Entity *self, float dt, const Tilemap *map) {
gd->patrol_dir = -gd->patrol_dir; gd->patrol_dir = -gd->patrol_dir;
} }
/* Turn around at ledge: check if there's ground ahead */ /* Turn around at ledge */
if (body->on_ground) { if (enemy_detect_cliff(body, gd->patrol_dir, GRUNT_SPEED, map)) {
float check_x = (gd->patrol_dir > 0) ? gd->patrol_dir = -gd->patrol_dir;
body->pos.x + body->size.x + 2.0f : body->vel.x = 0;
body->pos.x - 2.0f;
float check_y = body->pos.y + body->size.y + 4.0f;
int tx = world_to_tile(check_x);
int ty = world_to_tile(check_y);
if (!tilemap_is_solid(map, tx, ty)) {
gd->patrol_dir = -gd->patrol_dir;
}
} }
/* Animation */ /* Animation */
@@ -146,12 +177,14 @@ static Entity *find_player(EntityManager *em) {
} }
static void flyer_update(Entity *self, float dt, const Tilemap *map) { static void flyer_update(Entity *self, float dt, const Tilemap *map) {
(void)map; /* flyers don't collide with tiles */
FlyerData *fd = (FlyerData *)self->data; FlyerData *fd = (FlyerData *)self->data;
if (!fd) return; if (!fd) return;
Body *body = &self->body; Body *body = &self->body;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_flyer_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_flyer_death); animation_set(&self->anim, &anim_flyer_death);
@@ -289,6 +322,9 @@ static void charger_update(Entity *self, float dt, const Tilemap *map) {
Body *body = &self->body; Body *body = &self->body;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_charger_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_charger_death); animation_set(&self->anim, &anim_charger_death);
@@ -319,14 +355,10 @@ static void charger_update(Entity *self, float dt, const Tilemap *map) {
} }
/* Reverse at ledge */ /* Reverse at ledge */
if (body->on_ground) { if (enemy_detect_cliff(body, cd->patrol_dir,
float cx = (cd->patrol_dir > 0) ? CHARGER_PATROL_SPEED, map)) {
body->pos.x + body->size.x + 2.0f : cd->patrol_dir = -cd->patrol_dir;
body->pos.x - 2.0f; body->vel.x = 0;
float cy = body->pos.y + body->size.y + 4.0f;
if (!tilemap_is_solid(map, world_to_tile(cx), world_to_tile(cy))) {
cd->patrol_dir = -cd->patrol_dir;
}
} }
/* Detect player — horizontal line-of-sight */ /* Detect player — horizontal line-of-sight */
@@ -510,10 +542,12 @@ static int count_alive_grunts(EntityManager *em) {
} }
static void spawner_update(Entity *self, float dt, const Tilemap *map) { static void spawner_update(Entity *self, float dt, const Tilemap *map) {
(void)map;
SpawnerData *sd = (SpawnerData *)self->data; SpawnerData *sd = (SpawnerData *)self->data;
if (!sd) return; if (!sd) return;
/* Kill if fallen off bottom of level */
if (enemy_check_level_bottom(self, map, s_spawner_em)) return;
/* Death sequence */ /* Death sequence */
if (self->flags & ENTITY_DEAD) { if (self->flags & ENTITY_DEAD) {
animation_set(&self->anim, &anim_spawner_death); animation_set(&self->anim, &anim_spawner_death);

View File

@@ -177,36 +177,45 @@ static Camera *s_active_camera = NULL;
static void damage_entity(Entity *target, int damage) { static void damage_entity(Entity *target, int damage) {
target->health -= damage; target->health -= damage;
Vec2 center = vec2(
target->body.pos.x + target->body.size.x * 0.5f,
target->body.pos.y + target->body.size.y * 0.5f
);
if (target->health <= 0) { if (target->health <= 0) {
target->flags |= ENTITY_DEAD; target->flags |= ENTITY_DEAD;
/* Death particles — centered on entity */ /* Death particles — turrets get a metal explosion, others a puff */
Vec2 center = vec2( if (target->type == ENT_TURRET || target->type == ENT_LASER_TURRET) {
target->body.pos.x + target->body.size.x * 0.5f, particle_emit_metal_explosion(center);
target->body.pos.y + target->body.size.y * 0.5f
);
SDL_Color death_color;
if (target->type == ENT_ENEMY_GRUNT) {
death_color = (SDL_Color){200, 60, 60, 255}; /* red debris */
} else if (target->type == ENT_ENEMY_FLYER) {
death_color = (SDL_Color){140, 80, 200, 255}; /* purple puff */
} else if (target->type == ENT_TURRET || target->type == ENT_LASER_TURRET) {
death_color = (SDL_Color){160, 160, 160, 255}; /* metal scraps */
} else if (target->type == ENT_ENEMY_CHARGER) {
death_color = (SDL_Color){220, 140, 40, 255}; /* orange spark */
} else if (target->type == ENT_SPAWNER) {
death_color = (SDL_Color){180, 60, 180, 255}; /* purple burst */
} else { } else {
death_color = (SDL_Color){200, 200, 200, 255}; /* grey */ SDL_Color death_color;
if (target->type == ENT_ENEMY_GRUNT) {
death_color = (SDL_Color){200, 60, 60, 255};
} else if (target->type == ENT_ENEMY_FLYER) {
death_color = (SDL_Color){140, 80, 200, 255};
} else if (target->type == ENT_ENEMY_CHARGER) {
death_color = (SDL_Color){220, 140, 40, 255};
} else if (target->type == ENT_SPAWNER) {
death_color = (SDL_Color){180, 60, 180, 255};
} else {
death_color = (SDL_Color){200, 200, 200, 255};
}
particle_emit_death_puff(center, death_color);
} }
particle_emit_death_puff(center, death_color);
/* Screen shake on kill */ /* Screen shake on kill — stronger for turret explosions */
if (s_active_camera) { if (s_active_camera) {
camera_shake(s_active_camera, 2.0f, 0.15f); float intensity = (target->type == ENT_TURRET ||
target->type == ENT_LASER_TURRET) ? 3.5f : 2.0f;
camera_shake(s_active_camera, intensity, 0.15f);
} }
audio_play_sound_at(s_sfx_enemy_death, 80, center, 0); audio_play_sound_at(s_sfx_enemy_death, 80, center, 0);
} else if (target->type == ENT_TURRET || target->type == ENT_LASER_TURRET) {
/* Hit marker sparks on non-lethal turret damage */
particle_emit_hit_sparks(center);
} }
} }
@@ -228,11 +237,14 @@ static void damage_player(Entity *player, int damage, Entity *source) {
ppd->inv_timer = PLAYER_INV_TIME; ppd->inv_timer = PLAYER_INV_TIME;
player->flags |= ENTITY_INVINCIBLE; player->flags |= ENTITY_INVINCIBLE;
/* Knockback away from source */ /* Knockback away from source, scaled by source speed */
if (source) { if (source) {
float knock_dir = (player->body.pos.x < source->body.pos.x) ? float knock_dir = (player->body.pos.x < source->body.pos.x) ?
-1.0f : 1.0f; -1.0f : 1.0f;
player->body.vel.x = knock_dir * 150.0f; float src_speed = fabsf(source->body.vel.x);
float knock_str = 150.0f;
if (src_speed > knock_str) knock_str = src_speed;
player->body.vel.x = knock_dir * knock_str;
player->body.vel.y = -150.0f; player->body.vel.y = -150.0f;
} }
} }
@@ -294,8 +306,7 @@ static void handle_collisions(EntityManager *em) {
} }
/* ── Enemy contact damage to player ──── */ /* ── Enemy contact damage to player ──── */
if (player && !(player->flags & ENTITY_INVINCIBLE) && if (player && entity_is_enemy(a) && !(a->flags & ENTITY_DEAD)) {
entity_is_enemy(a) && !(a->flags & ENTITY_DEAD)) {
if (physics_overlap(&a->body, &player->body)) { if (physics_overlap(&a->body, &player->body)) {
/* Check if player is stomping (falling onto enemy from above) */ /* Check if player is stomping (falling onto enemy from above) */
bool stomping = (player->body.vel.y > 0) && bool stomping = (player->body.vel.y > 0) &&
@@ -307,8 +318,16 @@ static void handle_collisions(EntityManager *em) {
damage_entity(a, 2); damage_entity(a, 2);
if (a->flags & ENTITY_DEAD) stats_record_kill(); if (a->flags & ENTITY_DEAD) stats_record_kill();
player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f; player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f;
} else { } else if (!(player->flags & ENTITY_INVINCIBLE)) {
damage_player(player, a->damage, a); /* Charger deals extra damage and knockback while charging */
int dmg = a->damage;
if (a->type == ENT_ENEMY_CHARGER) {
ChargerData *cd = (ChargerData *)a->data;
if (cd && cd->state == CHARGER_CHARGE) {
dmg = 2;
}
}
damage_player(player, dmg, a);
} }
} }
} }

View File

@@ -308,8 +308,8 @@ void player_update(Entity *self, float dt, const Tilemap *map) {
pd->dash_timer = PLAYER_DASH_DURATION; pd->dash_timer = PLAYER_DASH_DURATION;
pd->dash_dir = vec2(0.0f, 1.0f); pd->dash_dir = vec2(0.0f, 1.0f);
/* Brief invincibility during dash */ /* Invincibility during dash + short grace period after */
pd->inv_timer = PLAYER_DASH_DURATION; pd->inv_timer = PLAYER_DASH_DURATION + PLAYER_DASH_INV_GRACE;
self->flags |= ENTITY_INVINCIBLE; self->flags |= ENTITY_INVINCIBLE;
/* Jetpack burst downward */ /* Jetpack burst downward */

View File

@@ -39,6 +39,7 @@
/* Invincibility after taking damage */ /* Invincibility after taking damage */
#define PLAYER_INV_TIME 1.5f /* seconds of invincibility */ #define PLAYER_INV_TIME 1.5f /* seconds of invincibility */
#define PLAYER_DASH_INV_GRACE 0.15f /* extra invincibility after dash */
/* Aim direction (for shooting) */ /* Aim direction (for shooting) */
typedef enum AimDir { typedef enum AimDir {