// IF YOU VALUE THIS WORK, PLEASE LEAVE ATTRIBUTION INTACT // // // // _|_| // // _| _| _|_|_|_| _|_| _|_|_| _|_| // // _| _| _| _| _| _| _| _|_|_|_| // // _| _| _| _| _| _| _| _| // // _|_| _|_|_|_| _|_| _| _| _|_|_| // // MiniVerse // //............................................................// //.....................▒▓▓▒▒▒▒▓▓▓▓▓▓▒▒▒▓▓░....................// //.................._▒▓▒.Ozone.MiniVerse.▒▓▒_.................// //................_▒▓▓▓▒____PRESENTS____▒▓▓▓▓▓▒_..............// //..............░▓▓▓▓▓▓▒___I.M.A.G.E.___▒▓▓▓▓▓▓▓░.............// //.............▒▓▓▓▓▓▓▓▒__IMAGE_MATRIX__▒▓▓▓▓▓▓▓▓▒............// //............▒▓▒▓▓▓▓▒_ACTION_GAME_ENGINE_▒▓▓▓▓▒▓▓▒...........// //..........▒▓▓▓▓░...▒▓▓▒_By_Spax_Orion_▒▓▓▒...░▓▓▓▓▒.........// //.........▒▓▓▓▓░......▒▓_& Dirty Helga_▓▓▒░.....░▒▓▓▓........// //.........▓▓▓▓▒░.._......░▒▓▓▓▓▓▓▓▓▓▓▓▒░......_..░▒▓▓▓.......// //.........▓▓▓▓░.._▓░▓░_....▓▓▓▓▓▓▓▓▓▓...._▒▓▒▓_..░▓▓▓▓.......// //.........▓▓▓▓.._▓█░▒█▒.....▒▓▓▓▓▓▓▒.....▒█▒░█▒_..▓▓▓▓.......// //........▒▓▓▓▓▓▒_..▒._░_▒▓▓▒░.▒▒.░▒▓▓▒_░_.▒.._▒▓▓▓▓▓▓▒.......// //.......▒▓▓▓▓▓▓▓▓▓_...▒▓▓▓▓▓░..▒▒..░▓▓▓▓▓▒..._▓▓▓▓▓▓▓▓▒......// //......▓▓▓▓▓▓▓▓▓▓▓_.▓▓▓▓▓▒.._▓▓▓█_..▒▓▓▓▓▓._▓▓▓▓▓▓▓▓▓▓▓......// //......▓▓▓▓▓▒▒▓▓▓▒▓▓▓▓▓▓▒░.▒▓▓▓▓▓█▒.░▒▓▓▓▓▓▒▒▓▓▓▒▓▓▓▓▓▓......// //......▓▓▓▓▓░.▓▓▓.▒▓▓▓▓▓░..▒▓▓▓▓▓▓▒..░▓▓▓▓▓░.▓▓▓.░▓▓▓▓▓......// //........▒▓▓█░.▓▓▓.▒▓▓▓▓▓__▓_..▒▒.._▓__▓▓▓▓▓░.▓▓▓.▒▓▓▓▒......// //.........░▒░░▒▓▒░▒▓▓▓▓▓▓▒▒▓▒▒▒▓▓▒▒▒▓▒▒▓▓▓▓▓▒░▒▓▒░░▒░░.......// //.........░▒.░▓▓░.▓▓▓▓█▒▒▒█▒▒▒█▒▒█▒▒▒█▒▒▒█▓▓▓▓.░▓▓░.▒░.......// //.............░▓▓░_▓▓▓▓█................█▓▓▓▓_░▓▓░...........// //..............░▒▓▓▓█▓._▒_.█░....█░._█_.▓█▓▓▓▒░..............// //.................░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░................// //.................░▒▓▓▓▒▒▓▒▒▓▓▓▓▒▒▓▓▓▓▒▒▓▓▒░.................// //..................▒▓▓░░▓░░▓▓▓▓░░▓▓▓▓░░▓▓▒...................// //.................▒▓░..█__▓▓▓▓__▓▓▓▓__█..░▓▒.................// //...................░...░..░▓▓░..░▓▓░..░...░.................// //.................... ..░...░▒░....░▒░...░...................// //............................................................// // Common sense not included and must be supplied by end user // // Read ENTIRE script first and LEARN: KNOW what you're using // // ===========================================================// // IMAGE: IMAGE Matrix Action Game Engine // // OpenSim 0.9.0x / Unix Mono / XEngine / OSSL enabled // // ===========================================================// // =========================================================== // CONFIGURATION (OWNER EDIT) // =========================================================== integer USE_IASR = TRUE; // TRUE: broadcast to IASR integer IASR_LOCAL = FALSE; // TRUE: ALSO play locally on board (risk: double audio) integer CH_IASR_CMD = -733102; // MUST match IASR integer EXPO_CH = -777777; // must match ExpoFlare listener channel // Default NPC count (overridden by game modes) integer GRUG_COUNT = 3; // "How far apart" the Grugs try to stay from each other (meters) float GRUG_SPACING = 4.0; // Base distance from the prey (agent) to keep while chasing (meters) float GRUG_RING_RADIUS = 2.8; // How often Grugs update their follow target (seconds). Larger = clunkier/slower. float FOLLOW_UPDATE_SEC = 1.6; // "Clunkiness": 0.2..0.9. Lower = more sluggish/laggy pursuit. float CLUNKINESS = 0.9; // Chase/attack ranges float ATTACK_SENSOR_RANGE = 96.0; float BITE_DISTANCE = 3.0; float MOVE_DISTANCE = 1.0; // Damage / healing float BITE_DAMAGE = 10.0; float LOW_HEALTH_THRESHOLD = 45.0; float HEAL_ON_TELEPORT = 100.0; // NPC "dies" when it has taken this many damage steps integer NPC_DEATH_POINTS = 1; // Sounds / Anims (must exist in inventory / anim set) string DeathAnim = "a.die"; string ZombieWalk = "a.walk"; string IdleAnim = "a.idle"; string AttractAnim = "a.dance"; string SOUND_ALARM = "Alarm"; string SOUND_GROWL = "Growl"; string SOUND_SCREAM2 = "Die2"; string SOUND_SCREAM1 = "Die1"; // Music loops (combat modes) string MUSIC_FAMINE = "Famine"; string MUSIC_HUNGER = "Hunger"; string MUSIC_FEAST = "Feast"; string MUSIC_ATTRACT = "Attract"; string gMusicNow = ""; // Notecard (NPC appearance) name string NPC_NOTECARD = ".grug"; // Teleport destination / landing string Destination; // auto-set to current region on script start vector LandingPoint = <0,0,0>; // bite-respawn target DEATH ZONE AND STAGING vector LookAt = <0, 1, 0>; // face north on bite-respawn arrival // SCOREBOARD LINKSET INTEGRATION string SCORE_PRIM_NAME = "ga-score"; // child prim name integer SCORE_LINKNUM = -1; // resolved at runtime integer SCORE_MSG_NUM = 9001; // arbitrary internal msg number // ARENA BOUNDS (REQUIRED — both corners must be set; no fallback radius) // NE corner holds the ceiling Z; SW corner holds the floor Z. 3D bounding box. vector ArenaNE = <0,0,0>; vector ArenaSW = <0,0,0>; // Kill attribution (slug -> controller HIT feed) integer CH_CAGE_HIT = -733101; // MUST match slug script channel integer HIT_WINDOW_SEC = 10; // last-shooter attribution window // ========================= // ATTRACT MODE (AUTO) // ========================= integer ATTRACT_IDLE_SEC = 40; // spectator idle time to start show float ATTRACT_IDLE_EPS = 0.50; // "idle" movement threshold (meters) float ATTRACT_RING_RADIUS = 8.0; // ring radius around ArenaDROP integer ATTRACT_SHOW_COUNT = 5; // number of show grugs (fixed) // Anchor prim: phantom child prim used as show center. // Name must match prim in linkset; TempGUN searches for the same name. // If missing, fallback position is used. string ARENA_DROP_PRIM = "ArenaDROP"; vector ArenaDropFallback = <0,0,0>; // ArenaDROP prim location (used if prim missing) // =========================================================== // CONSTANTS / TABLES // =========================================================== list Sayings = ["Chomps","Slurrps","Crunches","Nom Noms","Tastes","Bites","Licks","Gnaws","Chews","Grinds","Sucks","Nibbles"]; list Bits = ["Nose","Hand","Arm","Leg","Ass","Head","Foot","Ear","Chest","Stomach","Thighs"]; list TauntsTouch = [ "GRUG SAY NO MENU FOR YOU! KEEP RUNNING!", "TOUCH ALL YOU WANT... GRUG STILL HUNGRY!", "GRUG LIKE WHEN PREY PANIC!", "STOP POKING THING. START BLEEDING INSTEAD!" ]; list TauntsRandom = [ "YOU SMELL LIKE FEAR!", "GRUG THINKS YOU TASTE LIKE CHICKEN", "YUM YUM YUM, TIME TO GET ME SOME", "GRUG COMING. CLUNK CLUNK CLUNK!", "RUN LITTLE PREY!", "GRUG WILL MAKE SOUP OUT OF YOU!" ]; list LastNames = ["...","....","....."]; // =========================================================== // RUNTIME VARIABLES // =========================================================== // Active game state integer gRunning = FALSE; integer gGameSecondsTotal = 0; integer gGameSecondsLeft = 0; integer gWarned30 = FALSE; integer gFeastMode = FALSE; key gTarget = NULL_KEY; // PATCH #2: Player roster // Parallel-list of avatars currently in the arena during a running game. // Refreshed each sensor tick. Used for kill-attribution fallback and // for spawn position selection when the original starter has left. list gRoster; // keys (avatar UUIDs in arena) list gRosterPos; // vectors (last-known positions) // NPC tracking list gNpcs; // keys list gNpcLastNames; // strings list gNpcDeathInc; // integers list gNpcPrevHealth; // floats list gNpcAngles; // floats // Kill attribution per NPC (parallel arrays) list gNpcLastShooter; // keys list gNpcLastHitTime; // integers (unix time) // PATCH #6: Bite cooldown — per-NPC timestamps, replaces llSleep(1.3) in bite branch list gNpcLastBite; // floats (llGetTime() when last bite fired) float BITE_COOLDOWN_SEC = 3.0; // minimum seconds between bites by same Grug // PATCH #6: Deferred removal queue — replaces llSleep(0.8) in npcKilled() // Killed Grugs play death anim/sound, get queued here, and are removed by // the timer tick once their removal time arrives. The script never sleeps. list gPendingNpc; // keys (Grug UUIDs awaiting removal) list gPendingTime; // floats (llGetTime() when removal should fire) float DEATH_ANIM_SEC = 0.8; // seconds death animation plays before removal // Dialog / listeners integer gDialogChan = 0; integer gListenHandle = 0; integer gHitListenHandle = 0; // ========================= // ATTRACT RUNTIME // ========================= integer gAttractMode = FALSE; key gSpecKey = NULL_KEY; // tracked spectator for idle detection vector gSpecLastPos = ZERO_VECTOR; integer gSpecStillSince = 0; // unix time when spectator became "still" integer gAttractEmptySince = 0; // optional: stop show if nobody watching integer gArenaDropLink = -1; // cached link number of ArenaDROP integer gAttractCooldownUntil = 0; // unix time; do not re-arm attract before this // =========================================================== // HELPERS (DEBUG / UTILITY) // =========================================================== integer iasrSend(string msg) { if (!USE_IASR) return FALSE; llRegionSay(CH_IASR_CMD, msg); return TRUE; } integer iasrLoop(string snd, float vol) { // snd may be missing locally (board), but IASR has the inventory. if (!USE_IASR) return FALSE; if (vol < 0.0) vol = 0.0; if (vol > 1.0) vol = 1.0; return iasrSend("LOOP|" + snd + "|" + (string)vol); } integer iasrStop() { if (!USE_IASR) return FALSE; return iasrSend("STOP"); } integer iasrTrig(string snd, float vol) { if (!USE_IASR) return FALSE; if (vol < 0.0) vol = 0.0; if (vol > 1.0) vol = 1.0; return iasrSend("TRIG|" + snd + "|" + (string)vol); } // Use this instead of llTriggerSound so IASR mirrors one-shots. integer trigSound(string snd, float vol) { if (USE_IASR) iasrTrig(snd, vol); if (IASR_LOCAL) llTriggerSound(snd, vol); return TRUE; } integer ExpoFlareSignal(integer on) { // External module toggle: START when any mode active, STOP when idle. if (on) llRegionSay(EXPO_CH, "START"); else llRegionSay(EXPO_CH, "STOP"); return TRUE; } // =========================================================== // BOUNDS HELPERS // =========================================================== integer vecIsZero(vector v) { return (v.x == 0.0 && v.y == 0.0 && v.z == 0.0); } integer pointInRect3D(vector p, vector ne, vector sw) { float minX; float maxX; float minY; float maxY; float minZ; float maxZ; if (ne.x < sw.x) { minX = ne.x; maxX = sw.x; } else { minX = sw.x; maxX = ne.x; } if (ne.y < sw.y) { minY = ne.y; maxY = sw.y; } else { minY = sw.y; maxY = ne.y; } if (ne.z < sw.z) { minZ = ne.z; maxZ = sw.z; } else { minZ = sw.z; maxZ = ne.z; } if (p.x < minX || p.x > maxX) return FALSE; if (p.y < minY || p.y > maxY) return FALSE; if (p.z < minZ || p.z > maxZ) return FALSE; return TRUE; } integer inArena(vector p) { // Bounds required: both NE and SW must be configured. // If either is zero, no point is "in arena" — game cannot operate without bounds. if (vecIsZero(ArenaNE) || vecIsZero(ArenaSW)) return FALSE; return pointInRect3D(p, ArenaNE, ArenaSW); } // =========================================================== // NPC IDLE/STOP TRIPLET (CONSOLIDATED) // =========================================================== // Stop pursuit, ground the NPC, play idle animation. // Replaces three repeated inline triplets in the combat sensor loop. integer npcIdleStop(key npc) { osNpcStopMoveToTarget(npc); osNpcStand(npc); osNpcPlayAnimation(npc, IdleAnim); return TRUE; } // =========================================================== // ACCESS CONTROL (ARENA) // =========================================================== integer canLaunchFromPos(vector p) { // Only allow launch from inside arena. if (!inArena(p)) return FALSE; return TRUE; } integer canLaunchKey(key av) { list od = llGetObjectDetails(av, [OBJECT_POS]); vector p = llList2Vector(od, 0); if (p == ZERO_VECTOR) return FALSE; return canLaunchFromPos(p); } // =========================================================== // ARENA OCCUPANCY (FEAST end logic) // =========================================================== // Uses region-wide avatar list so FEAST doesn't depend on sensor range / sensor events. integer anyAvatarInArena() { // osGetAvatarList() returns a flat list: [key1, name1, pos1, vel1, key2, name2, pos2, vel2, ...] // We'll step by 4 and read the POS slot. list av = osGetAvatarList(); integer i; integer n = llGetListLength(av); for (i = 0; i + 2 < n; i += 4) { vector p = llList2Vector(av, i + 2); // Count any avatar inside the arena rectangle. if (inArena(p)) return TRUE; } return FALSE; } // =========================================================== // NEAREST PREY PICKER (MULTI-PLAYER ARENA) // =========================================================== // Picks the nearest detected avatar (from current sensor pass) // that is inside the arena. key nearestDetectedCombatant(integer num, vector fromPos) { key best = NULL_KEY; float bestD = 999999.0; integer k; for (k = 0; k < num; k++) { vector p = llDetectedPos(k); if (!inArena(p)) jump cont; float d = llVecDist(fromPos, p); if (d < bestD) { bestD = d; best = llDetectedKey(k); } @cont; } return best; } // =========================================================== // COUNTS / PICKERS // =========================================================== integer clampNpcCount(integer c) { if (c < 2) return 2; if (c > 5) return 5; // FEAST uses 5. return c; } string pickUniqueLastName(integer idx) { return llList2String(LastNames, idx % llGetListLength(LastNames)); } key randomNpcKey() { integer n = llGetListLength(gNpcs); if (n <= 0) return NULL_KEY; return llList2Key(gNpcs, (integer)llFrand((float)n)); } // =========================================================== // SOUND + MUSIC // =========================================================== integer playAlarm() { // One-shot: mirror to IASR; optionally play local. if (USE_IASR) iasrTrig(SOUND_ALARM, 1.0); if (IASR_LOCAL && llGetInventoryType(SOUND_ALARM) == INVENTORY_SOUND) { llTriggerSound(SOUND_ALARM, 1.0); return TRUE; } // If local audio is off, still return TRUE because IASR may have played it. return USE_IASR; } integer playMusic(string snd, float vol) { // IASR is the authoritative player when enabled. if (USE_IASR) iasrLoop(snd, vol); // Optional: also loop locally on the board. if (IASR_LOCAL && llGetInventoryType(snd) == INVENTORY_SOUND) { osLoopSound(LINK_THIS, snd, vol); return TRUE; } return USE_IASR; } integer stopMusic() { if (USE_IASR) iasrStop(); // Local stop is harmless even if nothing playing. osStopSound(LINK_THIS); llStopSound(); return TRUE; } // =========================================================== // ATTRACT: IDLE SHOW (NO SPECTATOR INVOLVEMENT) // =========================================================== float gAttractPhase = 0.0; float ATTRACT_STEP_RAD = 0.20; // clockwise speed per tick integer resolveArenaDropLink() { integer i; integer n = llGetNumberOfPrims(); for (i = 1; i <= n; i++) if (llGetLinkName(i) == ARENA_DROP_PRIM) return i; return -1; } vector linkWorldPos(integer linknum) { if (linknum <= 1) return llGetPos(); vector local = llList2Vector(llGetLinkPrimitiveParams(linknum, [PRIM_POS_LOCAL]), 0); return llGetPos() + (local * llGetRot()); } vector arenaDropPos() { if (gArenaDropLink < 0) gArenaDropLink = resolveArenaDropLink(); if (gArenaDropLink > 0) return linkWorldPos(gArenaDropLink); return ArenaDropFallback; } // =========================================================== // SPAWN VALIDITY GUARD (PATCH #2) // =========================================================== // Every Grug spawn position routes through here. If the candidate // is outside the arena, fall back to the show-center (arenaDropPos), // which is guaranteed inside the arena by definition. Last-resort // fallback is the controller position so a spawn can never silently // fail at <0,0,0>. vector validSpawnPos(vector candidate) { if (inArena(candidate)) return candidate; vector drop = arenaDropPos(); if (inArena(drop)) return drop; return llGetPos(); } // =========================================================== // DEFERRED REMOVAL QUEUE DRAIN (PATCH #6) // =========================================================== // Removes any NPCs whose death animation duration has elapsed. // Called from the timer event each tick. Replaces the llSleep(0.8) // that previously blocked the sensor handler in npcKilled(). integer drainPendingRemovals() { integer n = llGetListLength(gPendingNpc); if (n == 0) return TRUE; float now = llGetTime(); integer i = 0; while (i < n) { float t = llList2Float(gPendingTime, i); if (t <= now) { key npc = llList2Key(gPendingNpc, i); if (npc != NULL_KEY) osNpcRemove(npc); gPendingNpc = llDeleteSubList(gPendingNpc, i, i); gPendingTime = llDeleteSubList(gPendingTime, i, i); n--; } else { i++; } } return TRUE; } // =========================================================== // PLAYER ROSTER (PATCH #2) // =========================================================== // Refreshes the roster from a sensor pass: any detected agent inside // the arena is added, those outside or missing are pruned. Called // once per sensor tick from the existing combat sensor handler. integer refreshRoster(integer num) { list newRoster; list newRosterPos; integer k; for (k = 0; k < num; k++) { key ak = llDetectedKey(k); vector ap = llDetectedPos(k); if (inArena(ap)) { newRoster += [ak]; newRosterPos += [ap]; } } gRoster = newRoster; gRosterPos = newRosterPos; return TRUE; } // Returns a roster member (avatar UUID) if any are present, else NULL_KEY. // Preference order: starter (gTarget) if still in roster, else first member. key rosterPickAttribution() { integer n = llGetListLength(gRoster); if (n == 0) return NULL_KEY; if (gTarget != NULL_KEY) { if (llListFindList(gRoster, [gTarget]) >= 0) return gTarget; } return llList2Key(gRoster, 0); } // Returns nearest roster position to a reference point, or ZERO_VECTOR if empty. vector rosterNearestPos(vector ref) { integer n = llGetListLength(gRoster); if (n == 0) return ZERO_VECTOR; integer i; integer bestIdx = 0; float bestD = 999999.0; for (i = 0; i < n; i++) { vector p = llList2Vector(gRosterPos, i); float d = llVecDist(ref, p); if (d < bestD) { bestD = d; bestIdx = i; } } return llList2Vector(gRosterPos, bestIdx); } // Show spawn: no clamp to 2..5 integer spawnNpcShow(integer i, vector basePos, integer showCount) { string last = pickUniqueLastName(i); key npc = osNpcCreate("Grug", last, basePos, NPC_NOTECARD, OS_NPC_OBJECT_GROUP); gNpcs += [npc]; gNpcLastNames += [last]; gNpcDeathInc += [0]; gNpcPrevHealth += [osGetHealth(npc)]; float baseAng = (TWO_PI / (float)showCount) * (float)i; gNpcAngles += [baseAng]; gNpcLastShooter += [NULL_KEY]; gNpcLastHitTime += [0]; gNpcLastBite += [0.0]; // PATCH #6 osNpcStand(npc); osNpcPlayAnimation(npc, AttractAnim); return TRUE; } integer updateAttractCircle() { vector c = arenaDropPos(); integer i; integer n = llGetListLength(gNpcs); for (i = 0; i < n; i++) { key npc = llList2Key(gNpcs, i); if (npc == NULL_KEY) jump cont; float ang = ((TWO_PI / (float)ATTRACT_SHOW_COUNT) * (float)i) + gAttractPhase; vector p = ; if (!inArena(p)) { p = ; } osNpcMoveToTarget(npc, p, OS_NPC_NO_FLY); @cont; } gAttractPhase += ATTRACT_STEP_RAD; if (gAttractPhase > TWO_PI) gAttractPhase -= TWO_PI; return TRUE; } integer startAttract() { if (gRunning) return FALSE; if (gAttractMode) return TRUE; if (gArenaDropLink < 0) gArenaDropLink = resolveArenaDropLink(); if (gArenaDropLink < 0 && vecIsZero(ArenaDropFallback)) { llOwnerSay("IMAGE: Attract refused. ArenaDROP missing and fallback coords are <0,0,0>."); return FALSE; } gAttractMode = TRUE; gAttractPhase = 0.0; ExpoFlareSignal(TRUE); playAlarm(); llSleep(0.8); stopMusic(); playMusic(MUSIC_ATTRACT, 1.0); removeAllNpcs(); vector c = arenaDropPos(); integer i; for (i = 0; i < ATTRACT_SHOW_COUNT; i++) { float ang = (TWO_PI / (float)ATTRACT_SHOW_COUNT) * (float)i; vector p = ; spawnNpcShow(i, p, ATTRACT_SHOW_COUNT); llSleep(0.08); } // drive rotation (timer is also used by game; see timer edit) llSetTimerEvent(1.0); return TRUE; } integer stopAttract(string why) { why = why; // lint if (!gAttractMode) return TRUE; gAttractMode = FALSE; // Cooldown so staging/teleports don't immediately re-arm attract. // Uses ATTRACT_IDLE_SEC as the base unit so you don't need yet another knob. gAttractCooldownUntil = llGetUnixTime() + (ATTRACT_IDLE_SEC * 3); // Reset arming timer so we don't instantly trigger again after cooldown. gSpecStillSince = 0; stopMusic(); removeAllNpcs(); ExpoFlareSignal(FALSE); if (!gRunning) llSetTimerEvent(0.0); return TRUE; } // =========================================================== // NPC LIFECYCLE // =========================================================== integer removeAllNpcs() { integer i; integer n = llGetListLength(gNpcs); for (i = 0; i < n; i++) { key npc = llList2Key(gNpcs, i); if (npc != NULL_KEY) { osNpcStopMoveToTarget(npc); osNpcStand(npc); osNpcRemove(npc); } } gNpcs = []; gNpcLastNames = []; gNpcDeathInc = []; gNpcPrevHealth = []; gNpcAngles = []; gNpcLastShooter = []; gNpcLastHitTime = []; // PATCH #6: clear bite cooldowns; flush any pending death-removal queue. gNpcLastBite = []; integer pn = llGetListLength(gPendingNpc); integer pi; for (pi = 0; pi < pn; pi++) { key pk = llList2Key(gPendingNpc, pi); if (pk != NULL_KEY) osNpcRemove(pk); } gPendingNpc = []; gPendingTime = []; return TRUE; } integer spawnNpc(integer i, vector basePos) { // Combat spawns clamp to 2..5 (FEAST uses 5) GRUG_COUNT = clampNpcCount(GRUG_COUNT); string last = pickUniqueLastName(i); key npc = osNpcCreate("Grug", last, basePos, NPC_NOTECARD, OS_NPC_OBJECT_GROUP); gNpcs += [npc]; gNpcLastNames += [last]; gNpcDeathInc += [0]; gNpcPrevHealth += [osGetHealth(npc)]; float baseAng = (TWO_PI / (float)GRUG_COUNT) * (float)i; baseAng += 0.35; gNpcAngles += [baseAng]; // shooter tracking init gNpcLastShooter += [NULL_KEY]; gNpcLastHitTime += [0]; gNpcLastBite += [0.0]; // PATCH #6 osNpcStand(npc); osNpcPlayAnimation(npc, ZombieWalk); return TRUE; } integer announceFromNpc(key npc, string msg) { if (npc != NULL_KEY) osNpcSay(npc, msg); else llSay(0, msg); return TRUE; } // =========================================================== // CHASE GEOMETRY // =========================================================== vector desiredOffset(integer i, rotation agentRot) { float ang = llList2Float(gNpcAngles, i); vector local = ; local += <0.0, -1.2, 0.0>; vector world = local * agentRot; world.z = 0.0; return world; } vector applySeparation(vector candidatePos, integer selfIdx) { integer n = llGetListLength(gNpcs); integer j; for (j = 0; j < n; j++) { if (j == selfIdx) jump cont; key other = llList2Key(gNpcs, j); if (other == NULL_KEY) jump cont; vector otherPos = osNpcGetPos(other); float d = llVecDist(candidatePos, otherPos); if (d > 0.001 && d < GRUG_SPACING) { vector away = llVecNorm(candidatePos - otherPos); float push = (GRUG_SPACING - d); candidatePos += away * push; } @cont; } return candidatePos; } // =========================================================== // SCOREBOARD LINKING // =========================================================== integer resolveScoreLink() { integer i; integer n = llGetNumberOfPrims(); for (i = 2; i <= n; i++) { if (llGetLinkName(i) == SCORE_PRIM_NAME) return i; } return -1; } integer scoreAgentKill(key avatar) { if (avatar == NULL_KEY) return FALSE; if (SCORE_LINKNUM < 0) SCORE_LINKNUM = resolveScoreLink(); string nm = llKey2Name(avatar); string payload = "KILL|" + (string)avatar + "|" + nm; if (SCORE_LINKNUM > 0) llMessageLinked(SCORE_LINKNUM, SCORE_MSG_NUM, payload, avatar); else llMessageLinked(LINK_SET, SCORE_MSG_NUM, payload, avatar); return TRUE; } integer scoreGrugKill(integer points) { if (points < 0) points = 0; if (SCORE_LINKNUM < 0) SCORE_LINKNUM = resolveScoreLink(); string payload = "GRUG|" + (string)points; if (SCORE_LINKNUM > 0) llMessageLinked(SCORE_LINKNUM, SCORE_MSG_NUM, payload, NULL_KEY); else llMessageLinked(LINK_SET, SCORE_MSG_NUM, payload, NULL_KEY); return TRUE; } // =========================================================== // KILL ATTRIBUTION (slug -> controller HIT feed) // =========================================================== integer npcIndex(key npc) { integer i; integer n = llGetListLength(gNpcs); for (i = 0; i < n; i++) if (llList2Key(gNpcs, i) == npc) return i; return -1; } integer noteNpcHit(key shooter, key victimNpc) { integer idx = npcIndex(victimNpc); if (idx < 0) return FALSE; if (!osIsNpc(victimNpc)) return FALSE; integer now = llGetUnixTime(); gNpcLastShooter = llListReplaceList(gNpcLastShooter, [shooter], idx, idx); gNpcLastHitTime = llListReplaceList(gNpcLastHitTime, [now], idx, idx); return TRUE; } key getKillAttribution(integer idx) { integer now = llGetUnixTime(); key shooter = llList2Key(gNpcLastShooter, idx); integer t = llList2Integer(gNpcLastHitTime, idx); if (shooter != NULL_KEY && (now - t) <= HIT_WINDOW_SEC) return shooter; // PATCH #2: fall back to roster (starter if present, else first member, // else NULL_KEY for unattributed kills like physics-driven damage). return rosterPickAttribution(); } // =========================================================== // GAME START / END / NPC KILLED // =========================================================== integer startGame(integer minutes, key target) { // =========================================================== // BOUNDS-REQUIRED GUARD (PATCH #1) // =========================================================== // Refuse to start a game if arena bounds aren't configured. // Operator gets the welcome lecture; the touching player gets a // simpler note so they don't think they did something wrong. if (vecIsZero(ArenaNE) || vecIsZero(ArenaSW)) { llOwnerSay("WELCOME TO IMAGE: Please configure your software before attempting to utilize it. Read all scripts and documentation or enjoy reading more messages like this. READ EACH section carefully, don't just skim over it. YOU MIGHT JUST LEARN SOMETHING!"); llInstantMessage(target, "This IMAGE arena is not configured yet. Please notify the region owner."); return FALSE; } // Hard gate: no starts from out-of-arena. list _od0 = llGetObjectDetails(target, [OBJECT_POS]); vector tpos0 = llList2Vector(_od0, 0); if (tpos0 == ZERO_VECTOR) return FALSE; if (!canLaunchFromPos(tpos0)) { llInstantMessage(target, "GRUG SAY: YOU NOT IN ARENA. COME FEAST WITH GRUG."); return FALSE; } gRunning = TRUE; if (gAttractMode) stopAttract("START GAME"); ExpoFlareSignal(TRUE); gTarget = target; gFeastMode = (minutes <= 0); gGameSecondsTotal = minutes * 60; gGameSecondsLeft = gGameSecondsTotal; gWarned30 = FALSE; // start alarm playAlarm(); removeAllNpcs(); // PATCH #7: All Grugs originate at ArenaDROP. They spread around it // in a ring matching attract mode (ATTRACT_RING_RADIUS). No more // player-anchored spawns — eliminates spawn-in-wall and spawn-on-top // bugs. Players dropped at ArenaDROP have time to move before triggering. vector dropC = arenaDropPos(); integer i; for (i = 0; i < GRUG_COUNT; i++) { float ang = (TWO_PI / (float)GRUG_COUNT) * (float)i; vector p = ; // Keep validity guard as defensive belt-and-suspenders. p = validSpawnPos(p); spawnNpc(i, p); llSleep(0.25); } key speaker = randomNpcKey(); if (gFeastMode) { announceFromNpc(speaker, "FEAST MODE! ENDLESS BLOODBATH! RUN PREY!"); playMusic(MUSIC_FEAST, 1.0); } else { announceFromNpc(speaker, "GRUG ASSAULT START! " + (string)minutes + " MINUTES! RUN PREY!"); if (minutes <= 3) playMusic(MUSIC_FAMINE, 1.0); else playMusic(MUSIC_HUNGER, 1.0); } llSensorRemove(); llSensorRepeat("", "", AGENT, ATTACK_SENSOR_RANGE, PI, FOLLOW_UPDATE_SEC); llSetTimerEvent(1.0); return TRUE; } integer endGame(string reason) { // end alarm playAlarm(); key speaker = randomNpcKey(); announceFromNpc(speaker, "GRUG ASSAULT OVER! " + reason); // Return to idle behavior: keep sensor + timer alive for auto-attract. llSetTimerEvent(1.0); llSensorRemove(); llSensorRepeat("", "", AGENT, ATTACK_SENSOR_RANGE, PI, 2.0); // reset idle-attract arming timestamp gSpecStillSince = 0; removeAllNpcs(); gRunning = FALSE; gTarget = NULL_KEY; ExpoFlareSignal(FALSE); gFeastMode = FALSE; // PATCH #2: clear roster on game end. gRoster = []; gRosterPos = []; stopMusic(); return TRUE; } integer npcKilled(integer i) { // attribute kill to last shooter (10s) else gTarget key scorer = getKillAttribution(i); if (scorer != NULL_KEY) scoreAgentKill(scorer); key npc = llList2Key(gNpcs, i); if (npc != NULL_KEY) { osNpcStopMoveToTarget(npc); osNpcStand(npc); osNpcPlayAnimation(npc, DeathAnim); trigSound(SOUND_SCREAM1, 1.0); // PATCH #6: queue NPC for removal after death animation plays // instead of llSleep(0.8) blocking the sensor handler. Timer drains. gPendingNpc += [npc]; gPendingTime += [llGetTime() + DEATH_ANIM_SEC]; } gNpcs = llDeleteSubList(gNpcs, i, i); gNpcLastNames = llDeleteSubList(gNpcLastNames, i, i); gNpcDeathInc = llDeleteSubList(gNpcDeathInc, i, i); gNpcPrevHealth = llDeleteSubList(gNpcPrevHealth, i, i); gNpcAngles = llDeleteSubList(gNpcAngles, i, i); gNpcLastShooter = llDeleteSubList(gNpcLastShooter, i, i); gNpcLastHitTime = llDeleteSubList(gNpcLastHitTime, i, i); gNpcLastBite = llDeleteSubList(gNpcLastBite, i, i); // PATCH #6 if (gRunning && gTarget != NULL_KEY) { // PATCH #7: Replacement Grugs originate at ArenaDROP. No more // player-position anchoring, no jitter — known-good location, // Grug walks toward prey from there. Eliminates spawn-in-wall. vector p = arenaDropPos(); integer newIdx = llGetListLength(gNpcs); spawnNpc(newIdx, p); key speaker = randomNpcKey(); announceFromNpc(speaker, "GRUG FALL DOWN... NEW GRUG COME UP, HA HA!"); } return TRUE; } // =========================================================== // UI MENU (GAME MODE SELECT) // =========================================================== integer showMenu(key who) { list buttons = ["FAMINE", "HUNGER", "FEAST"]; llDialog( who, "\nYOU HUNT GRUGS OR HUNGRY GRUGS HUNT YOU!\n \nTouch ENTER ARENA - You will be sent to the staging area where you need to accept permissions to get your invisible tracking weapon. Confirm link by firing weapon with LMB in mouselook.\n \nGrug Defaults: 2 Shots needed to kill Grug. The good news is that players earn ONE point per KILL. The bad news is that Grugs score more points because clever humans are harder to KILL!\n \nSelect Game Mode and HAPPY SCREAMING, PREY!\n \n", buttons, gDialogChan ); return TRUE; } // =========================================================== // STATE: DEFAULT (GAME CORE) // =========================================================== default { state_entry() { Destination = llGetRegionName(); // =========================================================== // BOUNDS-REQUIRED GUARD (PATCH #1) // =========================================================== // ArenaNE and ArenaSW must both be configured before the engine // will function. There is no fallback radius. If either corner is // <0,0,0>, the operator gets the lecture and the game is inert // until configuration is corrected. if (vecIsZero(ArenaNE) || vecIsZero(ArenaSW)) { llOwnerSay("WELCOME TO IMAGE: Please configure your software before attempting to utilize it. Read all scripts and documentation or enjoy reading more messages like this. READ EACH section carefully, don't just skim over it. YOU MIGHT JUST LEARN SOMETHING!"); } SCORE_LINKNUM = resolveScoreLink(); vector pos = llGetPos(); gDialogChan = (integer)(-pos.x * pos.y); if (gDialogChan == 0) gDialogChan = -500123; if (gListenHandle) llListenRemove(gListenHandle); gListenHandle = llListen(gDialogChan, "", NULL_KEY, ""); // slug HIT feed if (gHitListenHandle) llListenRemove(gHitListenHandle); gHitListenHandle = llListen(CH_CAGE_HIT, "", NULL_KEY, ""); llPreloadSound(SOUND_ALARM); llPreloadSound(SOUND_GROWL); llPreloadSound(SOUND_SCREAM2); llPreloadSound(SOUND_SCREAM1); gRunning = FALSE; gTarget = NULL_KEY; gFeastMode = FALSE; // Idle systems: keep sensor + timer alive so auto-attract can work. llSetTimerEvent(1.0); llSensorRemove(); llSensorRepeat("", "", AGENT, ATTACK_SENSOR_RANGE, PI, 2.0); removeAllNpcs(); stopMusic(); ExpoFlareSignal(FALSE); // idle-attract arming timestamp gSpecStillSince = 0; } changed(integer change) { if (change & CHANGED_REGION_START) llResetScript(); if (change & CHANGED_INVENTORY) llResetScript(); if (change & CHANGED_LINK) llResetScript(); } touch_start(integer total_number) { total_number = total_number; // lint key toucher = llDetectedKey(0); // Idle: open game menu (combatants only) if (!gRunning) { vector tp = llDetectedPos(0); // No menu for spectators, and no menu outside arena. if (!canLaunchFromPos(tp)) { // Keep it simple: no dialog, no state changes. // Optional: swap llInstantMessage for announceFromNpc if you want Grug to mock them. llInstantMessage(toucher, "GRUG SAY: SPECTATORS NO PUSH BUTTONS. GO TO ARENA. FIGHT OR BE FOOD."); return; } showMenu(toucher); return; } // Running: taunt if (gRunning) { key speaker = randomNpcKey(); string t = llList2String(TauntsTouch, (integer)llFrand((float)llGetListLength(TauntsTouch))); announceFromNpc(speaker, t); } } listen(integer channel, string name, key id, string message) { name = name; // lint // HIT feed (slug -> controller) if (channel == CH_CAGE_HIT) { // Expected: HIT|| list parts = llParseString2List(message, ["|"], []); if (llGetListLength(parts) == 3 && llList2String(parts, 0) == "HIT") { key shooter = (key)llList2String(parts, 1); key victim = (key)llList2String(parts, 2); if (shooter != NULL_KEY && victim != NULL_KEY) noteNpcHit(shooter, victim); } return; } // Dialog selection if (channel != gDialogChan) return; if (gRunning) return; // block while running // Failsafe: even if someone got a dialog, do not allow start from spectator/outside arena. if (!canLaunchKey(id)) { llInstantMessage(id, "GRUG SAY: NO START FROM SPECTATOR ZONE. GO TO ARENA. BE MY NEXT MEAL."); return; } if (message == "FAMINE") { GRUG_COUNT = 2; startGame(3, id); return; } if (message == "HUNGER") { GRUG_COUNT = 3; startGame(5, id); return; } if (message == "FEAST") { GRUG_COUNT = 5; startGame(0, id); // 0 => feast mode (continuous) return; } } sensor(integer num) { // LSL: declarations must come before statements in the same block list _od; // ATTRACT running: stop if combatant enters arena if (gAttractMode) { integer k; for (k = 0; k < num; k++) { vector ap = llDetectedPos(k); if (inArena(ap)) { stopAttract("COMBATANT"); return; } } return; } // IDLE: if anyone is around (any detection), start attract after ATTRACT_IDLE_SEC if (!gRunning) { // Cooldown: do not re-arm attract if (llGetUnixTime() < gAttractCooldownUntil) { gSpecStillSince = 0; return; } // Use first detected agent as "presence" if (num > 0) { if (gSpecStillSince == 0) gSpecStillSince = llGetUnixTime(); if ((llGetUnixTime() - gSpecStillSince) >= ATTRACT_IDLE_SEC) { startAttract(); return; } } else { gSpecStillSince = 0; } return; } // ---- COMBAT continues ---- if (gTarget == NULL_KEY) return; // Do we have ANY valid combatant detected this tick? integer hasCombatant = FALSE; integer k2; for (k2 = 0; k2 < num; k2++) { vector ap2 = llDetectedPos(k2); if (inArena(ap2)) { hasCombatant = TRUE; } } // FEAST ends when arena has no avatars. // Timed modes simply idle if nobody is detected. if (!hasCombatant) { if (gFeastMode) { endGame("NO PREY LEFT!"); } return; } integer i; integer n = llGetListLength(gNpcs); // LSL: declare locals before executable statements -compiler goof list od; vector tpos; rotation trot; key prey; vector npcPos; float preyHealth; // PATCH #2: refresh player roster from this sensor pass before NPC loop. refreshRoster(num); for (i = 0; i < n; i++) { key npc = llList2Key(gNpcs, i); if (npc == NULL_KEY) jump nextNpc; npcPos = osNpcGetPos(npc); // Pick nearest detected prey to THIS NPC. prey = nearestDetectedCombatant(num, npcPos); if (prey == NULL_KEY) { npcIdleStop(npc); jump nextNpc; } // Pull prey pos/rot in one go. od = llGetObjectDetails(prey, [OBJECT_POS, OBJECT_ROT]); tpos = llList2Vector(od, 0); trot = llList2Rot(od, 1); if (tpos == ZERO_VECTOR) { // Prey vanished this tick. npcIdleStop(npc); jump nextNpc; } // Arena enforcement per prey. if (!inArena(tpos)) { npcIdleStop(npc); jump nextNpc; } preyHealth = osGetHealth(prey); float curNpcHealth = osGetHealth(npc); float prevNpcHealth = llList2Float(gNpcPrevHealth, i); if (curNpcHealth < prevNpcHealth - 0.1) { integer di = llList2Integer(gNpcDeathInc, i) + 1; gNpcDeathInc = llListReplaceList(gNpcDeathInc, [di], i, i); if (di <= NPC_DEATH_POINTS) { trigSound(SOUND_SCREAM2, 1.0); // PATCH #6: dropped llSleep(0.2) between IdleAnim and ZombieWalk. // Grug recovers visibly in same tick; no event-handler block. osNpcPlayAnimation(npc, ZombieWalk); } if (di >= NPC_DEATH_POINTS) { npcKilled(i); n = llGetListLength(gNpcs); i = -1; jump loopContinue; } } gNpcPrevHealth = llListReplaceList(gNpcPrevHealth, [curNpcHealth], i, i); vector want = tpos + desiredOffset(i, trot); want = applySeparation(want, i); want.z = tpos.z; vector step = npcPos + ((want - npcPos) * CLUNKINESS); float dist = llVecDist(npcPos, tpos); if (dist > MOVE_DISTANCE) { osNpcMoveToTarget(npc, step, OS_NPC_NO_FLY); } float biteDist = llVecDist(npcPos, tpos); if (biteDist < BITE_DISTANCE) { // PATCH #6: per-Grug bite cooldown replaces llSleep(1.3). // Each Grug can bite at most once per BITE_COOLDOWN_SEC. // The sensor handler never sleeps. Damage rate held constant. float lastBite = llList2Float(gNpcLastBite, i); float nowT = llGetTime(); if ((nowT - lastBite) >= BITE_COOLDOWN_SEC) { if (preyHealth <= LOW_HEALTH_THRESHOLD) { //NEXT LINE AFFECTS GRUGS SCORE integer grugPts = 1 + (integer)llFrand(3.0); // 1..2 scoreGrugKill(grugPts); announceFromNpc(npc, "SCORE ONE FOR GRUGS! COME BACK FOR REVENGE, PREY!"); osTeleportAgent(prey, Destination, LandingPoint, LookAt); osCauseHealing(prey, HEAL_ON_TELEPORT); preyHealth = osGetHealth(prey); } string s1 = llList2String(Sayings, (integer)llFrand((float)llGetListLength(Sayings))); string s2 = llList2String(Bits, (integer)llFrand((float)llGetListLength(Bits))); announceFromNpc(npc, s1 + " " + llKey2Name(prey) + "'s " + s2); osCauseDamage(prey, BITE_DAMAGE); gNpcLastBite = llListReplaceList(gNpcLastBite, [nowT], i, i); } } @nextNpc; @loopContinue; } if (llFrand(2.0) >= 1.2) trigSound(SOUND_GROWL, 1.0); } no_sensor() { // FEAST must end even when sensors detect nothing (sensor() won't fire). if (gRunning && gFeastMode) { if (!anyAvatarInArena()) { endGame("NO PREY LEFT!"); return; } } } timer() { // PATCH #6: drain queued NPC removals every tick (no llSleep needed) drainPendingRemovals(); if (gAttractMode) { updateAttractCircle(); return; } // IDLE: arm attract, then exit. Do NOT run combat countdown here. if (!gRunning) { // Cooldown: prevents re-start while players are staging/teleporting. if (llGetUnixTime() < gAttractCooldownUntil) return; if (!gAttractMode) { if (gSpecStillSince == 0) gSpecStillSince = llGetUnixTime(); if ((llGetUnixTime() - gSpecStillSince) >= ATTRACT_IDLE_SEC) { startAttract(); } } return; } // ---- COMBAT TIMER ---- // Timed modes only; FEAST has no countdown end. if (!gFeastMode) { gGameSecondsLeft--; if (!gWarned30 && gGameSecondsLeft == 30) { gWarned30 = TRUE; key speaker = randomNpcKey(); announceFromNpc(speaker, "THIRTY SECONDS LEFT, PREY! GRUG HUNGRY!"); } if (gGameSecondsLeft <= 0) { endGame("TIME UP!"); return; } } if (llFrand(1.0) < 0.10) { key speaker2 = randomNpcKey(); string t = llList2String(TauntsRandom, (integer)llFrand((float)llGetListLength(TauntsRandom))); announceFromNpc(speaker2, t); } } }