// ╔═══════════════════════════════════════════════════════════════════════════╗ // ║ ChaosEngine-KFS — Keyframe Sequencing Engine ║ // ║ Version 1.0 Public Release ║ // ║ ║ // ║ OpenSim 0.9.x (Yeti, Mono, XEngine, BulletSim, OSSL required) ║ // ║ ║ // ║ Original keyframe ride engine: Satyr Aeon (OpenSimWorld) ║ // ║ ChaosEngine rewrite: Dirty Helga & Spax Orion ║ // ║ ║ // ║ License: CC BY-NC 4.0 ║ // ║ https://creativecommons.org/licenses/by-nc/4.0/ ║ // ║ ║ // ║ You are free to share and adapt this work for non-commercial ║ // ║ purposes with attribution. See license link for full terms. ║ // ╚═══════════════════════════════════════════════════════════════════════════╝ // // ═══════════════════════════════════════════════════════════════════════════ // WHAT IS THIS? // ═══════════════════════════════════════════════════════════════════════════ // // ChaosEngine-KFS is a notecard-driven keyframe motion engine for // OpenSim. It reads waypoints from notecards and plays them back as // smooth, interpolated keyframed motion using llSetKeyframedMotion. // // Named "ChaosEngine" for its fast, smooth, and rapid playback — // ideal for roller coasters, trains, tour carts, boats, and any // vehicle that follows a fixed path. // // ═══════════════════════════════════════════════════════════════════════════ // FEATURES // ═══════════════════════════════════════════════════════════════════════════ // // - Notecard-driven routes with per-waypoint speed and dwell. // - Build mode for recording waypoints in-world. // - Auto-repositioning: move the vehicle, routes adjust. // - Continuous loop mode for trains and ambient vehicles. // - Multi-object sync (master/slave with time-stretch). // - Looped sound during motion. // - Dual stop detection (moving_end + fallback timer). // - Link message broadcasts ("START"/"STOP") for child scripts. // - Touch blocked during motion to prevent mid-route breakage. // // ═══════════════════════════════════════════════════════════════════════════ // QUICK START // ═══════════════════════════════════════════════════════════════════════════ // // 1. RECORD A ROUTE: // - Set BUILD_MODE = TRUE in the configuration section below. // - Rez your vehicle at the starting position. // - Touch the vehicle. You will see: ADD POINT, CLEAR PTS, SAVE CARD // plus any existing notecards. // - Fly/move the vehicle to each waypoint and click ADD POINT. // - When done, click SAVE CARD. A notecard named "RIDE" is created. // - Rename the notecard to something descriptive (e.g., "Track_1"). // - Set BUILD_MODE = FALSE for normal operation. // // 2. NOTECARD FORMAT: // Each line is one waypoint with four pipe-delimited fields: // // | | | // // Example: // <128.0, 128.0, 25.0> | <0.0, 0.0, 0.0, 1.0> | 5 | // <130.0, 128.0, 26.0> | <0.0, 0.0, 0.2, 0.98> | 8 | // <132.0, 130.0, 25.0> | <0.0, 0.0, 0.0, 1.0> | 10 | 3.0 // // - position: region coordinates (vector) // - rotation: full quaternion rotation (rotation) // - speed: meters per second (float). 0 = default 1.0 m/s. // Higher = faster travel between waypoints. // - dwell: optional seconds to pause at this waypoint (float). // Leave empty or 0 for no pause. Requires USE_STATIONS = TRUE. // // Line 0 is the origin reference. The vehicle repositions relative // to line 0, so you can move the vehicle anywhere and routes adjust. // // 3. PLAY A ROUTE: // - Touch the vehicle. All notecards appear as dialog buttons. // - Select a route. The vehicle positions at waypoint 0 and plays. // - Vehicle returns to start position when route completes. // - Touch is ONLY available when the vehicle is stopped at origin. // You CANNOT touch while the vehicle is in motion — this prevents // the dialog from repositioning the car mid-route and breaking KFM. // // 4. MULTIPLE ROUTES: // - Add multiple notecards to the vehicle's inventory. // - Each appears as a button in the touch dialog. // - Example: "Express", "Scenic", "Backwards" // // 5. CONTINUOUS MODE: // - Set CONTINUOUS = TRUE in the configuration section. // - The vehicle loops its route endlessly: plays to the end, // repositions to waypoint 0, dwells, then plays again. // - ORIGIN_DWELL sets how many seconds the vehicle pauses at the // start between loops. Set to 0.0 for no pause (immediate restart). // - USE_STATIONS works alongside continuous mode. The vehicle can // pause at station waypoints AND at origin each loop. This is // ideal for trains that stop at platforms and pause at the // terminal before departing again. // - To stop a continuous loop, type "Stop" on the LISTEN_CHANNEL // in nearby chat. Example: /7713 Stop // - Touch is blocked while the vehicle is in motion. Touch is only // available during the origin dwell pause (if ORIGIN_DWELL > 0) // or after a "Stop" command has been issued. // - In continuous mode, touch during the origin dwell pause shows // the dialog with a "Stop" option so passengers can exit. // // 6. MULTI-OBJECT SYNC: // - One object is IS_MASTER = TRUE, the rest are FALSE. // - All objects share the same SYNC_CHANNEL. // - Master broadcasts its totTime. Slaves receive it. // - If SYNC_STRETCH = TRUE, slaves stretch their timing to match // the master's total duration. This synchronizes objects with // different path lengths. // - If SYNC_STRETCH = FALSE, slaves use master's totTime for // fallback timer only. // // 7. SOUND: // - Place a sound asset in the vehicle's inventory. // - Set RIDE_SOUND to the asset name. // - Sound loops during motion and stops when route completes. // - In continuous mode, sound plays continuously across loops. // // 8. REPOSITIONING: // - Move the vehicle to a new location in-world. // - Reset the script. It captures the new position as home. // - All routes auto-adjust. No re-recording needed. // - Alternatively, type "GO" + notecard name on LISTEN_CHANNEL // to jump to a notecard's origin. Example: /7713 GOTrack_1 // // 9. TIPS: // - More waypoints = smoother curves. Use lots of points on turns. // - Speed 0 in notecard defaults to 1.0 m/s (safety floor). // - For coasters: use high speeds (10-30) on drops, low (2-5) on // climbs. The engine calculates time per segment automatically. // - For trains: set USE_STATIONS = TRUE and CONTINUOUS = TRUE. // Add dwell times at station waypoints. Set ORIGIN_DWELL for // terminal loading time. Train loops forever with station stops. // - The vehicle MUST have OSSL enabled. osGetNotecard is required. // - The vehicle stores its home position on script start. If you // move it manually, reset the script to update home. // - Touch is blocked during motion for safety. Use chat "Stop" // command to halt a moving vehicle. // // ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════ // [CONFIGURATION] — All tunable constants. Edit here only. // ═══════════════════════════════════════════════════════════════════════════ // ── MODE ────────────────────────────────────────────── integer BUILD_MODE = FALSE; // TRUE = authoring mode (ADD POINT etc.) // ── SYNC ────────────────────────────────────────────── integer IS_MASTER = TRUE; // TRUE = this object is sync master integer SYNC_STRETCH = FALSE; // TRUE = stretch animation to master duration integer SYNC_CHANNEL = -990001; // unique per ride group // ── RIDE BEHAVIOR ───────────────────────────────────── float DELAY = 0.0; // global dwell at every waypoint (seconds) float ORIGIN_TOLERANCE = 1.5; // meters — drift diagnostic threshold integer USE_STATIONS = FALSE; // TRUE = honor field 4 dwell times integer LISTEN_CHANNEL = 7713; // dialog response channel // ── CONTINUOUS MODE ─────────────────────────────────── integer CONTINUOUS = FALSE; // TRUE = loop route endlessly float ORIGIN_DWELL = 30.0; // seconds to pause at origin between loops // 0.0 = no pause, immediate restart // You must replace car at origin pos and rotation if you manually stop this // ride or at simulator restart. Do this before starting the tram-train again // Car cannot be touched while in motion. Origin dwell at 30 seconds advised. // more info below... // ── SOUND ───────────────────────────────────────────── string RIDE_SOUND = "Coaster"; // looped sound asset name // ── DIALOG ──────────────────────────────────────────── string DIALOG_MSG = "\nSelect option:\n"; // ═══════════════════════════════════════════════════════════════════════════ // [CONSTANTS] — Fixed values. Do not edit. // ═══════════════════════════════════════════════════════════════════════════ float SETTLE_TIME = 0.2; // seconds — KFM clear settle delay float WP0_FRAME_TIME = 0.1; // seconds — minimal frame for waypoint 0 float FALLBACK_PAD = 3.0; // seconds — added to totTime for safety timer // more info: // use higher fallback_pad in continuous mode if car is not making it to the origin // point. This way people can exit station properly. // ═══════════════════════════════════════════════════════════════════════════ // [RUNTIME VARIABLES] — All mutable state. // ═══════════════════════════════════════════════════════════════════════════ // ── Notecard data ───────────────────────────────────── list points; list rotations; list speeds; list stops; // ── Build mode data ─────────────────────────────────── list newPoints; // ── Repositioning ───────────────────────────────────── vector initPos; rotation initRot; vector offsetPos; rotation offsetRot; // ── Keyframe state ──────────────────────────────────── list kfResult; float totTime; integer rideDone; integer routePending; // ── Sync state ──────────────────────────────────────── float masterTotTime; integer syncListenHandle; // ── Continuous state ────────────────────────────────── integer isRunning; // TRUE while vehicle is in motion or dwelling integer originDwelling; // TRUE during origin dwell pause string activeNotecard; // notecard currently loaded for continuous replay // ── Dialog ──────────────────────────────────────────── integer dCH; // dynamic negative dialog channel integer lHand; // listen handle for dialog // ═══════════════════════════════════════════════════════════════════════════ // [HELPERS] — All named functions. // ═══════════════════════════════════════════════════════════════════════════ // ── parseNotecard ──────────────────────────────────────────────────────── parseNotecard(string cardName) { list lines = llParseString2List(osGetNotecard(cardName), ["\n"], []); integer j; integer count = llGetListLength(lines); points = []; rotations = []; speeds = []; stops = []; for (j = 0; j < count; j++) { string rawLine = llList2String(lines, j); if (llStringLength(llStringTrim(rawLine, STRING_TRIM)) == 0) jump next; list tk = llParseString2List(rawLine, ["|"], []); if (j == 0) { offsetPos = llList2Vector(tk, 0); offsetRot = llList2Rot(tk, 1); } points += (llList2Vector(tk, 0) - offsetPos + initPos) * (initRot / offsetRot); rotations += (llList2Rot(tk, 1) / offsetRot) * initRot; speeds += llList2Float(tk, 2); string stopField = llStringTrim(llList2String(tk, 3), STRING_TRIM); if (llStringLength(stopField) > 0) { stops += (float)stopField; } else { stops += 0.0; } @next; } } // ── buildKeyframes ─────────────────────────────────────────────────────── buildKeyframes() { kfResult = []; totTime = 0.0; integer i; integer count = llGetListLength(points); vector pos = llList2Vector(points, 0); rotation rot = llList2Rot(rotations, 0); for (i = 0; i < count; i++) { vector v = llList2Vector(points, i); rotation r2 = llList2Rot(rotations, i); float t; if (i > 0) { float spd = llList2Float(speeds, i); if (spd < 0.01) spd = 1.0; t = llVecMag(v - pos) / spd; if (t < 0.01) t = 0.01; kfResult += (v - pos); kfResult += (r2 / rot); kfResult += t; } else { kfResult += ZERO_VECTOR; kfResult += (r2 / rot); kfResult += WP0_FRAME_TIME; t = WP0_FRAME_TIME; } float dwellTime = DELAY; if (USE_STATIONS == TRUE) { float stationDwell = llList2Float(stops, i); if (stationDwell > 0.0) { dwellTime = stationDwell; } } if (dwellTime > 0.0) { kfResult += ZERO_VECTOR; kfResult += ZERO_ROTATION; kfResult += dwellTime; t += dwellTime; } totTime += t; pos = v; rot = r2; } } // ── stretchToMaster ────────────────────────────────────────────────────── stretchToMaster() { if (totTime < 0.01) return; float ratio = masterTotTime / totTime; integer i; integer count = llGetListLength(kfResult); for (i = 2; i < count; i += 3) { float t = llList2Float(kfResult, i); if (t < 0.001) jump skipStretch; t = t * ratio; if (t < 0.01) t = 0.01; kfResult = llListReplaceList(kfResult, [t], i, i); @skipStretch; } totTime = masterTotTime; } // ── beginRoute ─────────────────────────────────────────────────────────── beginRoute() { rideDone = FALSE; routePending = TRUE; isRunning = TRUE; originDwelling = FALSE; llSetKeyframedMotion([], []); llSetTimerEvent(SETTLE_TIME); } // ── executeRoute ───────────────────────────────────────────────────────── executeRoute() { routePending = FALSE; vector pos0 = llList2Vector(points, 0); rotation rot0 = llList2Rot(rotations, 0); llSetRegionPos(pos0); llSetRot(rot0); buildKeyframes(); if (IS_MASTER == TRUE) { llRegionSay(SYNC_CHANNEL, (string)totTime); } if (IS_MASTER == FALSE) { if (SYNC_STRETCH == TRUE) { if (masterTotTime > 0.0) { stretchToMaster(); } } } llLoopSound(RIDE_SOUND, 1.0); llSetKeyframedMotion(kfResult, [KFM_DATA, KFM_TRANSLATION | KFM_ROTATION, KFM_MODE, KFM_FORWARD]); llMessageLinked(LINK_SET, 0, "START", ""); float fallbackTime = totTime + FALLBACK_PAD; if (IS_MASTER == FALSE) { if (SYNC_STRETCH == FALSE) { if (masterTotTime > 0.0) { fallbackTime = masterTotTime + FALLBACK_PAD; } } } llSetTimerEvent(fallbackTime); } // ── doStop ─────────────────────────────────────────────────────────────── // Full stop. Stops sound, kills timer, notifies linkset, clears flags. // ────────────────────────────────────────────────────────────────────────── doStop() { llStopSound(); llSetTimerEvent(0); llMessageLinked(LINK_SET, 0, "STOP", ""); isRunning = FALSE; originDwelling = FALSE; } // ── handleRideEnd ──────────────────────────────────────────────────────── // Central ride-completion handler. Called from moving_end (primary) // and timer (fallback). Prevents double-fire via rideDone. // In continuous mode, starts origin dwell or immediate replay. // ────────────────────────────────────────────────────────────────────────── handleRideEnd() { if (rideDone == TRUE) return; rideDone = TRUE; // ── Continuous mode: loop ───────────────────────────────────── if (CONTINUOUS == TRUE) { // Stop KFM but keep running state llSetKeyframedMotion([], []); if (ORIGIN_DWELL > 0.0) { // Pause at origin — stop sound during dwell llStopSound(); originDwelling = TRUE; llSetTimerEvent(ORIGIN_DWELL); } else { // No pause — re-parse and fire immediately // Sound continues across loops originDwelling = FALSE; parseNotecard(activeNotecard); beginRoute(); } return; } // ── Normal mode: full stop ──────────────────────────────────── doStop(); } // ═══════════════════════════════════════════════════════════════════════════ // [STATE: default] — Main operational state. // ═══════════════════════════════════════════════════════════════════════════ default { state_entry() { llSetKeyframedMotion([], []); llSitTarget(ZERO_VECTOR, ZERO_ROTATION); llMessageLinked(LINK_SET, 0, "STOP", ""); llSetCameraEyeOffset(ZERO_VECTOR); llSetCameraAtOffset(ZERO_VECTOR); // Dynamic negative dialog channel dCH = -1 - (integer)("0x" + llGetSubString((string)llGetKey(), -7, -1)); // Listen on both dialog channel and chat channel llListen(dCH, "", "", ""); llListen(LISTEN_CHANNEL, "", "", ""); // Capture home position initPos = llGetPos(); initRot = llGetRot(); // Sync listener if (IS_MASTER == FALSE) { syncListenHandle = llListen(SYNC_CHANNEL, "", "", ""); } // Clear state rideDone = FALSE; routePending = FALSE; isRunning = FALSE; originDwelling = FALSE; masterTotTime = 0.0; newPoints = []; activeNotecard = ""; } on_rez(integer start_parm) { llResetScript(); } listen(integer channel, string name, key sender, string cmd) { // ── Sync: slave receives master totTime ─────────────────── if (channel == SYNC_CHANNEL) { masterTotTime = (float)cmd; return; } // ── Stop command — works on LISTEN_CHANNEL or dialog ────── if (cmd == "Stop") { llSetKeyframedMotion([], [KFM_COMMAND, KFM_CMD_STOP]); doStop(); return; } // ── Only process dialog responses on dCH ────────────────── if (channel != dCH) { return; } // ── Block all other commands while in motion ────────────── if (isRunning == TRUE && originDwelling == FALSE) { return; } // ── BUILD MODE: ADD POINT, SAVE CARD, CLEAR PTS ────────── if (BUILD_MODE == TRUE) { if (cmd == "ADD POINT") { newPoints += llGetPos(); newPoints += llGetRot(); llOwnerSay((string)(llGetListLength(newPoints) / 2) + " points"); return; } if (cmd == "SAVE CARD") { list lines = []; integer i; integer count = llGetListLength(newPoints); for (i = 0; i < count; i += 2) { lines += (string)llList2Vector(newPoints, i) + " | " + (string)llList2Rot(newPoints, i + 1) + " | " + "0" + " | "; } osMakeNotecard("RIDE", lines); llOwnerSay("Notecard Saved"); return; } if (cmd == "CLEAR PTS") { newPoints = []; llOwnerSay("Points cleared."); return; } } // ── GO command — reposition to notecard origin ──────────── if (llGetSubString(cmd, 0, 1) == "GO") { string nc = llGetSubString(cmd, 2, -1); if (llGetInventoryType(nc) == INVENTORY_NOTECARD) { list lines = llParseString2List(osGetNotecard(nc), ["\n"], []); list tk = llParseString2List(llList2String(lines, 0), ["|"], []); vector p = llList2Vector(tk, 0); llSetRegionPos(p); llSetRot(llList2Rot(tk, 1)); initPos = llGetPos(); initRot = llGetRot(); } return; } // ── Notecard selected from dialog — run route ───────────── if (llGetInventoryType(cmd) == INVENTORY_NOTECARD) { // If dwelling at origin in continuous mode, stop dwell and restart if (originDwelling == TRUE) { llSetTimerEvent(0); originDwelling = FALSE; } activeNotecard = cmd; parseNotecard(cmd); beginRoute(); return; } } touch_start(integer n) { // ── Block touch while vehicle is in motion ──────────────── // Touch is ONLY allowed when stopped at origin. // In continuous mode, touch is allowed during origin dwell. if (isRunning == TRUE && originDwelling == FALSE) { return; } list opt = []; integer i; integer ncCount = llGetInventoryNumber(INVENTORY_NOTECARD); for (i = 0; i < ncCount; i++) { opt += llGetInventoryName(INVENTORY_NOTECARD, i); } // Build mode buttons (owner only) if (BUILD_MODE == TRUE) { if (llGetOwner() == llDetectedKey(0)) { opt += "ADD POINT"; opt += "CLEAR PTS"; opt += "SAVE CARD"; } } // In continuous mode during origin dwell, add Stop option if (CONTINUOUS == TRUE && originDwelling == TRUE) { opt += "Stop"; } llDialog(llDetectedKey(0), DIALOG_MSG, opt, dCH); } timer() { // ── Route pending: KFM clear has settled, fire route ────── if (routePending == TRUE) { executeRoute(); return; } // ── Origin dwell complete: replay route ─────────────────── if (originDwelling == TRUE) { originDwelling = FALSE; parseNotecard(activeNotecard); beginRoute(); return; } // ── Fallback stop: KFM may not have fired moving_end ────── if (rideDone == FALSE) { handleRideEnd(); } else { llSetTimerEvent(0); } } // moving_end: primary stop trigger. // Fires when llSetKeyframedMotion completes its list. // On OpenSim this may not fire — fallback timer covers it. moving_end() { if (rideDone == TRUE) return; if (routePending == TRUE) return; handleRideEnd(); } link_message(integer sender_num, integer num, string str, key id) { // Reserved for child script coordination. // This script broadcasts "START" and "STOP" on LINK_SET. // Child scripts can listen for these to trigger effects // such as lights, particles, animations, etc. } } // ═══════════════════════════════════════════════════════════════════════════ // END OF SCRIPT — ChaosEngine-KFS v1.0 Public Release // // License: CC BY-NC 4.0 // https://creativecommons.org/licenses/by-nc/4.0/ // // Credits: // Original keyframe engine: Satyr Aeon (OpenSimWorld) // ChaosEngine rewrite: Dirty Helga & Spax Orion // Part of the IMAGE (IMAGE Matrix Action Game Engine) ecosystem // // For questions, feedback, or to visit the rides that inspired this // engine: xoaox.de:7000:Dismayland // ═══════════════════════════════════════════════════════════════════════════