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.
This commit is contained in:
mita 2026-05-07 06:10:45 +02:00
commit 31443b091f
13 changed files with 8846 additions and 0 deletions

249
AGENTS.md Normal file
View File

@ -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 <ms> [mode] <idx...>`) 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 `<pos>|<focus>`.
- `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.

966
HS_CamController.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

30
HS_CamEngine.properties Normal file
View File

@ -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

966
HS_CamEngineCore.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

989
HS_CamEngineTour.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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;
}
}
}

966
HS_CamFov.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

966
HS_CamMarkers.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

966
HS_CamMenu.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

966
HS_CamPlaylist.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

966
HS_CamTourCommands.lsl Normal file
View File

@ -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 <ms>)
- TOUR blocks: tour <total_ms> [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 "<x,y,z>")
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 <vector> 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 <idx>\n"
+ "/88 load <idx> (cut)\n"
+ "/88 moveto <idx> [ms]\n"
+ "/88 del <idx>\n"
+ "/88 list [from] [count]\n"
+ "/88 play <notecard> [gap_ms]\n"
+ "/88 stop\n"
+ "/88 tour <ms> [mode] <idx1> <idx2> ...\n"
+ "/88 cfg reload|dump\n"
+ "/88 show cams [N]\n"
+ "/88 hide cams\n"
+ "/88 lock on [<x,y,z>|uuid]\n"
+ "/88 lock off\n"
+ "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n"
+ "/88 follow off\n"
+ "/88 fov <rad> (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n"
+ "/88 fovdeg <deg> (sets viewer FOV via RLVa; deg 10..179)\n"
+ "/88 dollyzoom <ms> [mode] <idxA> <idxB>\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<llGetListLength(kv); ++i) {
string pair = llList2String(kv,i);
integer eq = llSubStringIndex(pair, "=");
if (eq < 1) jump next;
string k = llToLower(llGetSubString(pair, 0, eq-1));
string v = llGetSubString(pair, eq+1, -1);
say(" " + k + "=" + v);
if (k == "default_move_ms") gDefaultMoveMs = (integer)v;
@next;
}
return;
}
if (num == CE_EVT_MOVE_DONE ) {
hudShow();
return;
}
if (num == CE_EVT_STATE) {
// payload: reqId|<pos>|<focus>|<rot>
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 <total_ms> [mode] <idx1> <idx2> ... <idxN>
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 <total_ms> [mode] <idxA> <idxB> [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 <rad> (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");
}
}

506
HS_DollyCam_Manual Normal file
View File

@ -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 <idx>
Saves current camera position + focus to preset slot <idx>.
/88 load <idx>
Instant cut to preset <idx> (no smoothing).
/88 moveto <idx> [duration_ms]
Smooth move to preset <idx> over duration_ms.
If duration_ms is omitted, default_move_ms is used.
/88 del <idx>
Deletes preset <idx>.
/88 list [from] [count]
Lists saved presets in a range.
Important:
- preset index must be > 0 (slot 0 is reserved).
D) Playlist
/88 play <notecard> [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 [<x,y,z>|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 <rad>
Sets viewer FOV in radians. Values greater than 3.2 are treated as degrees.
/88 fovdeg <deg>
Sets viewer FOV in degrees. Range is clamped to 10..179.
/88 tour <ms> [mode] <idx1> <idx2> ... [fovdeg <a> <b>|fov <radA> <radB>]
Runs a one-line tour and optionally ramps FOV from a to b.
/88 dollyzoom <ms> [mode] <idxA> <idxB> [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 <idx> [ms]
- goto <idx> [ms] (alias of moveto)
- load <idx> [ms] (default is cut; if [ms] is provided it will be used)
- wait <ms>
- fov <rad> [quiet]
- fovdeg <deg> [quiet]
- dollyzoom <ms> [mode] <idxA> <idxB> [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 <idx>" is a cut by default, but "load <idx> <ms>" 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 <ms>
...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 <card> [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 <total_ms> [mode]
... waypoint commands ...
endtour
Fast compact notecard syntax:
tour <total_ms> [mode] <idx1> <idx2> ... [fovdeg <a> <b>|fov <radA> <radB>]
- 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 <idx> [ms] -> adds waypoint (ms ignored inside tour)
- goto <idx> -> same as moveto
- load <idx> -> adds waypoint (still part of tour; no instant cut inside)
- wait <ms> -> HOLD at the most recent waypoint (see below)
- fovdeg <a> <b> -> optional FOV ramp across the tour
- fov <a> <b> -> optional FOV ramp in radians
- waypoint lines may include w=<weight> or speed=<factor>
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 <same idx>`
before it if you want the viewer camera to be placed there first.
Wait inside tour = HOLD
- In a tour, "wait <ms>" 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 <uuid> world 500
Capture-follow a target, in world mode, blending in over 500ms.
/88 follow on <uuid> 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 <uuid> <posOffset> <focusOffset> yaw|local|world <transition_ms>
(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 <subject_uuid> yaw 400
lock on <target_uuid>

View File

@ -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:<radians>=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 <rad>
Examples:
/88 fov 1.0472 (≈ 60°)
/88 fov 0.5236 (≈ 30°)
B) Set FOV in degrees
/88 fovdeg <deg>
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 <rad> [quiet]
fovdeg <deg> [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<idx>”
- 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 presets stored FOV.
A) Chat:
/88 load <idx> (cut)
/88 moveto <idx> [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 <total_ms> [mode]
moveto <idx1> [optional weight tokens]
moveto <idx2> ...
fovdeg <degA> <degB> (optional, can be inside tour block)
wait <ms> (hold at the last waypoint)
endtour
B) Fast compact tour (chat or notecard):
/88 tour <ms> [mode] <idx1> <idx2> ... [fovdeg <a> <b>]
/88 tour <ms> [mode] <idx1> <idx2> ... [fov <radA> <radB>]
tour <ms> [mode] <idx1> <idx2> ... [fovdeg <a> <b>]
tour <ms> [mode] <idx1> <idx2> ... [fov <radA> <radB>]
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 <ms> [mode] <idxA> <idxB>
Chat/menu DollyZoom and one-line tours use HS_CamTourCommands.lsl.
Notecard syntax:
dollyzoom <ms> [mode] <idxA> <idxB>
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 <ms> [mode] <idxA> <idxB> keep
Notecard:
dollyzoom <ms> [mode] <idxA> <idxB> 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 doesnt 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 doesnt 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.

49
project-context.md Normal file
View File

@ -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.