commit 31443b091f4e7c309def600ada9c50e86a6e1eeb Author: mita Date: Thu May 7 06:10:45 2026 +0200 Add user manual for HS DollyCam and FOV extension; introduce project context for AI agents - Created HS DollyCam HUD user manual (v1.3.0) detailing features, setup, commands, and troubleshooting. - Added FOV extension manual outlining FOV commands, usage in playlists, and dollyzoom functionality. - Introduced project context file for AI agents, specifying technology stack, critical implementation rules, and existing patterns. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f2ad57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,249 @@ +# HS_DollyCam Project Information + +This repository contains LSL (Linden Scripting Language) scripts for **HS_DollyCam**, a professional camera control system for Second Life. + +The project is highly memory-sensitive. LSL script memory is limited, and large lists, mixed-type lists, and repeated string splitting can create avoidable memory pressure. Prefer targeted parsing and short-lived data where practical. + +## Core Components + +The system is split across specialized scripts that communicate mostly via `llMessageLinked`. + +### 1. `HS_CamController.lsl` (Main Controller) + +The central command and state coordinator. + +- Handles chat commands and menu commands. +- Coordinates playlist, menu, marker, FOV, and engine scripts. +- Saves presets into Linkset Data. +- Handles marker click events and forwards camera movement requests. +- Maintains FOLLOW and LOCK state in Linkset Data. + +Important note: movement math and continuous tour playback are not owned entirely by the Controller anymore. Controller dispatches movement/tour commands to helper scripts and engine scripts. + +### 2. `HS_CamPlaylist.lsl` (Playlist Manager) + +Primary script for notecard-driven workflows. + +- Reads playlist notecards line by line. +- Uses `llGetNotecardLineSync` when the simulator has the notecard cached, with fallback to `llGetNotecardLine` on `NAK`. +- Handles `moveto`, `goto`, `load`, `wait`, `tour ... endtour`, `dollyzoom`, `fov`, `lock`, `follow`, and marker show/hide commands. +- Builds tour payloads and sends them to `HS_CamEngineTour.lsl` via `CE_CMD_TOUR`. +- For notecard tour blocks, the builder stores preset indices, holds, and weights, then loads position/focus only at `endtour`. +- Also supports compact notecard tours (`tour [mode] `) to avoid slow multi-line notecard reads when no holds/weights are needed. +- Compact notecard tours add an internal `startfirst` mode flag so `HS_CamEngineTour.lsl` starts from the first preset without a current-camera-state roundtrip. +- `loadPreset()` intentionally uses targeted field parsing instead of splitting the full preset string into a list. +- Standard notecard command parsing uses token helpers rather than full `llParseString2List(line, [" "], [])`. + +The user's primary workflow is Notecard Tours, so optimize this file carefully and prioritize notecard-tour memory use over chat/menu one-liner convenience. + +### 3. `HS_CamTourCommands.lsl` (Secondary Tour Command Helper) + +Dedicated helper for chat/menu one-shot tour builders. + +- Handles `/88 tour ...`, `/88 dollyzoom ...`, and menu-built `TOURRUN` payloads. +- Builds `CE_CMD_TOUR` payloads for secondary workflows without adding memory pressure to `HS_CamPlaylist.lsl`. +- Uses targeted preset parsing for position/focus/FOV fields. +- Keep notecard playlist logic in `HS_CamPlaylist.lsl`; keep chat/menu convenience parsing here. + +### 4. `HS_CamEngineTour.lsl` (Continuous Tour Runtime) + +Dedicated runtime for continuous multi-waypoint camera tours. + +- Receives `CE_CMD_TOUR` payloads from Playlist and TourCommands paths. +- Requests current camera state from Core via `CE_CMD_GET_STATE`. +- Builds runtime lists for active tour playback: position, focus, holds, segment lengths, cumulative lengths, point times, and optional weights. +- Drives the camera by sending `CE_INT_SET_CAM` to `HS_CamEngineCore.lsl`. +- Supports spline/linear movement, easing profiles, FOV ramps, and keep-frame mode. +- Uses throttled camera-frame sending via `tourSendCam()` to reduce repeated `CE_INT_SET_CAM` messages. +- Uses segment caching during timer playback to reduce repeated `llList2Vector` access. +- Parses large `CE_CMD_TOUR` payloads using targeted pipe-field helpers rather than splitting the whole payload list. + +Do not move active tour runtime lists into Linkset Data. They are read every timer tick; Linkset Data would reduce script memory but likely hurt runtime performance due to repeated string reads and conversions. + +### 5. `HS_CamEngineCore.lsl` (Camera Engine Core) + +Low-level camera engine. + +- Handles camera permissions, base camera params, MoveTo, Follow, Lock, config, and state queries. +- Receives internal tour camera frames with `CE_INT_SET_CAM`. +- `CE_INT_SET_CAM` is parsed without `llParseString2List` because it is part of the active tour hot path. +- Applies `llSetCameraParams` for active camera movement. +- Holds engine configuration and emits config dumps consumed by Playlist and TourEngine. + +Tour-related config keys include: + +- `tour_max_points` +- `tour_cam_min_interval` +- `tour_pos_epsilon` +- `tour_focus_epsilon` + +### 6. `HS_CamMenu.lsl` (Menu/UI) + +Handles dialog menus, page state, menu-driven tour building, nearby target lists, and UI command dispatch. + +- Sends menu commands with `MN_CMD`. +- Keeps short UI lists such as nearby avatars/objects and selected tour indices. +- Menu paths are secondary compared with notecard tours. + +### 7. `HS_CamMarkers.lsl` (Marker Helper) + +Marker support is a comparatively rare workflow. + +- Rezzes `HS_CamMarker` objects at preset positions. +- Uses `MARKER_CH = -880088`. +- Uses `llRegionSayTo` for marker setup/cleanup when marker keys are known. +- Keeps a mixed marker list internally. This is known to be less memory-efficient, but marker usage is rare and should not be prioritized unless marker workflows become important. + +### 8. `HS_CamFov.lsl` + +Handles Field of View adjustments and FOV-related state synchronization. + +## Communication Protocol + +Primary communication uses `llMessageLinked`. + +Important constants: + +- `MC_CMD = 5100`: Marker helper command channel, e.g. `SHOW|N`, `HIDE`. +- `MC_EVT_CLICK = 5101`: Marker click event from Markers to Controller. +- `MN_CMD = 7000`: Menu command channel. +- `PH_CMD_PLAY = 6100`: Controller/Menu to Playlist play command. +- `PH_CMD_STOP = 6101`: Stop playlist command. +- `PH_CMD_CHAT_TOUR = 6102`: Chat one-liner tour forwarded to TourCommands. +- `PH_CMD_TOURRUN = 6103`: Menu-built tour forwarded to TourCommands. +- `PH_CMD_CHAT_DZ = 6104`: Chat DollyZoom forwarded to TourCommands. +- `CE_CMD_MOVE = 1010`: Engine move command. +- `CE_CMD_TOUR = 1011`: Continuous tour command handled by TourEngine. +- `CE_CMD_STOP = 1012`: Stop engine movement. +- `CE_CMD_LOCK = 1020`: Lock command. +- `CE_CMD_FOLLOW = 1030`: Follow command. +- `CE_CMD_FOV = 1040`: FOV command. +- `CE_CMD_GET_STATE = 1060`: Request current camera state. +- `CE_INT_SET_CAM = 3000`: TourEngine to Core camera frame, payload `|`. +- `CE_INT_TOUR_BEGIN = 3001`: TourEngine begins external drive. +- `CE_INT_TOUR_END = 3002`: TourEngine ends external drive. +- `CE_INT_TOUR_STOP = 3003`: Stop external tour drive. +- `MARKER_CH = -880088`: Region channel for marker communication. + +## Preset Storage + +Presets are stored in Linkset Data using keys like `P1`, `P2`, etc. + +Preset format: + +```text +px|py|pz|fx|fy|fz|rx|ry|rz|rs|fovRad +``` + +Many consumers only need the first six fields: + +- position: fields `0..2` +- focus: fields `3..5` +- optional FOV: field `10` + +When reading presets in memory-sensitive code, avoid full `llParseString2List(data, ["|"], [])` if only these fields are needed. + +## Performance And Memory Guidelines + +### Prefer targeted parsing in hot paths + +Avoid full `llParseString2List` when: + +- parsing `CE_INT_SET_CAM` +- parsing preset data +- parsing large `CE_CMD_TOUR` payloads +- parsing normal notecard playlist lines + +Use targeted helpers such as: + +- `lineToken()` in `HS_CamPlaylist.lsl` +- pipe-field helpers in `HS_CamEngineTour.lsl` +- direct separator lookup for two-field payloads in `HS_CamEngineCore.lsl` + +### Keep active runtime data in script memory + +Active tour playback reads position/focus/segment lists every timer tick. Keep those lists in `HS_CamEngineTour.lsl`; do not replace them with Linkset Data reads. + +Linkset Data is appropriate for persistent presets and shared state, not per-frame runtime data. + +### Minimize mixed-type and large temporary lists + +Avoid mixed-type list builders for large payloads where a direct string build is clear and safe. + +For notecard tour blocks: + +- Store preset indices while parsing. +- Load preset position/focus at `endtour`. +- Build the final `CE_CMD_TOUR` payload directly. + +### Be careful with timer hot paths + +Timer-driven code should avoid: + +- full string splitting +- repeated Linkset Data reads +- repeated object detail calls unless needed +- unnecessary `llMessageLinked` calls + +Tour playback currently reduces load with: + +- `tour_cam_min_interval` +- `tour_pos_epsilon` +- `tour_focus_epsilon` +- segment caching in TourEngine + +### Use `llRegionSayTo` when target keys are known + +For marker communication, prefer `llRegionSayTo` over `llRegionSay` when the marker key is known. This reduces unnecessary listener wakeups in the region. + +## Configuration + +Engine configuration lives in `HS_CamEngine.properties`. + +Useful current keys: + +```text +move_step=0.025 +follow_step=0.05 +default_move_ms=3000 +default_focus_dist=10.0 +move_pos_lag=0.5 +move_focus_lag=0.5 +follow_pos_lag=0.5 +follow_focus_lag=0.5 +pos_threshold=0.02 +focus_threshold=0.02 +tour_cam_min_interval=0.033 +tour_pos_epsilon=0.005 +tour_focus_epsilon=0.005 +follow_predict=0.10 +tour_max_points=20 +``` + +`tour_cam_min_interval=0.033` caps internal tour camera updates around 30 Hz while still allowing path computation at `move_step`. + +## Validation Notes + +There is no official local Second Life LSL compiler in this repository. The authoritative compile check is still in-world via the Second Life Viewer or Firestorm. + +Local checks that are useful before in-world compile: + +- brace-balance checks +- conflict marker search +- targeted `rg` scans for unwanted `llParseString2List` reintroductions in hot paths +- optional `lslint` if installed, with the understanding that it is not the official Linden compiler + +## Current Optimization Priorities + +Highest priority: + +- Notecard tour memory behavior. +- TourEngine timer hot path. +- Core `CE_INT_SET_CAM` hot path. +- Large `CE_CMD_TOUR` startup parsing. + +Lower priority: + +- Marker memory layout, because marker use is rare. +- Chat one-liner tour convenience paths. +- Menu-only tour building paths, unless they become a primary workflow. diff --git a/HS_CamController.lsl b/HS_CamController.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamController.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_CamEngine.properties b/HS_CamEngine.properties new file mode 100644 index 0000000..91ef434 --- /dev/null +++ b/HS_CamEngine.properties @@ -0,0 +1,30 @@ +# --- Ultra-cinematic baseline --- +move_step=0.025 +follow_step=0.05 + +default_move_ms=3000 +default_focus_dist=10.0 + +# Viewer smoothing (move) +move_pos_lag=0.5 +move_focus_lag=0.5 + +# Viewer smoothing (follow/idle) +follow_pos_lag=0.5 +follow_focus_lag=0.5 + +# Deadzone to reduce micro-jitter +pos_threshold=0.02 +focus_threshold=0.02 + +# TourEngine internal update throttle. +# 0.033 ~= 30 Hz camera updates while still computing the path at move_step. +tour_cam_min_interval=0.033 +tour_pos_epsilon=0.005 +tour_focus_epsilon=0.005 + +# Follow smoothing aid +follow_predict=0.10 + +# increasing value might cause memory anomalies +tour_max_points=20 diff --git a/HS_CamEngineCore.lsl b/HS_CamEngineCore.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamEngineCore.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_CamEngineTour.lsl b/HS_CamEngineTour.lsl new file mode 100644 index 0000000..f135392 --- /dev/null +++ b/HS_CamEngineTour.lsl @@ -0,0 +1,989 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} + gPendHoldMs = holdIn; + gPendWeight = wIn; + + // NEW: pending FOV + gPendFovOn = fovOn; + gPendFovA = fovA; + gPendFovB = fovB; + + if (!gCamReady) { + llMessageLinked(LINK_SET, CE_CMD_INIT, "src=TOUR", gOwner); + return; + } + + if (gPendStartFirst) { + startPendingAtFirst(); + return; + } + + requestStateForPending(); + return; + } + } +} diff --git a/HS_CamFov.lsl b/HS_CamFov.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamFov.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_CamMarkers.lsl b/HS_CamMarkers.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamMarkers.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_CamMenu.lsl b/HS_CamMenu.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamMenu.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_CamPlaylist.lsl b/HS_CamPlaylist.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamPlaylist.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_CamTourCommands.lsl b/HS_CamTourCommands.lsl new file mode 100644 index 0000000..4b27a8a --- /dev/null +++ b/HS_CamTourCommands.lsl @@ -0,0 +1,966 @@ +/* + HS_DollyCam - CamController (SLIM) + - /88 chat parsing + - Presets via Linkset Data (save/load/delete/list) + - Playlist player from notecards (one command per line, optional wait ) + - TOUR blocks: tour [linear|spline] ... endtour + - Config reload/dump + - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + + Notes: + - idx is valid only if > 0 (slot 0 reserved). + - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). + - Early-cut is disabled for tours (tour is one continuous move). +*/ + +integer CH = 88; + +// ===== DEMO MODE ===== +// If DEMO_MODE is TRUE, saving presets is limited to DEMO_MAX_SLOTS (last valid slot = DEMO_MAX_SLOTS) +integer DEMO_MODE = FALSE; +integer DEMO_MAX_SLOTS = 5; + +// Engine protocol (must match CamEngine) +integer CE_CMD_INIT = 1000; +integer CE_CMD_RELEASE = 1001; +integer CE_CMD_MOVE = 1010; +integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride +integer CE_CMD_STOP = 1012; +integer CE_CMD_LOCK = 1020; +integer CE_CMD_FOLLOW = 1030; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_CFG_RELOAD = 1050; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_GET_STATE = 1060; + +integer CE_EVT_READY = 2000; +integer CE_EVT_DENIED = 2001; +integer CE_EVT_MOVE_DONE = 2010; +integer CE_EVT_CFG_DUMP = 2051; +integer CE_EVT_STATE = 2060; + +// Helper scripts (separate memory budgets) +integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") +integer MN_CMD = 5200; // Menu helper -> Controller +integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) + +// Controller -> Playlist helper +integer PH_CMD_PLAY = 6100; +integer PH_CMD_STOP = 6101; +integer PH_CMD_CHAT_TOUR = 6102; +integer PH_CMD_TOURRUN = 6103; +integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands + +// ===== RLVa FOV ===== +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases + +// Presets +string PRE_KEY(integer idx) { return "P" + (string)idx; } + +// Controller runtime +key gOwner; +integer gListen; + +// Move ids +integer gMoveId = 100; // start non-zero +integer nextMoveId() { gMoveId++; return gMoveId; } + +// Defaults (updated when engine cfg dump arrives) +integer gDefaultMoveMs = 2200; + + +integer demoSlotOk(integer idx) +{ + if (!DEMO_MODE) return TRUE; + + if (idx <= DEMO_MAX_SLOTS) return TRUE; + + say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); + return FALSE; +} + +// ---- save pending ---- +integer gSavePending = FALSE; +integer gSaveIdx = 0; +integer gSaveReq = 0; + +// Temp preset buffer (set by loadPreset) +vector gTmpPos; +vector gTmpFoc; +integer gTmpHasFov = FALSE; +float gTmpFovRad = 0.0; + +// “last set by HUD” bleibt als Fallback ok +float gLastFovRad = 1.04719755; // ~60° + +// --- Marker menu state (persist across scripts) --- +string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" + +// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== +// FOLLOW: "on|uuid" +// LOCK: "on|arg" (uuid or "") +string LSKEY_FOLLOW = "HS_FOLLOW"; +string LSKEY_LOCK = "HS_LOCK"; + +followLockInitState() +{ + if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); +} + +followWrite(integer on, key target) +{ + if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); + else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); +} + +lockWrite(integer on, string arg) +{ + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); +} + +// small helpers (keep controller changes localized) +engineFollowOff() +{ + // Engine expects at least "on|target" + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); +} + +engineLockOff() +{ + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); +} + +integer gCamsShown = FALSE; +integer gCamsN = 12; + +camsLoadState() +{ + string s = llLinksetDataRead(LSKEY_CAMS); + if (s == "") { + gCamsShown = FALSE; gCamsN = 12; + llLinksetDataWrite(LSKEY_CAMS, "0|12"); + return; + } + list p = llParseString2List(s, ["|"], []); + if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) { + integer n = (integer)llList2String(p, 1); + if (n < 1) n = 1; + if (n > 30) n = 30; + gCamsN = n; + } +} + +camsWriteState() +{ + llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); +} + +float clampf(float v, float lo, float hi) +{ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +float clampFovRad(float rad) +{ + float deg = rad2deg(rad); + deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + return deg2rad(deg); +} + +string presetDescribe(integer idx, string data) +{ + list p = llParseString2List(data, ["|"], []); + integer L = llGetListLength(p); + if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + + vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + + // v2: optional fovRad at index 10 + if (L >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { + fr = clampFovRad(fr); + float deg = rad2deg(fr); + s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; + return s; + } + } + s += " fov=(none)"; + return s; +} + +// ===== RLVa helpers ===== +float deg2rad(float deg) { return deg * PI / 180.0; } +float rad2deg(float rad) { return rad * 180.0 / PI; } + +string fmtFloat(float v) +{ + // LSL string(float) is fine; this is just to keep logs readable + string s = (string)v; + if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); + return s; +} + +// ---------- helpers ---------- +integer isValidIdx(integer idx) { return (idx > 0); } + +say(string s) { llOwnerSay(s); } + +// Playlist / TourCommand helpers +phStop(string reason) +{ + llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); +} + +phPlay(string card, integer gapMs) +{ + llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); +} + +phChatTour(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); +} + +phMenuTourRun(string raw) +{ + llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); +} + +phChatDollyZoom(string line) +{ + llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); +} + +// Single-prim HUD (Controller in ROOT) +hudHide() +{ + llSetAlpha(0.0, ALL_SIDES); +} +hudShow() +{ + llSetAlpha(1.0, ALL_SIDES); +} + + +// ---------- engine commands ---------- +engineInit() +{ + llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); +} + +engineRelease() +{ + llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); +} + +integer engineMove(vector pos, vector foc, integer durMs) +{ + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + + // Hide HUD during non-instant moves (avoid flicker on cuts) + if (durMs > 0) hudHide(); + + return mid; +} + +engineStopMove() +{ + llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); +} + +engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } +engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } + +// ---------- presets ---------- +string packPreset(vector pos, vector foc, rotation rot, float fovRad) +{ + // v2 preset format: + fovRad at the end (index 10) + return llDumpList2String([ + (string)pos.x,(string)pos.y,(string)pos.z, + (string)foc.x,(string)foc.y,(string)foc.z, + (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, + (string)fovRad + ], "|"); +} + +integer loadPreset(integer idx) +{ + string data = llLinksetDataRead(PRE_KEY(idx)); + if (data == "") return FALSE; + + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) + list p = llParseString2List(data, ["|"], []); + if (llGetListLength(p) < 6) return FALSE; + + gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; + gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; + + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (llGetListLength(p) >= 11) { + float fr = (float)llList2String(p, 10); + if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } + } + + return TRUE; +} + +applyLoadedPresetFov() +{ + if (!gTmpHasFov) return; + + gLastFovRad = gTmpFovRad; // keep save fallback in sync + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); +} + +// ---------- follow parsing helpers ---------- +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; + +integer followModeFrom(string s) +{ + s = llToLower(s); + if (s == "yaw") return FOLLOW_YAW; + if (s == "local") return FOLLOW_LOCAL; + return FOLLOW_WORLD; +} + +// Re-join tokens that represent a that may contain spaces. +// returns [string joined, integer nextIndex] +list takeAngleToken(list toks, integer i) +{ + integer n = llGetListLength(toks); + if (i >= n) return ["", i]; + + string s = llList2String(toks, i); + + if (llGetSubString(s, 0, 0) != "<") { + return [s, i + 1]; + } + + while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + ++i; + s += " " + llList2String(toks, i); + } + return [s, i + 1]; +} + +// ---------- chat commands ---------- +printHelp() +{ + say( + "HS DollyCam — Commands (/88)\n" + + "/88 help\n" + + "/88 cam on|off\n" + + "/88 save \n" + + "/88 load (cut)\n" + + "/88 moveto [ms]\n" + + "/88 del \n" + + "/88 list [from] [count]\n" + + "/88 play [gap_ms]\n" + + "/88 stop\n" + + "/88 tour [mode] ...\n" + + "/88 cfg reload|dump\n" + + "/88 show cams [N]\n" + + "/88 hide cams\n" + + "/88 lock on [|uuid]\n" + + "/88 lock off\n" + + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" + + "/88 follow off\n" + + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" + + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" + + "/88 dollyzoom [mode] \n" + ); +} + +// ---------- default ---------- +default +{ + state_entry() + { + gOwner = llGetOwner(); + camsLoadState(); + followLockInitState(); // NEW: ensure menu keys exist + hudShow(); + gListen = llListen(CH, "", "", ""); + + say("HS DollyCam Controller (slim) ready. Type /88 help"); + engineCfgDump(); + + // AUTO CAM ON when script starts while worn + if (llGetAttached() != 0) + engineInit(); + } + + on_rez(integer sp) + { + gOwner = llGetOwner(); + } + + attach(key id) + { + if (id == NULL_KEY) { + phStop("HUD detached."); + hudShow(); + + gCamsShown = FALSE; + camsWriteState(); + + // keep menu toggles consistent after detach + followWrite(FALSE, NULL_KEY); + lockWrite(FALSE, ""); + + // IMPORTANT: + // Do NOT also send MC_CMD/HM_CMD here. + // Markers/Manual scripts should clean up in their own attach(NULL_KEY), + // otherwise you'll get double cleanup logs. + // llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + // llMessageLinked(LINK_SET, HM_CMD, "STOP", gOwner); + + engineRelease(); + } else { + gOwner = llGetOwner(); + hudShow(); + engineInit(); + engineCfgDump(); + } + } + + link_message(integer sender, integer num, string str, key id) + { + if (num == CE_EVT_READY) { + say("Camera control granted."); + return; + } + if (num == CE_EVT_DENIED) { + say("Camera permission denied."); + return; + } + if (num == CE_EVT_CFG_DUMP) { + list kv = llParseString2List(str, ["|"], []); + integer i; + say("Engine cfg:"); + for (i=0; i|| + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 4) return; + integer req = (integer)llList2String(p,0); + + if (gSavePending && req == gSaveReq) { + vector pos = (vector)llList2String(p,1); + vector foc = (vector)llList2String(p,2); + + rotation rot = (rotation)llList2String(p,3); + + // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback + float fovRad = gLastFovRad; + if (llGetListLength(p) >= 5) { + float got = (float)llList2String(p, 4); + if (got > 0.0001) fovRad = got; + } + fovRad = clampFovRad(fovRad); + + string data = packPreset(pos, foc, rot, fovRad); + llLinksetDataWrite(PRE_KEY(gSaveIdx), data); + + // volle Anzeige: + say("Saved " + presetDescribe(gSaveIdx, data)); + + gSavePending = FALSE; + } + return; + } + + // Marker click event from HS_CamMarkers.lsl + if (num == MC_EVT_CLICK) { + if (id != gOwner) return; + + integer idx = (integer)str; + if (!isValidIdx(idx)) return; + + if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } + + phStop("Interrupted by marker click."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); + say("Loaded via marker: " + (string)idx); + return; + } + + // Menu commands from HS_CamMenu.lsl + if (num == MN_CMD) { + if (id != gOwner) return; // only trust owner-routed messages + + list p = llParseString2List(str, ["|"], []); + integer len = llGetListLength(p); + if (len < 1) return; + + string typ = llToUpper(llList2String(p, 0)); + // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== + if (typ == "FOLLOW" && len >= 2) { + string actF = llToUpper(llList2String(p, 1)); + + if (actF == "OFF") { + engineFollowOff(); + say("Follow OFF (menu)"); + return; + } + + if (actF == "ON" && len >= 3) { + key tgtF = (key)llList2String(p, 2); + if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } + + // mutual exclusion + engineLockOff(); + + integer mode = FOLLOW_WORLD; + integer trans = 250; + + string payload = + "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, tgtF); + + say("Follow ON -> " + llKey2Name(tgtF)); + return; + } + return; + } + + if (typ == "LOCK" && len >= 2) { + string actL = llToUpper(llList2String(p, 1)); + + if (actL == "OFF") { + engineLockOff(); + say("Lock OFF (menu)"); + return; + } + + if (actL == "ON" && len >= 3) { + key tgtL = (key)llList2String(p, 2); + if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } + + // mutual exclusion + engineFollowOff(); + + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); + lockWrite(TRUE, (string)tgtL); + + say("Lock ON -> " + llKey2Name(tgtL)); + return; + } + return; + } + + // SAVE|idx + if (typ == "SAVE" && len >= 2) { + integer idx = (integer)llList2String(p, 1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by menu save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + // MOVETO|idx|ms + if (typ == "MOVETO" && len >= 3) { + integer idx2 = (integer)llList2String(p, 1); + integer ms = (integer)llList2String(p, 2); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx2)) { say("Preset not found."); return; } + + if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default + phStop("Interrupted by menu moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); + return; + } + + // LOAD|idx (optional: menu supports cut) + if (typ == "LOAD" && len >= 2) { + integer idx3 = (integer)llList2String(p, 1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + phStop("Interrupted by menu load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + // PLAY|card|gap + if (typ == "PLAY" && len >= 2) { + string card = llList2String(p, 1); + integer gap = 0; + if (len >= 3) gap = (integer)llList2String(p, 2); + phPlay(card, gap); + return; + } + + // STOP + if (typ == "STOP") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + // TOURRUN|totalMs|mode|count|idx1|idx2|... + if (typ == "TOURRUN") { + phStop("Interrupted by menu tour."); + phMenuTourRun(str); + return; + } + + // CAMS|SHOW|N or CAMS|HIDE + if (typ == "CAMS" && len >= 2) { + string act = llToUpper(llList2String(p, 1)); + + if (act == "SHOW") { + integer want = gCamsN; + if (len >= 3) want = (integer)llList2String(p, 2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (act == "HIDE") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + gCamsShown = FALSE; + camsWriteState(); + return; + } + } + return; + } + + } + + listen(integer channel, string name, key id, string msg) + { + // Accept commands from: my avatar OR any object owned by me (including attachments) + if (llGetOwnerKey(id) != gOwner) return; + + msg = llStringTrim(msg, STRING_TRIM); + if (msg == "") return; + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + string cmd = llToLower(llList2String(t,0)); + + if (cmd == "help") { printHelp(); return; } + + if (cmd == "cam" && n >= 2) { + string sw = llToLower(llList2String(t,1)); + if (sw == "on") engineInit(); + else engineRelease(); + hudShow(); + return; + } + + if (cmd == "cfg" && n >= 2) { + string sub = llToLower(llList2String(t,1)); + if (sub == "reload") engineCfgReload(); + else engineCfgDump(); + return; + } + + if (cmd == "save" && n >= 2) { + integer idx = (integer)llList2String(t,1); + if (!isValidIdx(idx)) { say("idx must be > 0"); return; } + + // DEMO limit + if (!demoSlotOk(idx)) return; + + phStop("Interrupted by save."); + gSavePending = TRUE; + gSaveIdx = idx; + gSaveReq++; + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); + say("Saving preset " + (string)idx + " ..."); + return; + } + + if ((cmd == "del" || cmd == "delete") && n >= 2) { + integer idx2 = (integer)llList2String(t,1); + if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } + llLinksetDataDelete(PRE_KEY(idx2)); + say("Deleted preset " + (string)idx2); + return; + } + + if (cmd == "load" && n >= 2) { + integer idx3 = (integer)llList2String(t,1); + if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } + + if (!loadPreset(idx3)) { say("Preset not found."); return; } + + phStop("Interrupted by load."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, 0); + say("Loaded preset " + (string)idx3 + " (cut)"); + return; + } + + if (cmd == "moveto" && n >= 2) { + integer idx4 = (integer)llList2String(t,1); + if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } + + integer ms = gDefaultMoveMs; + if (n >= 3) ms = (integer)llList2String(t,2); + + if (!loadPreset(idx4)) { say("Preset not found."); return; } + + phStop("Interrupted by moveto."); + engineStopMove(); + applyLoadedPresetFov(); + engineMove(gTmpPos, gTmpFoc, ms); + say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); + return; + } + + if (cmd == "stop") { + hudShow(); + phStop("User stop."); + engineStopMove(); + return; + } + + if (cmd == "play" && n >= 2) { + string card = llList2String(t,1); + integer gap = 0; + if (n >= 3) gap = (integer)llList2String(t,2); + phPlay(card, gap); + return; + } + + // Chat one-liner Tour: + // /88 tour [mode] ... + if (cmd == "tour") { + // Delegate heavy parsing/building to playlist helper (one-shot tour) + phStop("Interrupted by tour (chat)."); + phChatTour(msg); + return; + } + + // Chat one-liner DollyZoom: + // /88 dollyzoom [mode] [keepframe? optional later] + if (cmd == "dollyzoom") { + phStop("Interrupted by dollyzoom (chat)."); + phChatDollyZoom(msg); + return; + } + + if (cmd == "list") { + integer from = 1; + integer count = 20; + if (n >= 2) from = (integer)llList2String(t,1); + if (n >= 3) count = (integer)llList2String(t,2); + if (from < 1) from = 1; + + integer shown = 0; + integer i; + for (i = from; i <= 999 && shown < count; ++i) { + string data = llLinksetDataRead(PRE_KEY(i)); + if (data != "") { + say(presetDescribe(i, data)); + shown++; + } + } + if (!shown) say("No presets found in range."); + return; + } + + // markers routed to helper script + if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + integer want = 12; + if (n >= 3) want = (integer)llList2String(t,2); + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + + gCamsShown = TRUE; + gCamsN = want; + camsWriteState(); + return; + } + + if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + + gCamsShown = FALSE; + camsWriteState(); + return; + } + + if (cmd == "lock" && n >= 2) { + string sw3 = llToLower(llList2String(t,1)); + integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); + + if (!on3) { + llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); + lockWrite(FALSE, ""); + say("Lock OFF"); + return; + } + + // Mutual exclusion: Lock ON disables Follow + engineFollowOff(); + + string lockArgUsed = "<0,0,0>"; + + if (n >= 3) { + list r = takeAngleToken(t, 2); + string arg = llList2String(r, 0); + + if (llGetSubString(arg,0,0) == "<") { + lockArgUsed = arg; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + } else { + key k = (key)arg; + if (k != NULL_KEY) { + lockArgUsed = (string)k; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); + } else { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); + lockArgUsed = (string)foc2; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); + } + } + } else { + vector camPos2 = llGetCameraPos(); + rotation camRot2 = llGetCameraRot(); + vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); + lockArgUsed = (string)foc3; + llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); + } + + lockWrite(TRUE, lockArgUsed); + say("Lock ON"); + return; + } + + if (cmd == "follow" && n >= 2) { + string sw4 = llToLower(llList2String(t,1)); + integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); + + key target = gOwner; + if (n >= 3) target = (key)llList2String(t,2); + + integer mode = FOLLOW_WORLD; + integer trans = 0; + + if (n >= 4) mode = followModeFrom(llList2String(t,3)); + if (n >= 5) trans = (integer)llList2String(t,4); + if (trans < 0) trans = 0; + + if (!on4) { + // OFF: do not touch lock + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); + followWrite(FALSE, NULL_KEY); + say("Follow OFF"); + return; + } + + // Mutual exclusion: Follow ON disables Lock + engineLockOff(); + + // capture-follow: offsets ZERO, engine captures + string payload = + "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + followWrite(TRUE, target); + + say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; + } + + // /88 fov (or degrees if >3.2) + if (cmd == "fov" && n >= 2) { + float v = (float)llList2String(t, 1); + + // Heuristic: if user typed "60", it's likely degrees + float rad = v; + if (v > 3.2) rad = v * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + // quiet=0 for manual commands, flags=1 (sync) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + if (cmd == "fovdeg" && n >= 2) { + float deg = (float)llList2String(t, 1); + float rad = deg * PI / 180.0; + + rad = clampFovRad(rad); + gLastFovRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + return; + } + + say("Unknown command. /88 help"); + } +} diff --git a/HS_DollyCam_Manual b/HS_DollyCam_Manual new file mode 100644 index 0000000..5a6783b --- /dev/null +++ b/HS_DollyCam_Manual @@ -0,0 +1,506 @@ +HS DollyCam HUD - User Manual (v1.3.0) +==================================== + +1) What this HUD does +--------------------- +HS DollyCam is a cinematic camera dolly tool for Second Life. + +It lets you: +- Save camera presets (position + focus) +- Move smoothly between presets (moveto) or cut instantly (load) +- Play camera playlists from notecards (one command per line) +- Use TOUR mode for one continuous camera ride across waypoints +- Use FOV presets, FOV ramps and DollyZoom via RLVa-enabled viewers +- Use optional lock/follow modes (secondary, lower priority than moves) +- Visualize presets with clickable marker "pyramids" (optional helper) +- Use the touch menu for common Save / MoveTo / Play / Tour / Follow / Lock workflows + +2) Requirements & Permissions +----------------------------- +Camera permissions: +- The HUD requests Camera Control permission (viewer popup). +- It also requests Camera Tracking for better capture/saving accuracy. + +FOV / DollyZoom: +- FOV commands require a viewer with RLVa support enabled. +- If RLVa is off, normal camera motion still works, but FOV changes do nothing. + +Markers ("show cams"): +- Parcel must allow you to rez objects (or allow attachments to rez). +- Parcel must have enough remaining object capacity / LI. +- Markers will fail on no-rez parcels or if you hit parcel object limits. + +3) Setup +-------- +1) Wear the HUD. +2) The HUD automatically asks for camera control when it starts while worn. + You can also enable camera control manually: + + /88 cam on + + Tip (Viewer quirk / SL restriction): + Sometimes the viewer does not immediately switch into the scripted camera mode, + especially if you recently moved/rotated the camera manually or a UI element + has input focus. If the dolly cam feels like it is "not taking over", press + ESC once (sometimes twice). + + Why this happens: + This is a Second Life viewer limitation. ESC clears certain viewer-side camera + and input states (camera manipulation focus, mouselook, modal UI capture). + The HUD can set camera parameters only when the viewer is ready to accept them, + so ESC often nudges the viewer back into the correct state. + +3) Accept the permission popup. + +To release camera control later: + + /88 cam off + + +4) Quick Start (60 seconds) +--------------------------- +1) Move your viewer camera to a nice framing. + + /88 save 1 + +2) Aim for another framing. + + /88 save 2 + +3) Smooth cinematic moves: + + /88 moveto 1 2500 + /88 moveto 2 2500 + +4) Instant cut: + + /88 load 1 + +5) Chat Commands (/88) +---------------------- + +A) Help + /88 help + Shows a quick cheat sheet. + +B) Camera + /88 cam on + Requests permission and activates dolly camera. + + /88 cam off + Releases camera and clears camera params. + +C) Presets (Linkset Data) + /88 save + Saves current camera position + focus to preset slot . + + /88 load + Instant cut to preset (no smoothing). + + /88 moveto [duration_ms] + Smooth move to preset over duration_ms. + If duration_ms is omitted, default_move_ms is used. + + /88 del + Deletes preset . + + /88 list [from] [count] + Lists saved presets in a range. + + Important: + - preset index must be > 0 (slot 0 is reserved). + +D) Playlist + /88 play [gap_ms] + Plays a playlist notecard (one command per line). + gap_ms adds a delay after each action (default 0). + + /88 stop + Stops playlist and stops the current move. + +E) Config + /88 cfg reload + Reload engine config from HS_CamEngine.properties. + + /88 cfg dump + Prints current engine config values. + +F) Markers (requires HS_CamMarkers helper) + /88 show cams [N] + Rezzes up to N clickable markers for saved presets. + + /88 hide cams + Cleans up all markers created by the HUD. + +G) Secondary modes (lower priority than MoveTo / Tour) + LOCK (focus only): + /88 lock on [|uuid] + /88 lock off + + FOLLOW (capture follow): + /88 follow on [uuid] [yaw|local|world] [transition_ms] + /88 follow off + +H) FOV / DollyZoom (requires RLVa) + Note: one-line /88 tour, /88 dollyzoom and menu Tour Builder use + HS_CamTourCommands.lsl. + + /88 fov + Sets viewer FOV in radians. Values greater than 3.2 are treated as degrees. + + /88 fovdeg + Sets viewer FOV in degrees. Range is clamped to 10..179. + + /88 tour [mode] ... [fovdeg |fov ] + Runs a one-line tour and optionally ramps FOV from a to b. + + /88 dollyzoom [mode] [keep] + Runs a 2-point tour using stored FOV from the presets. "keep" computes + FOV from camera distance to keep framing more stable. + + Tip: + For reliable DollyZoom results, set FOV explicitly before saving both + presets, e.g. "/88 fovdeg 35" then "/88 save 1", then set the second + FOV and "/88 save 2". + +I) Touch menu + Touch the HUD to open the menu. + The menu supports Save, MoveTo/Cut, Play, Tour Builder, Settings, + Follow/Lock target picking, Show/Hide Cams and Stop. + + Notes: + - The menu shows preset slots 1..30. + - The Play menu shows only notecards whose names start with "shot_". + - Chat /88 play can still play any notecard name you provide. + +6) Playlist Notecard Format +--------------------------- +Create a notecard, put it into the HUD contents, then: + + /88 play MyPlaylist 250 + +The playlist reader uses the simulator notecard cache when available, with an +automatic fallback to normal dataserver reads. + +Rules: +- One command per line (NO "/88" prefix in notecards). +- Empty lines and comments are allowed: + # comment + // comment + ; comment +- Tokens are split by spaces. (The playlist parser does NOT re-join vectors that contain spaces.) + +Supported playlist commands (core): +- moveto [ms] +- goto [ms] (alias of moveto) +- load [ms] (default is cut; if [ms] is provided it will be used) +- wait +- fov [quiet] +- fovdeg [quiet] +- dollyzoom [mode] [keep] +- lock on|off [arg] +- follow on|off [target] [modeInt] [transition_ms] +- tour ... / endtour +- show cams [N] / hide cams + +Basic example: + # My cinematic playlist + moveto 1 2500 + wait 300 + moveto 2 2200 + wait 150 + load 3 + moveto 4 3000 + +Notes / pitfalls (playlist): +- "load " is a cut by default, but "load " will move smoothly (same as moveto with that duration). +- For lock vectors in notecards, write vectors without spaces, e.g. <128,128,30>. + (Vectors with spaces like <128, 128, 30> are NOT supported in playlists.) +- For follow in notecards, mode is an integer: 0=yaw, 1=local, 2=world. + Words like "yaw/local/world" are not parsed as words in playlists. +- For weighted tours, waypoint lines can include weight tokens such as + "w=1.5" or speed tokens such as "speed=2.0". +- Notecard tour blocks are the primary optimized workflow. Internally the HUD + stores only preset indices while reading the block, then loads position/focus + data at endtour. + +7) Playlist Timing: MOVE_DONE chaining + "early cut" +---------------------------------------------------- +Normal behavior: +- After a move command (moveto/goto/load), the playlist normally continues when the engine signals MOVE_DONE. + +Early cut rule (timing): +- If the NEXT actionable line (ignoring empty/comment lines) after a move command is: + + wait + + ...the playlist will "early cut": + - it starts that wait immediately, + - then continues after the wait ends, + - even if the move has not fully finished. + +Notes: +- Early cut applies after moveto, goto, and load. +- Early cut is automatically DISABLED for Tours (a Tour is one continuous move). +- gap_ms (from "/88 play [gap_ms]") is applied after MOVE_DONE; if early cut is used, + the playlist no longer waits for MOVE_DONE, so the gap may not be applied. + +8) TOUR Mode (continuous camera ride) +------------------------------------- +Why Tour exists: +- Back-to-back moveto segments each ease in/out. + That can feel like: speed up -> slow down -> speed up -> slow down. + +Tour runs ONE continuous move: +- One acceleration at the start, +- Constant motion through intermediate waypoints, +- One deceleration at the end. + +Syntax: + tour [mode] + ... waypoint commands ... + endtour + +Fast compact notecard syntax: + tour [mode] ... [fovdeg |fov ] + +- mode defaults to "linear" +- supported mode strings (engine): + linear / line + spline + constant / noease + ease_in, ease_out, ease_in_out + smoothstep, cubic, quint + You can combine spline + profile using "+", e.g. "spline+ease_in_out". + Use "spline+noease" when a long tour must visibly start immediately. + + +Inside a tour block: +- moveto [ms] -> adds waypoint (ms ignored inside tour) +- goto -> same as moveto +- load -> adds waypoint (still part of tour; no instant cut inside) +- wait -> HOLD at the most recent waypoint (see below) +- fovdeg -> optional FOV ramp across the tour +- fov -> optional FOV ramp in radians +- waypoint lines may include w= or speed= + +Example: 3-waypoint linear tour + tour 9000 linear + moveto 1 + moveto 2 + moveto 3 + endtour + +Example: 4-waypoint spline tour + tour 12000 spline + moveto 10 + moveto 11 + moveto 12 + moveto 13 + endtour + +Fast compact equivalent: + tour 12000 spline 10 11 12 13 + +Use the compact form when the tour only needs waypoints and optional FOV ramp. +It avoids waiting for many separate notecard line reads before the tour can +start. Use the block form when you need waypoint holds or per-waypoint weights. +The compact form starts directly at its first preset; put `load ` +before it if you want the viewer camera to be placed there first. + +Wait inside tour = HOLD +- In a tour, "wait " inserts a hold at the most recent waypoint. +- Holds add extra time ON TOP of the tour movement time. + +Example with hold: + tour 7000 linear + moveto 1 + wait 1500 + moveto 2 + moveto 3 + endtour + +Edge case: +- If a tour has fewer than 2 waypoints at endtour, it is ignored and the + playlist continues. + +9) LOCK (focus only) +-------------------- +Lock sets the camera focus to a fixed target while the camera position can +still move (moveto/tour/follow can still move position). + +Chat usage: + /88 lock on + Locks focus to a point in front of your current camera. + + /88 lock on <128,128,30> + Locks focus to a fixed world point. + + /88 lock on 01234567-89ab-cdef-0123-456789abcdef + Locks focus to an object/avatar UUID. + + /88 lock off + +Notes: +- In local chat, vectors with spaces are accepted: + <128, 128, 30> is OK in chat. +- In playlists, write vectors without spaces: + <128,128,30> + (Vectors with spaces are not supported in playlists.) + +Playlist note: +- In notecards, "lock on" must be written exactly as "on" to enable. + (Chat also accepts "1"/"true", but notecards do not.) + +10) FOLLOW (capture follow) +--------------------------- +Follow keeps the camera position/focus relative to a moving target. It is +intended for tracking shots when no moveto/tour is running. + +Chat usage: + /88 follow on + Follow yourself (default target), mode world, transition 0. + + /88 follow on world 500 + Capture-follow a target, in world mode, blending in over 500ms. + + /88 follow on yaw 800 + Yaw-only follow (nice for vehicles), blend over 800ms. + + /88 follow off + +Modes: +- world: offsets in world space +- local: offsets in target's rotation space +- yaw: only yaw rotation used (stabilizes banking/tilt) + +Chat note: +- When you want to provide mode or transition in chat, include the target UUID. + The short form "/88 follow on yaw 500" is not parsed as mode+transition. + +Playlist follow (notecard): +- Syntax: + follow on|off [uuid] [modeInt] [transition_ms] + +- modeInt: 0=yaw, 1=local, 2=world + +Deprecated / not supported in playlists (current build): +- Explicit follow offsets in notecards: + follow on yaw|local|world + (This syntax is not parsed by the playlist helper; it will behave as capture-follow instead.) + +11) Markers ("show cams") - helper script +----------------------------------------- +Requires HS_CamMarkers.lsl in the HUD. + + /88 show cams [N] + Rezzes up to N clickable markers near you, one per preset found. + + /88 hide cams + Cleans up markers. + +Clicking a marker: +- Loads that preset (moveto by default) +- Interrupts any running playlist + +12) Engine Config (HS_CamEngine.properties) +------------------------------------------- +Engine reads tuning values from a notecard named: + + HS_CamEngine.properties + +Commands: + /88 cfg reload + /88 cfg dump + +Common keys: + move_step=0.025 + follow_step=0.05 + default_move_ms=3000 + default_focus_dist=10.0 + + move_pos_lag=0.5 + move_focus_lag=0.5 + follow_pos_lag=0.5 + follow_focus_lag=0.5 + + pos_threshold=0.02 + focus_threshold=0.02 + + tour_cam_min_interval=0.033 + tour_pos_epsilon=0.005 + tour_focus_epsilon=0.005 + + follow_predict=0.10 + tour_max_points=20 + +Tuning tips: +- If motion looks steppy: lower move_step (0.03 -> 0.025). +- If script load matters more than maximum smoothness: raise move_step slightly + (0.025 -> 0.033). +- Tour playback also has an internal camera-frame cap: + tour_cam_min_interval=0.033 is about 30 Hz. Lowering move_step below this + still computes the tour more often, but it will not send camera frames faster + unless tour_cam_min_interval is also lowered. +- tour_pos_epsilon and tour_focus_epsilon skip tiny duplicate tour camera + updates. Lower values send more updates; higher values reduce script load. +- tour_max_points is capped by the scripts for safety. Very large tours also + hit link-message payload limits, so split extremely long rides into smaller + tours if needed. +- If camera feels too mushy: lower move_pos_lag / move_focus_lag. +- If follow jitters: raise thresholds slightly (0.02 -> 0.03). +- If follow trails: raise follow_predict slightly (0.10 -> 0.12). + +Reload behavior: +- Reload is asynchronous. +- During an active move/tour, base params may apply after the move ends. + +13) Troubleshooting +------------------- +"Nothing happens when I type /88 ..." +- Ensure the HUD is worn and scripts are running. +- Use LOCAL chat, not IM. +- Verify channel: /88 help + +"Camera permission denied" +- Run /88 cam on again and accept the popup. +- Some viewers block permission popups; check viewer settings. + +"Show cams does not rez markers" +- Parcel may block rez (no-rez). +- Parcel object limits may be reached. +- Ensure HS_CamMarkers helper is present. + +"Presets don't save" +- idx must be > 0. +- Linkset Data has capacity limits; avoid excessive preset counts. + +"Movement looks choppy" +- Region performance matters. +- Try lowering move_step slightly. +- Use reasonable durations (1500-5000ms for cinematic). + +14) Best Practices & Creative Workflows +--------------------------------------- +Preset organization: +- 1-20: a single scene +- 100-120: another scene +- 900+: experiments / temporary + +Use Tour for ride shots: + tour 14000 spline + moveto 1 + moveto 2 + moveto 3 + wait 1200 + moveto 4 + endtour + +Use early cut intentionally: + moveto 10 6000 + wait 900 + load 11 + +Combine follow + lock for action: + follow on yaw 400 + lock on diff --git a/HS_DollyCam_Manual_FOV_Extension b/HS_DollyCam_Manual_FOV_Extension new file mode 100644 index 0000000..5815a1e --- /dev/null +++ b/HS_DollyCam_Manual_FOV_Extension @@ -0,0 +1,261 @@ +0) IMPORTANT: RLVa REQUIRED + +All FOV features in HS DollyCam require a viewer with RLVa support enabled. +Firestorm: Preferences → Firestorm → RLVa → “Enable RLVa” +If RLVa is OFF, the camera can still move, but FOV commands will do nothing. + +HS DollyCam sets FOV via RLVa command: + @setcam_fov:=force + +Notes: +- This is viewer-side. The HUD sends commands via owner chat. +- Some viewers show “executes: @setcam_fov:...” in chat even when scripts send “quiet”. + That display is a viewer setting and not HUD spam. + + +1) UNITS: Radians vs Degrees + +FOV in HS DollyCam is stored internally in RADIANS. + +Useful references: +- 60° = 1.04719755 rad +- 45° = 0.78539816 rad +- 30° = 0.52359878 rad +- 90° = 1.57079633 rad + +Clamp range (safety): +- Minimum: 10° +- Maximum: 179° + + +2) STANDALONE FOV COMMANDS (Chat /88) + +A) Set FOV in radians + /88 fov + +Examples: + /88 fov 1.0472 (≈ 60°) + /88 fov 0.5236 (≈ 30°) + +B) Set FOV in degrees + /88 fovdeg + +Examples: + /88 fovdeg 60 + /88 fovdeg 35 + +C) Convenience heuristic: +If you use /88 fov with a value > 3.2 it is assumed to be degrees. +Example: + /88 fov 60 (interpreted as 60°, not 60 rad) + + +3) FOV IN PLAYLIST NOTECARDS + +You can set FOV from a playlist notecard as standalone lines. + +Syntax: + fov [quiet] + fovdeg [quiet] + +quiet: +- 1 = quiet (default) +- 0 = show debug output (if enabled) / more visible logs + +Examples: + fovdeg 60 + fovdeg 35 1 + fov 1.0472 + fov 0.7854 1 + +Tip: +If you run a playlist with a gap_ms, FOV lines follow the same pacing rules. + + +4) PRESETS: SAVING / STORING FOV + +HS DollyCam presets store camera position + focus + rotation + FOV. + +- Presets are saved in Linkset Data slots “P” +- v2 preset format includes FOV as the 11th field: + field index 10 = fovRad + +Workflow: +1) Move camera where you want it. +2) Set FOV you want (RLVa required), e.g.: + /88 fovdeg 35 +3) Save the preset: + /88 save 1 + +Important: +- The HUD stores the “last set by HUD” FOV as a fallback. +- Best practice: explicitly set FOV, then save, for reliable dollyzoom. + + +5) APPLYING PRESET FOV (Load / MoveTo) + +When you load or moveto a preset, HS DollyCam can apply the preset’s stored FOV. + +A) Chat: + /88 load (cut) + /88 moveto [ms] (animated) + +B) Menu / Markers: +- Clicking a marker or using the menu load/moveto applies preset FOV if present. + +C) Playlists: +- In playlists, moveto/load lines also apply preset FOV (outside tours). + +Notes: +- If a preset has no stored FOV, camera movement still works; FOV stays unchanged. + + +6) TOUR FOV: KEYFRAME RAMP DURING TOURS + +Tours are continuous rides through multiple waypoints. +You can optionally ramp FOV during a tour. + +A) Tour blocks in notecards: + tour [mode] + moveto [optional weight tokens] + moveto ... + fovdeg (optional, can be inside tour block) + wait (hold at the last waypoint) + endtour + +B) Fast compact tour (chat or notecard): + /88 tour [mode] ... [fovdeg ] + /88 tour [mode] ... [fov ] + tour [mode] ... [fovdeg ] + tour [mode] ... [fov ] + +Example: + /88 tour 8000 spline 1 2 3 4 fovdeg 35 70 + tour 8000 spline 1 2 3 4 fovdeg 35 70 + +What it does: +- Camera moves along the tour path +- FOV is interpolated from A → B based on path progress (weighted distance) +- FOV ticks are throttled (about 10 Hz) + deduped + +Tip: +FOV ramps in tours are a creative tool (stylized “zoom breathing”), not a perfect “dollyzoom”. + + +7) DOLLYZOOM (Classic) + +DollyZoom is a 2-point tour that changes camera distance while counter-changing FOV. + +Chat syntax: + /88 dollyzoom [mode] + +Chat/menu DollyZoom and one-line tours use HS_CamTourCommands.lsl. + +Notecard syntax: + dollyzoom [mode] + +Examples: + /88 dollyzoom 4000 linear 1 2 + /88 dollyzoom 4000 1 2 + dollyzoom 5000 spline 3 4 + +How it works: +- Builds an internal 2-point TOUR from preset A to preset B +- Uses FOV stored in preset A and preset B: + FOV ramps from fovA → fovB across the move + +Requirements: +- BOTH presets must have FOV stored (unless using “keep”, see below) +- Best results when both presets look at the same subject (same focus point) + + +8) DOLLYZOOM keep (Keepframe / constant framing) + +The current build supports “keepframe” with the “keep” option: + +Chat: + /88 dollyzoom [mode] keep + +Notecard: + dollyzoom [mode] keep + +Goal: +- Keep the apparent size of the subject as constant as possible while moving. + +How it works (math): +- Let d(t) = distance between camera position and the “motif focus” +- Define K from the start frame: + K = d0 * tan(FOV0/2) +- Then compute FOV per tick: + FOV(t) = 2 * atan( K / d(t) ) + +What is “motif focus”? +- If LOCK is ON, motif focus comes from the LOCK target (object position + offset). +- If LOCK is OFF, motif focus is fixed to “focA” (focus from the first dolly preset). +- Many setups also force focB = focA automatically for keep to avoid focus drift. + +Requirements: +- RLVa ON (for FOV updates) +- A meaningful distance change (camera must move closer/farther to the subject) +- Best when the focus corresponds to the actual subject (not an arbitrary point) + +When to use LOCK with keep: +- Use LOCK when the subject moves (avatar walking, vehicle, animated object). +- If the subject is static and focA is correct, you do not need LOCK. + +Notes / Limitations (Second Life reality): +- Viewer camera smoothing (Camera Lag/Focus Lag) can make keepframe look “off” + because the visible camera lags behind the scripted target position. +- FOV updates are throttled and deduped; tiny changes may not be sent every frame. +- Expect “usable” results, not perfect cinema-grade dollyzoom. + +Performance note: +- Tour FOV updates are intentionally throttled and deduped. +- Tour camera frame updates are also capped by HS_CamEngine.properties: + tour_cam_min_interval=0.033 + tour_pos_epsilon=0.005 + tour_focus_epsilon=0.005 +- Lower move_step values make the path computation run more often, but they do + not send camera frames faster than tour_cam_min_interval unless that value is + lowered too. Lower values increase script load. + + +9) QUALITY TIPS (Practical) + +A) For a strong dollyzoom: +- Make presets with a BIG distance difference (e.g. 3m → 15m) +- Keep focus on the same subject point (focA == focB), or use LOCK +- Use moderate FOV ranges (e.g. 30° → 70°) + +B) If it looks “mushy” or delayed: +- Reduce camera lag settings (Engine properties): + move_pos_lag / move_focus_lag too high will cause visible trailing. +- Consider lowering smoothing for recording, then increase for casual viewing. + +C) If dollyzoom looks like “just moving camera”: +- Your distance to focus might not be changing (dA ≈ dB). +- Your focus might be drifting (focA and focB are different, or moving with camera). +- The camera path may be sideways/orbit rather than in/out. + + +10) TROUBLESHOOTING + +Problem: “FOV commands do nothing” +- RLVa is OFF or viewer doesn’t support RLVa. +- Enable RLVa in viewer settings. + +Problem: “Chat shows executes: @setcam_fov...” +- That is viewer-side display. +- HUD already uses quiet mode where possible. + +Problem: “Dollyzoom says it needs FOV in both presets” +- Set FOV, then save preset A and preset B again: + /88 fovdeg 35 + /88 save 1 + /88 fovdeg 70 + /88 save 2 + +Problem: “keep doesn’t feel like dollyzoom” +- Ensure camera distance to motif changes substantially. +- Ensure motif focus is correct (LOCK target or correct focA). +- Lower camera lag (smoothing) if needed. diff --git a/project-context.md b/project-context.md new file mode 100644 index 0000000..47719e3 --- /dev/null +++ b/project-context.md @@ -0,0 +1,49 @@ +--- +project_name: 'HS_DollyCam' +user_name: 'mita' +date: 'Wed May 06 2026' +sections_completed: ['technology_stack', 'implementation_rules', 'patterns'] +existing_patterns_found: 7 +--- + +# Project Context for AI Agents + +_This file contains critical rules and patterns that AI agents must follow when implementing code in this project. Focus on unobvious details that agents might otherwise miss._ + +--- + +## Technology Stack & Versions + +- **Language:** LSL (Linden Scripting Language) +- **Platform:** Second Life +- **Core Communication:** `llMessageLinked` (Linkset Message) +- **Configuration:** `.properties` files (e.g., `HS_CamEngine.properties`) +- **Key Components:** Specialized LSL scripts (`Controller`, `Playlist`, `imEngineTour`, `Core`, etc.) + +## Critical Implementation Rules + +- **Memory Management (CRITICAL):** + - Avoid large or mixed-type lists to prevent memory pressure. + - Minimize the use of `llParseString2List` in hot paths (e.g., timer events or frequent `llMessageLinked` receivers). + - Use targeted parsing (token-based or separator-based) for payloads like `CE_INT_SET_CAM`. + - Prefer short-lived data and targeted field extraction. +- **Communication Protocol:** + - Adhere to the defined `CE_INT_*` and `CE_CMD_*` constants for inter-script communication. + - Use `llRegionSayTo` for marker communication when the target key is known to reduce region-wide listener wakeups. +- **Parsing & Data Handling:** + - For high-frequency updates (e.g., camera frames), use optimized parsing helpers as seen in `HS_CamEngineCore.lsl`. + - When reading presets, avoid full string splitting if only a subset of fields is required. +- **Workflow Constraints:** + - Keep continuous tour runtime lists in script memory (`HS_CamEngineTour.lsl`) rather than Linkset Data to ensure performance during timer ticks. + +## Existing Patterns & Conventions + +- **Naming Conventions:** + - Scripts follow the `HS_Cam[Name].lsl` pattern. + - Communication constants use the `CE_CMD_`, `CE_INT_`, `MN_CMD`, etc., prefixes. +- **Code Organization:** + - Modules are split by responsibility (Controller, Playlist, Engine, etc.). + - Use specialized helper scripts for secondary workflows (e.s. `HS_CamTourCommands.lsl`) to reduce memory pressure on main scripts. +- **Error Handling/Validation:** + - Local checks should include brace-balance and conflict marker searches. + - Use targeted `rg` scans to detect accidental reintroductions of inefficient `llParseString2List` calls.