diff --git a/src/cgame/default/cg_view.c b/src/cgame/default/cg_view.c index c3bd8c9af..21ef51401 100644 --- a/src/cgame/default/cg_view.c +++ b/src/cgame/default/cg_view.c @@ -300,6 +300,42 @@ static void Cg_UpdateAngles(const player_state_t *ps0, const player_state_t *ps1 Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); } +/** + * @brief While falling through a trigger_void, lock the camera at the entry point + * and re-aim it at the falling player each frame. Forces third person so the player + * model remains visible. Releases automatically once the void state clears. + */ +static void Cg_UpdateVoidCamera(const player_state_t *ps) { + static bool locked; + static vec3_t locked_origin; + + if (!ps->stats[STAT_VOID]) { + locked = false; + return; + } + + // snapshot the camera position at the moment we enter the void + if (!locked) { + locked_origin = cgi.view->origin; + locked = true; + } + + // force third person so the falling player model is rendered + cgi.client->third_person = true; + + // hold the camera in place + cgi.view->origin = locked_origin; + + // track the falling player + const cl_entity_t *self = Cg_Self(); + const vec3_t dir = Vec3_Subtract(self->origin, locked_origin); + + if (!Vec3_Equal(dir, Vec3_Zero())) { + cgi.view->angles = Vec3_Euler(Vec3_Normalize(dir)); + Vec3_Vectors(cgi.view->angles, &cgi.view->forward, &cgi.view->right, &cgi.view->up); + } +} + /** * @brief Updates the view ambient light level from the worldspawn entity definition. */ @@ -347,6 +383,8 @@ void Cg_PrepareView(const cl_frame_t *frame) { Cg_UpdateBob(ps1); + Cg_UpdateVoidCamera(ps1); + Cg_UpdateAmbient(); cgi.view->contents = cgi.PointContents(cgi.view->origin); diff --git a/src/game/default/g_client.c b/src/game/default/g_client.c index a2598c8f1..2788d9ec3 100644 --- a/src/game/default/g_client.c +++ b/src/game/default/g_client.c @@ -169,6 +169,9 @@ static void G_ClientObituary(g_client_t *cl, g_entity_t *attacker, uint32_t mod) case MOD_TRIGGER_HURT: msg = "%s was in the wrong place :actofgod:"; break; + case MOD_TRIGGER_VOID: + msg = "%s has succumbed to the void king :actofgod:"; + break; case MOD_ACT_OF_GOD: msg = "%s was killed by an act of god :actofgod:"; break; @@ -534,13 +537,15 @@ static void G_ClientDie(g_entity_t *ent, g_entity_t *attacker, uint32_t mod) { G_ClientObituary(cl, attacker, mod); + cl->in_void = false; + G_HookDetach(cl); G_TossQuadDamage(cl); G_TossInvisibility(cl); G_TossInvulnerability(cl); - if (g_level.gameplay == GAME_DEATHMATCH && mod != MOD_TRIGGER_HURT) { + if (g_level.gameplay == GAME_DEATHMATCH && mod != MOD_TRIGGER_HURT && mod != MOD_TRIGGER_VOID) { G_TossWeapon(cl); } @@ -986,7 +991,7 @@ static g_entity_t *G_SelectTeamSpawnPoint(g_client_t *cl) { /** * @brief Selects the most appropriate spawn point for the given client. */ -static g_entity_t *G_SelectSpawnPoint(g_client_t *cl) { +g_entity_t *G_SelectSpawnPoint(g_client_t *cl) { g_entity_t *spawn = NULL; if (g_level.teams || g_level.ctf) { // try team spawns first if applicable @@ -1110,6 +1115,7 @@ static void G_ClientRespawn_(g_client_t *cl) { } ent->dead = false; + cl->in_void = false; ent->Die = G_ClientDie; memset(&ent->ground, 0, sizeof(ent->ground)); ent->max_health = 100; @@ -1989,7 +1995,13 @@ void G_ClientBeginFrame(g_client_t *cl) { g_entity_t *ent = cl->entity; - if ((G_IsMeat(ent) && ent->dead) || ((cl->buttons | cl->latched_buttons) & BUTTON_SCORE)) { + // clear a stale void state if the player has left the brush without reaching + // the bottom (e.g. air-controlled back out over the lip) + if (cl->in_void && g_level.time > cl->void_touch_time + 100) { + cl->in_void = false; + } + + if ((G_IsMeat(ent) && ent->dead) || cl->in_void || ((cl->buttons | cl->latched_buttons) & BUTTON_SCORE)) { cl->show_scores = true; } else { cl->show_scores = false; diff --git a/src/game/default/g_client.h b/src/game/default/g_client.h index 5f7ba0290..4f87f4c30 100644 --- a/src/game/default/g_client.h +++ b/src/game/default/g_client.h @@ -25,6 +25,7 @@ #if defined(__GAME_LOCAL_H__) void G_ClientBegin(g_client_t *cl); +g_entity_t *G_SelectSpawnPoint(g_client_t *cl); void G_ClientBeginFrame(g_client_t *cl); bool G_ClientConnect(g_client_t *cl, char *user_info); void G_ClientDisconnect(g_client_t *cl); diff --git a/src/game/default/g_client_stats.c b/src/game/default/g_client_stats.c index 2de981c20..0b0d82178 100644 --- a/src/game/default/g_client_stats.c +++ b/src/game/default/g_client_stats.c @@ -219,6 +219,9 @@ void G_ClientStats(g_client_t *cl) { cl->ps.stats[STAT_SCORES] |= 1; } + // void fall: signals the client to lock the camera and track the falling player + cl->ps.stats[STAT_VOID] = cl->in_void ? 1 : 0; + if (cl->persistent.team) { // send team ID, -1 is no team cl->ps.stats[STAT_TEAM] = cl->persistent.team->id; } else { diff --git a/src/game/default/g_entity.c b/src/game/default/g_entity.c index 4a26640a2..518e0df4f 100644 --- a/src/game/default/g_entity.c +++ b/src/game/default/g_entity.c @@ -84,6 +84,7 @@ static const g_entity_class_t g_entity_classes[] = { { "trigger_once", G_trigger_once }, { "trigger_push", G_trigger_push }, { "trigger_relay", G_trigger_relay }, + { "trigger_void", G_trigger_void }, { "trigger_teleporter", G_misc_teleporter }, { "worldspawn", G_worldspawn }, @@ -244,6 +245,7 @@ static void G_InitMedia(void) { // precache player sounds; clients will load these when a new player model gets loaded. gi.SoundIndex("*death_1"); gi.SoundIndex("*death_2"); + gi.SoundIndex("*death_void"); gi.SoundIndex("*drown_1"); gi.SoundIndex("*fall_1"); gi.SoundIndex("*fall_2"); diff --git a/src/game/default/g_entity_trigger.c b/src/game/default/g_entity_trigger.c index 74decd0ae..6899d197a 100644 --- a/src/game/default/g_entity_trigger.c +++ b/src/game/default/g_entity_trigger.c @@ -437,6 +437,170 @@ static void G_trigger_exec_Touch(g_entity_t *ent, g_entity_t *other, const cm_tr } } +/** + * @brief Handles touch events on a `trigger_void`, pulling players into the void. + */ +static void G_trigger_void_Touch(g_entity_t *ent, g_entity_t *other, const cm_trace_t *trace) { + + if (!other->take_damage) { // handle dropped items landing in the void + if (other->item) { + if (other->item->def.type == ITEM_FLAG) { + G_ResetDroppedFlag(other); + } else if (other->item->def.type == ITEM_TECH) { + G_ResetDroppedTech(other); + } else { + G_FreeEntity(other); + } + } + return; + } + + if (!other->client || other->dead) { + return; + } + + g_client_t *cl = other->client; + + if (!cl->in_void) { + + // Only enter void from the top half of the brush (side entry is ignored) + const float mid_z = (ent->abs_bounds.mins.z + ent->abs_bounds.maxs.z) * 0.5f; + if (other->s.origin.z < mid_z) { + return; + } + + if (ent->spawn_flags & 1) { // VOID_TELEPORT: warp to a spawn instead of killing + const g_entity_t *spawn = NULL; + + if (ent->target) { // a specific destination pad was requested + spawn = G_Find(NULL, EOFS(target_name), ent->target); + if (!spawn) { + gi.Warn("trigger_void: target \"%s\" not found\n", ent->target); + } + } + + if (!spawn) { // no target, or it was missing; pick a random spawn point + spawn = G_SelectSpawnPoint(cl); + } + + if (spawn) { + gi.UnlinkEntity(other); + + other->s.origin = spawn->s.origin; + other->s.origin.z += 8.0; + + cl->ps.pm_state.flags &= ~PMF_TIME_MASK; + cl->ps.pm_state.flags |= PMF_TIME_TELEPORT; + cl->ps.pm_state.time = 20; + cl->ps.pm_state.delta_angles = Vec3_Subtract(spawn->s.angles, cl->cmd_angles); + + other->velocity = Vec3_Zero(); + cl->cmd_angles = Vec3_Zero(); + cl->angles = Vec3_Zero(); + + other->s.event = EV_CLIENT_TELEPORT; + + G_MulticastSound(&(const g_play_sound_t) { + .index = g_media.sounds.teleport, + .origin = &other->s.origin, + }, MULTICAST_PHS); + + gi.LinkEntity(other); + } + return; + } + + // Mark as falling into the void, suppress HUD, play death sound + cl->in_void = true; + cl->void_touch_time = g_level.time; + + G_MulticastSound(&(const g_play_sound_t) { + .index = gi.SoundIndex("*death_void"), + .entity = other, + }, MULTICAST_PHS); + + return; + } + + // Still overlapping the brush; refresh the touch time so the state isn't + // cleared as stale (see G_ClientBeginFrame). + cl->void_touch_time = g_level.time; + + // Already falling — kill when the player's feet reach the brush's bottom face + if (other->abs_bounds.mins.z > ent->abs_bounds.mins.z) { + return; + } + + // Strip powerups without dropping them + if (cl->inventory[POWERUP_QUAD]) { + cl->inventory[POWERUP_QUAD] = 0; + cl->quad_damage_time = 0; + other->s.effects &= ~EF_QUAD; + } + + if (cl->inventory[POWERUP_INVISIBILITY]) { + cl->inventory[POWERUP_INVISIBILITY] = 0; + cl->invisibility_time = 0; + other->s.effects &= ~EF_INVISIBILITY; + } + + if (cl->inventory[POWERUP_INVULNERABILITY]) { + cl->inventory[POWERUP_INVULNERABILITY] = 0; + cl->invulnerability_time = 0; + cl->invulnerability_countdown_time = 0; + other->s.effects &= ~EF_INVULNERABILITY; + } + + // Return the carried CTF flag immediately rather than dropping it + if (g_level.ctf) { + G_ReturnFlag(cl); + } + + // Respawn any carried tech back into its pool rather than dropping it + if (g_level.techs) { + g_entity_t *tech = G_TossTech(cl); + if (tech) { + G_ResetDroppedTech(tech); + } + } + + // Instant kill bypassing armor and god mode; G_ClientDie handles gibs + cl->in_void = false; + G_Damage(&(g_damage_t) { + .target = other, + .inflictor = ent, + .attacker = NULL, + .dir = Vec3_Zero(), + .point = other->s.origin, + .normal = Vec3_Zero(), + .damage = 999, + .knockback = 0, + .flags = DMG_NO_GOD, + .mod = MOD_TRIGGER_VOID + }); +} + +/*QUAKED trigger_void (.5 .5 .5) ? teleport + Players stepping onto the top face are pulled into the void. A special death sound plays and + the HUD is suppressed during the fall. Upon reaching the bottom face, the player is instantly + killed: powerups are removed, carried techs are respawned, and any carried CTF flag is returned. + + -------- Keys -------- + target : With the teleport flag, the target_name of a specific destination (e.g. a + misc_teleporter_dest) to warp to. If unset, a random spawn point is chosen. + + -------- Spawn flags -------- + teleport : Instead of killing the player, teleport them to a spawn point (see target). + */ +void G_trigger_void(g_entity_t *ent) { + + G_Trigger_Init(ent); + + ent->Touch = G_trigger_void_Touch; + + gi.LinkEntity(ent); +} + /*QUAKED trigger_exec (0 1 0) ? Executes a console command or script file when activated. diff --git a/src/game/default/g_entity_trigger.h b/src/game/default/g_entity_trigger.h index 5d3878a74..8c71f1d72 100644 --- a/src/game/default/g_entity_trigger.h +++ b/src/game/default/g_entity_trigger.h @@ -31,4 +31,5 @@ void G_trigger_multiple(g_entity_t *ent); void G_trigger_once(g_entity_t *ent); void G_trigger_push(g_entity_t *ent); void G_trigger_relay(g_entity_t *ent); +void G_trigger_void(g_entity_t *ent); #endif /* __GAME_LOCAL_H__ */ diff --git a/src/game/default/g_item.c b/src/game/default/g_item.c index 42c796127..fae0e2d4e 100644 --- a/src/game/default/g_item.c +++ b/src/game/default/g_item.c @@ -814,6 +814,49 @@ g_entity_t *G_TossFlag(g_client_t *cl) { return G_DropItem(cl, flag); } +/** + * @brief Removes the CTF flag carried by the client and returns it directly to + * its base, announcing both actions in a single message. Used when a carrier is + * removed without dropping the flag (e.g. falling into a trigger_void). + */ +void G_ReturnFlag(g_client_t *cl) { + + const g_item_t *flag = G_GetFlag(cl); + + if (!flag) { + return; + } + + const g_team_t *team = &g_team_list[flag->def.tag - FLAG_FIRST]; + const g_item_tag_t index = flag->def.tag; + + if (!cl->inventory[index]) { + return; + } + + // remove the flag from the carrier + cl->inventory[index] = 0; + cl->entity->s.model3 = 0; + cl->entity->s.effects &= ~EF_CTF_MASK; + + // send the team's flag entity straight back to its base + g_entity_t *f = G_FlagForTeam(team); + if (f) { + f->sv_flags &= ~SVF_NO_CLIENT; + f->s.event = EV_ITEM_RESPAWN; + f->s.event_data = f->item->def.tag; + f->solid = SOLID_TRIGGER; + gi.LinkEntity(f); + } + + G_MulticastSound(&(const g_play_sound_t) { + .index = g_media.sounds.ctf_return + }, MULTICAST_PHS_R); + + gi.BroadcastPrint(PRINT_HIGH, "%s dropped the %s flag and it was returned :flag%d_return:\n", + cl->persistent.net_name, team->name, team->id + 1); +} + /** * @brief Drop command callback that tosses the client's carried CTF flag. */ diff --git a/src/game/default/g_item.h b/src/game/default/g_item.h index c0d173a9c..b7c9910e0 100644 --- a/src/game/default/g_item.h +++ b/src/game/default/g_item.h @@ -87,6 +87,7 @@ void G_SpawnItem(g_entity_t *ent, const g_item_t *item); bool G_SetAmmo(g_client_t *cl, const g_item_t *item, int16_t count); g_entity_t *G_TossFlag(g_client_t *cl); + void G_ReturnFlag(g_client_t *cl); g_entity_t *G_TossTech(g_client_t *cl); g_entity_t *G_TossQuadDamage(g_client_t *cl); g_entity_t *G_TossInvisibility(g_client_t *cl); diff --git a/src/game/default/g_types.h b/src/game/default/g_types.h index 5f2bb44f4..7ea13b273 100644 --- a/src/game/default/g_types.h +++ b/src/game/default/g_types.h @@ -94,7 +94,8 @@ typedef enum { STAT_TEAM, STAT_TECH, STAT_TIME, - STAT_WEAPON + STAT_WEAPON, + STAT_VOID // new stats must be appended at the end to preserve wire indices for mismatched servers } g_stat_t; /** @@ -914,6 +915,7 @@ typedef struct { MOD_SUICIDE, MOD_EXPLOSIVE, MOD_TRIGGER_HURT, + MOD_TRIGGER_VOID, MOD_HANDGRENADE, MOD_HANDGRENADE_SPLASH, MOD_HANDGRENADE_SUICIDE, @@ -1509,6 +1511,17 @@ struct g_client_s { */ const g_item_t *last_dropped; + /** + * @brief True while the client is falling through a trigger_void brush. + */ + bool in_void; + + /** + * @brief Last time the client touched a trigger_void; used to clear a stale + * in_void state if the player leaves the brush without reaching the bottom. + */ + uint32_t void_touch_time; + /** * @brief True if the scoreboard layout flag should be set. */