/* * CSB KZ Timer * Copyright (C) 2026 counter-strike-boost.com * * A Kreedz (climb) timer. The run starts when a player presses a start button * (targetname contains "start") and stops on the end/counter button. A live * HUD shows the elapsed time and jump count, checkpoints let you save/teleport * (/cp, /tp), and finish times are stored per map in nVault. /pb shows your * personal best, /top shows the map's ten best runs. * * Inspired by the classic KZ Timer / xJ Timer plugins. Independent GPL * re-implementation. * * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, either version 3 of the License, or (at your option) * any later version. Distributed WITHOUT ANY WARRANTY. See the GNU General * Public License for more details: . */ #include #include #include #include #include new const PLUGIN[] = "CSB KZ Timer" new const VERSION[] = "1.0.0" new const AUTHOR[] = "counter-strike-boost.com" #define TOP_MAX 10 new g_pEnabled new bool:g_bRunning[33] new Float:g_fStart[33] new g_iJumps[33] new bool:g_bHasCp[33] new Float:g_fCp[33][3] new Float:g_fCpAngles[33][3] new g_iVault new g_szMap[32] // in-memory top table for the current map new Float:g_fTopTime[TOP_MAX] new g_szTopName[TOP_MAX][32] new g_iTopCount public plugin_init() { register_plugin(PLUGIN, VERSION, AUTHOR) g_pEnabled = register_cvar("csb_kz_enabled", "1") RegisterHam(Ham_Use, "func_button", "fwButtonUse", 1) register_forward(FM_PlayerPreThink, "fwPreThink") register_clcmd("say /cp", "cmdSaveCp") register_clcmd("say /tp", "cmdTeleCp") register_clcmd("say /start", "cmdRestart") register_clcmd("say /pb", "cmdPersonalBest") register_clcmd("say /top", "cmdTop") get_mapname(g_szMap, charsmax(g_szMap)) g_iVault = nvault_open("csb_kz_timer") loadTop() set_task(0.1, "taskHud", _, _, _, "b") } public plugin_end() { if (g_iVault != INVALID_HANDLE) nvault_close(g_iVault) } public client_putinserver(id) { resetRun(id) g_bHasCp[id] = false } resetRun(id) { g_bRunning[id] = false g_fStart[id] = 0.0 g_iJumps[id] = 0 } public fwButtonUse(ent, idcaller, idactivator, use_type, Float:value) { if (!get_pcvar_num(g_pEnabled)) return HAM_IGNORED if (idactivator < 1 || idactivator > 32 || !is_user_alive(idactivator)) return HAM_IGNORED new tname[64] pev(ent, pev_targetname, tname, charsmax(tname)) strtolower(tname) if (contain(tname, "start") != -1) { startRun(idactivator) } else if (contain(tname, "end") != -1 || contain(tname, "finish") != -1 || contain(tname, "counter") != -1) { stopRun(idactivator) } return HAM_IGNORED } startRun(id) { g_bRunning[id] = true g_fStart[id] = get_gametime() g_iJumps[id] = 0 client_print(id, print_center, "Timer started -- go!") } stopRun(id) { if (!g_bRunning[id]) return new Float:elapsed = get_gametime() - g_fStart[id] g_bRunning[id] = false new name[32] get_user_name(id, name, charsmax(name)) new tbuf[16] formatTime(elapsed, tbuf, charsmax(tbuf)) client_print(0, print_chat, "[KZ] %s finished %s in %s (%d jumps).", name, g_szMap, tbuf, g_iJumps[id]) savePersonal(id, elapsed) tryInsertTop(name, elapsed) } public fwPreThink(id) { if (!is_user_alive(id)) return new button = pev(id, pev_button) new oldbutton = pev(id, pev_oldbuttons) new flags = pev(id, pev_flags) // count a take-off: jump pressed this frame, not last, while on ground if ((button & IN_JUMP) && !(oldbutton & IN_JUMP) && (flags & FL_ONGROUND)) { if (g_bRunning[id]) g_iJumps[id]++ } } public cmdSaveCp(id) { if (!is_user_alive(id)) return PLUGIN_HANDLED pev(id, pev_origin, g_fCp[id]) pev(id, pev_v_angle, g_fCpAngles[id]) g_bHasCp[id] = true client_print(id, print_center, "Checkpoint saved.") return PLUGIN_HANDLED } public cmdTeleCp(id) { if (!is_user_alive(id)) return PLUGIN_HANDLED if (!g_bHasCp[id]) { client_print(id, print_chat, "[KZ] No checkpoint saved yet. Use /cp first.") return PLUGIN_HANDLED } set_pev(id, pev_origin, g_fCp[id]) set_pev(id, pev_velocity, Float:{0.0, 0.0, 0.0}) set_pev(id, pev_v_angle, g_fCpAngles[id]) set_pev(id, pev_fixangle, 1) client_print(id, print_center, "Teleported to checkpoint.") return PLUGIN_HANDLED } public cmdRestart(id) { resetRun(id) client_print(id, print_center, "Run reset. Hit the start button to begin.") return PLUGIN_HANDLED } public cmdPersonalBest(id) { new authid[35], key[80], data[16], timestamp get_user_authid(id, authid, charsmax(authid)) formatex(key, charsmax(key), "%s#%s", g_szMap, authid) if (nvault_lookup(g_iVault, key, data, charsmax(data), timestamp)) { new Float:best = str_to_float(data) new tbuf[16] formatTime(best, tbuf, charsmax(tbuf)) client_print(id, print_chat, "[KZ] Your personal best on %s: %s", g_szMap, tbuf) } else { client_print(id, print_chat, "[KZ] You have no recorded time on %s yet.", g_szMap) } return PLUGIN_HANDLED } public cmdTop(id) { if (g_iTopCount == 0) { client_print(id, print_chat, "[KZ] No records on %s yet -- be the first!", g_szMap) return PLUGIN_HANDLED } client_print(id, print_chat, "[KZ] Top times on %s:", g_szMap) for (new i = 0; i < g_iTopCount; i++) { new tbuf[16] formatTime(g_fTopTime[i], tbuf, charsmax(tbuf)) client_print(id, print_chat, " %d. %s -- %s", i + 1, g_szTopName[i], tbuf) } return PLUGIN_HANDLED } savePersonal(id, Float:elapsed) { new authid[35], key[80], data[16], stored[16], timestamp get_user_authid(id, authid, charsmax(authid)) formatex(key, charsmax(key), "%s#%s", g_szMap, authid) new bool:save = true if (nvault_lookup(g_iVault, key, stored, charsmax(stored), timestamp)) { if (str_to_float(stored) <= elapsed) save = false } if (save) { formatex(data, charsmax(data), "%f", elapsed) nvault_set(g_iVault, key, data) client_print(id, print_chat, "[KZ] New personal best saved!") } } tryInsertTop(const name[], Float:elapsed) { // find insertion slot (ascending time) new pos = -1 for (new i = 0; i < g_iTopCount; i++) { if (elapsed < g_fTopTime[i]) { pos = i break } } if (pos == -1) { if (g_iTopCount >= TOP_MAX) return pos = g_iTopCount } if (g_iTopCount < TOP_MAX) g_iTopCount++ // shift down from the end for (new i = g_iTopCount - 1; i > pos; i--) { g_fTopTime[i] = g_fTopTime[i - 1] copy(g_szTopName[i], charsmax(g_szTopName[]), g_szTopName[i - 1]) } g_fTopTime[pos] = elapsed copy(g_szTopName[pos], charsmax(g_szTopName[]), name) saveTop() } saveTop() { // serialise as "time|name;time|name;..." new blob[512], piece[80] blob[0] = 0 for (new i = 0; i < g_iTopCount; i++) { formatex(piece, charsmax(piece), "%f|%s;", g_fTopTime[i], g_szTopName[i]) add(blob, charsmax(blob), piece) } new key[48] formatex(key, charsmax(key), "TOP#%s", g_szMap) nvault_set(g_iVault, key, blob) } loadTop() { g_iTopCount = 0 new key[48], blob[512], timestamp formatex(key, charsmax(key), "TOP#%s", g_szMap) if (!nvault_lookup(g_iVault, key, blob, charsmax(blob), timestamp)) return new record[96], pos = 0 while (g_iTopCount < TOP_MAX && (pos = kzNext(blob, pos, ';', record, charsmax(record)))) { if (!record[0]) continue new bar = contain(record, "|") if (bar < 1) continue new stime[24], sname[32] new copylen = bar < charsmax(stime) ? bar : charsmax(stime) copy(stime, copylen, record) copy(sname, charsmax(sname), record[bar + 1]) g_fTopTime[g_iTopCount] = str_to_float(stime) copy(g_szTopName[g_iTopCount], charsmax(g_szTopName[]), sname) g_iTopCount++ } } // walk a delimited blob; returns new position after the delimiter, 0 at end kzNext(const source[], start, delim, dest[], len) { new slen = strlen(source) if (start >= slen) return 0 new i = 0 while (start < slen && source[start] != delim) { if (i < len) dest[i++] = source[start] start++ } dest[i] = 0 if (start < slen && source[start] == delim) start++ return start } public taskHud() { if (!get_pcvar_num(g_pEnabled)) return for (new id = 1; id <= 32; id++) { if (!is_user_alive(id) || !g_bRunning[id]) continue new Float:elapsed = get_gametime() - g_fStart[id] new tbuf[16] formatTime(elapsed, tbuf, charsmax(tbuf)) set_hudmessage(0, 255, 100, 0.02, 0.25, 0, 0.0, 0.15, 0.0, 0.0, -1) show_hudmessage(id, "Time: %s^nJumps: %d", tbuf, g_iJumps[id]) } } formatTime(Float:seconds, out[], len) { new total_ms = floatround(seconds * 1000.0) new mins = total_ms / 60000 new secs = (total_ms / 1000) % 60 new ms = total_ms % 1000 formatex(out, len, "%02d:%02d.%03d", mins, secs, ms) }