From 280c620a15eaea5fcf619249506ef2f8dcd6e043 Mon Sep 17 00:00:00 2001 From: mita Date: Wed, 20 May 2026 06:03:40 +0200 Subject: [PATCH] Sicherung --- HS_CamEngine.properties | 4 +- HS_CamEngineCore.lsl | 1660 +++++++-------- HS_CamEngineTour.lsl | 1885 +++++++++-------- HS_CamFov.lsl | 1046 ++------- HS_CamMarkers.lsl | 1013 ++------- HS_CamMenu.lsl | 1748 ++++++++------- HS_CamPlaylist.lsl | 1832 ++++++++-------- HS_CamPresetTransfer.lsl | 329 +++ HS_CamTourCommands.lsl | 1151 +++------- .../spec-preset-transfer-utility.md | 107 + 10 files changed, 4801 insertions(+), 5974 deletions(-) create mode 100644 HS_CamPresetTransfer.lsl create mode 100644 _bmad-output/implementation-artifacts/spec-preset-transfer-utility.md diff --git a/HS_CamEngine.properties b/HS_CamEngine.properties index 91ef434..90890bd 100644 --- a/HS_CamEngine.properties +++ b/HS_CamEngine.properties @@ -20,8 +20,8 @@ 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 +tour_pos_epsilon=0.002 +tour_focus_epsilon=0.002 # Follow smoothing aid follow_predict=0.10 diff --git a/HS_CamEngineCore.lsl b/HS_CamEngineCore.lsl index 4b27a8a..4ae581d 100644 --- a/HS_CamEngineCore.lsl +++ b/HS_CamEngineCore.lsl @@ -1,166 +1,122 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message - - Notes: - - idx is valid only if > 0 (slot 0 reserved). - - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). - - Early-cut is disabled for tours (tour is one continuous move). + HS_DollyCam - CamCore (tour split, memory-safe) + - Permissions, base camera params, MoveTo, Follow/Lock, Config, GetState + - Tour playback is offloaded to HS_CamTourEngine.lsl via internal link messages: + CE_INT_TOUR_BEGIN / CE_INT_SET_CAM / CE_INT_TOUR_END / CE_INT_TOUR_STOP */ -integer CH = 88; +string CFG_CARD = "HS_CamEngine.properties"; -// ===== 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) +// Link message protocol (Controller -> Core) 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_MOVE = 1010; // payload: moveId|durMs|| +integer CE_CMD_TOUR = 1011; // handled by HS_CamTourEngine (Core ignores) 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_LOCK = 1020; // payload: 0| or 1| or 1| +integer CE_CMD_FOLLOW = 1030; // payload: 0||| or 1|... integer CE_CMD_CFG_RELOAD = 1050; integer CE_CMD_CFG_DUMP = 1051; -integer CE_CMD_GET_STATE = 1060; +integer CE_CMD_GET_STATE = 1060; // payload: reqId -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; +// Link message protocol (Core -> Controller) +integer CE_EVT_READY = 2000; // payload: CAM_OK +integer CE_EVT_DENIED = 2001; // payload: CAM_DENIED +integer CE_EVT_MOVE_DONE = 2010; // payload: moveId +integer CE_EVT_CFG_DUMP = 2051; // payload: key=val|... +integer CE_EVT_STATE = 2060; // payload: reqId||||fovRad -// 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) +// Internal protocol (TourEngine -> Core) +integer CE_INT_SET_CAM = 3000; // payload: | +integer CE_INT_TOUR_BEGIN = 3001; // payload: moveId +integer CE_INT_TOUR_END = 3002; // payload: moveId +integer CE_INT_TOUR_STOP = 3003; // payload: (unused) -// 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 +integer FOLLOW_YAW = 0; +integer FOLLOW_LOCAL = 1; +integer FOLLOW_WORLD = 2; -// ===== 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 +// --- Defaults (Notecard can override) --- +float gMOVE_STEP = 0.025; +float gFOLLOW_STEP = 0.05; -// Presets -string PRE_KEY(integer idx) { return "P" + (string)idx; } +integer gDEFAULT_MOVE_MS = 3000; +integer gTOUR_MAX_POINTS = 20; +float gDEFAULT_FOCUS_DIST = 10.0; +float gTOUR_CAM_MIN_INTERVAL = 0.033; +float gTOUR_POS_EPS = 0.005; +float gTOUR_FOCUS_EPS = 0.005; -// Controller runtime -key gOwner; -integer gListen; +float gMOVE_POS_LAG = 2.0; +float gMOVE_FOCUS_LAG = 2.0; +float gFOLLOW_POS_LAG = 1.6; +float gFOLLOW_FOCUS_LAG= 1.6; -// Move ids -integer gMoveId = 100; // start non-zero -integer nextMoveId() { gMoveId++; return gMoveId; } +float gPOS_THRESHOLD = 0.02; +float gFOCUS_THRESHOLD = 0.02; -// Defaults (updated when engine cfg dump arrives) -integer gDefaultMoveMs = 2200; +float gFOLLOW_PREDICT = 0.10; +// Runtime state +key gOwner; +integer gHasPerm = FALSE; +integer gCamActive = FALSE; -integer demoSlotOk(integer idx) -{ - if (!DEMO_MODE) return TRUE; +integer gMoving = FALSE; +integer gMoveId = 0; +float gMoveStart = 0.0; +float gMoveDur = 1.0; - if (idx <= DEMO_MAX_SLOTS) return TRUE; +integer gMovePending = FALSE; +integer gMovePendId; +integer gMovePendDurMs; +vector gMovePendPos; +vector gMovePendFoc; - say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); - return FALSE; -} +vector gStartPos; +vector gStartFocus; +vector gTargetPos; +vector gTargetFocus; -// ---- save pending ---- -integer gSavePending = FALSE; -integer gSaveIdx = 0; -integer gSaveReq = 0; +vector gCurPos; +vector gCurFocus; +rotation gCurRot; -// Temp preset buffer (set by loadPreset) -vector gTmpPos; -vector gTmpFoc; -integer gTmpHasFov = FALSE; -float gTmpFovRad = 0.0; +// Follow/Lock +integer gFollowMode = FOLLOW_WORLD; -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° +vector gCamOffW; vector gFocusOffW; +vector gCamOffL; vector gFocusOffL; +vector gCamOffYaw; vector gFocusOffYaw; -// --- Marker menu state (persist across scripts) --- -string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" +integer gFollowBlendOn = FALSE; +float gFollowBlendDur = 0.0; +float gFollowBlendStart = 0.0; +vector gFollowBlendStartCam; +vector gFollowBlendStartFocus; -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; +integer gLockOn = FALSE; +vector gLockFocus; +key gLockTarget = NULL_KEY; +vector gLockTargetOffset = <0,0,1.0>; -followLockInitState() -{ - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); -} +integer gFollowOn = FALSE; +key gFollowTarget; -followWrite(integer on, key target) -{ - if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); -} +// Config +key gCfgQuery; +integer gCfgLine = 0; +integer gPendingBaseApply = FALSE; -lockWrite(integer on, string arg) -{ - if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); - else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); -} +// Tour external drive flag +integer gExternDrive = FALSE; -// 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); -} +// Cached camera param lists +list gBaseMoveParams; +list gBaseFollowParams; +integer gBaseParamsDirty = TRUE; +// ---------- helpers ---------- float clampf(float v, float lo, float hi) { if (v < lo) return lo; @@ -168,243 +124,453 @@ float clampf(float v, float lo, float hi) return v; } -float clampFovRad(float rad) +float smootherstep(float x) { - float deg = rad2deg(rad); - deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); - return deg2rad(deg); + if (x <= 0.0) return 0.0; + if (x >= 1.0) return 1.0; + return x*x*x*(x*(x*6.0 - 15.0) + 10.0); } -string presetDescribe(integer idx, string data) +rotation rotFromPosFocus(vector pos, vector focus) { - list p = llParseString2List(data, ["|"], []); - integer L = llGetListLength(p); - if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + vector fwd = llVecNorm(focus - pos); + vector up = <0,0,1>; + vector left = up % fwd; + if (llVecMag(left) < 0.0001) left = <1,0,0>; + else left = llVecNorm(left); - 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)>; + vector up2 = llVecNorm(fwd % left); + return llAxes2Rot(left, up2, fwd); +} - string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; +rotation yawOnlyRot(rotation r, vector vel) +{ + vector fwd = llRot2Fwd(r); + if (r == ZERO_ROTATION && vel != ZERO_VECTOR) fwd = llVecNorm(vel); - // 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; - } + fwd.z = 0.0; + if (fwd == ZERO_VECTOR) return ZERO_ROTATION; + fwd = llVecNorm(fwd); + return llRotBetween(<1,0,0>, fwd); +} + +integer camControlOk() +{ + return ((llGetPermissions() & PERMISSION_CONTROL_CAMERA) && (llGetPermissionsKey() == gOwner)); +} +integer camTrackOk() +{ + return ((llGetPermissions() & PERMISSION_TRACK_CAMERA) && (llGetPermissionsKey() == gOwner)); +} + +buildBaseParams() +{ + gBaseMoveParams = [ + CAMERA_ACTIVE, TRUE, + CAMERA_POSITION_LOCKED, TRUE, + CAMERA_FOCUS_LOCKED, TRUE, + CAMERA_POSITION_LAG, gMOVE_POS_LAG, + CAMERA_FOCUS_LAG, gMOVE_FOCUS_LAG, + CAMERA_POSITION_THRESHOLD, gPOS_THRESHOLD, + CAMERA_FOCUS_THRESHOLD, gFOCUS_THRESHOLD + ]; + + gBaseFollowParams = [ + CAMERA_ACTIVE, TRUE, + CAMERA_POSITION_LOCKED, TRUE, + CAMERA_FOCUS_LOCKED, TRUE, + CAMERA_POSITION_LAG, gFOLLOW_POS_LAG, + CAMERA_FOCUS_LAG, gFOLLOW_FOCUS_LAG, + CAMERA_POSITION_THRESHOLD, gPOS_THRESHOLD, + CAMERA_FOCUS_THRESHOLD, gFOCUS_THRESHOLD + ]; + + gBaseParamsDirty = FALSE; +} + +applyBaseMove() +{ + if (!gHasPerm) return; + if (gBaseParamsDirty || llGetListLength(gBaseMoveParams) == 0) buildBaseParams(); + llSetCameraParams(gBaseMoveParams); + gCamActive = TRUE; +} + +applyBaseFollow() +{ + if (!gHasPerm) return; + if (gBaseParamsDirty || llGetListLength(gBaseFollowParams) == 0) buildBaseParams(); + llSetCameraParams(gBaseFollowParams); + gCamActive = TRUE; +} + +applyBaseForMode() +{ + if (!gHasPerm) return; + if (gMoving) applyBaseMove(); + else applyBaseFollow(); +} + +setTimerForMode() +{ + if (gExternDrive) { llSetTimerEvent(0.0); return; } + + if (gMoving) { llSetTimerEvent(gMOVE_STEP); return; } + if (gFollowOn || gLockOn) { llSetTimerEvent(gFOLLOW_STEP); return; } + llSetTimerEvent(0.0); +} + +requestCamPerm() +{ + if (gOwner == NULL_KEY) gOwner = llGetOwner(); + + if ((llGetPermissions() & PERMISSION_CONTROL_CAMERA) && (llGetPermissionsKey() == gOwner)) { + gHasPerm = TRUE; + applyBaseForMode(); + + llMessageLinked(LINK_SET, CE_EVT_READY, "CAM_OK", gOwner); + + return; } - s += " fov=(none)"; - return s; + llRequestPermissions(gOwner, PERMISSION_CONTROL_CAMERA | PERMISSION_TRACK_CAMERA); } -// ===== RLVa helpers ===== -float deg2rad(float deg) { return deg * PI / 180.0; } -float rad2deg(float rad) { return rad * 180.0 / PI; } - -string fmtFloat(float v) +releaseCam() { - // 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; + // Idempotenz: verhindert doppelte Release-Sequenzen (attach(NULL_KEY) + CE_CMD_RELEASE) + if (!gCamActive && !gHasPerm && !gExternDrive && !gMoving && !gFollowOn && !gLockOn) return; + + llMessageLinked(LINK_SET, CE_INT_TOUR_STOP, "", gOwner); + + gExternDrive = FALSE; + gMoving = FALSE; + gFollowOn = FALSE; + gLockOn = FALSE; + gCamActive = FALSE; + + // reset permission flags (avoid stale state) + gHasPerm = FALSE; + + if (camControlOk()) + llClearCameraParams(); + + llSetTimerEvent(0.0); } -// ---------- helpers ---------- -integer isValidIdx(integer idx) { return (idx > 0); } - -say(string s) { llOwnerSay(s); } - -// Playlist / TourCommand helpers -phStop(string reason) +integer followCapture(key target, integer mode, integer transitionMs) { - llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); -} + list d = llGetObjectDetails(target, [OBJECT_POS, OBJECT_ROT, OBJECT_VELOCITY]); + if (llGetListLength(d) < 1) return FALSE; -phPlay(string card, integer gapMs) -{ - llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); -} + vector p = llList2Vector(d, 0); + rotation r = ZERO_ROTATION; + vector v = ZERO_VECTOR; -phChatTour(string line) -{ - llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); -} + if (llGetListLength(d) >= 2) r = llList2Rot(d, 1); + if (llGetListLength(d) >= 3) v = llList2Vector(d, 2); -phMenuTourRun(string raw) -{ - llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); -} + p += v * gFOLLOW_PREDICT; -phChatDollyZoom(string line) -{ - llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); -} + vector camPos; + rotation camRot; -// Single-prim HUD (Controller in ROOT) -hudHide() -{ - llSetAlpha(0.0, ALL_SIDES); -} -hudShow() -{ - llSetAlpha(1.0, ALL_SIDES); -} + if (camTrackOk()) { + camPos = llGetCameraPos(); + camRot = llGetCameraRot(); + } else { + camPos = gCurPos; + camRot = rotFromPosFocus(gCurPos, gCurFocus); + } + if (camPos == ZERO_VECTOR) { + rotation rr = r; + if (mode == FOLLOW_YAW) rr = yawOnlyRot(r, v); + camPos = p + (<-4.0, 0.0, 2.0> * rr); + camRot = rr; + } -// ---------- engine commands ---------- -engineInit() -{ - llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); -} + vector focusNow = camPos + (llRot2Fwd(camRot) * gDEFAULT_FOCUS_DIST); -engineRelease() -{ - llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); -} + gFollowTarget = target; + gFollowMode = mode; -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); + if (mode == FOLLOW_WORLD) { + gCamOffW = camPos - p; + gFocusOffW = focusNow - p; + } + else if (mode == FOLLOW_LOCAL) { + gCamOffL = (camPos - p) / r; + gFocusOffL = (focusNow - p) / r; + } + else { + rotation yawR = yawOnlyRot(r, v); + gCamOffYaw = (camPos - p) / yawR; + gFocusOffYaw = (focusNow - p) / yawR; + gFollowMode = FOLLOW_YAW; + } - // Hide HUD during non-instant moves (avoid flicker on cuts) - if (durMs > 0) hudHide(); + gFollowBlendOn = (transitionMs > 0); + if (gFollowBlendOn) { + gFollowBlendDur = (float)transitionMs / 1000.0; + if (gFollowBlendDur < 0.01) gFollowBlendDur = 0.01; + gFollowBlendStart = llGetTime(); + gFollowBlendStartCam = camPos; + gFollowBlendStartFocus = focusNow; + } - 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() +applyFollowOnce() { - if (!gTmpHasFov) return; + if (!gHasPerm) return; + if (!gFollowOn || gFollowTarget == NULL_KEY) return; - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); -} + list d = llGetObjectDetails(gFollowTarget, [OBJECT_POS, OBJECT_ROT, OBJECT_VELOCITY]); + integer len = llGetListLength(d); + if (len < 1) return; -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; + vector p = llList2Vector(d, 0); + rotation r = ZERO_ROTATION; + vector v = ZERO_VECTOR; + if (len >= 2) r = llList2Rot(d, 1); + if (len >= 3) v = llList2Vector(d, 2); -integer followModeFrom(string s) -{ - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; -} + p += v * gFOLLOW_PREDICT; -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) -{ - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; + vector pos; + vector foc; - string s = llList2String(toks, i); - - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; + if (gFollowMode == FOLLOW_WORLD) { + pos = p + gCamOffW; + foc = p + gFocusOffW; + } + else if (gFollowMode == FOLLOW_LOCAL) { + pos = p + (gCamOffL * r); + foc = p + (gFocusOffL * r); + } + else { + rotation yawR = yawOnlyRot(r, v); + pos = p + (gCamOffYaw * yawR); + foc = p + (gFocusOffYaw * yawR); } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { - ++i; - s += " " + llList2String(toks, i); + if (gFollowBlendOn) { + float b = (llGetTime() - gFollowBlendStart) / gFollowBlendDur; + if (b >= 1.0) { b = 1.0; gFollowBlendOn = FALSE; } + pos = gFollowBlendStartCam + (pos - gFollowBlendStartCam) * b; + foc = gFollowBlendStartFocus + (foc - gFollowBlendStartFocus) * b; } - return [s, i + 1]; + + if (gLockOn) { + if (gLockTarget != NULL_KEY) { + list det2 = llGetObjectDetails(gLockTarget, [OBJECT_POS]); + if (llGetListLength(det2) >= 1) { + vector tpos = llList2Vector(det2, 0); + foc = tpos + gLockTargetOffset; + } + } else { + foc = gLockFocus; + } + } + + gCurPos = pos; + gCurFocus = foc; + llSetCameraParams([CAMERA_POSITION, pos, CAMERA_FOCUS, foc]); } -// ---------- chat commands ---------- -printHelp() +applyLockOnce() { - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" - ); + if (!gHasPerm) return; + if (!gLockOn) return; + if (gMoving || gExternDrive) return; + + if (gCurPos == ZERO_VECTOR && camTrackOk()) { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + gCurPos = camPos; + gCurFocus = camPos + (llRot2Fwd(camRot) * gDEFAULT_FOCUS_DIST); + } + if (gCurPos == ZERO_VECTOR) return; + + vector foc = gCurFocus; + + if (gLockTarget != NULL_KEY) { + list det = llGetObjectDetails(gLockTarget, [OBJECT_POS]); + if (llGetListLength(det) >= 1) { + vector tpos = llList2Vector(det, 0); + foc = tpos + gLockTargetOffset; + } + } else { + foc = gLockFocus; + } + + gCurFocus = foc; + llSetCameraParams([CAMERA_POSITION, gCurPos, CAMERA_FOCUS, foc]); } -// ---------- default ---------- +startMove(integer moveId, integer durMs, vector pos, vector focus) +{ + // stop external tour playback (a move overrides) + gExternDrive = FALSE; + llMessageLinked(LINK_SET, CE_INT_TOUR_STOP, "", gOwner); + + if (!gHasPerm) return; + + applyBaseMove(); + + if (gCurPos == ZERO_VECTOR && gCurFocus == ZERO_VECTOR) { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + vector camFocus = camPos + (llRot2Fwd(camRot) * gDEFAULT_FOCUS_DIST); + gCurPos = camPos; + gCurFocus = camFocus; + } + + gMoveId = moveId; + gStartPos = gCurPos; + gStartFocus = gCurFocus; + gTargetPos = pos; + gTargetFocus = focus; + + float d = (float)durMs / 1000.0; + + if (durMs <= 0 || d <= gMOVE_STEP) { + gMoving = FALSE; + gCurPos = gTargetPos; + gCurFocus = gTargetFocus; + gCurRot = rotFromPosFocus(gCurPos, gCurFocus); + llSetCameraParams([CAMERA_POSITION, gCurPos, CAMERA_FOCUS, gCurFocus]); + llMessageLinked(LINK_SET, CE_EVT_MOVE_DONE, (string)gMoveId, gOwner); + + if (gPendingBaseApply) { + gPendingBaseApply = FALSE; + applyBaseForMode(); + } + setTimerForMode(); + return; + } + + gMoveStart = llGetTime(); + gMoveDur = d; + gMoving = TRUE; + + llSetCameraParams([CAMERA_POSITION, gStartPos, CAMERA_FOCUS, gStartFocus]); + setTimerForMode(); +} + +stopMove() +{ + // stop external tour too + llMessageLinked(LINK_SET, CE_INT_TOUR_STOP, "", gOwner); + + gExternDrive = FALSE; + gMoving = FALSE; + setTimerForMode(); +} + +// ---------- config loading ---------- +cfgStart() +{ + gCfgLine = 0; + gCfgQuery = NULL_KEY; + + if (llGetInventoryType(CFG_CARD) != INVENTORY_NOTECARD) { + llMessageLinked(LINK_SET, CE_EVT_CFG_DUMP, cfgDump(), gOwner); + + if (gCamActive) { + if (gMoving || gExternDrive) gPendingBaseApply = TRUE; + else applyBaseForMode(); + } + return; + } + + gCfgQuery = llGetNotecardLine(CFG_CARD, gCfgLine); +} + +cfgParseLine(string line) +{ + line = llStringTrim(line, STRING_TRIM); + if (line == "") return; + if (llGetSubString(line, 0, 0) == "#") return; + if (llGetSubString(line, 0, 1) == "//") return; + + integer eq = llSubStringIndex(line, "="); + if (eq < 1) return; + + string k = llToLower(llStringTrim(llGetSubString(line, 0, eq - 1), STRING_TRIM)); + string v = llStringTrim(llGetSubString(line, eq + 1, -1), STRING_TRIM); + + if (k == "move_step") gMOVE_STEP = (float)v; + else if (k == "follow_step") gFOLLOW_STEP = (float)v; + else if (k == "default_move_ms") gDEFAULT_MOVE_MS = (integer)v; + else if (k == "default_focus_dist") gDEFAULT_FOCUS_DIST = (float)v; + + else if (k == "move_pos_lag") gMOVE_POS_LAG = (float)v; + else if (k == "move_focus_lag") gMOVE_FOCUS_LAG = (float)v; + else if (k == "follow_pos_lag") gFOLLOW_POS_LAG = (float)v; + else if (k == "follow_focus_lag") gFOLLOW_FOCUS_LAG = (float)v; + + else if (k == "pos_threshold") gPOS_THRESHOLD = (float)v; + else if (k == "focus_threshold") gFOCUS_THRESHOLD = (float)v; + + else if (k == "follow_predict") gFOLLOW_PREDICT = (float)v; + else if (k == "tour_cam_min_interval") { + gTOUR_CAM_MIN_INTERVAL = clampf((float)v, 0.0, 0.25); + } + else if (k == "tour_pos_epsilon") { + gTOUR_POS_EPS = clampf((float)v, 0.0, 1.0); + } + else if (k == "tour_focus_epsilon") { + gTOUR_FOCUS_EPS = clampf((float)v, 0.0, 1.0); + } + else if (k == "tour_max_points") { + integer n = (integer)v; + if (n < 2) n = 2; + if (n > 40) n = 40; // safety cap + gTOUR_MAX_POINTS = n; + } + + // Backward-compatible aliases + else if (k == "camera_position_lag" || k == "poslag") { + float f = (float)v; + gMOVE_POS_LAG = f; gFOLLOW_POS_LAG = f; + } + else if (k == "camera_focus_lag" || k == "focuslag") { + float f2 = (float)v; + gMOVE_FOCUS_LAG = f2; gFOLLOW_FOCUS_LAG = f2; + } + + gBaseParamsDirty = TRUE; +} + +string cfgDump() +{ + return + "move_step=" + (string)gMOVE_STEP + "|" + + "follow_step=" + (string)gFOLLOW_STEP + "|" + + "default_move_ms=" + (string)gDEFAULT_MOVE_MS + "|" + + "default_focus_dist=" + (string)gDEFAULT_FOCUS_DIST + "|" + + "move_pos_lag=" + (string)gMOVE_POS_LAG + "|" + + "move_focus_lag=" + (string)gMOVE_FOCUS_LAG + "|" + + "follow_pos_lag=" + (string)gFOLLOW_POS_LAG + "|" + + "follow_focus_lag=" + (string)gFOLLOW_FOCUS_LAG + "|" + + "pos_threshold=" + (string)gPOS_THRESHOLD + "|" + + "focus_threshold=" + (string)gFOCUS_THRESHOLD + "|" + + "follow_predict=" + (string)gFOLLOW_PREDICT + "|" + + "tour_cam_min_interval=" + (string)gTOUR_CAM_MIN_INTERVAL + "|" + + "tour_pos_epsilon=" + (string)gTOUR_POS_EPS + "|" + + "tour_focus_epsilon=" + (string)gTOUR_FOCUS_EPS + "|" + + "tour_max_points=" + (string)gTOUR_MAX_POINTS; +} + +// ---------- LSL events ---------- 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(); + cfgStart(); } on_rez(integer sp) @@ -414,553 +580,387 @@ default attach(key id) { + gOwner = llGetOwner(); 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(); + releaseCam(); + } + } + + run_time_permissions(integer perm) + { + gHasPerm = ((perm & PERMISSION_CONTROL_CAMERA) != 0); + + if (gHasPerm) { + applyBaseForMode(); + llMessageLinked(LINK_SET, CE_EVT_READY, "CAM_OK", gOwner); + + if (gMovePending) { + gMovePending = FALSE; + startMove(gMovePendId, gMovePendDurMs, gMovePendPos, gMovePendFoc); + } } else { - gOwner = llGetOwner(); - hudShow(); - engineInit(); - engineCfgDump(); + gHasPerm = FALSE; + llMessageLinked(LINK_SET, CE_EVT_DENIED, "CAM_DENIED", gOwner); + } + + } + + dataserver(key qid, string data) + { + if (qid != gCfgQuery) return; + + if (data == EOF) { + if (gCamActive) { + if (gMoving || gExternDrive) gPendingBaseApply = TRUE; + else applyBaseForMode(); + } + + llMessageLinked(LINK_SET, CE_EVT_CFG_DUMP, cfgDump(), gOwner); + return; + } + + cfgParseLine(data); + gCfgLine++; + gCfgQuery = llGetNotecardLine(CFG_CARD, gCfgLine); + } + + timer() + { + if (!camControlOk()) { + // verhindert Spam-Warnungen und "tote" Moves + gHasPerm = FALSE; + gMoving = FALSE; + gExternDrive = FALSE; + llSetTimerEvent(0.0); + return; + } + + if (gExternDrive) return; // TourEngine drives the camera + + if (gMoving) { + float now = llGetTime(); + float t = (now - gMoveStart) / gMoveDur; + + if (t >= 1.0) { + gMoving = FALSE; + gCurPos = gTargetPos; + gCurFocus = gTargetFocus; + gCurRot = rotFromPosFocus(gCurPos, gCurFocus); + + llSetCameraParams([CAMERA_POSITION, gCurPos, CAMERA_FOCUS, gCurFocus]); + llMessageLinked(LINK_SET, CE_EVT_MOVE_DONE, (string)gMoveId, gOwner); + + if (gPendingBaseApply) { + gPendingBaseApply = FALSE; + applyBaseForMode(); + } else { + applyBaseForMode(); + } + + setTimerForMode(); + return; + } + + float e = smootherstep(t); + vector pos = gStartPos + (gTargetPos - gStartPos) * e; + vector foc = gStartFocus + (gTargetFocus - gStartFocus) * e; + + gCurPos = pos; + gCurFocus = foc; + + llSetCameraParams([CAMERA_POSITION, pos, CAMERA_FOCUS, foc]); + return; + } + + // Secondary modes (only when not moving) + vector pos = gCurPos; + vector foc = gCurFocus; + integer did = FALSE; + + if (gFollowOn && gFollowTarget != NULL_KEY) { + list d = llGetObjectDetails(gFollowTarget, [OBJECT_POS, OBJECT_ROT, OBJECT_VELOCITY]); + integer len = llGetListLength(d); + if (len < 1) { + gFollowOn = FALSE; + } else { + vector p = llList2Vector(d, 0); + rotation r = ZERO_ROTATION; + vector v = ZERO_VECTOR; + if (len >= 2) r = llList2Rot(d, 1); + if (len >= 3) v = llList2Vector(d, 2); + + p += v * gFOLLOW_PREDICT; + + if (gFollowMode == FOLLOW_WORLD) { + pos = p + gCamOffW; + foc = p + gFocusOffW; + } + else if (gFollowMode == FOLLOW_LOCAL) { + pos = p + (gCamOffL * r); + foc = p + (gFocusOffL * r); + } + else { + rotation yawR = yawOnlyRot(r, v); + pos = p + (gCamOffYaw * yawR); + foc = p + (gFocusOffYaw * yawR); + } + + if (gFollowBlendOn) { + float b = (llGetTime() - gFollowBlendStart) / gFollowBlendDur; + if (b >= 1.0) { b = 1.0; gFollowBlendOn = FALSE; } + pos = gFollowBlendStartCam + (pos - gFollowBlendStartCam) * b; + foc = gFollowBlendStartFocus + (foc - gFollowBlendStartFocus) * b; + } + + did = TRUE; + } + } + + if (gLockOn) { + if (gLockTarget != NULL_KEY) { + list det2 = llGetObjectDetails(gLockTarget, [OBJECT_POS]); + if (llGetListLength(det2) >= 1) { + vector tpos = llList2Vector(det2, 0); + foc = tpos + gLockTargetOffset; + did = TRUE; + } + } else { + foc = gLockFocus; + did = TRUE; + } + } + + if (did && gHasPerm) { + gCurPos = pos; + gCurFocus = foc; + llSetCameraParams([CAMERA_POSITION, pos, CAMERA_FOCUS, foc]); } } link_message(integer sender, integer num, string str, key id) { - if (num == CE_EVT_READY) { - say("Camera control granted."); - return; - } - if (num == CE_EVT_DENIED) { - say("Camera permission denied."); - return; - } - if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i|| - list p = llParseString2List(str, ["|"], []); - if (llGetListLength(p) < 4) return; - integer req = (integer)llList2String(p,0); + if (num == CE_INT_TOUR_END) { + gExternDrive = FALSE; + gMoving = FALSE; - 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); + if (gPendingBaseApply) { gPendingBaseApply = FALSE; } + applyBaseForMode(); + setTimerForMode(); return; } + if (num == CE_INT_TOUR_STOP) { + // IMPORTANT: + // Core broadcastet CE_INT_TOUR_STOP um TourEngine zu stoppen. + // Ohne Guard würde Core damit JEDE normale MOVE sofort wieder beenden. + if (!gExternDrive) return; // ignorieren, wenn wir nicht extern gefahren werden - // Menu commands from HS_CamMenu.lsl - if (num == MN_CMD) { - if (id != gOwner) return; // only trust owner-routed messages + gExternDrive = FALSE; + gMoving = FALSE; + + if (gPendingBaseApply) { gPendingBaseApply = FALSE; } + applyBaseForMode(); + setTimerForMode(); + return; + } + // ---- controller protocol ---- + if (num == CE_CMD_INIT) { requestCamPerm(); return; } + if (num == CE_CMD_RELEASE) { releaseCam(); return; } + if (num == CE_CMD_CFG_RELOAD) { cfgStart(); return; } + if (num == CE_CMD_CFG_DUMP) { llMessageLinked(LINK_SET, CE_EVT_CFG_DUMP, cfgDump(), gOwner); return; } + if (num == CE_CMD_STOP) { stopMove(); return; } + + if (num == CE_CMD_TOUR) { + // handled by HS_CamTourEngine.lsl + return; + } + + if (num == CE_CMD_MOVE) { + list p = llParseString2List(str, ["|"], []); + + if (llGetListLength(p) < 4) return; + + integer mid = (integer)llList2String(p, 0); + integer dms = (integer)llList2String(p, 1); + vector pos = (vector)llList2String(p, 2); + vector foc = (vector)llList2String(p, 3); + + // Permission wirklich prüfen (nicht nur gHasPerm vertrauen) + if (!camControlOk()) { + gHasPerm = FALSE; + + gMovePending = TRUE; + gMovePendId = mid; + gMovePendDurMs = dms; + gMovePendPos = pos; + gMovePendFoc = foc; + + requestCamPerm(); // hol Permission nach + return; + } + + gHasPerm = TRUE; + startMove(mid, dms, pos, foc); + return; + } + + if (num == CE_CMD_LOCK) { + list p = llParseString2List(str, ["|"], []); + if (llGetListLength(p) < 2) return; + + gLockOn = (integer)llList2String(p, 0); + + if (!gLockOn) { + gLockTarget = NULL_KEY; + if (!gMoving && !gExternDrive) setTimerForMode(); + return; + } + + // Lock wins + gFollowOn = FALSE; + gFollowTarget = NULL_KEY; + gFollowBlendOn = FALSE; + + string arg = llList2String(p, 1); + + if (llGetSubString(arg,0,0) == "<") { + gLockTarget = NULL_KEY; + gLockFocus = (vector)arg; + } else { + key k = (key)arg; + if (k != NULL_KEY) gLockTarget = k; + else { gLockTarget = NULL_KEY; gLockFocus = (vector)arg; } + } + + if (gHasPerm && !gCamActive) applyBaseForMode(); + applyLockOnce(); + + if (!gMoving && !gExternDrive) setTimerForMode(); + return; + } + + if (num == CE_CMD_FOLLOW) { list p = llParseString2List(str, ["|"], []); integer len = llGetListLength(p); - if (len < 1) return; + if (len < 2) 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)); + gFollowOn = (integer)llList2String(p, 0); + gFollowTarget = (key)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 (gFollowOn) { + gLockOn = FALSE; + gLockTarget = NULL_KEY; } - 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; + vector posOff = ZERO_VECTOR; + vector focOff = ZERO_VECTOR; + if (len >= 4) { + posOff = (vector)llList2String(p, 2); + focOff = (vector)llList2String(p, 3); } - - // SAVE|idx - if (typ == "SAVE" && len >= 2) { - integer idx = (integer)llList2String(p, 1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by menu save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - // MOVETO|idx|ms - if (typ == "MOVETO" && len >= 3) { - integer idx2 = (integer)llList2String(p, 1); - integer ms = (integer)llList2String(p, 2); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx2)) { say("Preset not found."); return; } - - if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default - phStop("Interrupted by menu moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); - return; - } - - // LOAD|idx (optional: menu supports cut) - if (typ == "LOAD" && len >= 2) { - integer idx3 = (integer)llList2String(p, 1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - phStop("Interrupted by menu load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - // PLAY|card|gap - if (typ == "PLAY" && len >= 2) { - string card = llList2String(p, 1); - integer gap = 0; - if (len >= 3) gap = (integer)llList2String(p, 2); - phPlay(card, gap); - return; - } - - // STOP - if (typ == "STOP") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - // TOURRUN|totalMs|mode|count|idx1|idx2|... - if (typ == "TOURRUN") { - phStop("Interrupted by menu tour."); - phMenuTourRun(str); - return; - } - - // CAMS|SHOW|N or CAMS|HIDE - if (typ == "CAMS" && len >= 2) { - string act = llToUpper(llList2String(p, 1)); - - if (act == "SHOW") { - integer want = gCamsN; - if (len >= 3) want = (integer)llList2String(p, 2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (act == "HIDE") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - gCamsShown = FALSE; - camsWriteState(); - return; - } - } - return; - } - - } - - listen(integer channel, string name, key id, string msg) - { - // Accept commands from: my avatar OR any object owned by me (including attachments) - if (llGetOwnerKey(id) != gOwner) return; - - msg = llStringTrim(msg, STRING_TRIM); - if (msg == "") return; - - list t = llParseString2List(msg, [" "], []); - integer n = llGetListLength(t); - string cmd = llToLower(llList2String(t,0)); - - if (cmd == "help") { printHelp(); return; } - - if (cmd == "cam" && n >= 2) { - string sw = llToLower(llList2String(t,1)); - if (sw == "on") engineInit(); - else engineRelease(); - hudShow(); - return; - } - - if (cmd == "cfg" && n >= 2) { - string sub = llToLower(llList2String(t,1)); - if (sub == "reload") engineCfgReload(); - else engineCfgDump(); - return; - } - - if (cmd == "save" && n >= 2) { - integer idx = (integer)llList2String(t,1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - if ((cmd == "del" || cmd == "delete") && n >= 2) { - integer idx2 = (integer)llList2String(t,1); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - llLinksetDataDelete(PRE_KEY(idx2)); - say("Deleted preset " + (string)idx2); - return; - } - - if (cmd == "load" && n >= 2) { - integer idx3 = (integer)llList2String(t,1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - - phStop("Interrupted by load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - if (cmd == "moveto" && n >= 2) { - integer idx4 = (integer)llList2String(t,1); - if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } - - integer ms = gDefaultMoveMs; - if (n >= 3) ms = (integer)llList2String(t,2); - - if (!loadPreset(idx4)) { say("Preset not found."); return; } - - phStop("Interrupted by moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); - return; - } - - if (cmd == "stop") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - if (cmd == "play" && n >= 2) { - string card = llList2String(t,1); - integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; - integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; - } - } - if (!shown) say("No presets found in range."); - return; - } - - // markers routed to helper script - if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - integer want = 12; - if (n >= 3) want = (integer)llList2String(t,2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - - gCamsShown = FALSE; - camsWriteState(); - return; - } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); - return; - } - - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); - - string lockArgUsed = "<0,0,0>"; - - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); - - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); - } else { - key k = (key)arg; - if (k != NULL_KEY) { - lockArgUsed = (string)k; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); - } else { - vector camPos = llGetCameraPos(); - rotation camRot = llGetCameraRot(); - vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); - lockArgUsed = (string)foc2; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); - } - } - } else { - vector camPos2 = llGetCameraPos(); - rotation camRot2 = llGetCameraRot(); - vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); - lockArgUsed = (string)foc3; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); - } - - lockWrite(TRUE, lockArgUsed); - say("Lock ON"); - return; - } - - if (cmd == "follow" && n >= 2) { - string sw4 = llToLower(llList2String(t,1)); - integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); - - key target = gOwner; - if (n >= 3) target = (key)llList2String(t,2); integer mode = FOLLOW_WORLD; integer trans = 0; + if (len >= 5) mode = (integer)llList2String(p, 4); + if (len >= 6) trans = (integer)llList2String(p, 5); - 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"); + if (!gFollowOn) { + gFollowTarget = NULL_KEY; + gFollowBlendOn = FALSE; + if (!gExternDrive) setTimerForMode(); return; } - // Mutual exclusion: Follow ON disables Lock - engineLockOff(); + if (gFollowTarget == NULL_KEY) gFollowTarget = gOwner; + if (gHasPerm && !gCamActive) applyBaseForMode(); - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; + integer ok = FALSE; - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); + if (posOff != ZERO_VECTOR || focOff != ZERO_VECTOR) { + gFollowMode = FOLLOW_WORLD; + gCamOffW = posOff; + gFocusOffW = focOff; - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + gFollowBlendOn = (trans > 0); + if (gFollowBlendOn) { + gFollowBlendDur = (float)trans / 1000.0; + if (gFollowBlendDur < 0.01) gFollowBlendDur = 0.01; + gFollowBlendStart = llGetTime(); + + if (camTrackOk()) { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + gFollowBlendStartCam = camPos; + gFollowBlendStartFocus = camPos + (llRot2Fwd(camRot) * gDEFAULT_FOCUS_DIST); + } else { + gFollowBlendStartCam = gCurPos; + gFollowBlendStartFocus = gCurFocus; + } + } + ok = TRUE; + } else { + ok = followCapture(gFollowTarget, mode, trans); + } + + if (!ok) { + gFollowOn = FALSE; + gFollowTarget = NULL_KEY; + gFollowBlendOn = FALSE; + if (!gExternDrive) setTimerForMode(); + return; + } + + applyFollowOnce(); + if (!gMoving && !gExternDrive) setTimerForMode(); return; } - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + float fov = 1.04719755; // default 60 Grad + if (num == CE_CMD_GET_STATE) { + string reqId = str; + + if (camTrackOk()) { + vector camPos = llGetCameraPos(); + rotation camRot = llGetCameraRot(); + gCurPos = camPos; + gCurRot = camRot; + gCurFocus = camPos + (llRot2Fwd(camRot) * gDEFAULT_FOCUS_DIST); + fov = llGetCameraFOV(); // radians, viewer-reported + } else { + if (gCurRot == ZERO_ROTATION) gCurRot = rotFromPosFocus(gCurPos, gCurFocus); + } + + llMessageLinked(LINK_SET, CE_EVT_STATE, + reqId + "|" + (string)gCurPos + "|" + (string)gCurFocus + "|" + (string)gCurRot + "|" + (string)fov, + gOwner + ); return; } - - if (cmd == "fovdeg" && n >= 2) { - float deg = (float)llList2String(t, 1); - float rad = deg * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - say("Unknown command. /88 help"); } } diff --git a/HS_CamEngineTour.lsl b/HS_CamEngineTour.lsl index f135392..46f7977 100644 --- a/HS_CamEngineTour.lsl +++ b/HS_CamEngineTour.lsl @@ -1,37 +1,18 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message - - Notes: - - idx is valid only if > 0 (slot 0 reserved). - - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). - - Early-cut is disabled for tours (tour is one continuous move). + HS_DollyCam - TourEngine (offloaded) + - Receives CE_CMD_TOUR from Controller + - Requests current camera state from Core via CE_CMD_GET_STATE + - Plays tour via timer and sends CE_INT_SET_CAM to Core */ -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_TOUR = 1011; 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_MOVE = 1010; +integer CE_CMD_RELEASE = 1001; integer CE_CMD_GET_STATE = 1060; +integer CE_CMD_CFG_DUMP = 1051; +integer CE_CMD_FOV = 1040; // NEW: TourEngine -> Controller (payload: rad|quiet) integer CE_EVT_READY = 2000; integer CE_EVT_DENIED = 2001; @@ -39,128 +20,181 @@ 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) +// Internal to Core +integer CE_INT_SET_CAM = 3000; // | +integer CE_INT_TOUR_BEGIN = 3001; // moveId +integer CE_INT_TOUR_END = 3002; // moveId +integer CE_INT_TOUR_STOP = 3003; -// 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 +integer DEBUG_FOV = FALSE; +float gFovDbgNext = 0.0; -// ===== 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 +float deg2rad(float d) { return d * PI / 180.0; } +float rad2deg(float r) { return r * 180.0 / PI; } -// Presets -string PRE_KEY(integer idx) { return "P" + (string)idx; } +float gMOVE_STEP = 0.025; +float gDEFAULT_FOCUS_DIST = 10.0; +float gTourCamMinInterval = 0.033; // 30 Hz link-message cap for tour camera frames +float gTourPosEps = 0.005; +float gTourFocusEps = 0.005; -// Controller runtime -key gOwner; -integer gListen; +key gOwner; +integer gCamReady = FALSE; -// Move ids -integer gMoveId = 100; // start non-zero -integer nextMoveId() { gMoveId++; return gMoveId; } +// ---- pending tour (waiting for state) ---- +integer gPend = FALSE; +string gPendReqId = ""; +integer gPendMoveId = 0; +integer gPendMoveMs = 0; +string gPendMode = "linear"; +integer gPendCount = 0; +integer gPendStartFirst = FALSE; -// Defaults (updated when engine cfg dump arrives) -integer gDefaultMoveMs = 2200; +list gPendPos; +list gPendFoc; +list gPendHoldMs; +list gPendWeight; +integer gPendFovOn = FALSE; +float gPendFovA = 0.0; // radians +float gPendFovB = 0.0; // radians -integer demoSlotOk(integer idx) +string gPipeParse = ""; +integer gPipeAt = 0; +integer gPipeLen = 0; + +// ---- TOUR runtime ---- +integer gTourActive = FALSE; +string gTourMode = "linear"; + +list gTourPos; // vectors (includes captured start) +list gTourFoc; // vectors (includes captured start) +list gTourHoldSec; // floats, hold AFTER each point (same length) + +list gTourSegLen; // floats, per segment (weighted) +list gTourCumLen; // floats, per point cumulative length +list gTourPointMoveT; // floats, per point time on "move clock" (excludes holds) + +integer gTourCount = 0; +integer gTourLastSeg = 0; + +float gTourStart = 0.0; +float gTourMoveDur = 0.0; +float gTourHoldSum = 0.0; +float gTourTotalDur = 0.0; +float gTourPathLen = 0.0; + +// trapezoid motion params +float gTourTe = 0.0; // accel duration +float gTourTc = 0.0; // cruise duration +float gTourVmax = 0.0; +float gTourS1 = 0.0; +float gTourS2 = 0.0; + +float gTourTeOut = 0.0; // decel duration (asymmetric) +float TOUR_EASE_FRAC = 0.20; +float gTourEaseInFrac = TOUR_EASE_FRAC; +float gTourEaseOutFrac = TOUR_EASE_FRAC; + +integer gTourSpline = FALSE; + +integer gTourFovOn = FALSE; +float gTourFovA = 0.0; // radians +float gTourFovB = 0.0; // radians + +float gTourFovLastSend = 0.0; +float gTourFovLastRad = -1.0; +float gTourFovMinInterval = 0.10; // 10 Hz +integer DEBUG_FOV_SEND = FALSE; // zum Verifizieren, danach auf FALSE + +string LSKEY_LOCK = "HS_LOCK"; +vector KEEP_LOCK_OFFSET = <0,0,1.0>; + +integer gTourKeepOn = FALSE; + +vector gKeepFixedFocus = ZERO_VECTOR; // focA (oder first waypoint foc) +vector gKeepMotifFocus = ZERO_VECTOR; // fixed oder lock-derived + +float gKeepLockNextPoll = 0.0; +float gKeepLockPollInterval = 0.05; // 20 Hz lock tracking + +float gTourKeepK = 0.0; // K = d0 * tan(fov0/2) + +integer gHoldCamValid = FALSE; +vector gHoldCamPos = ZERO_VECTOR; +vector gHoldCamFoc = ZERO_VECTOR; +float HOLD_CAM_EPS = 0.001; + +integer gCamSendValid = FALSE; +float gCamSendLast = -999.0; +float gCamSendNext = 0.0; +vector gCamSendPos = ZERO_VECTOR; +vector gCamSendFoc = ZERO_VECTOR; + +integer gSegCacheIdx = -1; +float gSegCacheStart = 0.0; +float gSegCacheEnd = 0.0; +float gSegCacheLen = 0.0; +vector gSegPos0 = ZERO_VECTOR; +vector gSegPos1 = ZERO_VECTOR; +vector gSegPos2 = ZERO_VECTOR; +vector gSegPos3 = ZERO_VECTOR; +vector gSegFoc0 = ZERO_VECTOR; +vector gSegFoc1 = ZERO_VECTOR; +vector gSegFoc2 = ZERO_VECTOR; +vector gSegFoc3 = ZERO_VECTOR; + +// Debug (default OFF) +integer DEBUG_KEEP = FALSE; +dbgKeep(string s){ if (DEBUG_KEEP) llOwnerSay("[KEEP] " + s); } + +float clampFovRad(float rad) { - 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; + // clamp via degrees 10..179 + float deg = rad2deg(rad); + deg = clampf(deg, 10.0, 179.0); + return deg2rad(deg); } -// ---- save pending ---- -integer gSavePending = FALSE; -integer gSaveIdx = 0; -integer gSaveReq = 0; - -// Temp preset buffer (set by loadPreset) -vector gTmpPos; -vector gTmpFoc; -integer gTmpHasFov = FALSE; -float gTmpFovRad = 0.0; - -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° - -// --- Marker menu state (persist across scripts) --- -string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" - -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; - -followLockInitState() +float keepFovRadFrom(vector pos, vector foc, float baseRad) { - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); -} + float d = llVecDist(pos, foc); + if (d < 0.01) d = 0.01; -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; + if (gTourKeepK <= 0.000001) { + return clampFovRad(baseRad); } + + // FOV = 2 * atan(K/d) -> use atan2(K, d) + float rad = 2.0 * llAtan2(gTourKeepK, d); + return clampFovRad(rad); } -camsWriteState() +keepFovMaybeSend(vector pos, vector foc, float baseRad) { - llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); + float rad = keepFovRadFrom(pos, foc, baseRad); + + float now = llGetTime(); + if ((now - gTourFovLastSend) < gTourFovMinInterval) return; + if (gTourFovLastRad >= 0.0 && llFabs(rad - gTourFovLastRad) < 0.002) return; + + gTourFovLastSend = now; + gTourFovLastRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|1", gOwner); } +keepFovSendNow(vector pos, vector foc, float baseRad) +{ + float rad = keepFovRadFrom(pos, foc, baseRad); + + gTourFovLastSend = llGetTime(); + gTourFovLastRad = rad; + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|1", gOwner); +} + +// ---------- helpers ---------- float clampf(float v, float lo, float hi) { if (v < lo) return lo; @@ -168,802 +202,945 @@ float clampf(float v, float lo, float hi) return v; } -float clampFovRad(float rad) +float clamp01(float x) { - float deg = rad2deg(rad); - deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); - return deg2rad(deg); + if (x < 0.0) return 0.0; + if (x > 1.0) return 1.0; + return x; } -string presetDescribe(integer idx, string data) +integer findCharFrom(string s, string ch, integer start) { - list p = llParseString2List(data, ["|"], []); - integer L = llGetListLength(p); - if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + integer L = llStringLength(s); + integer i; + for (i = start; i < L; ++i) { + if (llGetSubString(s, i, i) == ch) return i; + } + return -1; +} - 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)>; +integer pipeFieldCount(string s) +{ + integer L = llStringLength(s); + if (L < 1) return 0; - string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; + integer count = 1; + integer i; + for (i = 0; i < L; ++i) { + if (llGetSubString(s, i, i) == "|") ++count; + } + return count; +} - // 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; +string pipeField(string s, integer want) +{ + integer L = llStringLength(s); + integer start = 0; + integer idx = 0; + + while (start <= L) { + integer end = findCharFrom(s, "|", start); + if (end < 0) end = L; + + if (idx == want) return llGetSubString(s, start, end - 1); + + ++idx; + start = end + 1; + } + + return ""; +} + +pipeParseBegin(string s) +{ + gPipeParse = s; + gPipeAt = 0; + gPipeLen = llStringLength(s); +} + +string pipeNext() +{ + if (gPipeAt > gPipeLen) return ""; + + integer end = findCharFrom(gPipeParse, "|", gPipeAt); + if (end < 0) end = gPipeLen; + + string v = llGetSubString(gPipeParse, gPipeAt, end - 1); + gPipeAt = end + 1; + return v; +} + +pipeParseClear() +{ + gPipeParse = ""; + gPipeAt = 0; + gPipeLen = 0; +} + +tourFovMaybeSend(float frac) +{ + if (!gTourFovOn) return; + + frac = clamp01(frac); + float rad = gTourFovA + (gTourFovB - gTourFovA) * frac; + + float now = llGetTime(); + if ((now - gTourFovLastSend) < gTourFovMinInterval) return; + + if (gTourFovLastRad >= 0.0 && llFabs(rad - gTourFovLastRad) < 0.002) return; + + gTourFovLastSend = now; + gTourFovLastRad = rad; + + if (DEBUG_FOV_SEND) + llOwnerSay("[FOV-DBG TOUR] payload-send frac=" + (string)frac + " rad=" + (string)rad); + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|1", gOwner); +} + +tourFovForceFinal() +{ + if (!gTourFovOn) return; + + float rad = gTourFovB; + + if (DEBUG_FOV_SEND) + llOwnerSay("[FOV-DBG TOUR] payload-final rad=" + (string)rad); + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|1", gOwner); +} + +integer tourSendCam(vector pos, vector foc, integer force) +{ + float now = llGetTime(); + + if (!force && gCamSendValid) { + if (gTourCamMinInterval > 0.0 && now < gCamSendNext) return FALSE; + if (llVecDist(pos, gCamSendPos) <= gTourPosEps && llVecDist(foc, gCamSendFoc) <= gTourFocusEps) return FALSE; + } + + gCamSendValid = TRUE; + gCamSendLast = now; + if (gTourCamMinInterval > 0.0) { + if (force || gCamSendNext <= 0.0) gCamSendNext = now + gTourCamMinInterval; + else { + gCamSendNext += gTourCamMinInterval; + if (gCamSendNext < now) gCamSendNext = now + gTourCamMinInterval; } } - s += " fov=(none)"; - return s; -} + gCamSendPos = pos; + gCamSendFoc = foc; -// ===== 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); } - } - + llMessageLinked(LINK_SET, CE_INT_SET_CAM, (string)pos + "|" + (string)foc, gOwner); return TRUE; } -applyLoadedPresetFov() +tourSendHoldCam(vector pos, vector foc) { - if (!gTmpHasFov) return; + integer first = !gHoldCamValid; - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); -} - -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; - -integer followModeFrom(string s) -{ - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; -} - -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) -{ - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; - - string s = llList2String(toks, i); - - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; + if (gHoldCamValid) { + if (llVecDist(pos, gHoldCamPos) < HOLD_CAM_EPS && llVecDist(foc, gHoldCamFoc) < HOLD_CAM_EPS) return; } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { - ++i; - s += " " + llList2String(toks, i); - } - return [s, i + 1]; + gHoldCamValid = TRUE; + gHoldCamPos = pos; + gHoldCamFoc = foc; + + tourSendCam(pos, foc, first); } -// ---------- chat commands ---------- -printHelp() +tourCacheSegment(integer seg) { - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" + if (seg == gSegCacheIdx) return; + if (gTourCount < 2) return; + + integer last = gTourCount - 1; + integer i1 = seg; + integer i2 = seg + 1; + + if (i1 < 0) i1 = 0; + if (i2 > last) i2 = last; + + integer i0 = i1 - 1; if (i0 < 0) i0 = 0; + integer i3 = i2 + 1; if (i3 > last) i3 = last; + + gSegCacheIdx = seg; + gSegCacheStart = llList2Float(gTourCumLen, seg); + gSegCacheEnd = llList2Float(gTourCumLen, seg + 1); + gSegCacheLen = gSegCacheEnd - gSegCacheStart; + + gSegPos0 = llList2Vector(gTourPos, i0); + gSegPos1 = llList2Vector(gTourPos, i1); + gSegPos2 = llList2Vector(gTourPos, i2); + gSegPos3 = llList2Vector(gTourPos, i3); + + gSegFoc0 = llList2Vector(gTourFoc, i0); + gSegFoc1 = llList2Vector(gTourFoc, i1); + gSegFoc2 = llList2Vector(gTourFoc, i2); + gSegFoc3 = llList2Vector(gTourFoc, i3); +} + +keepUpdateMotifFocus() +{ + // default: fixed focus + gKeepMotifFocus = gKeepFixedFocus; + + string s = llLinksetDataRead(LSKEY_LOCK); + if (s == "") return; + + list p = llParseString2List(s, ["|"], []); + integer L = llGetListLength(p); + if (L < 1) return; + + integer on = (integer)llList2String(p, 0); + if (!on) return; + if (L < 2) return; + + string arg = llList2String(p, 1); + + // vector lock + if (llGetSubString(arg, 0, 0) == "<") { + gKeepMotifFocus = (vector)arg; + return; + } + + // key lock + key k = (key)arg; + if (k != NULL_KEY) { + list d = llGetObjectDetails(k, [OBJECT_POS]); + if (llGetListLength(d) >= 1) { + vector tpos = llList2Vector(d, 0); + gKeepMotifFocus = tpos + KEEP_LOCK_OFFSET; + } + return; + } + + // fallback: treat as vector string + gKeepMotifFocus = (vector)arg; +} + +vector catmullRomVec(vector p0, vector p1, vector p2, vector p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + + return 0.5 * ( + (2.0 * p1) + + (-p0 + p2) * t + + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3 ); } -// ---------- default ---------- +vector tourSplineAt(list pts, integer seg, float u) +{ + integer last = llGetListLength(pts) - 1; + + integer i1 = seg; + integer i2 = seg + 1; + + integer i0 = i1 - 1; if (i0 < 0) i0 = 0; + integer i3 = i2 + 1; if (i3 > last) i3 = last; + + vector p0 = llList2Vector(pts, i0); + vector p1 = llList2Vector(pts, i1); + vector p2 = llList2Vector(pts, i2); + vector p3 = llList2Vector(pts, i3); + + return catmullRomVec(p0, p1, p2, p3, u); +} + +float tourDistAt(float t) +{ + if (gTourMoveDur <= 0.0001 || gTourPathLen <= 0.0001 || gTourVmax <= 0.0001) return 0.0; + t = clampf(t, 0.0, gTourMoveDur); + + if (gTourTe <= 0.0001 && gTourTeOut <= 0.0001) { + return gTourPathLen * (t / gTourMoveDur); + } + + float teIn = gTourTe; + float teOut = gTourTeOut; + + if (t <= teIn) { + return 0.5 * gTourVmax * (t * t / teIn); + } + + if (t <= (teIn + gTourTc)) { + return gTourS1 + gTourVmax * (t - teIn); + } + + float t2 = t - (teIn + gTourTc); + return gTourS2 + gTourVmax * t2 - 0.5 * gTourVmax * (t2 * t2 / teOut); +} + +float tourTimeAtDist(float s) +{ + if (gTourMoveDur <= 0.0001 || gTourPathLen <= 0.0001 || gTourVmax <= 0.0001) return 0.0; + s = clampf(s, 0.0, gTourPathLen); + + if (gTourTe <= 0.0001 && gTourTeOut <= 0.0001) { + return gTourMoveDur * (s / gTourPathLen); + } + + float teIn = gTourTe; + float teOut = gTourTeOut; + + if (s <= gTourS1) { + return llSqrt((2.0 * s * teIn) / gTourVmax); + } + + if (s <= gTourS2) { + return teIn + ((s - gTourS1) / gTourVmax); + } + + float s3 = s - gTourS2; + float disc = (teOut * teOut) - ((2.0 * teOut * s3) / gTourVmax); + if (disc < 0.0) disc = 0.0; + + float t2 = teOut - llSqrt(disc); + return (teIn + gTourTc) + t2; +} + +tourStopInternal() +{ + gTourActive = FALSE; + gPend = FALSE; + gPendReqId = ""; + gPendStartFirst = FALSE; + + gTourMode = "linear"; + gTourPos = []; + gTourFoc = []; + gTourHoldSec = []; + gTourSegLen = []; + gTourCumLen = []; + gTourPointMoveT = []; + gTourCount = 0; + gTourLastSeg = 0; + + gTourStart = 0.0; + gTourMoveDur = 0.0; + gTourHoldSum = 0.0; + gTourTotalDur = 0.0; + gTourPathLen = 0.0; + + gTourTe = gTourTc = gTourVmax = gTourS1 = gTourS2 = 0.0; + gTourTeOut = 0.0; + + gTourSpline = FALSE; + gTourEaseInFrac = TOUR_EASE_FRAC; + gTourEaseOutFrac = TOUR_EASE_FRAC; + + gPendFovOn = FALSE; + gPendFovA = 0.0; + gPendFovB = 0.0; + + gTourFovOn = FALSE; + gTourFovA = 0.0; + gTourFovB = 0.0; + gTourFovLastSend = 0.0; + gTourFovLastRad = -1.0; + + gTourKeepOn = FALSE; + gTourKeepK = 0.0; + gKeepFixedFocus = ZERO_VECTOR; + gKeepMotifFocus = ZERO_VECTOR; + gKeepLockNextPoll = 0.0; + + gHoldCamValid = FALSE; + gHoldCamPos = ZERO_VECTOR; + gHoldCamFoc = ZERO_VECTOR; + + gCamSendValid = FALSE; + gCamSendLast = -999.0; + gCamSendNext = 0.0; + gCamSendPos = ZERO_VECTOR; + gCamSendFoc = ZERO_VECTOR; + + gSegCacheIdx = -1; + gSegCacheStart = 0.0; + gSegCacheEnd = 0.0; + gSegCacheLen = 0.0; + gSegPos0 = ZERO_VECTOR; + gSegPos1 = ZERO_VECTOR; + gSegPos2 = ZERO_VECTOR; + gSegPos3 = ZERO_VECTOR; + gSegFoc0 = ZERO_VECTOR; + gSegFoc1 = ZERO_VECTOR; + gSegFoc2 = ZERO_VECTOR; + gSegFoc3 = ZERO_VECTOR; + + llSetTimerEvent(0.0); +} + +applyCfgDump(string dump) +{ + // dump format: key=val|key=val|... + list parts = llParseString2List(dump, ["|"], []); + integer i; + for (i = 0; i < llGetListLength(parts); ++i) { + string kv = llList2String(parts, i); + integer eq = llSubStringIndex(kv, "="); + if (eq < 1) jump next; + string k = llToLower(llStringTrim(llGetSubString(kv, 0, eq - 1), STRING_TRIM)); + string v = llStringTrim(llGetSubString(kv, eq + 1, -1), STRING_TRIM); + + if (k == "move_step") gMOVE_STEP = (float)v; + else if (k == "default_focus_dist") gDEFAULT_FOCUS_DIST = (float)v; + else if (k == "tour_cam_min_interval") gTourCamMinInterval = clampf((float)v, 0.0, 0.25); + else if (k == "tour_pos_epsilon") gTourPosEps = clampf((float)v, 0.0, 1.0); + else if (k == "tour_focus_epsilon") gTourFocusEps = clampf((float)v, 0.0, 1.0); +@next; + } + + if (gTourActive) llSetTimerEvent(gMOVE_STEP); +} + +startTourFromState(vector startPos, vector startFoc) +{ + // ---- FIX: preserve pending FOV before runtime reset ---- + integer keepFovOn = gPendFovOn; + float keepFovA = gPendFovA; + float keepFovB = gPendFovB; + integer keepStartFirst = gPendStartFirst; + + // Cancel any previous tour runtime + tourStopInternal(); + + // restore into runtime (tourStopInternal may clear pend vars) + gTourFovOn = keepFovOn; + gTourFovA = keepFovA; + gTourFovB = keepFovB; + gPendStartFirst = keepStartFirst; + + // Mode parsing (supports "spline+ease_in+keep") + string m = llToLower(gPendMode); + integer useSpline = FALSE; + string prof = "linear"; + + gTourEaseInFrac = TOUR_EASE_FRAC; + gTourEaseOutFrac = TOUR_EASE_FRAC; + + integer wantKeep = FALSE; + + list partsM = llParseString2List(m, ["+"], []); + integer mi; + for (mi = 0; mi < llGetListLength(partsM); ++mi) { + string tok = llStringTrim(llList2String(partsM, mi), STRING_TRIM); + if (tok == "") jump mnext; + + if (tok == "keep" || tok == "keepframe" || tok == "kf") { wantKeep = TRUE; jump mnext; } + + if (tok == "spline") useSpline = TRUE; + else if (tok == "linear" || tok == "line") prof = "linear"; + else if (tok == "constant" || tok == "noease" || tok == "no_ease") { prof = "constant"; gTourEaseInFrac = 0.0; gTourEaseOutFrac = 0.0; } + else if (tok == "ease_in" || tok == "easein") { prof = "ease_in"; gTourEaseInFrac = 0.35; gTourEaseOutFrac = 0.15; } + else if (tok == "ease_out" || tok == "easeout") { prof = "ease_out"; gTourEaseInFrac = 0.15; gTourEaseOutFrac = 0.35; } + else if (tok == "ease_in_out" || tok == "easeinout" || tok == "ease-in-out") { prof = "ease_in_out"; gTourEaseInFrac = 0.30; gTourEaseOutFrac = 0.30; } + else if (tok == "smoothstep") { prof = "smoothstep"; gTourEaseInFrac = 0.45; gTourEaseOutFrac = 0.45; } + else if (tok == "cubic") { prof = "cubic"; gTourEaseInFrac = 0.25; gTourEaseOutFrac = 0.25; } + else if (tok == "quint" || tok == "quintic") { prof = "quint"; gTourEaseInFrac = 0.40; gTourEaseOutFrac = 0.40; } +@mnext; + } + + gTourKeepOn = wantKeep; + gTourSpline = useSpline; + + if (gTourSpline) { + if (prof == "linear") gTourMode = "spline"; + else gTourMode = "spline+" + prof; + } else { + if (prof == "linear") gTourMode = "linear"; + else gTourMode = prof; + } + + if (gPendStartFirst) { + gTourPos = gPendPos; + gTourFoc = gPendFoc; + } else { + gTourPos = [startPos] + gPendPos; + gTourFoc = [startFoc] + gPendFoc; + } + + // ===== KEEP: choose fixed focus (prefer focA = first waypoint focus) and pick motif focus (lock if on) ===== + if (gTourKeepOn) { + gKeepFixedFocus = startFoc; + if (llGetListLength(gPendFoc) > 0) { + gKeepFixedFocus = llList2Vector(gPendFoc, 0); // focA + } + + keepUpdateMotifFocus(); + gKeepLockNextPoll = llGetTime() + gKeepLockPollInterval; + + // compute K from startPos and CURRENT motif focus + float baseRad0 = gTourFovA; + if (!gTourFovOn || baseRad0 <= 0.0001) baseRad0 = deg2rad(60.0); + + float d0 = llVecDist(startPos, gKeepMotifFocus); + if (d0 < 0.01) d0 = 0.01; + + gTourKeepK = d0 * llTan(baseRad0 * 0.5); + + dbgKeep("keep ON baseDeg=" + (string)rad2deg(baseRad0) + " d0=" + (string)d0 + " K=" + (string)gTourKeepK); + } + + if (gPendStartFirst) gTourHoldSec = []; + else gTourHoldSec = [0.0]; + integer i; + for (i = 0; i < llGetListLength(gPendHoldMs); ++i) { + float hs = (float)llList2Integer(gPendHoldMs, i) / 1000.0; + if (hs < 0.0) hs = 0.0; + gTourHoldSec += [hs]; + } + + gTourCount = llGetListLength(gTourPos); + + gTourMoveDur = (float)gPendMoveMs / 1000.0; + if (gTourMoveDur < 0.0) gTourMoveDur = 0.0; + + // Sum holds + gTourHoldSum = 0.0; + for (i = 0; i < gTourCount; ++i) gTourHoldSum += llList2Float(gTourHoldSec, i); + + gTourTotalDur = gTourMoveDur + gTourHoldSum; + if (gTourTotalDur < 0.01) gTourTotalDur = 0.01; + + // Segment lengths (weighted) + gTourSegLen = []; + gTourCumLen = [0.0]; + gTourPathLen = 0.0; + + for (i = 0; i < gTourCount - 1; ++i) { + float L = llVecDist(llList2Vector(gTourPos, i), llList2Vector(gTourPos, i + 1)); + if (L < 0.000001) L = 0.0; + + float w = 1.0; + if (i < llGetListLength(gPendWeight)) w = llList2Float(gPendWeight, i); + w = clampf(w, 0.10, 10.0); + + float WL = L * w; + + gTourSegLen += [WL]; + gTourPathLen += WL; + + gTourCumLen += [gTourPathLen]; + } + + // Trapezoid profile params (movement only), asymmetric ease + if (gTourMoveDur > 0.0001 && gTourPathLen > 0.0001) { + float teIn = gTourMoveDur * gTourEaseInFrac; + float teOut = gTourMoveDur * gTourEaseOutFrac; + + if (teIn > 0.0 && teIn < 0.10) teIn = 0.10; + if (teOut > 0.0 && teOut < 0.10) teOut = 0.10; + + float maxTe = gTourMoveDur * 0.45; + if (teIn > maxTe) teIn = maxTe; + if (teOut > maxTe) teOut = maxTe; + + if (teIn < 0.01) teIn = 0.0; + if (teOut < 0.01) teOut = 0.0; + + if ((teIn + teOut) > gTourMoveDur && (teIn + teOut) > 0.0001) { + float sc = gTourMoveDur / (teIn + teOut); + teIn *= sc; + teOut *= sc; + gTourTc = 0.0; + } else { + gTourTc = gTourMoveDur - teIn - teOut; + if (gTourTc < 0.0) gTourTc = 0.0; + } + + gTourTe = teIn; + gTourTeOut = teOut; + + float denom = gTourTc + 0.5 * (gTourTe + gTourTeOut); + if (denom < 0.01) denom = 0.01; + + gTourVmax = gTourPathLen / denom; + + gTourS1 = 0.5 * gTourVmax * gTourTe; + gTourS2 = gTourS1 + (gTourVmax * gTourTc); + } else { + gTourTe = gTourTc = gTourVmax = gTourS1 = gTourS2 = 0.0; + gTourTeOut = 0.0; + } + + // Arrival times on move clock + gTourPointMoveT = []; + if (gTourMoveDur <= 0.0001 || gTourPathLen <= 0.0001) { + for (i = 0; i < gTourCount; ++i) gTourPointMoveT += [0.0]; + } else { + for (i = 0; i < gTourCount; ++i) { + float dist = llList2Float(gTourCumLen, i); + gTourPointMoveT += [tourTimeAtDist(dist)]; + } + gTourPointMoveT = llListReplaceList(gTourPointMoveT, [gTourMoveDur], gTourCount - 1, gTourCount - 1); + } + + // Start runtime + gTourStart = llGetTime(); + gTourLastSeg = 0; + gTourActive = TRUE; + + // Tell Core: begin external drive + set first frame + llMessageLinked(LINK_SET, CE_INT_TOUR_BEGIN, (string)gPendMoveId, gOwner); + + vector startFocSend = startFoc; + if (gTourKeepOn) startFocSend = gKeepMotifFocus; + + tourSendCam(startPos, startFocSend, TRUE); + + // send initial FOV (quiet) + if (gTourKeepOn) { + float baseRad1 = gTourFovA; + if (!gTourFovOn || baseRad1 <= 0.0001) baseRad1 = deg2rad(60.0); + keepFovSendNow(startPos, startFocSend, baseRad1); + } + else if (gTourFovOn) { + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTourFovA + "|1", gOwner); + gTourFovLastSend = llGetTime(); + gTourFovLastRad = gTourFovA; + } + + llSetTimerEvent(gMOVE_STEP); + + gPend = FALSE; + gPendReqId = ""; + gPendPos = []; + gPendFoc = []; + gPendHoldMs = []; + gPendWeight = []; + gPendStartFirst = FALSE; +} + +requestStateForPending() +{ + gPendReqId = "TOUR:" + (string)gPendMoveId + ":" + (string)llGetUnixTime(); + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, gPendReqId, gOwner); +} + +startPendingAtFirst() +{ + if (!gPend || llGetListLength(gPendPos) < 1 || llGetListLength(gPendFoc) < 1) return; + startTourFromState(llList2Vector(gPendPos, 0), llList2Vector(gPendFoc, 0)); +} + 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(); + + // cfg sync (move_step etc.) + llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } on_rez(integer sp) { - gOwner = llGetOwner(); + gOwner = llGetOwner(); + tourStopInternal(); } attach(key id) { + gOwner = llGetOwner(); 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(); + if (gTourActive || gPend) tourStopInternal(); + } + } + + timer() + { + if (!gTourActive) { llSetTimerEvent(0.0); return; } + + // ---- DEBUG: periodic FOV ticks during ANY active tour (ignore payload for now) ---- + if (DEBUG_FOV) { + float nowD = llGetTime(); + if (nowD >= gFovDbgNext) { + gFovDbgNext = nowD + 0.25; // 4 Hz + + float fracD = 0.0; + if (gTourTotalDur > 0.001) fracD = (nowD - gTourStart) / gTourTotalDur; + if (fracD < 0.0) fracD = 0.0; + if (fracD > 1.0) fracD = 1.0; + + float radD = deg2rad(30.0 + (90.0 - 30.0) * fracD); + + llOwnerSay("[FOV-DBG TOUR] send tick frac=" + (string)fracD + + " rad=" + (string)radD + " deg=" + (string)rad2deg(radD)); + + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)radD + "|1", gOwner); + } + } + + float now = llGetTime(); + float elapsed = now - gTourStart; + + // baseRad used for keepframe (start FOV) + float baseRad = gTourFovA; + if (!gTourFovOn || baseRad <= 0.0001) baseRad = deg2rad(60.0); + + if (elapsed >= gTourTotalDur) { + vector endPos = llList2Vector(gTourPos, gTourCount - 1); + vector endFoc = llList2Vector(gTourFoc, gTourCount - 1); + + if (gTourKeepOn) { + keepUpdateMotifFocus(); + endFoc = gKeepMotifFocus; + } + + tourSendCam(endPos, endFoc, TRUE); + llMessageLinked(LINK_SET, CE_INT_TOUR_END, (string)gPendMoveId, gOwner); + llMessageLinked(LINK_SET, CE_EVT_MOVE_DONE, (string)gPendMoveId, gOwner); + + if (gTourKeepOn) { + keepFovSendNow(endPos, endFoc, baseRad); + } else { + tourFovForceFinal(); + } + + tourStopInternal(); + return; + } + + // Determine holds (elapsed is overall timeline) + float holdPassed = 0.0; + integer holdIdx = -1; + + integer i; + for (i = 0; i < gTourCount; ++i) { + float tArr = llList2Float(gTourPointMoveT, i) + holdPassed; + float h = llList2Float(gTourHoldSec, i); + + if (h > 0.0) { + if (elapsed >= tArr && elapsed < (tArr + h)) { + holdIdx = i; + jump foundHold; + } + if (elapsed >= (tArr + h)) holdPassed += h; + } + } + @foundHold; + + if (holdIdx != -1) { + vector hp = llList2Vector(gTourPos, holdIdx); + vector hf = llList2Vector(gTourFoc, holdIdx); + + if (gTourKeepOn) { + if (now >= gKeepLockNextPoll) { + keepUpdateMotifFocus(); + gKeepLockNextPoll = now + gKeepLockPollInterval; + } + hf = gKeepMotifFocus; + } + + tourSendHoldCam(hp, hf); + + if (gTourKeepOn) { + keepFovMaybeSend(hp, hf, baseRad); + } + + return; + } + + float moveT = elapsed - holdPassed; + gHoldCamValid = FALSE; + if (moveT < 0.0) moveT = 0.0; + if (moveT > gTourMoveDur) moveT = gTourMoveDur; + + float dist; + if (gTourMoveDur <= 0.0001) dist = gTourPathLen; + else dist = tourDistAt(moveT); + + integer segCount = gTourCount - 1; + integer seg = gTourLastSeg; + if (seg < 0) seg = 0; + if (seg >= segCount) seg = segCount - 1; + + while (seg < segCount - 1 && dist > llList2Float(gTourCumLen, seg + 1) + 0.0001) ++seg; + while (seg > 0 && dist < llList2Float(gTourCumLen, seg) - 0.0001) --seg; + + gTourLastSeg = seg; + tourCacheSegment(seg); + + float u = 0.0; + if (gSegCacheLen > 0.000001) u = (dist - gSegCacheStart) / gSegCacheLen; + u = clampf(u, 0.0, 1.0); + + vector pos; + vector foc; + + if (gTourSpline) { + pos = catmullRomVec(gSegPos0, gSegPos1, gSegPos2, gSegPos3, u); + foc = catmullRomVec(gSegFoc0, gSegFoc1, gSegFoc2, gSegFoc3, u); } else { - gOwner = llGetOwner(); - hudShow(); - engineInit(); - engineCfgDump(); + pos = gSegPos1 + (gSegPos2 - gSegPos1) * u; + foc = gSegFoc1 + (gSegFoc2 - gSegFoc1) * u; + } + + if (gTourKeepOn) { + if (now >= gKeepLockNextPoll) { + keepUpdateMotifFocus(); + gKeepLockNextPoll = now + gKeepLockPollInterval; + } + foc = gKeepMotifFocus; + } + + tourSendCam(pos, foc, FALSE); + + // FOV: keepframe or ramp + float frac = 1.0; + if (gTourPathLen > 0.0001) frac = dist / gTourPathLen; + else if (gTourMoveDur > 0.0001) frac = moveT / gTourMoveDur; + + if (gTourKeepOn) { + keepFovMaybeSend(pos, foc, baseRad); + } else { + tourFovMaybeSend(frac); } } link_message(integer sender, integer num, string str, key id) { + if (num == CE_INT_TOUR_STOP) { + tourStopInternal(); + return; + } + if (num == CE_EVT_READY) { - say("Camera control granted."); - return; - } - if (num == CE_EVT_DENIED) { - say("Camera permission denied."); - return; - } - if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i|| - list p = llParseString2List(str, ["|"], []); - if (llGetListLength(p) < 4) return; - integer req = (integer)llList2String(p,0); + if (pipeFieldCount(str) < 4) return; - 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)); + string reqId = pipeField(str, 0); + if (!gPend || reqId != gPendReqId) return; - gSavePending = FALSE; + vector startPos = (vector)pipeField(str, 1); + vector startFoc = (vector)pipeField(str, 2); + + // If Core returned zeros (very early), fall back to first waypoint + if (startPos == ZERO_VECTOR && llGetListLength(gPendPos) > 0) { + startPos = llList2Vector(gPendPos, 0); + startFoc = llList2Vector(gPendFoc, 0); } + + startTourFromState(startPos, startFoc); return; } - // Marker click event from HS_CamMarkers.lsl - if (num == MC_EVT_CLICK) { - if (id != gOwner) return; + if (num == CE_CMD_TOUR) { + integer len = pipeFieldCount(str); + if (len < 4) return; - integer idx = (integer)str; - if (!isValidIdx(idx)) return; + pipeParseBegin(str); + integer mid = (integer)pipeNext(); + integer dms = (integer)pipeNext(); + string mode = pipeNext(); + integer count = (integer)pipeNext(); - 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; - } + if (count < 2) { + pipeParseClear(); + llMessageLinked(LINK_SET, CE_EVT_MOVE_DONE, (string)mid, gOwner); return; } - if (typ == "LOCK" && len >= 2) { - string actL = llToUpper(llList2String(p, 1)); + // Optional header extension: |FOV||| + integer base = 4; + integer fovOn = FALSE; + float fovA = 0.0; + float fovB = 0.0; - 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; + string next = pipeNext(); + if (len >= 7 && llToUpper(next) == "FOV") { + fovOn = TRUE; + fovA = (float)pipeNext(); + fovB = (float)pipeNext(); + base = 7; } - - // 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 + " ..."); + else { + gPipeAt = 0; + pipeNext(); pipeNext(); pipeNext(); pipeNext(); + } + + integer per = 3; + integer needed3 = base + (count * 3); + integer needed4 = base + (count * 4); + + if (len >= needed4) per = 4; + else if (len < needed3) { + pipeParseClear(); 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; } + list posIn = []; + list focIn = []; + list holdIn = []; + list wIn = []; - if (!loadPreset(idx2)) { say("Preset not found."); return; } - - if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default - phStop("Interrupted by menu moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); - return; - } - - // LOAD|idx (optional: menu supports cut) - if (typ == "LOAD" && len >= 2) { - integer idx3 = (integer)llList2String(p, 1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - phStop("Interrupted by menu load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - // PLAY|card|gap - if (typ == "PLAY" && len >= 2) { - string card = llList2String(p, 1); - integer gap = 0; - if (len >= 3) gap = (integer)llList2String(p, 2); - phPlay(card, gap); - return; - } - - // STOP - if (typ == "STOP") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - // TOURRUN|totalMs|mode|count|idx1|idx2|... - if (typ == "TOURRUN") { - phStop("Interrupted by menu tour."); - phMenuTourRun(str); - return; - } - - // CAMS|SHOW|N or CAMS|HIDE - if (typ == "CAMS" && len >= 2) { - string act = llToUpper(llList2String(p, 1)); - - if (act == "SHOW") { - integer want = gCamsN; - if (len >= 3) want = (integer)llList2String(p, 2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (act == "HIDE") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - gCamsShown = FALSE; - camsWriteState(); - return; - } - } - return; - } - - } - - listen(integer channel, string name, key id, string msg) - { - // Accept commands from: my avatar OR any object owned by me (including attachments) - if (llGetOwnerKey(id) != gOwner) return; - - msg = llStringTrim(msg, STRING_TRIM); - if (msg == "") return; - - list t = llParseString2List(msg, [" "], []); - integer n = llGetListLength(t); - string cmd = llToLower(llList2String(t,0)); - - if (cmd == "help") { printHelp(); return; } - - if (cmd == "cam" && n >= 2) { - string sw = llToLower(llList2String(t,1)); - if (sw == "on") engineInit(); - else engineRelease(); - hudShow(); - return; - } - - if (cmd == "cfg" && n >= 2) { - string sub = llToLower(llList2String(t,1)); - if (sub == "reload") engineCfgReload(); - else engineCfgDump(); - return; - } - - if (cmd == "save" && n >= 2) { - integer idx = (integer)llList2String(t,1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - if ((cmd == "del" || cmd == "delete") && n >= 2) { - integer idx2 = (integer)llList2String(t,1); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - llLinksetDataDelete(PRE_KEY(idx2)); - say("Deleted preset " + (string)idx2); - return; - } - - if (cmd == "load" && n >= 2) { - integer idx3 = (integer)llList2String(t,1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - - phStop("Interrupted by load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - if (cmd == "moveto" && n >= 2) { - integer idx4 = (integer)llList2String(t,1); - if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } - - integer ms = gDefaultMoveMs; - if (n >= 3) ms = (integer)llList2String(t,2); - - if (!loadPreset(idx4)) { say("Preset not found."); return; } - - phStop("Interrupted by moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); - return; - } - - if (cmd == "stop") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - if (cmd == "play" && n >= 2) { - string card = llList2String(t,1); - integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; + for (i = 0; i < count; ++i) { + vector pos = (vector)pipeNext(); + vector foc = (vector)pipeNext(); + integer hms = (integer)pipeNext(); + if (hms < 0) hms = 0; + + posIn += [pos]; + focIn += [foc]; + holdIn += [hms]; + + if (per == 4) { + float w = (float)pipeNext(); + wIn += [w]; } } - 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; + pipeParseClear(); - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + // store pending + stop our current playback + tourStopInternal(); - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - - gCamsShown = FALSE; - camsWriteState(); - return; - } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); - return; - } - - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); - - string lockArgUsed = "<0,0,0>"; - - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); - - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); - } else { - key k = (key)arg; - if (k != NULL_KEY) { - lockArgUsed = (string)k; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); - } else { - vector camPos = llGetCameraPos(); - rotation camRot = llGetCameraRot(); - vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); - lockArgUsed = (string)foc2; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); - } - } - } else { - vector camPos2 = llGetCameraPos(); - rotation camRot2 = llGetCameraRot(); - vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); - lockArgUsed = (string)foc3; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); - } - - lockWrite(TRUE, lockArgUsed); - say("Lock ON"); - return; - } - - if (cmd == "follow" && n >= 2) { - string sw4 = llToLower(llList2String(t,1)); - integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); - - key target = gOwner; - if (n >= 3) target = (key)llList2String(t,2); - - integer mode = FOLLOW_WORLD; - integer trans = 0; - - if (n >= 4) mode = followModeFrom(llList2String(t,3)); - if (n >= 5) trans = (integer)llList2String(t,4); - if (trans < 0) trans = 0; - - if (!on4) { - // OFF: do not touch lock - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); - say("Follow OFF"); - return; - } - - // Mutual exclusion: Follow ON disables Lock - engineLockOff(); - - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); - - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); - return; - } - - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - if (cmd == "fovdeg" && n >= 2) { - float deg = (float)llList2String(t, 1); - float rad = deg * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - say("Unknown command. /88 help"); - } -} + gPend = TRUE; + gPendMoveId = mid; + gPendMoveMs = dms; + gPendMode = mode; + gPendCount = count; + gPendStartFirst = (llSubStringIndex(llToLower(mode), "startfirst") >= 0); + gPendPos = posIn; + gPendFoc = focIn; gPendHoldMs = holdIn; gPendWeight = wIn; diff --git a/HS_CamFov.lsl b/HS_CamFov.lsl index 4b27a8a..8e401b1 100644 --- a/HS_CamFov.lsl +++ b/HS_CamFov.lsl @@ -1,172 +1,48 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + HS_DollyCam - FOV Helper + - Central place that actually sends RLVa @setcam_fov + - Receives CE_CMD_FOV link messages: "rad|quiet" or "rad|quiet|flags" + - Dedupe + optional force flag (and optional sync hook via CE_CMD_GET_STATE if you want later) - 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). + Put this script into the HUD linkset. */ -integer CH = 88; +integer CE_CMD_FOV = 1040; // payload: rad|quiet|flags(optional) +integer CE_CMD_RELEASE = 1001; // for cleanup +integer CE_CMD_GET_STATE = 1060; // optional sync (not required for basic) +integer CE_EVT_STATE = 2060; // optional sync response -// ===== 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; +// --- RLVa --- +string RLV_FOV_CMD = "setcam_fov"; // if your viewer uses another name, change here +float RLV_FOV_MIN_DEG = 10.0; +float RLV_FOV_MAX_DEG = 179.0; // allow wide values (e.g. rad ~ 3.0) -// 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; +// flags (optional third field) +integer FOVF_SYNC = 1; // request state before deciding (optional) +integer FOVF_FORCE = 2; // bypass dedupe -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; +// runtime +key gOwner; -// 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) +float gLastSentRad = -1.0; +float gEpsRad = 0.0005; // ~0.03deg +float gMinInterval = 0.02; // small safety throttle (senders already throttle) +float gLastSendT = 0.0; -// 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 +// optional sync +integer gSyncPend = FALSE; +string gSyncReq = ""; +float gSyncRad = 0.0; +integer gSyncQuiet= 1; +integer gSyncFlags= 0; -// ===== 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 +// debug (keep OFF in production) +integer DEBUG_FOV = FALSE; +dbg(string s){ if (DEBUG_FOV) llOwnerSay("[FOV] " + s); } -// Presets -string PRE_KEY(integer idx) { return "P" + (string)idx; } - -// Controller runtime -key gOwner; -integer gListen; - -// Move ids -integer gMoveId = 100; // start non-zero -integer nextMoveId() { gMoveId++; return gMoveId; } - -// Defaults (updated when engine cfg dump arrives) -integer gDefaultMoveMs = 2200; - - -integer demoSlotOk(integer idx) -{ - if (!DEMO_MODE) return TRUE; - - if (idx <= DEMO_MAX_SLOTS) return TRUE; - - say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); - return FALSE; -} - -// ---- save pending ---- -integer gSavePending = FALSE; -integer gSaveIdx = 0; -integer gSaveReq = 0; - -// Temp preset buffer (set by loadPreset) -vector gTmpPos; -vector gTmpFoc; -integer gTmpHasFov = FALSE; -float gTmpFovRad = 0.0; - -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° - -// --- Marker menu state (persist across scripts) --- -string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" - -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; - -followLockInitState() -{ - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); -} - -followWrite(integer on, key target) -{ - if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); -} - -lockWrite(integer on, string arg) -{ - if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); - else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); -} - -// small helpers (keep controller changes localized) -engineFollowOff() -{ - // Engine expects at least "on|target" - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); -} - -engineLockOff() -{ - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); -} - -integer gCamsShown = FALSE; -integer gCamsN = 12; - -camsLoadState() -{ - string s = llLinksetDataRead(LSKEY_CAMS); - if (s == "") { - gCamsShown = FALSE; gCamsN = 12; - llLinksetDataWrite(LSKEY_CAMS, "0|12"); - return; - } - list p = llParseString2List(s, ["|"], []); - if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); - if (llGetListLength(p) >= 2) { - integer n = (integer)llList2String(p, 1); - if (n < 1) n = 1; - if (n > 30) n = 30; - gCamsN = n; - } -} - -camsWriteState() -{ - llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); -} - -float clampf(float v, float lo, float hi) -{ - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} +float clampf(float v, float lo, float hi){ if (vhi) return hi; return v; } +float deg2rad(float d){ return d * PI / 180.0; } +float rad2deg(float r){ return r * 180.0 / PI; } float clampFovRad(float rad) { @@ -175,792 +51,170 @@ float clampFovRad(float rad) return deg2rad(deg); } -string presetDescribe(integer idx, string data) +integer camTrackOk() { - list p = llParseString2List(data, ["|"], []); - integer L = llGetListLength(p); - if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + return ((llGetPermissions() & PERMISSION_TRACK_CAMERA) && (llGetPermissionsKey() == gOwner)); +} - 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)>; +float getViewerFovRadOrNeg() +{ + if (!camTrackOk()) return -1.0; + float f = llGetCameraFOV(); + if (f <= 0.0001) return -1.0; + return clampFovRad(f); +} - string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; +sendRlvaFov(float rad, integer quiet, integer flags) +{ + rad = clampFovRad(rad); - // 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; + float now = llGetTime(); + if ((now - gLastSendT) < gMinInterval) { + // tiny global throttle (prevents double-sends from multiple sources same frame) + if (!(flags & FOVF_FORCE)) return; + } + + if (!(flags & FOVF_FORCE)) { + // Prefer dedupe against ACTUAL viewer FOV (resists manual viewer changes) + float cur = getViewerFovRadOrNeg(); + if (cur > 0.0) { + if (llFabs(rad - cur) < gEpsRad) return; // viewer already at this FOV + } else { + // fallback: dedupe against last value we sent + if (gLastSentRad > 0.0 && llFabs(rad - gLastSentRad) < gEpsRad) return; } } - 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; } + gLastSendT = now; + gLastSentRad = rad; -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; -} + // RLVa commands are sent via owner chat from attachment scripts. + // Many viewers hide "@..." automatically. + string cmd = "@" + RLV_FOV_CMD + ":" + (string)rad + "=force"; -// ---------- helpers ---------- -integer isValidIdx(integer idx) { return (idx > 0); } - -say(string s) { llOwnerSay(s); } - -// Playlist / TourCommand helpers -phStop(string reason) -{ - llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); -} - -phPlay(string card, integer gapMs) -{ - llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); -} - -phChatTour(string line) -{ - llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); -} - -phMenuTourRun(string raw) -{ - llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); -} - -phChatDollyZoom(string line) -{ - llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); -} - -// Single-prim HUD (Controller in ROOT) -hudHide() -{ - llSetAlpha(0.0, ALL_SIDES); -} -hudShow() -{ - llSetAlpha(1.0, ALL_SIDES); -} - - -// ---------- engine commands ---------- -engineInit() -{ - llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); -} - -engineRelease() -{ - llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); -} - -integer engineMove(vector pos, vector foc, integer durMs) -{ - integer mid = nextMoveId(); - string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=CTRL"; - llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); - - // Hide HUD during non-instant moves (avoid flicker on cuts) - if (durMs > 0) hudHide(); - - return mid; -} - -engineStopMove() -{ - llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); -} - -engineCfgReload() { llMessageLinked(LINK_SET, CE_CMD_CFG_RELOAD, "", gOwner); } -engineCfgDump() { llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } - -// ---------- presets ---------- -string packPreset(vector pos, vector foc, rotation rot, float fovRad) -{ - // v2 preset format: + fovRad at the end (index 10) - return llDumpList2String([ - (string)pos.x,(string)pos.y,(string)pos.z, - (string)foc.x,(string)foc.y,(string)foc.z, - (string)rot.x,(string)rot.y,(string)rot.z,(string)rot.s, - (string)fovRad - ], "|"); -} - -integer loadPreset(integer idx) -{ - string data = llLinksetDataRead(PRE_KEY(idx)); - if (data == "") return FALSE; - - // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs (we only need first 6 here) - list p = llParseString2List(data, ["|"], []); - if (llGetListLength(p) < 6) return FALSE; - - gTmpPos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; - gTmpFoc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; - - gTmpHasFov = FALSE; - gTmpFovRad = 0.0; - if (llGetListLength(p) >= 11) { - float fr = (float)llList2String(p, 10); - if (fr > 0.0001) { gTmpHasFov = TRUE; gTmpFovRad = clampFovRad(fr); } - } - - return TRUE; -} - -applyLoadedPresetFov() -{ - if (!gTmpHasFov) return; - - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); -} - -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; - -integer followModeFrom(string s) -{ - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; -} - -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) -{ - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; - - string s = llList2String(toks, i); - - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; + if (!quiet) { + dbg("send " + cmd + " (" + (string)rad2deg(rad) + " deg)"); } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { - ++i; - s += " " + llList2String(toks, i); - } - return [s, i + 1]; + llOwnerSay(cmd); } -// ---------- chat commands ---------- -printHelp() +string newReqId() { - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" - ); + // unique enough + return "FOVSYNC:" + (string)llGetUnixTime() + ":" + (string)((integer)llFrand(1000000.0)); +} + +requestSyncThenMaybeSend(float rad, integer quiet, integer flags) +{ + // optional path: ask Core for state (which can include fov if you patched it), + // then decide whether to send. Good for "manual viewer change" corrections + gSyncPend = TRUE; + gSyncRad = rad; + gSyncQuiet = quiet; + gSyncFlags = flags; + gSyncReq = newReqId(); + + llMessageLinked(LINK_SET, CE_CMD_GET_STATE, gSyncReq, gOwner); } -// ---------- 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(); + gLastSentRad = -1.0; + gLastSendT = 0.0; + gSyncPend = FALSE; + gSyncReq = ""; } attach(key id) { + gOwner = llGetOwner(); 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(); + // detach cleanup + gLastSentRad = -1.0; + gLastSendT = 0.0; + gSyncPend = FALSE; + gSyncReq = ""; } } link_message(integer sender, integer num, string str, key id) { - if (num == CE_EVT_READY) { - say("Camera control granted."); - return; - } - if (num == CE_EVT_DENIED) { - say("Camera permission denied."); - return; - } - if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i|| - list p = llParseString2List(str, ["|"], []); - if (llGetListLength(p) < 4) return; - integer req = (integer)llList2String(p,0); - - if (gSavePending && req == gSaveReq) { - vector pos = (vector)llList2String(p,1); - vector foc = (vector)llList2String(p,2); - - rotation rot = (rotation)llList2String(p,3); - - // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback - float fovRad = gLastFovRad; - if (llGetListLength(p) >= 5) { - float got = (float)llList2String(p, 4); - if (got > 0.0001) fovRad = got; - } - fovRad = clampFovRad(fovRad); - - string data = packPreset(pos, foc, rot, fovRad); - llLinksetDataWrite(PRE_KEY(gSaveIdx), data); - - // volle Anzeige: - say("Saved " + presetDescribe(gSaveIdx, data)); - - gSavePending = FALSE; - } + if (num == CE_CMD_RELEASE) { + // reset internal state; do NOT spam RLVa on release + gLastSentRad = -1.0; + gLastSendT = 0.0; + gSyncPend = FALSE; + gSyncReq = ""; return; } - // Marker click event from HS_CamMarkers.lsl - if (num == MC_EVT_CLICK) { + if (num == CE_CMD_FOV) { 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; + integer L = llGetListLength(p); + if (L < 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)); + float rad = (float)llList2String(p, 0); + integer quiet = 1; + if (L >= 2) quiet = (integer)llList2String(p, 1); - if (actF == "OFF") { - engineFollowOff(); - say("Follow OFF (menu)"); - return; - } + integer flags = 0; + if (L >= 3) flags = (integer)llList2String(p, 2); - if (actF == "ON" && len >= 3) { - key tgtF = (key)llList2String(p, 2); - if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } - - // mutual exclusion - engineLockOff(); - - integer mode = FOLLOW_WORLD; - integer trans = 250; - - string payload = - "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, tgtF); - - say("Follow ON -> " + llKey2Name(tgtF)); - return; - } - return; - } - - if (typ == "LOCK" && len >= 2) { - string actL = llToUpper(llList2String(p, 1)); - - if (actL == "OFF") { - engineLockOff(); - say("Lock OFF (menu)"); - return; - } - - if (actL == "ON" && len >= 3) { - key tgtL = (key)llList2String(p, 2); - if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } - - // mutual exclusion - engineFollowOff(); - - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); - lockWrite(TRUE, (string)tgtL); - - say("Lock ON -> " + llKey2Name(tgtL)); - return; - } - return; - } - - // SAVE|idx - if (typ == "SAVE" && len >= 2) { - integer idx = (integer)llList2String(p, 1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by menu save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - // MOVETO|idx|ms - if (typ == "MOVETO" && len >= 3) { - integer idx2 = (integer)llList2String(p, 1); - integer ms = (integer)llList2String(p, 2); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx2)) { say("Preset not found."); return; } - - if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default - phStop("Interrupted by menu moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); - return; - } - - // LOAD|idx (optional: menu supports cut) - if (typ == "LOAD" && len >= 2) { - integer idx3 = (integer)llList2String(p, 1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - phStop("Interrupted by menu load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - // PLAY|card|gap - if (typ == "PLAY" && len >= 2) { - string card = llList2String(p, 1); - integer gap = 0; - if (len >= 3) gap = (integer)llList2String(p, 2); - phPlay(card, gap); - return; - } - - // STOP - if (typ == "STOP") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - // TOURRUN|totalMs|mode|count|idx1|idx2|... - if (typ == "TOURRUN") { - phStop("Interrupted by menu tour."); - phMenuTourRun(str); - return; - } - - // CAMS|SHOW|N or CAMS|HIDE - if (typ == "CAMS" && len >= 2) { - string act = llToUpper(llList2String(p, 1)); - - if (act == "SHOW") { - integer want = gCamsN; - if (len >= 3) want = (integer)llList2String(p, 2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (act == "HIDE") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - gCamsShown = FALSE; - camsWriteState(); - return; - } - } - return; - } - - } - - listen(integer channel, string name, key id, string msg) - { - // Accept commands from: my avatar OR any object owned by me (including attachments) - if (llGetOwnerKey(id) != gOwner) return; - - msg = llStringTrim(msg, STRING_TRIM); - if (msg == "") return; - - list t = llParseString2List(msg, [" "], []); - integer n = llGetListLength(t); - string cmd = llToLower(llList2String(t,0)); - - if (cmd == "help") { printHelp(); return; } - - if (cmd == "cam" && n >= 2) { - string sw = llToLower(llList2String(t,1)); - if (sw == "on") engineInit(); - else engineRelease(); - hudShow(); - return; - } - - if (cmd == "cfg" && n >= 2) { - string sub = llToLower(llList2String(t,1)); - if (sub == "reload") engineCfgReload(); - else engineCfgDump(); - return; - } - - if (cmd == "save" && n >= 2) { - integer idx = (integer)llList2String(t,1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - if ((cmd == "del" || cmd == "delete") && n >= 2) { - integer idx2 = (integer)llList2String(t,1); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - llLinksetDataDelete(PRE_KEY(idx2)); - say("Deleted preset " + (string)idx2); - return; - } - - if (cmd == "load" && n >= 2) { - integer idx3 = (integer)llList2String(t,1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - - phStop("Interrupted by load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - if (cmd == "moveto" && n >= 2) { - integer idx4 = (integer)llList2String(t,1); - if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } - - integer ms = gDefaultMoveMs; - if (n >= 3) ms = (integer)llList2String(t,2); - - if (!loadPreset(idx4)) { say("Preset not found."); return; } - - phStop("Interrupted by moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); - return; - } - - if (cmd == "stop") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - if (cmd == "play" && n >= 2) { - string card = llList2String(t,1); - integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; - integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; - } - } - if (!shown) say("No presets found in range."); - return; - } - - // markers routed to helper script - if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - integer want = 12; - if (n >= 3) want = (integer)llList2String(t,2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - - gCamsShown = FALSE; - camsWriteState(); - return; - } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); - return; - } - - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); - - string lockArgUsed = "<0,0,0>"; - - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); - - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); - } else { - key k = (key)arg; - if (k != NULL_KEY) { - lockArgUsed = (string)k; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); - } else { - vector camPos = llGetCameraPos(); - rotation camRot = llGetCameraRot(); - vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); - lockArgUsed = (string)foc2; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); - } - } + // If you don't want sync at all, just ignore FOVF_SYNC and always send: + if (flags & FOVF_SYNC) { + requestSyncThenMaybeSend(rad, quiet, flags); } 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); + sendRlvaFov(rad, quiet, flags); + } + return; + } + + // optional sync response (only if you patched Core to append fov to CE_EVT_STATE) + if (num == CE_EVT_STATE) { + if (!gSyncPend) return; + + list p2 = llParseString2List(str, ["|"], []); + if (llGetListLength(p2) < 1) return; + + string reqId = llList2String(p2, 0); + if (reqId != gSyncReq) return; + + // field 4 (index 4) expected to be fov rad if available + float curFov = -1.0; + if (llGetListLength(p2) >= 5) { + curFov = (float)llList2String(p2, 4); + if (curFov > 0.0001) curFov = clampFovRad(curFov); + else curFov = -1.0; + } + + // If Core did not provide a usable FOV, force-send to avoid stale cache dedupe + if (curFov < 0.0 && !(gSyncFlags & FOVF_FORCE)) { + gSyncFlags = gSyncFlags | FOVF_FORCE; + } + + // clear pending first + gSyncPend = FALSE; + gSyncReq = ""; + + // if we have current fov, use it for dedupe decision + if (curFov > 0.0 && !(gSyncFlags & FOVF_FORCE)) { + float want = clampFovRad(gSyncRad); + if (llFabs(want - curFov) < gEpsRad) return; } - lockWrite(TRUE, lockArgUsed); - say("Lock ON"); + sendRlvaFov(gSyncRad, gSyncQuiet, gSyncFlags); return; } - - if (cmd == "follow" && n >= 2) { - string sw4 = llToLower(llList2String(t,1)); - integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); - - key target = gOwner; - if (n >= 3) target = (key)llList2String(t,2); - - integer mode = FOLLOW_WORLD; - integer trans = 0; - - if (n >= 4) mode = followModeFrom(llList2String(t,3)); - if (n >= 5) trans = (integer)llList2String(t,4); - if (trans < 0) trans = 0; - - if (!on4) { - // OFF: do not touch lock - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); - say("Follow OFF"); - return; - } - - // Mutual exclusion: Follow ON disables Lock - engineLockOff(); - - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); - - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); - return; - } - - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - if (cmd == "fovdeg" && n >= 2) { - float deg = (float)llList2String(t, 1); - float rad = deg * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - say("Unknown command. /88 help"); } -} +} \ No newline at end of file diff --git a/HS_CamMarkers.lsl b/HS_CamMarkers.lsl index 4b27a8a..b145398 100644 --- a/HS_CamMarkers.lsl +++ b/HS_CamMarkers.lsl @@ -1,966 +1,209 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message - - Notes: - - idx is valid only if > 0 (slot 0 reserved). - - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). - - Early-cut is disabled for tours (tour is one continuous move). + HS_DollyCam - Marker helper (separate script to save Controller memory) + - Receives link_message MC_CMD: + "SHOW|N" -> rez markers for first N presets found + "HIDE" -> cleanup markers + - Listens MARKER_CH for HELLO/CLICK + - On CLICK sends MC_EVT_CLICK with payload idx to Controller */ -integer CH = 88; +integer MC_CMD = 5100; +integer MC_EVT_CLICK = 5101; -// ===== 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; +string MARKER_OBJECT = "HS_CamMarker"; +integer MARKER_CH = -880088; -// 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; +key gOwner; +integer gShowToken = 0; -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; +// [key objKey, integer idx, vector pos, rotation rot] repeating +list gMarkers; -// Helper scripts (separate memory budgets) -integer MC_CMD = 5100; // Controller -> Markers script ("SHOW|N"/"HIDE") -integer MN_CMD = 5200; // Menu helper -> Controller -integer MC_EVT_CLICK = 5101; // Markers script -> Controller (payload: idx) - -// Controller -> Playlist helper -integer PH_CMD_PLAY = 6100; -integer PH_CMD_STOP = 6101; -integer PH_CMD_CHAT_TOUR = 6102; -integer PH_CMD_TOURRUN = 6103; -integer PH_CMD_CHAT_DZ = 6104; // chat one-liner dollyzoom delegated to TourCommands - -// ===== RLVa FOV ===== -float RLV_FOV_MIN_DEG = 10.0; -float RLV_FOV_MAX_DEG = 179.0; // viewer erlaubt >160; 179 vermeidet “near-180” edge cases - -// Presets string PRE_KEY(integer idx) { return "P" + (string)idx; } -// Controller runtime -key gOwner; -integer gListen; - -// Move ids -integer gMoveId = 100; // start non-zero -integer nextMoveId() { gMoveId++; return gMoveId; } - -// Defaults (updated when engine cfg dump arrives) -integer gDefaultMoveMs = 2200; - - -integer demoSlotOk(integer idx) -{ - if (!DEMO_MODE) return TRUE; - - if (idx <= DEMO_MAX_SLOTS) return TRUE; - - say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); - return FALSE; -} - -// ---- save pending ---- -integer gSavePending = FALSE; -integer gSaveIdx = 0; -integer gSaveReq = 0; - -// Temp preset buffer (set by loadPreset) -vector gTmpPos; -vector gTmpFoc; -integer gTmpHasFov = FALSE; -float gTmpFovRad = 0.0; - -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° - -// --- Marker menu state (persist across scripts) --- -string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" - -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; - -followLockInitState() -{ - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); -} - -followWrite(integer on, key target) -{ - if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); -} - -lockWrite(integer on, string arg) -{ - if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); - else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); -} - -// small helpers (keep controller changes localized) -engineFollowOff() -{ - // Engine expects at least "on|target" - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); -} - -engineLockOff() -{ - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); -} - -integer gCamsShown = FALSE; -integer gCamsN = 12; - -camsLoadState() -{ - string s = llLinksetDataRead(LSKEY_CAMS); - if (s == "") { - gCamsShown = FALSE; gCamsN = 12; - llLinksetDataWrite(LSKEY_CAMS, "0|12"); - return; - } - list p = llParseString2List(s, ["|"], []); - if (llGetListLength(p) >= 1) gCamsShown = (integer)llList2String(p, 0); - if (llGetListLength(p) >= 2) { - integer n = (integer)llList2String(p, 1); - if (n < 1) n = 1; - if (n > 30) n = 30; - gCamsN = n; - } -} - -camsWriteState() -{ - llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); -} - -float clampf(float v, float lo, float hi) -{ - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} - -float clampFovRad(float rad) -{ - float deg = rad2deg(rad); - deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); - return deg2rad(deg); -} - -string presetDescribe(integer idx, string data) -{ - list p = llParseString2List(data, ["|"], []); - integer L = llGetListLength(p); - if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; - - vector pos = <(float)llList2String(p,0),(float)llList2String(p,1),(float)llList2String(p,2)>; - vector foc = <(float)llList2String(p,3),(float)llList2String(p,4),(float)llList2String(p,5)>; - - string s = "Preset " + (string)idx + " pos=" + (string)pos + " foc=" + (string)foc; - - // v2: optional fovRad at index 10 - if (L >= 11) { - float fr = (float)llList2String(p, 10); - if (fr > 0.0001) { - fr = clampFovRad(fr); - float deg = rad2deg(fr); - s += " fov=" + fmtFloat(deg) + "° (" + fmtFloat(fr) + "rad)"; - return s; - } - } - s += " fov=(none)"; - return s; -} - -// ===== RLVa helpers ===== -float deg2rad(float deg) { return deg * PI / 180.0; } -float rad2deg(float rad) { return rad * 180.0 / PI; } - -string fmtFloat(float v) -{ - // LSL string(float) is fine; this is just to keep logs readable - string s = (string)v; - if (llStringLength(s) > 10) s = llGetSubString(s, 0, 9); - return s; -} - -// ---------- helpers ---------- -integer isValidIdx(integer idx) { return (idx > 0); } +vector gTmpPos; +vector gTmpFoc; +rotation gTmpRot; 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 isValidIdx(integer idx) { return (idx > 0); } 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; + if (llGetListLength(p) < 10) 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); } - } - + gTmpRot = <(float)llList2String(p,6),(float)llList2String(p,7),(float)llList2String(p,8),(float)llList2String(p,9)>; return TRUE; } -applyLoadedPresetFov() +rotation markerRotFromPosFocus(vector pos, vector focus) { - if (!gTmpHasFov) return; - - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); + vector fwd = llVecNorm(focus - pos); + vector up = <0,0,1>; + vector left = up % fwd; + if (llVecMag(left) < 0.0001) left = <1,0,0>; + else left = llVecNorm(left); + vector up2 = llVecNorm(fwd % left); + return llAxes2Rot(left, up2, fwd); } -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; - -integer followModeFrom(string s) +markersClearLocal() { - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; + gMarkers = []; } -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) +integer markersFind(key obj) { - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; + integer i; + for (i = 0; i < llGetListLength(gMarkers); i += 4) { + if (llList2Key(gMarkers, i) == obj) return i; + } + return -1; +} - string s = llList2String(toks, i); +markersHide() +{ + integer i; + for (i = 0; i < llGetListLength(gMarkers); i += 4) { + key obj = llList2Key(gMarkers, i); + if (obj != NULL_KEY) + llRegionSayTo(obj, MARKER_CH, "DIE|" + (string)gShowToken); + } + markersClearLocal(); + say("Markers cleaned up."); +} - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; +showCams(integer want) +{ + if (llGetInventoryType(MARKER_OBJECT) != INVENTORY_OBJECT) { + say("Missing marker object in HUD contents: " + MARKER_OBJECT); + return; } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { - ++i; - s += " " + llList2String(toks, i); + markersHide(); + gShowToken = (integer)llFrand(2147483647.0); + + vector aPos = llList2Vector(llGetObjectDetails(gOwner, [OBJECT_POS]), 0); + vector rezPos = aPos + <0,0,1>; + + integer found = 0; + integer idx; + for (idx = 1; idx <= 999 && found < want; ++idx) { + if (!loadPreset(idx)) jump cont; + vector pos = gTmpPos; + vector foc = gTmpFoc; + rotation mrot = markerRotFromPosFocus(pos, foc); + + llRezObject(MARKER_OBJECT, rezPos, ZERO_VECTOR, ZERO_ROTATION, idx); + + gMarkers += [NULL_KEY, idx, pos, mrot]; + found++; +@cont; } - return [s, i + 1]; + + say("Showing cams: " + (string)found + " (token " + (string)gShowToken + ")"); } -// ---------- chat commands ---------- -printHelp() -{ - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" - ); -} - -// ---------- default ---------- default { state_entry() { gOwner = llGetOwner(); - camsLoadState(); - followLockInitState(); // NEW: ensure menu keys exist - hudShow(); - gListen = llListen(CH, "", "", ""); - - say("HS DollyCam Controller (slim) ready. Type /88 help"); - engineCfgDump(); - - // AUTO CAM ON when script starts while worn - if (llGetAttached() != 0) - engineInit(); - } - - on_rez(integer sp) - { - gOwner = llGetOwner(); + llListen(MARKER_CH, "", "", ""); } 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(); + markersHide(); } 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."); + if (num != MC_CMD) return; + if (id != gOwner) return; + + str = llStringTrim(str, STRING_TRIM); + if (str == "") return; + + list p = llParseString2List(str, ["|"], []); + string cmd = llToUpper(llList2String(p, 0)); + + if (cmd == "HIDE") { + markersHide(); return; } - if (num == CE_EVT_DENIED) { - say("Camera permission denied."); + if (cmd == "SHOW") { + integer want = 12; + if (llGetListLength(p) >= 2) want = (integer)llList2String(p, 1); + if (want < 1) want = 1; + if (want > 30) want = 30; + showCams(want); return; } - if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i|| - list p = llParseString2List(str, ["|"], []); - if (llGetListLength(p) < 4) return; - integer req = (integer)llList2String(p,0); - - if (gSavePending && req == gSaveReq) { - vector pos = (vector)llList2String(p,1); - vector foc = (vector)llList2String(p,2); - - rotation rot = (rotation)llList2String(p,3); - - // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback - float fovRad = gLastFovRad; - if (llGetListLength(p) >= 5) { - float got = (float)llList2String(p, 4); - if (got > 0.0001) fovRad = got; - } - fovRad = clampFovRad(fovRad); - - string data = packPreset(pos, foc, rot, fovRad); - llLinksetDataWrite(PRE_KEY(gSaveIdx), data); - - // volle Anzeige: - say("Saved " + presetDescribe(gSaveIdx, data)); - - gSavePending = FALSE; - } - return; - } - - // Marker click event from HS_CamMarkers.lsl - if (num == MC_EVT_CLICK) { - if (id != gOwner) return; - - integer idx = (integer)str; - if (!isValidIdx(idx)) return; - - if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } - - phStop("Interrupted by marker click."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); - say("Loaded via marker: " + (string)idx); - return; - } - - // Menu commands from HS_CamMenu.lsl - if (num == MN_CMD) { - if (id != gOwner) return; // only trust owner-routed messages - - list p = llParseString2List(str, ["|"], []); - integer len = llGetListLength(p); - if (len < 1) return; - - string typ = llToUpper(llList2String(p, 0)); - // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== - if (typ == "FOLLOW" && len >= 2) { - string actF = llToUpper(llList2String(p, 1)); - - if (actF == "OFF") { - engineFollowOff(); - say("Follow OFF (menu)"); - return; - } - - if (actF == "ON" && len >= 3) { - key tgtF = (key)llList2String(p, 2); - if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } - - // mutual exclusion - engineLockOff(); - - integer mode = FOLLOW_WORLD; - integer trans = 250; - - string payload = - "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, tgtF); - - say("Follow ON -> " + llKey2Name(tgtF)); - return; - } - return; - } - - if (typ == "LOCK" && len >= 2) { - string actL = llToUpper(llList2String(p, 1)); - - if (actL == "OFF") { - engineLockOff(); - say("Lock OFF (menu)"); - return; - } - - if (actL == "ON" && len >= 3) { - key tgtL = (key)llList2String(p, 2); - if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } - - // mutual exclusion - engineFollowOff(); - - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); - lockWrite(TRUE, (string)tgtL); - - say("Lock ON -> " + llKey2Name(tgtL)); - return; - } - return; - } - - // SAVE|idx - if (typ == "SAVE" && len >= 2) { - integer idx = (integer)llList2String(p, 1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by menu save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - // MOVETO|idx|ms - if (typ == "MOVETO" && len >= 3) { - integer idx2 = (integer)llList2String(p, 1); - integer ms = (integer)llList2String(p, 2); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx2)) { say("Preset not found."); return; } - - if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default - phStop("Interrupted by menu moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); - return; - } - - // LOAD|idx (optional: menu supports cut) - if (typ == "LOAD" && len >= 2) { - integer idx3 = (integer)llList2String(p, 1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - phStop("Interrupted by menu load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - // PLAY|card|gap - if (typ == "PLAY" && len >= 2) { - string card = llList2String(p, 1); - integer gap = 0; - if (len >= 3) gap = (integer)llList2String(p, 2); - phPlay(card, gap); - return; - } - - // STOP - if (typ == "STOP") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - // TOURRUN|totalMs|mode|count|idx1|idx2|... - if (typ == "TOURRUN") { - phStop("Interrupted by menu tour."); - phMenuTourRun(str); - return; - } - - // CAMS|SHOW|N or CAMS|HIDE - if (typ == "CAMS" && len >= 2) { - string act = llToUpper(llList2String(p, 1)); - - if (act == "SHOW") { - integer want = gCamsN; - if (len >= 3) want = (integer)llList2String(p, 2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (act == "HIDE") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - gCamsShown = FALSE; - camsWriteState(); - return; - } - } - return; - } - } listen(integer channel, string name, key id, string msg) { - // Accept commands from: my avatar OR any object owned by me (including attachments) - if (llGetOwnerKey(id) != gOwner) return; + if (channel != MARKER_CH) return; - msg = llStringTrim(msg, STRING_TRIM); - if (msg == "") return; + list p = llParseString2List(msg, ["|"], []); + if (llGetListLength(p) < 2) return; - list t = llParseString2List(msg, [" "], []); - integer n = llGetListLength(t); - string cmd = llToLower(llList2String(t,0)); + string typ = llToUpper(llList2String(p,0)); + integer tok = (integer)llList2String(p,1); + if (tok != gShowToken) return; - if (cmd == "help") { printHelp(); return; } + if (typ == "HELLO") { + integer at = markersFind(id); + if (at >= 0) { + integer idx = llList2Integer(gMarkers, at+1); + vector pos = llList2Vector(gMarkers, at+2); + rotation rot = llList2Rot(gMarkers, at+3); - if (cmd == "cam" && n >= 2) { - string sw = llToLower(llList2String(t,1)); - if (sw == "on") engineInit(); - else engineRelease(); - hudShow(); - return; - } - - if (cmd == "cfg" && n >= 2) { - string sub = llToLower(llList2String(t,1)); - if (sub == "reload") engineCfgReload(); - else engineCfgDump(); - return; - } - - if (cmd == "save" && n >= 2) { - integer idx = (integer)llList2String(t,1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - if ((cmd == "del" || cmd == "delete") && n >= 2) { - integer idx2 = (integer)llList2String(t,1); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - llLinksetDataDelete(PRE_KEY(idx2)); - say("Deleted preset " + (string)idx2); - return; - } - - if (cmd == "load" && n >= 2) { - integer idx3 = (integer)llList2String(t,1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - - phStop("Interrupted by load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - if (cmd == "moveto" && n >= 2) { - integer idx4 = (integer)llList2String(t,1); - if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } - - integer ms = gDefaultMoveMs; - if (n >= 3) ms = (integer)llList2String(t,2); - - if (!loadPreset(idx4)) { say("Preset not found."); return; } - - phStop("Interrupted by moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); - return; - } - - if (cmd == "stop") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - if (cmd == "play" && n >= 2) { - string card = llList2String(t,1); - integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; - integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; - } + llRegionSayTo(id, MARKER_CH, + "SET|" + (string)gShowToken + "|" + (string)idx + "|" + (string)pos + "|" + (string)rot + ); } - 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); + if (typ == "CLICK" && llGetListLength(p) >= 3) { + integer idx2 = (integer)llList2String(p,2); + if (!isValidIdx(idx2)) return; - gCamsShown = FALSE; - camsWriteState(); + // Inform Controller (it will stop playlist + move camera) + llMessageLinked(LINK_SET, MC_EVT_CLICK, (string)idx2, gOwner); return; } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); - return; - } - - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); - - string lockArgUsed = "<0,0,0>"; - - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); - - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); - } else { - key k = (key)arg; - if (k != NULL_KEY) { - lockArgUsed = (string)k; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); - } else { - vector camPos = llGetCameraPos(); - rotation camRot = llGetCameraRot(); - vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); - lockArgUsed = (string)foc2; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); - } - } - } else { - vector camPos2 = llGetCameraPos(); - rotation camRot2 = llGetCameraRot(); - vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); - lockArgUsed = (string)foc3; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); - } - - lockWrite(TRUE, lockArgUsed); - say("Lock ON"); - return; - } - - if (cmd == "follow" && n >= 2) { - string sw4 = llToLower(llList2String(t,1)); - integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); - - key target = gOwner; - if (n >= 3) target = (key)llList2String(t,2); - - integer mode = FOLLOW_WORLD; - integer trans = 0; - - if (n >= 4) mode = followModeFrom(llList2String(t,3)); - if (n >= 5) trans = (integer)llList2String(t,4); - if (trans < 0) trans = 0; - - if (!on4) { - // OFF: do not touch lock - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); - say("Follow OFF"); - return; - } - - // Mutual exclusion: Follow ON disables Lock - engineLockOff(); - - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); - - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); - return; - } - - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - if (cmd == "fovdeg" && n >= 2) { - float deg = (float)llList2String(t, 1); - float rad = deg * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - say("Unknown command. /88 help"); } } diff --git a/HS_CamMenu.lsl b/HS_CamMenu.lsl index 4b27a8a..4781f44 100644 --- a/HS_CamMenu.lsl +++ b/HS_CamMenu.lsl @@ -1,153 +1,108 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message + HS_DollyCam - Menu Helper (HS_CamMenu.lsl) [UX STABLE NAV] + - Touch HUD -> llDialog menus + - Stable pagination UX: + * Always 12 buttons + * Always shows: "<<", "Back", ">>" side-by-side + * Blank filler buttons (" ") keep layout stable + * Wrap-around: << on first page -> last, >> on last -> first + * Back goes to previous menu level (not always main) + - Save presets (1..N) + - MoveTo presets (1..N) using configurable Duration + - Play playlists: notecards starting with "shot_" + - Settings: Mode linear/spline + Duration (ms) + - Tour Builder: collect points -> run one tour - 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). + Talks to Controller via: + llMessageLinked(LINK_SET, MN_CMD, "TYPE|...", owner) + + Put this script into the prim(s) you want clickable. */ -integer CH = 88; +integer MN_CMD = 5200; -// ===== 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; +string NC_PREFIX = "shot_"; // play menu shows only notecards starting with this (case-insensitive) +integer PRESET_MAX = 30; // max preset slots shown in menus -// 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; +// Stable 12-button layouts (no "Close"; viewer has Ignore) +integer SAVE_PER_PAGE = 9; // 9 numbers + nav(3) = 12 +integer MOVE_PER_PAGE = 8; // 8 numbers + toggle(1) + nav(3) = 12 +integer PLAY_PER_PAGE = 9; // 9 cards + nav(3) = 12 +integer TOUR_PER_PAGE = 9; // 9 numbers + nav(3) = 12 -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; +// menu states +integer M_MAIN = 0; +integer M_SAVE = 1; +integer M_MOVETO = 2; +integer M_PLAY = 3; +integer M_SETTINGS = 4; +integer M_SETMODE = 5; +integer M_SETDUR = 6; +integer M_TOUR = 7; +integer M_TOUR_PICK = 8; -// 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) +// ===== ADD: Follow/Lock Target Pick ===== +integer M_FOLLOW_PICK = 9; +integer M_LOCK_PICK = 10; -// 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 +// Nearby config (leicht änderbar) +float NEARBY_RANGE = 20.0; // meters +integer AV_PER_PAGE = 9; // 9 entries + navRow(3) = 12 buttons stable -// ===== 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 +// LinksetData keys written by Controller (see Controller patch) +string LSKEY_FOLLOW = "HS_FOLLOW"; // "on|uuid" +string LSKEY_LOCK = "HS_LOCK"; // "on|arg" (uuid or "") -// Presets -string PRE_KEY(integer idx) { return "P" + (string)idx; } +// runtime state (menu-side) +integer gFollowOn = FALSE; +string gFollowArg = ""; // uuid (string) if ON +integer gLockOn = FALSE; +string gLockArg = ""; // uuid or "<...>" if ON -// Controller runtime -key gOwner; -integer gListen; +// cached nearby lists for pick menus (kept across pages) +list gNearKeys; // keys +list gNearNames; // names +list gNearLabels; // short labels shown in llDialog -// Move ids -integer gMoveId = 100; // start non-zero -integer nextMoveId() { gMoveId++; return gMoveId; } +key gOwner; -// Defaults (updated when engine cfg dump arrives) -integer gDefaultMoveMs = 2200; +integer gChan = 0; +integer gListen = 0; +// current menu state +integer gState = M_MAIN; +integer gPage = 0; -integer demoSlotOk(integer idx) -{ - if (!DEMO_MODE) return TRUE; +// previous menu stack: [state, page, state, page, ...] +list gBackStack; - if (idx <= DEMO_MAX_SLOTS) return TRUE; +// settings +integer gDurMs = 3000; // used for MoveTo + Tour total +string gMode = "linear"; // "linear" or "spline" +integer gGapMs = 0; // optional for Play (kept simple) - say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); - return FALSE; -} +// MoveTo toggle +integer gCutMode = FALSE; // FALSE -> moveto, TRUE -> cut(load) -// ---- save pending ---- -integer gSavePending = FALSE; -integer gSaveIdx = 0; -integer gSaveReq = 0; +// Tour builder +list gTourIdx; // strings (idx) -// Temp preset buffer (set by loadPreset) -vector gTmpPos; -vector gTmpFoc; -integer gTmpHasFov = FALSE; -float gTmpFovRad = 0.0; +// timeout +float MENU_TIMEOUT = 15.0; -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° - -// --- Marker menu state (persist across scripts) --- +// --- Marker toggle state (persisted in Linkset Data by Controller) --- string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" - -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; - -followLockInitState() -{ - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); -} - -followWrite(integer on, key target) -{ - if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); -} - -lockWrite(integer on, string arg) -{ - if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); - else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); -} - -// small helpers (keep controller changes localized) -engineFollowOff() -{ - // Engine expects at least "on|target" - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); -} - -engineLockOff() -{ - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); -} - integer gCamsShown = FALSE; integer gCamsN = 12; camsLoadState() { string s = llLinksetDataRead(LSKEY_CAMS); - if (s == "") { - gCamsShown = FALSE; gCamsN = 12; - llLinksetDataWrite(LSKEY_CAMS, "0|12"); - return; - } + if (s == "") { gCamsShown = FALSE; gCamsN = 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; @@ -156,255 +111,677 @@ camsLoadState() } } -camsWriteState() +sendCamsShow() { - llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); + llMessageLinked(LINK_SET, MN_CMD, "CAMS|SHOW|" + (string)gCamsN, gOwner); } -float clampf(float v, float lo, float hi) +sendCamsHide() { - if (v < lo) return lo; - if (v > hi) return hi; - return v; + llMessageLinked(LINK_SET, MN_CMD, "CAMS|HIDE", gOwner); } -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); } - +// ---- small utils ---- say(string s) { llOwnerSay(s); } -// Playlist / TourCommand helpers -phStop(string reason) +integer isOwner(key k) { return (k == gOwner); } + +integer startsWithNoCase(string s, string pref) { - llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); + s = llToLower(s); + pref = llToLower(pref); + return (llGetSubString(s, 0, llStringLength(pref)-1) == pref); } -phPlay(string card, integer gapMs) +// ===== ADD: Follow/Lock state from Controller (LinksetData) ===== +followLockLoadState() { - llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); + // FOLLOW: "on|uuid" + gFollowOn = FALSE; + gFollowArg = ""; + string fs = llLinksetDataRead(LSKEY_FOLLOW); + if (fs != "") { + list p = llParseString2List(fs, ["|"], []); + if (llGetListLength(p) >= 1) gFollowOn = (integer)llList2String(p, 0); + if (llGetListLength(p) >= 2) gFollowArg = llList2String(p, 1); + } + + // LOCK: "on|arg" (uuid or "") + gLockOn = FALSE; + gLockArg = ""; + string ls = llLinksetDataRead(LSKEY_LOCK); + if (ls != "") { + list q = llParseString2List(ls, ["|"], []); + if (llGetListLength(q) >= 1) gLockOn = (integer)llList2String(q, 0); + if (llGetListLength(q) >= 2) gLockArg = llList2String(q, 1); + } } -phChatTour(string line) +string shortNameFromArg(string arg) { - llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); + if (arg == "") return ""; + if (llGetSubString(arg, 0, 0) == "<") return "vec"; + string nm = llKey2Name((key)arg); + if (nm != "") return nm; + return llGetSubString(arg, 0, 7); } -phMenuTourRun(string raw) +string followLockStatusLine() { - llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); + string f = "OFF"; + if (gFollowOn) f = "ON (" + shortNameFromArg(gFollowArg) + ")"; + + string l = "OFF"; + if (gLockOn) l = "ON (" + shortNameFromArg(gLockArg) + ")"; + + return "Follow: " + f + "\nLock: " + l; } -phChatDollyZoom(string line) +// ===== ADD: Nearby list building (AGENT_LIST_REGION + distance filter) ===== +string sanitizeName(string nm) { - llMessageLinked(LINK_SET, PH_CMD_CHAT_DZ, line, gOwner); + if (nm == "") nm = "(unknown)"; + // avoid breaking our label format + if (llSubStringIndex(nm, "|") != -1) + nm = llDumpList2String(llParseString2List(nm, ["|"], []), ":"); + return nm; } -// Single-prim HUD (Controller in ROOT) -hudHide() +string makeAgentLabel(string name, key agentId) { - llSetAlpha(0.0, ALL_SIDES); -} -hudShow() -{ - llSetAlpha(1.0, ALL_SIDES); + // llDialog button limit -> short + uuid prefix + string pref = llGetSubString((string)agentId, 0, 3); // 4 chars + integer maxName = 24 - (1 + 4); // "name|abcd" => name max 19 chars + if (llStringLength(name) > maxName) name = llGetSubString(name, 0, maxName - 1); + + string lbl = name + "|" + pref; + + // avoid reserved nav labels (paranoia) + if (lbl == "Back" || lbl == "<<" || lbl == ">>") lbl = name + "_" + pref; + return lbl; } +buildNearbyLists() +{ + gNearKeys = []; + gNearNames = []; + gNearLabels = []; -// ---------- engine commands ---------- -engineInit() -{ - llMessageLinked(LINK_SET, CE_CMD_INIT, "", gOwner); + // Owner position for distance filter + vector myPos = ZERO_VECTOR; + list od = llGetObjectDetails(gOwner, [OBJECT_POS]); + if (llGetListLength(od) >= 1) myPos = llList2Vector(od, 0); + + list agents = llGetAgentList(AGENT_LIST_REGION, []); + + // Ensure owner included + if (llListFindList(agents, [gOwner]) == -1) agents += [gOwner]; + + // build sortable stride list: [nameLower, uuidStr, name, label]... + list tmp = []; + integer i; + for (i = 0; i < llGetListLength(agents); ++i) + { + key k = llList2Key(agents, i); + + list d = llGetObjectDetails(k, [OBJECT_POS]); + if (llGetListLength(d) < 1) jump nextAgent; + + vector p = llList2Vector(d, 0); + + // distance filter; if owner pos is unavailable, include region agents + if (myPos != ZERO_VECTOR) + { + if (llVecDist(p, myPos) > NEARBY_RANGE) jump nextAgent; + } + // else owner pos unknown; keep all region agents + + string nm = sanitizeName(llKey2Name(k)); + string lbl = makeAgentLabel(nm, k); + + tmp += [llToLower(nm), (string)k, nm, lbl]; + +@nextAgent; + } + + if (llGetListLength(tmp) > 0) + tmp = llListSort(tmp, 4, TRUE); + + for (i = 0; i < llGetListLength(tmp); i += 4) + { + gNearKeys += [(key)llList2String(tmp, i + 1)]; + gNearNames += [llList2String(tmp, i + 2)]; + gNearLabels += [llList2String(tmp, i + 3)]; + } } -engineRelease() +menuClose() { - llMessageLinked(LINK_SET, CE_CMD_RELEASE, "src=CTRL", gOwner); + if (gListen) llListenRemove(gListen); + gListen = 0; + llSetTimerEvent(0.0); } -integer engineMove(vector pos, vector foc, integer durMs) +menuOpenListen() { - 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; + menuClose(); + gChan = (integer)(llFrand(1000000.0) + 1000.0) * -1; + gListen = llListen(gChan, "", gOwner, ""); + llSetTimerEvent(MENU_TIMEOUT); } -engineStopMove() +pushBack(integer st, integer pg) { - llMessageLinked(LINK_SET, CE_CMD_STOP, "src=CTRL", gOwner); + gBackStack += [st, pg]; } -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) +integer popBack() { - // 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 n = llGetListLength(gBackStack); + if (n < 2) return FALSE; -integer loadPreset(integer idx) -{ - string data = llLinksetDataRead(PRE_KEY(idx)); - if (data == "") return FALSE; + integer prevPage = llList2Integer(gBackStack, n - 1); + integer prevState = llList2Integer(gBackStack, n - 2); - // 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; + gBackStack = llDeleteSubList(gBackStack, n - 2, n - 1); + + gState = prevState; + gPage = prevPage; - 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() +// Returns a list padded with filler " " to exactly wantLen entries. +list padTo(list items, integer wantLen) { - if (!gTmpHasFov) return; - - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); + integer n = llGetListLength(items); + while (n < wantLen) { + items += [" "]; + ++n; + } + if (n > wantLen) items = llList2List(items, 0, wantLen - 1); + return items; } -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; - -integer followModeFrom(string s) +// Stable nav row: always present, always same order. +list navRow() { - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; + return ["<<", "Back", ">>"]; } -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) +// Wrap-around paging +integer wrapPrev(integer page, integer pages) { - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; + page -= 1; + if (page < 0) page = pages - 1; + return page; +} - string s = llList2String(toks, i); +integer wrapNext(integer page, integer pages) +{ + page += 1; + if (page >= pages) page = 0; + return page; +} - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; +// ---- inventory listing ---- +list getShotNotecards() +{ + list out = []; + integer n = llGetInventoryNumber(INVENTORY_NOTECARD); + integer i; + for (i = 0; i < n; ++i) { + string name = llGetInventoryName(INVENTORY_NOTECARD, i); + if (startsWithNoCase(name, NC_PREFIX)) out += [name]; + } + return out; +} + +// ---- controller triggers ---- +sendSave(integer idx) +{ + llMessageLinked(LINK_SET, MN_CMD, "SAVE|" + (string)idx, gOwner); +} + +sendMoveTo(integer idx) +{ + llMessageLinked(LINK_SET, MN_CMD, "MOVETO|" + (string)idx + "|" + (string)gDurMs, gOwner); +} + +sendLoad(integer idx) +{ + llMessageLinked(LINK_SET, MN_CMD, "LOAD|" + (string)idx, gOwner); +} + +sendPlay(string card) +{ + llMessageLinked(LINK_SET, MN_CMD, "PLAY|" + card + "|" + (string)gGapMs, gOwner); +} + +sendStop() +{ + llMessageLinked(LINK_SET, MN_CMD, "STOP", gOwner); +} + +sendTourRun() +{ + integer n = llGetListLength(gTourIdx); + if (n < 2) { + say("Tour Builder: add at least 2 points."); + return; } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { - ++i; - s += " " + llList2String(toks, i); - } - return [s, i + 1]; + // TOURRUN|totalMs|mode|count|idx1|idx2|... + list parts = ["TOURRUN", (string)gDurMs, gMode, (string)n]; + integer i; + for (i = 0; i < n; ++i) parts += [llList2String(gTourIdx, i)]; + + llMessageLinked(LINK_SET, MN_CMD, llDumpList2String(parts, "|"), gOwner); } -// ---------- chat commands ---------- -printHelp() +// ---- UI strings ---- +string settingsLine() { - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" - ); + return "Mode: " + llToUpper(gMode) + "\nDuration: " + (string)gDurMs + " ms"; } -// ---------- default ---------- +string tourListLine() +{ + integer n = llGetListLength(gTourIdx); + if (n < 1) return "(none)"; + string s = ""; + integer i; + for (i = 0; i < n; ++i) { + s += llList2String(gTourIdx, i); + if (i < n - 1) s += ", "; + if (llStringLength(s) > 200) { s += " ..."; jump done; } + } +@done; + return s; +} + +// ---- show menus ---- +showMainEx(integer doLoad) +{ + camsLoadState(); + if (doLoad) followLockLoadState(); // nur wenn wir "frisch" aus LSD lesen wollen + + gState = M_MAIN; + gPage = 0; + gCutMode = FALSE; + gBackStack = []; // root + menuOpenListen(); + + string camsBtn = "Show Cams"; + if (gCamsShown) camsBtn = "Hide Cams"; + + string msg = + "HS DollyCam Menu\n\n" + + settingsLine() + + "\n\n" + + followLockStatusLine() + + "\n\nSelect:"; + + list buttons = ["Follow","Lock", "Settings", "Tour", camsBtn, "Stop", "Save","MoveTo","Play"]; + llDialog(gOwner, msg, buttons, gChan); +} + +showMain() +{ + showMainEx(TRUE); +} + +showSave(integer page) +{ + pushBack(gState, gPage); + gState = M_SAVE; + gPage = page; + gCutMode = FALSE; + menuOpenListen(); + + integer pages = (PRESET_MAX + SAVE_PER_PAGE - 1) / SAVE_PER_PAGE; + if (pages < 1) pages = 1; + if (gPage < 0) gPage = 0; + if (gPage >= pages) gPage = pages - 1; + + integer start = gPage * SAVE_PER_PAGE + 1; + integer end = start + SAVE_PER_PAGE - 1; + if (end > PRESET_MAX) end = PRESET_MAX; + + list btn = []; + integer i; + for (i = start; i <= end; ++i) btn += [(string)i]; + + btn = padTo(btn, SAVE_PER_PAGE); + btn += navRow(); // total 12 + + string msg = + "Save preset:\n" + + "(Select a slot)\n\n" + + "Page " + (string)(gPage + 1) + "/" + (string)pages + + "\nRange: " + (string)start + "-" + (string)end; + + llDialog(gOwner, msg, btn, gChan); +} + +showMoveTo(integer page) +{ + pushBack(gState, gPage); + gState = M_MOVETO; + gPage = page; + menuOpenListen(); + + integer pages = (PRESET_MAX + MOVE_PER_PAGE - 1) / MOVE_PER_PAGE; + if (pages < 1) pages = 1; + if (gPage < 0) gPage = 0; + if (gPage >= pages) gPage = pages - 1; + + integer start = gPage * MOVE_PER_PAGE + 1; + integer end = start + MOVE_PER_PAGE - 1; + if (end > PRESET_MAX) end = PRESET_MAX; + + list btn = []; + integer i; + for (i = start; i <= end; ++i) btn += [(string)i]; + + btn = padTo(btn, MOVE_PER_PAGE); + + // Toggle button (always present, stable position) + if (gCutMode) btn += ["Move"]; + else btn += ["Cut"]; + + btn += navRow(); // total 12 + + string modeLine; + if (gCutMode) modeLine = "Selection action: CUT (load)"; + else modeLine = "Selection action: MOVETO"; + + string msg = + "MoveTo preset:\n" + + "Duration: " + (string)gDurMs + " ms\n" + + modeLine + "\n\n" + + "Page " + (string)(gPage + 1) + "/" + (string)pages + + "\nRange: " + (string)start + "-" + (string)end; + + llDialog(gOwner, msg, btn, gChan); +} + +showPlay(integer page) +{ + pushBack(gState, gPage); + gState = M_PLAY; + gPage = page; + gCutMode = FALSE; + menuOpenListen(); + + list cards = getShotNotecards(); + integer total = llGetListLength(cards); + + if (total < 1) { + // Keep stable 12 buttons: 9 blanks + nav + list btn = padTo([], PLAY_PER_PAGE); + btn += navRow(); + + string msg = + "Play playlist:\n" + + "No notecards found with prefix '" + NC_PREFIX + "'.\n\n" + + "Press Back to return."; + llDialog(gOwner, msg, btn, gChan); + return; + } + + integer pages = (total + PLAY_PER_PAGE - 1) / PLAY_PER_PAGE; + if (pages < 1) pages = 1; + if (gPage < 0) gPage = 0; + if (gPage >= pages) gPage = pages - 1; + + integer start = gPage * PLAY_PER_PAGE; + integer end = start + PLAY_PER_PAGE - 1; + if (end > total - 1) end = total - 1; + + list btn2 = []; + integer i; + for (i = start; i <= end; ++i) btn2 += [llList2String(cards, i)]; + + btn2 = padTo(btn2, PLAY_PER_PAGE); + btn2 += navRow(); + + string msg2 = + "Play playlist:\n" + + "(Notecards starting with '" + NC_PREFIX + "')\n" + + "gap: " + (string)gGapMs + " ms\n\n" + + "Page " + (string)(gPage + 1) + "/" + (string)pages; + + llDialog(gOwner, msg2, btn2, gChan); +} + +showSettings() +{ + pushBack(gState, gPage); + gState = M_SETTINGS; + gPage = 0; + gCutMode = FALSE; + menuOpenListen(); + + string msg = + "Settings\n\n" + + settingsLine() + + "\n\nSelect:"; + list btn = ["Mode","Duration","Back"]; + llDialog(gOwner, msg, btn, gChan); +} + +showSetMode() +{ + pushBack(gState, gPage); + gState = M_SETMODE; + gPage = 0; + gCutMode = FALSE; + menuOpenListen(); + + string msg = + "Set Tour Mode\n\n" + + "Current: " + llToUpper(gMode) + "\n" + + "(Used for Tours)\n"; + + // Keep it simple: one screen, no pagination needed + list btn = ["LINEAR","SPLINE","EASE_IN","EASE_OUT","EASE_IN_OUT","Back"]; + llDialog(gOwner, msg, btn, gChan); +} + + +showSetDur() +{ + pushBack(gState, gPage); + gState = M_SETDUR; + gPage = 0; + gCutMode = FALSE; + menuOpenListen(); + + string msg = + "Set Duration (ms)\n\n" + + "Current: " + (string)gDurMs + "\n" + + "Used for MoveTo + Tour total.\n"; + list btn = ["1500","2500","3000","4000","6000","Custom","Back"]; + llDialog(gOwner, msg, btn, gChan); +} + +showTour() +{ + pushBack(gState, gPage); + gState = M_TOUR; + gPage = 0; + gCutMode = FALSE; + menuOpenListen(); + + string msg = + "Tour Builder\n\n" + + "Mode: " + llToUpper(gMode) + "\n" + + "Total: " + (string)gDurMs + " ms\n" + + "Points: " + tourListLine() + + "\n\nSelect:"; + list btn = ["Add","Run","Clear","Back"]; + llDialog(gOwner, msg, btn, gChan); +} + +showTourPick(integer page) +{ + pushBack(gState, gPage); + gState = M_TOUR_PICK; + gPage = page; + gCutMode = FALSE; + menuOpenListen(); + + integer pages = (PRESET_MAX + TOUR_PER_PAGE - 1) / TOUR_PER_PAGE; + if (pages < 1) pages = 1; + if (gPage < 0) gPage = 0; + if (gPage >= pages) gPage = pages - 1; + + integer start = gPage * TOUR_PER_PAGE + 1; + integer end = start + TOUR_PER_PAGE - 1; + if (end > PRESET_MAX) end = PRESET_MAX; + + list btn = []; + integer i; + for (i = start; i <= end; ++i) btn += [(string)i]; + + btn = padTo(btn, TOUR_PER_PAGE); + btn += navRow(); + + string msg = + "Add Tour Point\n\n" + + "Select preset index to add.\n" + + "Current points: " + tourListLine() + + "\n\n" + + "Page " + (string)(gPage + 1) + "/" + (string)pages + + "\nRange: " + (string)start + "-" + (string)end; + + llDialog(gOwner, msg, btn, gChan); +} + +// ===== ADD: Follow/Lock pick menus ===== +showFollowPickInternal(integer page, integer rebuild) +{ + pushBack(gState, gPage); + gState = M_FOLLOW_PICK; + gPage = page; + gCutMode = FALSE; + menuOpenListen(); + + if (rebuild) buildNearbyLists(); + integer total = llGetListLength(gNearLabels); + + integer pages = (total + AV_PER_PAGE - 1) / AV_PER_PAGE; + if (pages < 1) pages = 1; + if (gPage < 0) gPage = 0; + if (gPage >= pages) gPage = pages - 1; + + integer start = gPage * AV_PER_PAGE; + integer end = start + AV_PER_PAGE - 1; + if (end > total - 1) end = total - 1; + + list btn = []; + integer i; + for (i = start; i <= end; ++i) btn += [llList2String(gNearLabels, i)]; + + btn = padTo(btn, AV_PER_PAGE); + btn += navRow(); + + string msg = + "Select Follow Target\n" + + "Range: " + (string)NEARBY_RANGE + " m\n" + + "Found: " + (string)total + "\n" + + "Page " + (string)(gPage + 1) + "/" + (string)pages; + + llDialog(gOwner, msg, btn, gChan); +} + +showFollowPick(integer page) +{ + showFollowPickInternal(page, TRUE); +} + +showFollowPickCached(integer page) +{ + showFollowPickInternal(page, FALSE); +} + +showLockPickInternal(integer page, integer rebuild) +{ + pushBack(gState, gPage); + gState = M_LOCK_PICK; + gPage = page; + gCutMode = FALSE; + menuOpenListen(); + + if (rebuild) buildNearbyLists(); + integer total = llGetListLength(gNearLabels); + + integer pages = (total + AV_PER_PAGE - 1) / AV_PER_PAGE; + if (pages < 1) pages = 1; + if (gPage < 0) gPage = 0; + if (gPage >= pages) gPage = pages - 1; + + integer start = gPage * AV_PER_PAGE; + integer end = start + AV_PER_PAGE - 1; + if (end > total - 1) end = total - 1; + + list btn = []; + integer i; + for (i = start; i <= end; ++i) btn += [llList2String(gNearLabels, i)]; + + btn = padTo(btn, AV_PER_PAGE); + btn += navRow(); + + string msg = + "Lock Ziel wählen\n" + + "Reichweite: " + (string)NEARBY_RANGE + " m\n" + + "Gefunden: " + (string)total + "\n" + + "Seite " + (string)(gPage + 1) + "/" + (string)pages; + + llDialog(gOwner, msg, btn, gChan); +} + +showLockPick(integer page) +{ + showLockPickInternal(page, TRUE); +} + +showLockPickCached(integer page) +{ + showLockPickInternal(page, FALSE); +} + +// ---- navigation dispatcher (previous menu) ---- +showByState(integer st, integer pg) +{ + integer beforeN = llGetListLength(gBackStack); + + if (st == M_MAIN) { showMain(); return; } + else if (st == M_SAVE) { showSave(pg); } + else if (st == M_MOVETO) { showMoveTo(pg); } + else if (st == M_PLAY) { showPlay(pg); } + else if (st == M_SETTINGS) { showSettings(); } + else if (st == M_SETMODE) { showSetMode(); } + else if (st == M_SETDUR) { showSetDur(); } + else if (st == M_TOUR) { showTour(); } + else if (st == M_TOUR_PICK) { showTourPick(pg); } + else if (st == M_FOLLOW_PICK) { showFollowPickCached(pg); } + else if (st == M_LOCK_PICK) { showLockPickCached(pg); } + else { showMain(); return; } + + integer afterN = llGetListLength(gBackStack); + if (afterN == beforeN + 2) { + gBackStack = llDeleteSubList(gBackStack, afterN - 2, afterN - 1); + } +} + +// ---- LSL events ---- 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) @@ -414,553 +791,298 @@ default 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(); - } + gOwner = llGetOwner(); + if (id == NULL_KEY) menuClose(); } - link_message(integer sender, integer num, string str, key id) + touch_start(integer n) { - if (num == CE_EVT_READY) { - say("Camera control granted."); - return; - } - if (num == CE_EVT_DENIED) { - say("Camera permission denied."); - return; - } - if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i|| - list p = llParseString2List(str, ["|"], []); - if (llGetListLength(p) < 4) return; - integer req = (integer)llList2String(p,0); - - if (gSavePending && req == gSaveReq) { - vector pos = (vector)llList2String(p,1); - vector foc = (vector)llList2String(p,2); - - rotation rot = (rotation)llList2String(p,3); - - // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback - float fovRad = gLastFovRad; - if (llGetListLength(p) >= 5) { - float got = (float)llList2String(p, 4); - if (got > 0.0001) fovRad = got; - } - fovRad = clampFovRad(fovRad); - - string data = packPreset(pos, foc, rot, fovRad); - llLinksetDataWrite(PRE_KEY(gSaveIdx), data); - - // volle Anzeige: - say("Saved " + presetDescribe(gSaveIdx, data)); - - gSavePending = FALSE; - } - return; - } - - // Marker click event from HS_CamMarkers.lsl - if (num == MC_EVT_CLICK) { - if (id != gOwner) return; - - integer idx = (integer)str; - if (!isValidIdx(idx)) return; - - if (!loadPreset(idx)) { say("Marker click: preset not found."); return; } - - phStop("Interrupted by marker click."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, gDefaultMoveMs); - say("Loaded via marker: " + (string)idx); - return; - } - - // Menu commands from HS_CamMenu.lsl - if (num == MN_CMD) { - if (id != gOwner) return; // only trust owner-routed messages - - list p = llParseString2List(str, ["|"], []); - integer len = llGetListLength(p); - if (len < 1) return; - - string typ = llToUpper(llList2String(p, 0)); - // ===== ADD: FOLLOW/LOCK from Menu (place BEFORE other returns) ===== - if (typ == "FOLLOW" && len >= 2) { - string actF = llToUpper(llList2String(p, 1)); - - if (actF == "OFF") { - engineFollowOff(); - say("Follow OFF (menu)"); - return; - } - - if (actF == "ON" && len >= 3) { - key tgtF = (key)llList2String(p, 2); - if (tgtF == NULL_KEY) { say("Follow: invalid target."); return; } - - // mutual exclusion - engineLockOff(); - - integer mode = FOLLOW_WORLD; - integer trans = 250; - - string payload = - "1|" + (string)tgtF + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, tgtF); - - say("Follow ON -> " + llKey2Name(tgtF)); - return; - } - return; - } - - if (typ == "LOCK" && len >= 2) { - string actL = llToUpper(llList2String(p, 1)); - - if (actL == "OFF") { - engineLockOff(); - say("Lock OFF (menu)"); - return; - } - - if (actL == "ON" && len >= 3) { - key tgtL = (key)llList2String(p, 2); - if (tgtL == NULL_KEY) { say("Lock: invalid target."); return; } - - // mutual exclusion - engineFollowOff(); - - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)tgtL, gOwner); - lockWrite(TRUE, (string)tgtL); - - say("Lock ON -> " + llKey2Name(tgtL)); - return; - } - return; - } - - // SAVE|idx - if (typ == "SAVE" && len >= 2) { - integer idx = (integer)llList2String(p, 1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by menu save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - // MOVETO|idx|ms - if (typ == "MOVETO" && len >= 3) { - integer idx2 = (integer)llList2String(p, 1); - integer ms = (integer)llList2String(p, 2); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx2)) { say("Preset not found."); return; } - - if (ms < 1) ms = gDefaultMoveMs; // menu can send 0 => use default - phStop("Interrupted by menu moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx2 + " (" + (string)ms + "ms)"); - return; - } - - // LOAD|idx (optional: menu supports cut) - if (typ == "LOAD" && len >= 2) { - integer idx3 = (integer)llList2String(p, 1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - phStop("Interrupted by menu load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - // PLAY|card|gap - if (typ == "PLAY" && len >= 2) { - string card = llList2String(p, 1); - integer gap = 0; - if (len >= 3) gap = (integer)llList2String(p, 2); - phPlay(card, gap); - return; - } - - // STOP - if (typ == "STOP") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - // TOURRUN|totalMs|mode|count|idx1|idx2|... - if (typ == "TOURRUN") { - phStop("Interrupted by menu tour."); - phMenuTourRun(str); - return; - } - - // CAMS|SHOW|N or CAMS|HIDE - if (typ == "CAMS" && len >= 2) { - string act = llToUpper(llList2String(p, 1)); - - if (act == "SHOW") { - integer want = gCamsN; - if (len >= 3) want = (integer)llList2String(p, 2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (act == "HIDE") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - gCamsShown = FALSE; - camsWriteState(); - return; - } - } - return; - } - + timer() + { + menuClose(); } 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; + if (channel != gChan) return; + if (!isOwner(id)) return; + // IMPORTANT: keep blanks " " non-functional: + // the trim turns " " into "", which we ignore. 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(); + // Back goes to previous menu level + if (msg == "Back") { + if (!popBack()) { showMain(); return; } + showByState(gState, gPage); return; } - if (cmd == "cfg" && n >= 2) { - string sub = llToLower(llList2String(t,1)); - if (sub == "reload") engineCfgReload(); - else engineCfgDump(); - return; - } + // Stable paging nav for paginated menus only + if (msg == "<<" || msg == ">>") { + integer pages = 1; - if (cmd == "save" && n >= 2) { - integer idx = (integer)llList2String(t,1); - if (!isValidIdx(idx)) { say("idx must be > 0"); return; } - - // DEMO limit - if (!demoSlotOk(idx)) return; - - phStop("Interrupted by save."); - gSavePending = TRUE; - gSaveIdx = idx; - gSaveReq++; - llMessageLinked(LINK_SET, CE_CMD_GET_STATE, (string)gSaveReq, gOwner); - say("Saving preset " + (string)idx + " ..."); - return; - } - - if ((cmd == "del" || cmd == "delete") && n >= 2) { - integer idx2 = (integer)llList2String(t,1); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - llLinksetDataDelete(PRE_KEY(idx2)); - say("Deleted preset " + (string)idx2); - return; - } - - if (cmd == "load" && n >= 2) { - integer idx3 = (integer)llList2String(t,1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - - phStop("Interrupted by load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - if (cmd == "moveto" && n >= 2) { - integer idx4 = (integer)llList2String(t,1); - if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } - - integer ms = gDefaultMoveMs; - if (n >= 3) ms = (integer)llList2String(t,2); - - if (!loadPreset(idx4)) { say("Preset not found."); return; } - - phStop("Interrupted by moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); - return; - } - - if (cmd == "stop") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - if (cmd == "play" && n >= 2) { - string card = llList2String(t,1); - integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; - integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; - } - } - if (!shown) say("No presets found in range."); - return; - } - - // markers routed to helper script - if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - integer want = 12; - if (n >= 3) want = (integer)llList2String(t,2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - - gCamsShown = FALSE; - camsWriteState(); - return; - } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); + if (gState == M_SAVE) { + pages = (PRESET_MAX + SAVE_PER_PAGE - 1) / SAVE_PER_PAGE; + if (pages < 1) pages = 1; + if (msg == "<<") gPage = wrapPrev(gPage, pages); + else gPage = wrapNext(gPage, pages); + showByState(M_SAVE, gPage); return; } - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); + if (gState == M_MOVETO) { + pages = (PRESET_MAX + MOVE_PER_PAGE - 1) / MOVE_PER_PAGE; + if (pages < 1) pages = 1; + if (msg == "<<") gPage = wrapPrev(gPage, pages); + else gPage = wrapNext(gPage, pages); + showByState(M_MOVETO, gPage); + return; + } - string lockArgUsed = "<0,0,0>"; + if (gState == M_PLAY) { + list cards = getShotNotecards(); + integer total = llGetListLength(cards); + pages = (total + PLAY_PER_PAGE - 1) / PLAY_PER_PAGE; + if (pages < 1) pages = 1; + if (msg == "<<") gPage = wrapPrev(gPage, pages); + else gPage = wrapNext(gPage, pages); + showByState(M_PLAY, gPage); + return; + } - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); + if (gState == M_TOUR_PICK) { + pages = (PRESET_MAX + TOUR_PER_PAGE - 1) / TOUR_PER_PAGE; + if (pages < 1) pages = 1; + if (msg == "<<") gPage = wrapPrev(gPage, pages); + else gPage = wrapNext(gPage, pages); + showByState(M_TOUR_PICK, gPage); + return; + } - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); + if (gState == M_FOLLOW_PICK) { + integer totalF = llGetListLength(gNearLabels); + pages = (totalF + AV_PER_PAGE - 1) / AV_PER_PAGE; + if (pages < 1) pages = 1; + if (msg == "<<") gPage = wrapPrev(gPage, pages); + else gPage = wrapNext(gPage, pages); + showByState(M_FOLLOW_PICK, gPage); + return; + } + + if (gState == M_LOCK_PICK) { + integer totalL = llGetListLength(gNearLabels); + pages = (totalL + AV_PER_PAGE - 1) / AV_PER_PAGE; + if (pages < 1) pages = 1; + if (msg == "<<") gPage = wrapPrev(gPage, pages); + else gPage = wrapNext(gPage, pages); + showByState(M_LOCK_PICK, gPage); + return; + } + + // if not a paginated menu, ignore + return; + } + + // ---- main ---- + if (gState == M_MAIN) { + if (msg == "Save") { showSave(0); return; } + if (msg == "MoveTo") { showMoveTo(0); return; } + if (msg == "Play") { showPlay(0); return; } + if (msg == "Settings") { showSettings(); return; } + if (msg == "Tour") { showTour(); return; } + + if (msg == "Show Cams") { + sendCamsShow(); + gCamsShown = TRUE; + showMain(); // refresh label + return; + } + if (msg == "Hide Cams") { + sendCamsHide(); + gCamsShown = FALSE; + showMain(); // refresh label + return; + } + + if (msg == "Stop") { sendStop(); return; } + + if (msg == "Follow") { + // lokalen Status nutzen (nicht sofort LSD neu lesen) + followLockLoadState(); + + if (gFollowOn) { + llMessageLinked(LINK_SET, MN_CMD, "FOLLOW|OFF", gOwner); + + // sofort UI-korrekt + gFollowOn = FALSE; gFollowArg = ""; + showMainEx(FALSE); } 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); - } + showFollowPick(0); } - } 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(); + if (msg == "Lock") { + followLockLoadState(); - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; + if (gLockOn) { + llMessageLinked(LINK_SET, MN_CMD, "LOCK|OFF", gOwner); - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); + // sofort UI-korrekt + gLockOn = FALSE; gLockArg = ""; + showMainEx(FALSE); + } else { + showLockPick(0); + } + return; + } - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); + return; } - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); + // ---- save ---- + if (gState == M_SAVE) { + integer idx = (integer)msg; + if (idx >= 1 && idx <= PRESET_MAX) { + sendSave(idx); + return; + } + return; + } + + // ---- moveto ---- + if (gState == M_MOVETO) { + if (msg == "Cut") { gCutMode = TRUE; showByState(M_MOVETO, gPage); return; } + if (msg == "Move") { gCutMode = FALSE; showByState(M_MOVETO, gPage); return; } + + integer idx2 = (integer)msg; + if (idx2 >= 1 && idx2 <= PRESET_MAX) { + if (gCutMode) { + sendLoad(idx2); + gCutMode = FALSE; + } else { + sendMoveTo(idx2); + } + return; + } + return; + } + + // ---- play ---- + if (gState == M_PLAY) { + // msg is notecard name button + if (llGetInventoryType(msg) == INVENTORY_NOTECARD) { + sendPlay(msg); + return; + } + return; + } + + // ---- settings ---- + if (gState == M_SETTINGS) { + if (msg == "Mode") { showSetMode(); return; } + if (msg == "Duration") { showSetDur(); return; } + return; + } + + // ---- set mode ---- + if (gState == M_SETMODE) { + if (msg == "LINEAR") gMode = "linear"; + else if (msg == "SPLINE") gMode = "spline"; + else if (msg == "EASE_IN") gMode = "ease_in"; + else if (msg == "EASE_OUT") gMode = "ease_out"; + else if (msg == "EASE_IN_OUT") gMode = "ease_in_out"; + + // go back one level + if (!popBack()) { showMain(); return; } + showByState(gState, gPage); + return; + } + + // ---- set duration ---- + if (gState == M_SETDUR) { + if (msg == "Custom") { + llTextBox(gOwner, "Enter duration in ms (e.g. 3000):", gChan); + return; + } + + integer d = (integer)msg; + if (d >= 200 && d <= 60000) gDurMs = d; + + // return to previous level + if (!popBack()) { showMain(); return; } + showByState(gState, gPage); + return; + } + + // ---- tour builder ---- + if (gState == M_TOUR) { + if (msg == "Add") { showTourPick(0); return; } + if (msg == "Clear") { gTourIdx = []; showByState(M_TOUR, 0); return; } + if (msg == "Run") { sendTourRun(); return; } + return; + } + + // ---- tour pick ---- + if (gState == M_TOUR_PICK) { + integer idxT = (integer)msg; + if (idxT >= 1 && idxT <= PRESET_MAX) { + gTourIdx += [(string)idxT]; + + // Go back one level (to Tour builder) after adding + if (!popBack()) { showMain(); return; } + showByState(gState, gPage); + return; + } 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); + // ---- follow pick ---- + if (gState == M_FOLLOW_PICK) { + integer idxF = llListFindList(gNearLabels, [msg]); + if (idxF < 0) return; + + key kF = llList2Key(gNearKeys, idxF); + string nmF = llList2String(gNearNames, idxF); + + say("Gewählt: " + nmF + " | UUID: " + (string)kF); + llMessageLinked(LINK_SET, MN_CMD, "FOLLOW|ON|" + (string)kF, gOwner); + + // sofort UI korrekt + Mutual Exclusion im Menü + gFollowOn = TRUE; gFollowArg = (string)kF; + gLockOn = FALSE; gLockArg = ""; + + showMainEx(FALSE); return; } - - say("Unknown command. /88 help"); + + // ---- lock pick ---- + if (gState == M_LOCK_PICK) { + integer idxL = llListFindList(gNearLabels, [msg]); + if (idxL < 0) return; + + key kL = llList2Key(gNearKeys, idxL); + string nmL = llList2String(gNearNames, idxL); + + say("Gewählt: " + nmL + " | UUID: " + (string)kL); + llMessageLinked(LINK_SET, MN_CMD, "LOCK|ON|" + (string)kL, gOwner); + + // sofort UI korrekt + Mutual Exclusion im Menü + gLockOn = TRUE; gLockArg = (string)kL; + gFollowOn = FALSE; gFollowArg = ""; + + showMainEx(FALSE); + return; + } + + // If we got here, ignore. } } diff --git a/HS_CamPlaylist.lsl b/HS_CamPlaylist.lsl index 4b27a8a..ba87ab0 100644 --- a/HS_CamPlaylist.lsl +++ b/HS_CamPlaylist.lsl @@ -1,166 +1,229 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message - - Notes: - - idx is valid only if > 0 (slot 0 reserved). - - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). - - Early-cut is disabled for tours (tour is one continuous move). + HS_DollyCam - Playlist Helper (decoupled) + - Reads playlist notecards (one command per line) + - Builds TOUR payloads incl optional per-waypoint weights + - Sends CE_CMD_MOVE / CE_CMD_TOUR to the core/tour engine scripts + - Handles MOVE_DONE chaining + early-cut waits + - Chat/menu one-shot tour builders live in HS_CamTourCommands.lsl */ -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; +// ---- Engine protocol (must match CamEngine) ---- integer CE_CMD_MOVE = 1010; -integer CE_CMD_TOUR = 1011; // NEW: continuous multi-waypoint ride -integer CE_CMD_STOP = 1012; +integer CE_CMD_TOUR = 1011; 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_FOV = 1040; // (optional for non-tour lines later) 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) +// ---- Helper protocol (Controller -> Playlist Helper) ---- +integer PH_CMD_PLAY = 6100; // payload: card|gapMs +integer PH_CMD_STOP = 6101; // payload: reason -// 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 +// ---- Markers helper (optional) ---- +integer MC_CMD = 5100; // "SHOW|N" / "HIDE" +string LSKEY_CAMS = "HS_CAMS"; +string LSKEY_LOCK = "HS_LOCK"; -// ===== 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 +// ---- Presets (Linkset Data) ---- string PRE_KEY(integer idx) { return "P" + (string)idx; } -// Controller runtime +// ---- Runtime ---- key gOwner; -integer gListen; -// Move ids -integer gMoveId = 100; // start non-zero -integer nextMoveId() { gMoveId++; return gMoveId; } +// Move ids (separate range to avoid collisions with Controller moves) +integer gMoveId = 5000; +integer nextMoveId() { ++gMoveId; return gMoveId; } -// Defaults (updated when engine cfg dump arrives) +// Defaults (updated via cfg dump) integer gDefaultMoveMs = 2200; +// ---- playlist state ---- +integer PL_IDLE = 0; +integer PL_RUN = 1; +integer PL_WAIT_MOVE = 2; +integer PL_WAIT_DELAY = 3; -integer demoSlotOk(integer idx) -{ - if (!DEMO_MODE) return TRUE; +integer gPlState = 0; +integer gPlActive = FALSE; +string gPlCard = ""; +integer gPlLine = 0; +key gPlQuery; +integer gPlGapMs = 0; +integer gPlSyncDepth = 0; - if (idx <= DEMO_MAX_SLOTS) return TRUE; +integer gPlWaitMoveId = 0; - say("!!!DEMO Version !!! limited to max " + (string)DEMO_MAX_SLOTS + " Slots"); - return FALSE; -} +// Prefetch / buffer +integer gPlHasBuf = FALSE; +string gPlBufLine = ""; +integer gPlEofPending = FALSE; -// ---- save pending ---- -integer gSavePending = FALSE; -integer gSaveIdx = 0; -integer gSaveReq = 0; +// Early wait after moveto +integer gPlEarlyWaitActive = FALSE; +float gPlResumeAt = 0.0; +integer gPlEarlyCutAllowed = TRUE; -// Temp preset buffer (set by loadPreset) +// FOV +integer gTourFovOn = FALSE; +float gTourFovA = 0.0; // radians +float gTourFovB = 0.0; // radians + +// ---- temp preset buffer ---- vector gTmpPos; vector gTmpFoc; + +// NEW: optional preset FOV (v2 format, field 10) integer gTmpHasFov = FALSE; float gTmpFovRad = 0.0; -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° +// NEW: enable/disable applying preset FOV on moveto/load (outside tours) +integer APPLY_PRESET_FOV = TRUE; -// --- Marker menu state (persist across scripts) --- -string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" +// ---- tour build state ---- +integer gTourActive = FALSE; +integer gTourTotalMs = 0; +string gTourMode = "linear"; +list gTourIdx; // integers; pos/focus are loaded only when the payload is built +list gTourHoldMs; // integers +list gTourWeight; // floats +integer gTourHasWeight = FALSE; -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; +integer gTourMaxPoints = 20; // default, overridden by cfg dump +integer gTourPayloadMax = 1800; // safety: keep link_message payloads reasonable -followLockInitState() +// ---- HUD show/hide (single prim HUD) ---- +hudHide() { llSetAlpha(0.0, ALL_SIDES); } +hudShow() { llSetAlpha(1.0, ALL_SIDES); } + +// ---- tiny helpers ---- +integer isValidIdx(integer idx) { return (idx > 0); } +say(string s) { llOwnerSay(s); } + +stopTimer() { llSetTimerEvent(0.0); } + +startDelayMs(integer ms) { - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); + gPlResumeAt = llGetTime() + ((float)ms / 1000.0); + llSetTimerEvent(0.05); } -followWrite(integer on, key target) +integer isCommentOrEmpty(string line) { - if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); + line = llStringTrim(line, STRING_TRIM); + if (line == "") return TRUE; + if (llGetSubString(line,0,0) == "#") return TRUE; + if (llGetSubString(line,0,1) == "//") return TRUE; + if (llGetSubString(line,0,0) == ";") return TRUE; + return FALSE; } -lockWrite(integer on, string arg) +// Minimal, list-free parse of default_move_ms from cfg dump string +integer findCharFrom(string s, string ch, integer start) { - 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; + integer L = llStringLength(s); + integer i; + for (i = start; i < L; ++i) { + if (llGetSubString(s, i, i) == ch) return i; } + return -1; } -camsWriteState() +string lineToken(string s, integer want) { - llLinksetDataWrite(LSKEY_CAMS, (string)gCamsShown + "|" + (string)gCamsN); + integer L = llStringLength(s); + integer start = 0; + integer idx = 0; + + while (start < L) { + while (start < L && llGetSubString(s, start, start) == " ") ++start; + if (start >= L) return ""; + + integer end = findCharFrom(s, " ", start); + if (end < 0) end = L; + + if (idx == want) return llGetSubString(s, start, end - 1); + + ++idx; + start = end + 1; + } + + return ""; } +integer cfgGetInt(string dump, string keyName, integer def) +{ + integer at = llSubStringIndex(dump, keyName); + if (at < 0) return def; + + at += llStringLength(keyName); + + integer end = findCharFrom(dump, "|", at); + string v = ""; + if (end < 0) v = llGetSubString(dump, at, -1); + else v = llGetSubString(dump, at, end - 1); + + v = llStringTrim(v, STRING_TRIM); + if (v == "") return def; + return (integer)v; +} + +// ---- Preset loading ---- +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|fovRad + integer start = 0; + integer end = 0; + integer field = 0; + float px = 0.0; float py = 0.0; float pz = 0.0; + float fx = 0.0; float fy = 0.0; float fz = 0.0; + float fr = 0.0; + + while (field <= 10) { + end = findCharFrom(data, "|", start); + + string v = ""; + if (end < 0) v = llGetSubString(data, start, -1); + else v = llGetSubString(data, start, end - 1); + + if (field <= 5 && v == "") return FALSE; + + if (field == 0) px = (float)v; + else if (field == 1) py = (float)v; + else if (field == 2) pz = (float)v; + else if (field == 3) fx = (float)v; + else if (field == 4) fy = (float)v; + else if (field == 5) fz = (float)v; + else if (field == 10) fr = (float)v; + + ++field; + if (end < 0) jump done; + start = end + 1; + } +@done; + + if (field < 6) return FALSE; + + gTmpPos = ; + gTmpFoc = ; + + // NEW: optional v2 preset: fovRad at index 10 + gTmpHasFov = FALSE; + gTmpFovRad = 0.0; + if (fr > 0.0001) { + gTmpHasFov = TRUE; + gTmpFovRad = clampFovRad(fr); + } + + return TRUE; +} + +// ---- TOUR weights ---- float clampf(float v, float lo, float hi) { if (v < lo) return lo; @@ -170,797 +233,902 @@ float clampf(float v, float lo, float hi) float clampFovRad(float rad) { - float deg = rad2deg(rad); - deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + float deg = rad * 180.0 / PI; + deg = clampf(deg, 10.0, 179.0); return deg2rad(deg); } -string presetDescribe(integer idx, string data) +// Keep mode string small; Engine already normalizes/fallbacks. +string modeSanitize(string raw) { - 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; + raw = llToLower(llStringTrim(raw, STRING_TRIM)); + if (raw == "") return "linear"; + return raw; } -// ===== RLVa helpers ===== float deg2rad(float deg) { return deg * PI / 180.0; } -float rad2deg(float rad) { return rad * 180.0 / PI; } -string fmtFloat(float v) +float tourWeightFromLine(string line, integer startIdx) { - // 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; + integer i = startIdx; + while (i < 24) { + string tok = lineToken(line, i); + if (tok == "") return 1.0; + + tok = llToLower(tok); + + if (llSubStringIndex(tok, "w=") == 0) { + float w = (float)llGetSubString(tok, 2, -1); + return clampf(w, 0.10, 10.0); + } + if (llSubStringIndex(tok, "weight=") == 0) { + float w2 = (float)llGetSubString(tok, 7, -1); + return clampf(w2, 0.10, 10.0); + } + if (tok == "w" || tok == "weight") { + string next = lineToken(line, i + 1); + if (next != "") return clampf((float)next, 0.10, 10.0); + } + + if (llSubStringIndex(tok, "spd=") == 0) { + float sp = (float)llGetSubString(tok, 4, -1); + if (sp > 0.0001) return clampf(1.0 / sp, 0.10, 10.0); + } + if (llSubStringIndex(tok, "speed=") == 0) { + float sp2 = (float)llGetSubString(tok, 6, -1); + if (sp2 > 0.0001) return clampf(1.0 / sp2, 0.10, 10.0); + } + if (tok == "spd" || tok == "speed") { + string next2 = lineToken(line, i + 1); + if (next2 != "") { + float sp3 = (float)next2; + if (sp3 > 0.0001) return clampf(1.0 / sp3, 0.10, 10.0); + } + } + + ++i; + } + + return 1.0; } -// ---------- helpers ---------- -integer isValidIdx(integer idx) { return (idx > 0); } - -say(string s) { llOwnerSay(s); } - -// Playlist / TourCommand helpers -phStop(string reason) +// ---- TOUR builder ---- +tourReset() { - llMessageLinked(LINK_SET, PH_CMD_STOP, reason, gOwner); + gTourActive = FALSE; + gTourTotalMs = 0; + gTourMode = "linear"; + gTourIdx = []; + gTourHoldMs = []; + gTourWeight = []; + gTourHasWeight = FALSE; + gTourFovOn = FALSE; + gTourFovA = 0.0; + gTourFovB = 0.0; } -phPlay(string card, integer gapMs) +tourBegin(integer totalMs, string mode) { - llMessageLinked(LINK_SET, PH_CMD_PLAY, card + "|" + (string)gapMs, gOwner); + if (totalMs < 0) totalMs = 0; + gTourActive = TRUE; + gTourTotalMs = totalMs; + gTourMode = modeSanitize(mode); + gTourIdx = []; + gTourHoldMs = []; + gTourWeight = []; + gTourHasWeight = FALSE; + gTourFovOn = FALSE; + gTourFovA = 0.0; + gTourFovB = 0.0; } -phChatTour(string line) +integer tourAddWaypointIdxW(integer idx, float w) { - llMessageLinked(LINK_SET, PH_CMD_CHAT_TOUR, line, gOwner); -} + if (llGetListLength(gTourIdx) >= gTourMaxPoints) return FALSE; + if (llLinksetDataRead(PRE_KEY(idx)) == "") return FALSE; -phMenuTourRun(string raw) -{ - llMessageLinked(LINK_SET, PH_CMD_TOURRUN, raw, gOwner); -} + w = clampf(w, 0.10, 10.0); + if (llFabs(w - 1.0) > 0.001) gTourHasWeight = TRUE; -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); } - } - + gTourIdx += [idx]; + gTourHoldMs += [0]; + gTourWeight += [w]; return TRUE; } -applyLoadedPresetFov() +tourAddHold(integer ms) { - if (!gTmpHasFov) return; - - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); + if (ms < 0) ms = 0; + integer n = llGetListLength(gTourHoldMs); + if (n < 1) return; + integer cur = llList2Integer(gTourHoldMs, n - 1); + cur += ms; + gTourHoldMs = llListReplaceList(gTourHoldMs, [cur], n - 1, n - 1); } -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; - -integer followModeFrom(string s) +string tourBuildPayload(integer moveId) { - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; -} + integer count = llGetListLength(gTourIdx); + string body = ""; + integer valid = 0; -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) -{ - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; + integer i; + for (i = 0; i < count; ++i) { + integer idx = llList2Integer(gTourIdx, i); + if (!loadPreset(idx)) jump next; - string s = llList2String(toks, i); + string part = (string)gTmpPos + "|" + (string)gTmpFoc + "|" + (string)llList2Integer(gTourHoldMs, i); + if (gTourHasWeight) { + float w = 1.0; + if (i < llGetListLength(gTourWeight)) w = llList2Float(gTourWeight, i); + part += "|" + (string)w; + } - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; + if (body == "") body = part; + else body += "|" + part; + ++valid; +@next; } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { + if (valid < 2) return ""; + + string payload = (string)moveId + "|" + (string)gTourTotalMs + "|" + gTourMode + "|" + (string)valid; + if (gTourFovOn) payload += "|FOV|" + (string)gTourFovA + "|" + (string)gTourFovB; + + return payload + "|" + body; +} + +integer tourApplyFovFromLine(string line, integer startIdx) +{ + string fovTok = llToLower(lineToken(line, startIdx)); + if (fovTok != "fovdeg" && fovTok != "fov") return FALSE; + + string tokA = lineToken(line, startIdx + 1); + string tokB = lineToken(line, startIdx + 2); + if (tokA == "" || tokB == "") return FALSE; + + float a = (float)tokA; + float b = (float)tokB; + gTourFovOn = TRUE; + + if (fovTok == "fovdeg") { + gTourFovA = deg2rad(clampf(a, 10.0, 179.0)); + gTourFovB = deg2rad(clampf(b, 10.0, 179.0)); + } else { + gTourFovA = deg2rad(clampf(a * 180.0 / PI, 10.0, 179.0)); + gTourFovB = deg2rad(clampf(b * 180.0 / PI, 10.0, 179.0)); + } + + return TRUE; +} + +integer tourRunInlineFromLine(string line, integer startIdx) +{ + integer sawPoint = FALSE; + integer bad = FALSE; + integer i = startIdx; + + while (i < 80) { + string tok = lineToken(line, i); + if (tok == "") jump inline_done; + + string low = llToLower(tok); + if (low == "fovdeg" || low == "fov") { + tourApplyFovFromLine(line, i); + jump inline_done; + } + + integer idx = (integer)tok; + if (!isValidIdx(idx) || (string)idx != tok) { + say("Tour: bad inline token: " + tok); + bad = TRUE; + jump inline_done; + } + + if (llGetListLength(gTourIdx) >= gTourMaxPoints) { + say("Tour: too many points, max " + (string)gTourMaxPoints); + bad = TRUE; + jump inline_done; + } + + sawPoint = TRUE; + if (!tourAddWaypointIdxW(idx, 1.0)) { + say("Tour: bad/missing preset: " + tok); + bad = TRUE; + jump inline_done; + } + ++i; - s += " " + llList2String(toks, i); } - return [s, i + 1]; + +@inline_done; + if (!sawPoint) return FALSE; + + if (bad || llGetListLength(gTourIdx) < 2) { + if (!bad) say("Tour: needs at least 2 presets."); + tourReset(); + plFetchNextActionable(); + return TRUE; + } + + if (llSubStringIndex(llToLower(gTourMode), "startfirst") < 0) { + gTourMode += "+startfirst"; + } + + integer mid = nextMoveId(); + string payload = tourBuildPayload(mid); + if (payload == "") { + say("Tour: needs at least 2 valid presets."); + tourReset(); + plFetchNextActionable(); + return TRUE; + } + + if (llStringLength(payload) > gTourPayloadMax) { + say("Tour payload too large (" + (string)llStringLength(payload) + " chars). Reduce points or simplify values."); + tourReset(); + plFetchNextActionable(); + return TRUE; + } + + hudHide(); + llMessageLinked(LINK_SET, CE_CMD_TOUR, payload, gOwner); + + tourReset(); + plOnMoveIssuedEx(mid, FALSE); + return TRUE; } -// ---------- chat commands ---------- -printHelp() +// ---- Engine sending ---- +integer engineMove(vector pos, vector foc, integer durMs) { - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" - ); + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)durMs + "|" + (string)pos + "|" + (string)foc + "|src=PL"; + llMessageLinked(LINK_SET, CE_CMD_MOVE, payload, gOwner); + if (durMs > 0) hudHide(); + return mid; } -// ---------- default ---------- +// ---- Playlist core ---- +plStop(string reason) +{ + if (!gPlActive) return; + + gPlActive = FALSE; + gPlState = PL_IDLE; + gPlHasBuf = FALSE; + gPlBufLine = ""; + gPlEofPending = FALSE; + gPlEarlyWaitActive = FALSE; + gPlEarlyCutAllowed = TRUE; + tourReset(); + stopTimer(); + hudShow(); + + say("Playlist stopped. " + reason); +} + +plRequestLine() +{ + if (!gPlActive) return; + + if (gPlSyncDepth < 12) { + string data = llGetNotecardLineSync(gPlCard, gPlLine); + if (data != NAK) { + ++gPlSyncDepth; + plHandleNotecardData(data); + --gPlSyncDepth; + return; + } + } + + gPlQuery = llGetNotecardLine(gPlCard, gPlLine); +} + +plFetchNextActionable() +{ + plRequestLine(); +} + +plHandleNotecardData(string data) +{ + if (!gPlActive) return; + + if (data == EOF) { + if (gPlState == PL_WAIT_MOVE || gPlState == PL_WAIT_DELAY) { + gPlEofPending = TRUE; + return; + } + plStop("EOF reached."); + return; + } + + gPlLine++; + + if (isCommentOrEmpty(data)) { + plFetchNextActionable(); + return; + } + + if (gPlState == PL_WAIT_MOVE || gPlState == PL_WAIT_DELAY) { + plBufferOrExecuteFromWaitState(data); + return; + } + + plExecuteLine(data); +} + +plBufferOrExecuteFromWaitState(string line) +{ + integer wms = 0; + + // Early cut: after moveto, if next actionable line is "wait " + if (gPlState == PL_WAIT_MOVE && !gPlEarlyWaitActive && gPlEarlyCutAllowed) { + string tmp = llStringTrim(line, STRING_TRIM); + if (llToLower(lineToken(tmp, 0)) == "wait") { + string tok1 = lineToken(tmp, 1); + if (tok1 != "") { + wms = (integer)tok1; + if (wms < 0) wms = 0; + gPlEarlyWaitActive = TRUE; + startDelayMs(wms); + plFetchNextActionable(); + return; + } + } + } + + gPlHasBuf = TRUE; + gPlBufLine = line; +} + +plContinueFromWaitState() +{ + if (!gPlActive) return; + + if (gPlHasBuf) { + string line = gPlBufLine; + gPlHasBuf = FALSE; + gPlBufLine = ""; + gPlState = PL_RUN; + // execute buffered line + // (LSL allows calling functions declared later) + // plExecuteLine(line); + plExecuteLine(line); + } else { + if (gPlEofPending) { + plStop("EOF reached."); + return; + } + gPlState = PL_RUN; + plFetchNextActionable(); + } +} + +plOnMoveIssuedEx(integer moveId, integer allowEarlyCut) +{ + gPlState = PL_WAIT_MOVE; + gPlWaitMoveId = moveId; + gPlEarlyWaitActive = FALSE; + gPlEarlyCutAllowed = allowEarlyCut; + + plFetchNextActionable(); +} + +plStart(string card, integer gapMs) +{ + if (llGetInventoryType(card) != INVENTORY_NOTECARD) { + say("Playlist notecard not found: " + card); + return; + } + + // stop existing + if (gPlActive) plStop("Restart."); + + gPlActive = TRUE; + gPlCard = card; + gPlLine = 0; + gPlGapMs = gapMs; + gPlState = PL_RUN; + gPlHasBuf = FALSE; + gPlBufLine = ""; + gPlEofPending = FALSE; + gPlEarlyWaitActive = FALSE; + gPlEarlyCutAllowed = TRUE; + tourReset(); + stopTimer(); + + say("Playlist start: " + card + " (gap=" + (string)gapMs + "ms)"); + plFetchNextActionable(); +} + +camsWriteState(integer shown, integer n) +{ + if (n < 1) n = 1; + if (n > 30) n = 30; + llLinksetDataWrite(LSKEY_CAMS, (string)shown + "|" + (string)n); +} + +plExecuteLine(string line) +{ + if (!gPlActive) return; + + line = llStringTrim(line, STRING_TRIM); + if (isCommentOrEmpty(line)) { plFetchNextActionable(); return; } + + string cmd = llToLower(lineToken(line, 0)); + if (cmd == "") { plFetchNextActionable(); return; } + + // ---- TOUR BLOCK ---- + if (gTourActive) { + + if (cmd == "endtour") { + integer count = llGetListLength(gTourIdx); + if (count < 2) { + tourReset(); + plFetchNextActionable(); + return; + } + + integer mid = nextMoveId(); + string payload = tourBuildPayload(mid); + if (payload == "") { + say("Tour: needs at least 2 valid presets."); + tourReset(); + plFetchNextActionable(); + return; + } + + if (llStringLength(payload) > gTourPayloadMax) { + say("Tour payload too large (" + (string)llStringLength(payload) + " chars). Reduce points or simplify values."); + tourReset(); + plFetchNextActionable(); + return; + } + + hudHide(); + llMessageLinked(LINK_SET, CE_CMD_TOUR, payload, gOwner); + + tourReset(); + plOnMoveIssuedEx(mid, FALSE); // no early-cut for tours + return; + + } + + if (cmd == "wait") { + string tok1 = lineToken(line, 1); + if (tok1 == "") { plFetchNextActionable(); return; } + + integer ms = (integer)tok1; + if (ms < 0) ms = 0; + tourAddHold(ms); + plFetchNextActionable(); + return; + } + + if (cmd == "moveto" || cmd == "goto" || cmd == "load") { + string tok1m = lineToken(line, 1); + if (tok1m == "") { plFetchNextActionable(); return; } + + integer idx = (integer)tok1m; + if (!isValidIdx(idx)) { plFetchNextActionable(); return; } + + float w = tourWeightFromLine(line, 2); + if (!tourAddWaypointIdxW(idx, w)) { + // ignore if too many or missing preset + } + plFetchNextActionable(); + return; + } + + // Allow FOV ramp lines inside tour blocks: + // fovdeg + // fov + if (cmd == "fovdeg" || cmd == "fov") { + tourApplyFovFromLine(line, 0); + plFetchNextActionable(); + return; + } + + // ignore other commands inside tour + plFetchNextActionable(); + return; + } + + // ---- NORMAL PLAYLIST ---- + + if (cmd == "tour") { + string tok1 = lineToken(line, 1); + if (tok1 == "") { plFetchNextActionable(); return; } + + integer totalMs = (integer)tok1; + + string mode = "linear"; + integer scanFrom = 2; + + string tok2 = lineToken(line, 2); + if (tok2 != "") { + string tok2l = llToLower(tok2); + integer maybeIdx = (integer)tok2; + if (tok2l != "fovdeg" && tok2l != "fov" && (maybeIdx <= 0 || (string)maybeIdx != tok2)) { + mode = tok2; + scanFrom = 3; + } + } + + tourBegin(totalMs, mode); + + // Header FOV keeps block syntax working: + // tour 9000 spline fovdeg 35 70 + tourApplyFovFromLine(line, scanFrom); + + // Compact notecard syntax avoids waiting for many dataserver lines: + // tour 9000 spline 1 2 3 4 + if (tourRunInlineFromLine(line, scanFrom)) return; + + plFetchNextActionable(); + return; + } + + if (cmd == "wait") { + string tok1w = lineToken(line, 1); + if (tok1w == "") { plFetchNextActionable(); return; } + + integer ms = (integer)tok1w; + if (ms < 0) ms = 0; + + gPlState = PL_WAIT_DELAY; + startDelayMs(ms); + plFetchNextActionable(); + return; + } + + // ---- FOV (standalone, outside tours) ---- + // Usage in notecard: + // fovdeg (deg 10..179) + // fov (rad ~ 0.17..3.12) + // Optional: + // fovdeg (quiet=1 default) + // fov + if (cmd == "fovdeg" || cmd == "fov") { + string tok1f2 = lineToken(line, 1); + if (tok1f2 == "") { plFetchNextActionable(); return; } + + integer quiet = 1; + string tok2f2 = lineToken(line, 2); + if (tok2f2 != "") quiet = (integer)tok2f2; + + float rad = 1.0472; // fallback ~60deg + if (cmd == "fovdeg") { + float deg = (float)tok1f2; + deg = clampf(deg, 10.0, 179.0); + rad = deg2rad(deg); + } else { + float inrad = (float)tok1f2; + // clamp via deg equivalent for sanity + float indeg = clampf(inrad * 180.0 / PI, 10.0, 179.0); + rad = deg2rad(indeg); + } + + // Send to Controller -> RLVa (quiet to avoid chat spam) + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|" + (string)quiet, gOwner); + + // keep playlist pacing consistent with other commands + if (gPlGapMs > 0) { + gPlState = PL_WAIT_DELAY; + startDelayMs(gPlGapMs); + plFetchNextActionable(); + return; + } + + plFetchNextActionable(); + return; + } + + + if (cmd == "moveto" || cmd == "goto" || cmd == "load") { + string tok1m2 = lineToken(line, 1); + if (tok1m2 == "") { plFetchNextActionable(); return; } + + integer idx = (integer)tok1m2; + if (!isValidIdx(idx)) { plFetchNextActionable(); return; } + if (!loadPreset(idx)) { plFetchNextActionable(); return; } + + // NEW: apply preset FOV (outside tours) + if (APPLY_PRESET_FOV && gTmpHasFov) { + llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); + } + + integer dur = gDefaultMoveMs; + if (cmd == "load") dur = 0; + string tok2m2 = lineToken(line, 2); + if (tok2m2 != "") dur = (integer)tok2m2; + + integer mid = engineMove(gTmpPos, gTmpFoc, dur); + plOnMoveIssuedEx(mid, TRUE); + return; + } + + // dollyzoom [mode] + // Builds a 2-point TOUR with FOV from the two presets. + // dollyzoom [mode] [keep] + // Builds a 2-point TOUR with FOV from the two presets. + if (cmd == "dollyzoom") { + string tok1d = lineToken(line, 1); + string tok2d = lineToken(line, 2); + if (tok1d == "" || tok2d == "") { plFetchNextActionable(); return; } + + integer totalMs = (integer)tok1d; + if (totalMs < 0) totalMs = 0; + + integer i = 2; + string mode = "linear"; + + integer maybeIdx = (integer)tok2d; + if (maybeIdx <= 0 || (string)maybeIdx != tok2d) { + mode = tok2d; + i = 3; + } + mode = modeSanitize(mode); + + string tokIdxA = lineToken(line, i); + string tokIdxB = lineToken(line, i + 1); + if (tokIdxA == "" || tokIdxB == "") { plFetchNextActionable(); return; } + + integer idxA = (integer)tokIdxA; + integer idxB = (integer)tokIdxB; + + // Optional keep token after idxB + integer keepOn = FALSE; + integer k; + for (k = i + 2; k < i + 8; ++k) { + string tokK = llToLower(lineToken(line, k)); + if (tokK == "") jump doneDollyOpts; + if (tokK == "keep" || tokK == "keepframe" || tokK == "kf") { keepOn = TRUE; } + } +@doneDollyOpts; + if (keepOn) mode = mode + "+keep"; + + if (!isValidIdx(idxA) || !isValidIdx(idxB)) { plFetchNextActionable(); return; } + + if (!loadPreset(idxA)) { say("DollyZoom: missing preset " + (string)idxA); plFetchNextActionable(); return; } + vector posA = gTmpPos; + vector focA = gTmpFoc; + integer hasFovA = gTmpHasFov; + float fovA = gTmpFovRad; + + if (!loadPreset(idxB)) { say("DollyZoom: missing preset " + (string)idxB); plFetchNextActionable(); return; } + vector posB = gTmpPos; + vector focB = gTmpFoc; + integer hasFovB = gTmpHasFov; + float fovB = gTmpFovRad; + + if (keepOn) { + focB = focA; + } + + // For keepframe we only need A's FOV (start FOV); B is optional. + if (!hasFovA || (!keepOn && !hasFovB)) { + say("DollyZoom needs FOV stored in BOTH presets (unless using 'keep'). Set FOV and save presets again."); + plFetchNextActionable(); + return; + } + if (keepOn && !hasFovB) fovB = fovA; // harmless placeholder + + integer mid = nextMoveId(); + + string payload = (string)mid + "|" + (string)totalMs + "|" + mode + "|2" + + "|FOV|" + (string)fovA + "|" + (string)fovB + + "|" + (string)posA + "|" + (string)focA + "|0" + + "|" + (string)posB + "|" + (string)focB + "|0"; + + hudHide(); + llMessageLinked(LINK_SET, CE_CMD_TOUR, payload, gOwner); + + // Tours are one continuous move: no early-cut + plOnMoveIssuedEx(mid, FALSE); + return; + } + + // lock on|off + if (cmd == "lock") { + string tok1l = lineToken(line, 1); + if (tok1l == "") { plFetchNextActionable(); return; } + + integer on = (llToLower(tok1l) == "on"); + string arg = "<0,0,0>"; + string tok2l = lineToken(line, 2); + if (tok2l != "") arg = tok2l; + llMessageLinked(LINK_SET, CE_CMD_LOCK, (string)on + "|" + arg, gOwner); + + if (!on) llLinksetDataWrite(LSKEY_LOCK, "0|"); + else llLinksetDataWrite(LSKEY_LOCK, "1|" + arg); + + if (gPlGapMs > 0) { + gPlState = PL_WAIT_DELAY; + startDelayMs(gPlGapMs); + plFetchNextActionable(); + return; + } + plFetchNextActionable(); + return; + } + + // follow on|off ... + if (cmd == "follow") { + string tok1fo = lineToken(line, 1); + if (tok1fo == "") { plFetchNextActionable(); return; } + + integer on2 = (llToLower(tok1fo) == "on"); + // pass-through minimal payload: on|target|||mode|trans + // For notecards: keep it simple; advanced offsets can still be written as single tokens (no spaces) + key target = gOwner; + string tok2fo = lineToken(line, 2); + if (tok2fo != "") target = (key)tok2fo; + + integer mode = 2; // world + integer trans = 0; + string tok3fo = lineToken(line, 3); + string tok4fo = lineToken(line, 4); + if (tok3fo != "") mode = (integer)tok3fo; + if (tok4fo != "") trans = (integer)tok4fo; + + string payload = + (string)on2 + "|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR + + "|" + (string)mode + "|" + (string)trans; + + llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); + + if (gPlGapMs > 0) { + gPlState = PL_WAIT_DELAY; + startDelayMs(gPlGapMs); + plFetchNextActionable(); + return; + } + plFetchNextActionable(); + return; + } + + // markers show/hide cams + if (cmd == "show" && llToLower(lineToken(line, 1)) == "cams") { + integer want = 12; + string tok2s = lineToken(line, 2); + if (tok2s != "") want = (integer)tok2s; + if (want < 1) want = 1; + if (want > 30) want = 30; + + llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); + camsWriteState(TRUE, want); + + if (gPlGapMs > 0) { + gPlState = PL_WAIT_DELAY; + startDelayMs(gPlGapMs); + plFetchNextActionable(); + return; + } + plFetchNextActionable(); + return; + } + + if (cmd == "hide" && llToLower(lineToken(line, 1)) == "cams") { + llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); + camsWriteState(FALSE, 12); + + if (gPlGapMs > 0) { + gPlState = PL_WAIT_DELAY; + startDelayMs(gPlGapMs); + plFetchNextActionable(); + return; + } + plFetchNextActionable(); + return; + } + + // Unknown: ignore to keep playlists robust + plFetchNextActionable(); +} + +// ---- Events ---- 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(); + // ask engine for cfg dump once (helper parses only default_move_ms) + llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } - on_rez(integer sp) - { - gOwner = llGetOwner(); - } + on_rez(integer sp) { gOwner = llGetOwner(); } attach(key id) { + gOwner = llGetOwner(); 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(); + if (gPlActive) plStop("HUD detached."); } } + timer() + { + float now = llGetTime(); + + if (gPlActive && gPlState == PL_WAIT_MOVE && gPlEarlyWaitActive) { + if (now >= gPlResumeAt) { + gPlEarlyWaitActive = FALSE; + stopTimer(); + plContinueFromWaitState(); + } + return; + } + + if (gPlActive && gPlState == PL_WAIT_DELAY) { + if (now >= gPlResumeAt) { + stopTimer(); + plContinueFromWaitState(); + } + } + } + + dataserver(key qid, string data) + { + if (qid != gPlQuery) return; + if (!gPlActive) return; + plHandleNotecardData(data); + } + 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; - } + // Engine events if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i= 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)); + if (!gPlActive) return; - 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(); + integer mid = (integer)str; + if (gPlState == PL_WAIT_MOVE && !gPlEarlyWaitActive && mid == gPlWaitMoveId) { + if (gPlGapMs > 0) { + gPlState = PL_WAIT_DELAY; + startDelayMs(gPlGapMs); return; } + plContinueFromWaitState(); } 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; + // Controller -> Helper commands (trust only owner-routed) + if (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(); + if (num == PH_CMD_STOP) { + if (gPlActive) plStop(str); 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); + if (num == PH_CMD_PLAY) { integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; - integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; - } + string card = str; + integer sep = llSubStringIndex(str, "|"); + if (sep >= 0) { + card = llGetSubString(str, 0, sep - 1); + gap = (integer)llGetSubString(str, sep + 1, -1); } - 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(); + if (card == "") return; + plStart(card, gap); return; } - if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - - gCamsShown = FALSE; - camsWriteState(); - return; - } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); - return; - } - - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); - - string lockArgUsed = "<0,0,0>"; - - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); - - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); - } else { - key k = (key)arg; - if (k != NULL_KEY) { - lockArgUsed = (string)k; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); - } else { - vector camPos = llGetCameraPos(); - rotation camRot = llGetCameraRot(); - vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); - lockArgUsed = (string)foc2; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); - } - } - } else { - vector camPos2 = llGetCameraPos(); - rotation camRot2 = llGetCameraRot(); - vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); - lockArgUsed = (string)foc3; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); - } - - lockWrite(TRUE, lockArgUsed); - say("Lock ON"); - return; - } - - if (cmd == "follow" && n >= 2) { - string sw4 = llToLower(llList2String(t,1)); - integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); - - key target = gOwner; - if (n >= 3) target = (key)llList2String(t,2); - - integer mode = FOLLOW_WORLD; - integer trans = 0; - - if (n >= 4) mode = followModeFrom(llList2String(t,3)); - if (n >= 5) trans = (integer)llList2String(t,4); - if (trans < 0) trans = 0; - - if (!on4) { - // OFF: do not touch lock - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); - say("Follow OFF"); - return; - } - - // Mutual exclusion: Follow ON disables Lock - engineLockOff(); - - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); - - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); - return; - } - - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - if (cmd == "fovdeg" && n >= 2) { - float deg = (float)llList2String(t, 1); - float rad = deg * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - say("Unknown command. /88 help"); } } diff --git a/HS_CamPresetTransfer.lsl b/HS_CamPresetTransfer.lsl new file mode 100644 index 0000000..8dab389 --- /dev/null +++ b/HS_CamPresetTransfer.lsl @@ -0,0 +1,329 @@ +/* + HS_DollyCam - Preset Transfer Utility + + Drop this script into a camera object only when importing/exporting presets. + It reads and writes the existing Linkset Data preset keys: P1, P2, ... + + Commands on channel 88: + /88 xfer help + /88 xfer status + /88 xfer export [from] [count] + /88 xfer import + /88 xfer importreplace + /88 xfer poof + + Export format: + HS_DOLLYCAM_PRESETS_V1 + P1=px|py|pz|fx|fy|fz|rx|ry|rz|rs|fovRad + HS_DOLLYCAM_PRESETS_END +*/ + +integer CH = 88; +integer MAX_PRESET_SCAN = 999; + +string HEADER = "HS_DOLLYCAM_PRESETS_V1"; +string FOOTER = "HS_DOLLYCAM_PRESETS_END"; + +key gOwner; +integer gListen; + +string gImportCard; +key gImportQuery; +integer gImportLine; +integer gImporting; +integer gReplaceBeforeImport; +integer gImported; +integer gSkipped; + +string preKey(integer idx) +{ + return "P" + (string)idx; +} + +say(string msg) +{ + llOwnerSay(msg); +} + +string firstToken(string msg) +{ + msg = llStringTrim(msg, STRING_TRIM); + integer sp = llSubStringIndex(msg, " "); + if (sp < 0) return msg; + return llGetSubString(msg, 0, sp - 1); +} + +string restAfterFirstToken(string msg) +{ + msg = llStringTrim(msg, STRING_TRIM); + integer sp = llSubStringIndex(msg, " "); + if (sp < 0) return ""; + return llStringTrim(llGetSubString(msg, sp + 1, -1), STRING_TRIM); +} + +integer isCommentOrMarker(string line) +{ + if (line == "") return TRUE; + if (line == HEADER) return TRUE; + if (line == FOOTER) return TRUE; + if (llGetSubString(line, 0, 0) == "#") return TRUE; + if (llGetSubString(line, 0, 1) == "//") return TRUE; + return FALSE; +} + +integer hasPresetMinimumFields(string data) +{ + integer pipes = 0; + integer pos = -1; + integer len = llStringLength(data); + + while (pipes < 5) { + integer next = llSubStringIndex(llGetSubString(data, pos + 1, -1), "|"); + if (next < 0) return FALSE; + pos += next + 1; + ++pipes; + if (pos >= len) return FALSE; + } + return TRUE; +} + +integer validPresetIndexString(string s) +{ + integer len = llStringLength(s); + if (len < 2) return FALSE; + if (llToUpper(llGetSubString(s, 0, 0)) != "P") return FALSE; + + integer idx = (integer)llGetSubString(s, 1, -1); + if (idx < 1 || idx > MAX_PRESET_SCAN) return FALSE; + + if ("P" + (string)idx != llToUpper(s)) return FALSE; + return TRUE; +} + +integer importLine(string line) +{ + line = llStringTrim(line, STRING_TRIM); + if (isCommentOrMarker(line)) return FALSE; + + integer eq = llSubStringIndex(line, "="); + if (eq < 2) return FALSE; + + string keyName = llStringTrim(llGetSubString(line, 0, eq - 1), STRING_TRIM); + string data = llStringTrim(llGetSubString(line, eq + 1, -1), STRING_TRIM); + + if (!validPresetIndexString(keyName)) return FALSE; + if (!hasPresetMinimumFields(data)) return FALSE; + + integer idx = (integer)llGetSubString(keyName, 1, -1); + llLinksetDataWrite(preKey(idx), data); + return TRUE; +} + +clearPresets() +{ + integer i; + for (i = 1; i <= MAX_PRESET_SCAN; ++i) { + llLinksetDataDelete(preKey(i)); + } +} + +printHelp() +{ + say( + "HS DollyCam Preset Transfer\n" + + "/88 xfer status\n" + + "/88 xfer export [from] [count]\n" + + "/88 xfer import \n" + + "/88 xfer importreplace \n" + + "/88 xfer poof\n" + + "Import notecard lines use: P1=px|py|pz|fx|fy|fz|rx|ry|rz|rs|fovRad" + ); +} + +printStatus() +{ + integer count = 0; + integer first = 0; + integer last = 0; + integer i; + + for (i = 1; i <= MAX_PRESET_SCAN; ++i) { + if (llLinksetDataRead(preKey(i)) != "") { + ++count; + if (first == 0) first = i; + last = i; + } + } + + if (count == 0) { + say("Preset Transfer: no saved presets found."); + return; + } + say("Preset Transfer: " + (string)count + " presets found, first P" + (string)first + ", last P" + (string)last + "."); +} + +exportPresets(string args) +{ + integer from = 1; + integer count = 50; + + string tok = firstToken(args); + if (tok != "") { + from = (integer)tok; + string rest = restAfterFirstToken(args); + tok = firstToken(rest); + if (tok != "") count = (integer)tok; + } + + if (from < 1) from = 1; + if (from > MAX_PRESET_SCAN) from = MAX_PRESET_SCAN; + if (count < 1) count = 50; + if (count > 100) count = 100; + + integer stop = from + count - 1; + if (stop > MAX_PRESET_SCAN) stop = MAX_PRESET_SCAN; + + integer shown = 0; + integer i; + + say(HEADER); + for (i = from; i <= stop; ++i) { + string data = llLinksetDataRead(preKey(i)); + if (data != "") { + say(preKey(i) + "=" + data); + ++shown; + } + } + say(FOOTER); + + if (shown == 0) { + say("Preset Transfer: no presets found in requested range."); + return; + } + say("Preset Transfer: exported " + (string)shown + " presets from P" + (string)from + " to P" + (string)stop + "."); +} + +startImport(string card, integer replace) +{ + card = llStringTrim(card, STRING_TRIM); + if (card == "") { + say("Preset Transfer: missing notecard name."); + return; + } + + if (llGetInventoryType(card) != INVENTORY_NOTECARD) { + say("Preset Transfer: notecard not found: " + card); + return; + } + + if (gImporting) { + say("Preset Transfer: import already running."); + return; + } + + gImportCard = card; + gImportLine = 0; + gImported = 0; + gSkipped = 0; + gReplaceBeforeImport = replace; + gImporting = TRUE; + + if (gReplaceBeforeImport) { + clearPresets(); + say("Preset Transfer: cleared existing P1-P" + (string)MAX_PRESET_SCAN + " presets."); + } + + say("Preset Transfer: importing from notecard '" + gImportCard + "' ..."); + gImportQuery = llGetNotecardLine(gImportCard, gImportLine); +} + +handleCommand(string msg) +{ + string cmd = llToLower(firstToken(msg)); + if (cmd != "xfer" && cmd != "transfer") return; + + string rest = restAfterFirstToken(msg); + string sub = llToLower(firstToken(rest)); + string args = restAfterFirstToken(rest); + + if (sub == "" || sub == "help") { + printHelp(); + return; + } + + if (sub == "status") { + printStatus(); + return; + } + + if (sub == "export") { + exportPresets(args); + return; + } + + if (sub == "import") { + startImport(args, FALSE); + return; + } + + if (sub == "importreplace") { + startImport(args, TRUE); + return; + } + + if (sub == "poof" || sub == "done" || sub == "remove") { + say("Preset Transfer: removing script."); + llRemoveInventory(llGetScriptName()); + return; + } + + say("Preset Transfer: unknown command. Use /88 xfer help"); +} + +default +{ + state_entry() + { + gOwner = llGetOwner(); + gListen = llListen(CH, "", "", ""); + say("HS DollyCam Preset Transfer ready. Use /88 xfer help"); + } + + on_rez(integer start_param) + { + gOwner = llGetOwner(); + } + + changed(integer change) + { + if (change & CHANGED_OWNER) { + llResetScript(); + } + } + + listen(integer channel, string name, key id, string msg) + { + if (llGetOwnerKey(id) != gOwner) return; + handleCommand(llStringTrim(msg, STRING_TRIM)); + } + + dataserver(key query_id, string data) + { + if (!gImporting || query_id != gImportQuery) return; + + if (data == EOF) { + say("Preset Transfer: import complete. imported=" + (string)gImported + " skipped=" + (string)gSkipped + "."); + gImporting = FALSE; + return; + } + + if (importLine(data)) ++gImported; + else { + string trimmed = llStringTrim(data, STRING_TRIM); + if (!isCommentOrMarker(trimmed)) ++gSkipped; + } + + ++gImportLine; + gImportQuery = llGetNotecardLine(gImportCard, gImportLine); + } +} diff --git a/HS_CamTourCommands.lsl b/HS_CamTourCommands.lsl index 4b27a8a..4c7418a 100644 --- a/HS_CamTourCommands.lsl +++ b/HS_CamTourCommands.lsl @@ -1,165 +1,43 @@ /* - HS_DollyCam - CamController (SLIM) - - /88 chat parsing - - Presets via Linkset Data (save/load/delete/list) - - Playlist player from notecards (one command per line, optional wait ) - - TOUR blocks: tour [linear|spline] ... endtour - - Config reload/dump - - Menu, Playlist, TourCommands and Markers are ROUTED to helper scripts via link_message - - Notes: - - idx is valid only if > 0 (slot 0 reserved). - - Playlist chains moves on Engine MOVE_DONE unless a wait line appears directly after a moveto (early cut). - - Early-cut is disabled for tours (tour is one continuous move). + HS_DollyCam - Tour command helper + - Handles secondary chat/menu one-shot tour builders + - Keeps HS_CamPlaylist.lsl focused on memory-sensitive notecard playback */ -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; +// Engine protocol +integer CE_CMD_TOUR = 1011; 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 +// Controller -> helper protocol +integer PH_CMD_CHAT_TOUR = 6102; // payload: full chat line starting with "tour ..." +integer PH_CMD_TOURRUN = 6103; // payload: raw menu string "TOURRUN|..." +integer PH_CMD_CHAT_DZ = 6104; // payload: full chat line starting with "dollyzoom ..." // 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 gMoveId = 8000; integer gDefaultMoveMs = 2200; +integer gTourMaxPoints = 20; - -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; +float gTmpFovRad = 0.0; -// “last set by HUD” bleibt als Fallback ok -float gLastFovRad = 1.04719755; // ~60° - -// --- Marker menu state (persist across scripts) --- -string LSKEY_CAMS = "HS_CAMS"; // "shown|N" e.g. "1|12" - -// ===== ADD: Follow/Lock state persisted for Menu via LinksetData ===== -// FOLLOW: "on|uuid" -// LOCK: "on|arg" (uuid or "") -string LSKEY_FOLLOW = "HS_FOLLOW"; -string LSKEY_LOCK = "HS_LOCK"; - -followLockInitState() +integer nextMoveId() { - if (llLinksetDataRead(LSKEY_FOLLOW) == "") llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - if (llLinksetDataRead(LSKEY_LOCK) == "") llLinksetDataWrite(LSKEY_LOCK, "0|"); + ++gMoveId; + return gMoveId; } -followWrite(integer on, key target) -{ - if (!on) llLinksetDataWrite(LSKEY_FOLLOW, "0|"); - else llLinksetDataWrite(LSKEY_FOLLOW, "1|" + (string)target); -} +integer isValidIdx(integer idx) { return (idx > 0); } +say(string s) { llOwnerSay(s); } +hudHide() { llSetAlpha(0.0, ALL_SIDES); } -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 deg2rad(float deg) { return deg * PI / 180.0; } float clampf(float v, float lo, float hi) { @@ -170,131 +48,43 @@ float clampf(float v, float lo, float hi) float clampFovRad(float rad) { - float deg = rad2deg(rad); - deg = clampf(deg, RLV_FOV_MIN_DEG, RLV_FOV_MAX_DEG); + float deg = rad * 180.0 / PI; + deg = clampf(deg, 10.0, 179.0); return deg2rad(deg); } -string presetDescribe(integer idx, string data) +string modeSanitize(string raw) { - list p = llParseString2List(data, ["|"], []); - integer L = llGetListLength(p); - if (L < 6) return "Preset " + (string)idx + " = (corrupt/too short)"; + raw = llToLower(llStringTrim(raw, STRING_TRIM)); + if (raw == "") return "linear"; + return raw; +} - 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; - } +integer findCharFrom(string s, string ch, integer start) +{ + integer L = llStringLength(s); + integer i; + for (i = start; i < L; ++i) { + if (llGetSubString(s, i, i) == ch) return i; } - s += " fov=(none)"; - return s; + return -1; } -// ===== RLVa helpers ===== -float deg2rad(float deg) { return deg * PI / 180.0; } -float rad2deg(float rad) { return rad * 180.0 / PI; } - -string fmtFloat(float v) +integer cfgGetInt(string dump, string keyName, integer def) { - // 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; -} + integer at = llSubStringIndex(dump, keyName); + if (at < 0) return def; -// ---------- helpers ---------- -integer isValidIdx(integer idx) { return (idx > 0); } + at += llStringLength(keyName); -say(string s) { llOwnerSay(s); } + integer end = findCharFrom(dump, "|", at); + string v = ""; + if (end < 0) v = llGetSubString(dump, at, -1); + else v = llGetSubString(dump, at, end - 1); -// 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 - ], "|"); + v = llStringTrim(v, STRING_TRIM); + if (v == "") return def; + return (integer)v; } integer loadPreset(integer idx) @@ -302,109 +92,263 @@ 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; + // packed: px|py|pz|fx|fy|fz|rx|ry|rz|rs|fovRad + integer start = 0; + integer end = 0; + integer field = 0; + float px = 0.0; float py = 0.0; float pz = 0.0; + float fx = 0.0; float fy = 0.0; float fz = 0.0; + float fr = 0.0; + + while (field <= 10) { + end = findCharFrom(data, "|", start); + + string v = ""; + if (end < 0) v = llGetSubString(data, start, -1); + else v = llGetSubString(data, start, end - 1); + + if (field <= 5 && v == "") return FALSE; + + if (field == 0) px = (float)v; + else if (field == 1) py = (float)v; + else if (field == 2) pz = (float)v; + else if (field == 3) fx = (float)v; + else if (field == 4) fy = (float)v; + else if (field == 5) fz = (float)v; + else if (field == 10) fr = (float)v; + + ++field; + if (end < 0) jump preset_done; + start = end + 1; + } +@preset_done; + + if (field < 6) return FALSE; + + gTmpPos = ; + gTmpFoc = ; - 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); } - } - + if (fr > 0.0001) { + gTmpHasFov = TRUE; + gTmpFovRad = clampFovRad(fr); + } + return TRUE; } -applyLoadedPresetFov() +list tourParseFov(list t, integer startIdx) { - if (!gTmpHasFov) return; + integer n = llGetListLength(t); + integer i; + for (i = startIdx; i < n; ++i) { + string tok = llToLower(llList2String(t, i)); - gLastFovRad = gTmpFovRad; // keep save fallback in sync - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)gTmpFovRad + "|1", gOwner); + if ((tok == "fovdeg" || tok == "fov") && (i + 2 < n)) { + float a = (float)llList2String(t, i + 1); + float b = (float)llList2String(t, i + 2); + + if (tok == "fovdeg") { + a = clampf(a, 10.0, 179.0); + b = clampf(b, 10.0, 179.0); + return [1, deg2rad(a), deg2rad(b)]; + } + + float adeg = clampf(a * 180.0 / PI, 10.0, 179.0); + float bdeg = clampf(b * 180.0 / PI, 10.0, 179.0); + return [1, deg2rad(adeg), deg2rad(bdeg)]; + } + } + return [0, 0.0, 0.0]; } -// ---------- follow parsing helpers ---------- -integer FOLLOW_YAW = 0; -integer FOLLOW_LOCAL = 1; -integer FOLLOW_WORLD = 2; - -integer followModeFrom(string s) +runChatTour(string msg) { - s = llToLower(s); - if (s == "yaw") return FOLLOW_YAW; - if (s == "local") return FOLLOW_LOCAL; - return FOLLOW_WORLD; -} + msg = llStringTrim(msg, STRING_TRIM); + if (llToLower(llGetSubString(msg, 0, 3)) != "tour") return; -// Re-join tokens that represent a that may contain spaces. -// returns [string joined, integer nextIndex] -list takeAngleToken(list toks, integer i) -{ - integer n = llGetListLength(toks); - if (i >= n) return ["", i]; - - string s = llList2String(toks, i); - - if (llGetSubString(s, 0, 0) != "<") { - return [s, i + 1]; + if (llStringLength(msg) > 600) { + say("Tour one-liner too long. Use notecard tour blocks."); + return; } - while (i + 1 < n && llGetSubString(s, -1, -1) != ">") { - ++i; - s += " " + llList2String(toks, i); + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + if (n < 4) { say("Tour: usage: tour [mode] ..."); return; } + + integer totalMs = (integer)llList2String(t, 1); + if (totalMs < 0) totalMs = 0; + + integer i = 2; + string mode = "linear"; + + string tok2 = llList2String(t, 2); + integer maybeIdx = (integer)tok2; + if (maybeIdx <= 0 || (string)maybeIdx != tok2) { + mode = tok2; + i = 3; } - return [s, i + 1]; + mode = modeSanitize(mode); + + list posList = []; + list focList = []; + integer k = i; + integer fovAt = -1; + + for (; k < n; ++k) { + string tok = llToLower(llList2String(t, k)); + if (tok == "fovdeg" || tok == "fov") { fovAt = k; jump chat_idx_done; } + + integer idx = (integer)llList2String(t, k); + if (!isValidIdx(idx) || !loadPreset(idx)) { say("Tour: bad/missing preset: " + llList2String(t, k)); return; } + posList += [gTmpPos]; + focList += [gTmpFoc]; + } +@chat_idx_done; + + integer want = llGetListLength(posList); + if (want < 2) { say("Tour: needs at least 2 presets."); return; } + if (want > gTourMaxPoints) { + say("Tour: too many points, capping to " + (string)gTourMaxPoints); + want = gTourMaxPoints; + posList = llList2List(posList, 0, want - 1); + focList = llList2List(focList, 0, want - 1); + } + + integer fovOn = FALSE; + float fovA = 0.0; + float fovB = 0.0; + if (fovAt != -1) { + list ff2 = tourParseFov(t, fovAt); + fovOn = (integer)llList2String(ff2, 0); + fovA = llList2Float(ff2, 1); + fovB = llList2Float(ff2, 2); + } + + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)totalMs + "|" + mode + "|" + (string)want; + + if (fovOn) payload += "|FOV|" + (string)fovA + "|" + (string)fovB; + + integer j; + for (j = 0; j < want; ++j) { + payload += "|" + (string)llList2Vector(posList, j) + "|" + (string)llList2Vector(focList, j) + "|0"; + } + + hudHide(); + llMessageLinked(LINK_SET, CE_CMD_TOUR, payload, gOwner); } -// ---------- chat commands ---------- -printHelp() +runChatDollyZoom(string msg) { - say( - "HS DollyCam — Commands (/88)\n" - + "/88 help\n" - + "/88 cam on|off\n" - + "/88 save \n" - + "/88 load (cut)\n" - + "/88 moveto [ms]\n" - + "/88 del \n" - + "/88 list [from] [count]\n" - + "/88 play [gap_ms]\n" - + "/88 stop\n" - + "/88 tour [mode] ...\n" - + "/88 cfg reload|dump\n" - + "/88 show cams [N]\n" - + "/88 hide cams\n" - + "/88 lock on [|uuid]\n" - + "/88 lock off\n" - + "/88 follow on [uuid] [yaw|local|world] [transition_ms]\n" - + "/88 follow off\n" - + "/88 fov (sets viewer FOV via RLVa; rad ~ 1.0472 for 60°)\n" - + "/88 fovdeg (sets viewer FOV via RLVa; deg 10..179)\n" - + "/88 dollyzoom [mode] \n" - ); + msg = llStringTrim(msg, STRING_TRIM); + if (llToLower(llGetSubString(msg, 0, 8)) != "dollyzoom") return; + + if (llStringLength(msg) > 600) { + say("DollyZoom one-liner too long. Use notecard/playlist."); + return; + } + + list t = llParseString2List(msg, [" "], []); + integer n = llGetListLength(t); + if (n < 4) { say("DollyZoom: usage: dollyzoom [mode] "); return; } + + integer totalMs = (integer)llList2String(t, 1); + if (totalMs < 0) totalMs = 0; + + integer i = 2; + string mode = "linear"; + + string tok2 = llList2String(t, 2); + integer maybeIdx = (integer)tok2; + if (maybeIdx <= 0 || (string)maybeIdx != tok2) { + mode = tok2; + i = 3; + } + mode = modeSanitize(mode); + + if (i + 1 >= n) { say("DollyZoom: usage: dollyzoom [mode] "); return; } + + integer idxA = (integer)llList2String(t, i); + integer idxB = (integer)llList2String(t, i + 1); + + integer keepOn = FALSE; + integer k; + for (k = i + 2; k < n; ++k) { + string tokK = llToLower(llList2String(t, k)); + if (tokK == "keep" || tokK == "keepframe" || tokK == "kf") { keepOn = TRUE; } + } + if (keepOn) mode = mode + "+keep"; + + if (!isValidIdx(idxA) || !isValidIdx(idxB)) { say("DollyZoom: idx must be > 0"); return; } + + if (!loadPreset(idxA)) { say("DollyZoom: missing preset " + (string)idxA); return; } + vector posA = gTmpPos; vector focA = gTmpFoc; + integer hasA = gTmpHasFov; float fovA = gTmpFovRad; + + if (!loadPreset(idxB)) { say("DollyZoom: missing preset " + (string)idxB); return; } + vector posB = gTmpPos; vector focB = gTmpFoc; + integer hasB = gTmpHasFov; float fovB = gTmpFovRad; + + if (keepOn) focB = focA; + + if (!hasA || (!keepOn && !hasB)) { + say("DollyZoom needs FOV stored in BOTH presets (unless using 'keep'). Set FOV (fovdeg) and save presets again."); + return; + } + if (keepOn && !hasB) fovB = fovA; + + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)totalMs + "|" + mode + "|2" + + "|FOV|" + (string)fovA + "|" + (string)fovB + + "|" + (string)posA + "|" + (string)focA + "|0" + + "|" + (string)posB + "|" + (string)focB + "|0"; + + hudHide(); + llMessageLinked(LINK_SET, CE_CMD_TOUR, payload, gOwner); +} + +runMenuTourRun(string raw) +{ + // raw: "TOURRUN|totalMs|mode|count|idx1|idx2|..." + if (llStringLength(raw) > 1800) { say("Tour: menu payload too large."); return; } + + list p = llParseString2List(raw, ["|"], []); + integer len = llGetListLength(p); + if (len < 5) return; + + if (llToUpper(llList2String(p, 0)) != "TOURRUN") return; + + integer totalMs = (integer)llList2String(p, 1); + string mode = modeSanitize(llList2String(p, 2)); + integer count = (integer)llList2String(p, 3); + + if (totalMs < 1) totalMs = gDefaultMoveMs; + if (count < 2) { say("Tour: needs at least 2 points."); return; } + if (count > gTourMaxPoints) { say("Tour: too many points, capping to " + (string)gTourMaxPoints); count = gTourMaxPoints; } + if (len < (4 + count)) { say("Tour: bad menu payload."); return; } + + integer mid = nextMoveId(); + string payload = (string)mid + "|" + (string)totalMs + "|" + mode + "|" + (string)count; + + integer i; + for (i = 0; i < count; ++i) { + integer idx = (integer)llList2String(p, 4 + i); + if (!isValidIdx(idx) || !loadPreset(idx)) { say("Tour: bad/missing preset: " + (string)idx); return; } + payload += "|" + (string)gTmpPos + "|" + (string)gTmpFoc + "|0"; + } + + hudHide(); + llMessageLinked(LINK_SET, CE_CMD_TOUR, payload, gOwner); } -// ---------- 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(); + llMessageLinked(LINK_SET, CE_CMD_CFG_DUMP, "", gOwner); } on_rez(integer sp) @@ -414,553 +358,36 @@ default 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(); - } + gOwner = llGetOwner(); } link_message(integer sender, integer num, string str, key id) { - if (num == CE_EVT_READY) { - say("Camera control granted."); - return; - } - if (num == CE_EVT_DENIED) { - say("Camera permission denied."); - return; - } if (num == CE_EVT_CFG_DUMP) { - list kv = llParseString2List(str, ["|"], []); - integer i; - say("Engine cfg:"); - for (i=0; i|| - list p = llParseString2List(str, ["|"], []); - if (llGetListLength(p) < 4) return; - integer req = (integer)llList2String(p,0); - - if (gSavePending && req == gSaveReq) { - vector pos = (vector)llList2String(p,1); - vector foc = (vector)llList2String(p,2); - - rotation rot = (rotation)llList2String(p,3); - - // fovRad: aus CE_EVT_STATE (falls vorhanden), sonst fallback - float fovRad = gLastFovRad; - if (llGetListLength(p) >= 5) { - float got = (float)llList2String(p, 4); - if (got > 0.0001) fovRad = got; - } - fovRad = clampFovRad(fovRad); - - string data = packPreset(pos, foc, rot, fovRad); - llLinksetDataWrite(PRE_KEY(gSaveIdx), data); - - // volle Anzeige: - say("Saved " + presetDescribe(gSaveIdx, data)); - - gSavePending = FALSE; - } + integer n = cfgGetInt(str, "tour_max_points=", gTourMaxPoints); + if (n < 2) n = 2; + if (n > 40) n = 40; + gTourMaxPoints = n; return; } - // Marker click event from HS_CamMarkers.lsl - if (num == MC_EVT_CLICK) { - if (id != gOwner) return; + 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(); + if (num == PH_CMD_CHAT_TOUR) { + runChatTour(str); return; } - if (cmd == "cfg" && n >= 2) { - string sub = llToLower(llList2String(t,1)); - if (sub == "reload") engineCfgReload(); - else engineCfgDump(); + if (num == PH_CMD_CHAT_DZ) { + runChatDollyZoom(str); 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 + " ..."); + if (num == PH_CMD_TOURRUN) { + runMenuTourRun(str); return; } - - if ((cmd == "del" || cmd == "delete") && n >= 2) { - integer idx2 = (integer)llList2String(t,1); - if (!isValidIdx(idx2)) { say("idx must be > 0"); return; } - llLinksetDataDelete(PRE_KEY(idx2)); - say("Deleted preset " + (string)idx2); - return; - } - - if (cmd == "load" && n >= 2) { - integer idx3 = (integer)llList2String(t,1); - if (!isValidIdx(idx3)) { say("idx must be > 0"); return; } - - if (!loadPreset(idx3)) { say("Preset not found."); return; } - - phStop("Interrupted by load."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, 0); - say("Loaded preset " + (string)idx3 + " (cut)"); - return; - } - - if (cmd == "moveto" && n >= 2) { - integer idx4 = (integer)llList2String(t,1); - if (!isValidIdx(idx4)) { say("idx must be > 0"); return; } - - integer ms = gDefaultMoveMs; - if (n >= 3) ms = (integer)llList2String(t,2); - - if (!loadPreset(idx4)) { say("Preset not found."); return; } - - phStop("Interrupted by moveto."); - engineStopMove(); - applyLoadedPresetFov(); - engineMove(gTmpPos, gTmpFoc, ms); - say("MoveTo preset " + (string)idx4 + " (" + (string)ms + "ms)"); - return; - } - - if (cmd == "stop") { - hudShow(); - phStop("User stop."); - engineStopMove(); - return; - } - - if (cmd == "play" && n >= 2) { - string card = llList2String(t,1); - integer gap = 0; - if (n >= 3) gap = (integer)llList2String(t,2); - phPlay(card, gap); - return; - } - - // Chat one-liner Tour: - // /88 tour [mode] ... - if (cmd == "tour") { - // Delegate heavy parsing/building to playlist helper (one-shot tour) - phStop("Interrupted by tour (chat)."); - phChatTour(msg); - return; - } - - // Chat one-liner DollyZoom: - // /88 dollyzoom [mode] [keepframe? optional later] - if (cmd == "dollyzoom") { - phStop("Interrupted by dollyzoom (chat)."); - phChatDollyZoom(msg); - return; - } - - if (cmd == "list") { - integer from = 1; - integer count = 20; - if (n >= 2) from = (integer)llList2String(t,1); - if (n >= 3) count = (integer)llList2String(t,2); - if (from < 1) from = 1; - - integer shown = 0; - integer i; - for (i = from; i <= 999 && shown < count; ++i) { - string data = llLinksetDataRead(PRE_KEY(i)); - if (data != "") { - say(presetDescribe(i, data)); - shown++; - } - } - if (!shown) say("No presets found in range."); - return; - } - - // markers routed to helper script - if (cmd == "show" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - integer want = 12; - if (n >= 3) want = (integer)llList2String(t,2); - if (want < 1) want = 1; - if (want > 30) want = 30; - - llMessageLinked(LINK_SET, MC_CMD, "SHOW|" + (string)want, gOwner); - - gCamsShown = TRUE; - gCamsN = want; - camsWriteState(); - return; - } - - if (cmd == "hide" && n >= 2 && llToLower(llList2String(t,1)) == "cams") { - llMessageLinked(LINK_SET, MC_CMD, "HIDE", gOwner); - - gCamsShown = FALSE; - camsWriteState(); - return; - } - - if (cmd == "lock" && n >= 2) { - string sw3 = llToLower(llList2String(t,1)); - integer on3 = (sw3 == "on" || sw3 == "1" || sw3 == "true"); - - if (!on3) { - llMessageLinked(LINK_SET, CE_CMD_LOCK, "0|<0,0,0>", gOwner); - lockWrite(FALSE, ""); - say("Lock OFF"); - return; - } - - // Mutual exclusion: Lock ON disables Follow - engineFollowOff(); - - string lockArgUsed = "<0,0,0>"; - - if (n >= 3) { - list r = takeAngleToken(t, 2); - string arg = llList2String(r, 0); - - if (llGetSubString(arg,0,0) == "<") { - lockArgUsed = arg; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + arg, gOwner); - } else { - key k = (key)arg; - if (k != NULL_KEY) { - lockArgUsed = (string)k; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)k, gOwner); - } else { - vector camPos = llGetCameraPos(); - rotation camRot = llGetCameraRot(); - vector foc2 = camPos + (llRot2Fwd(camRot) * 10.0); - lockArgUsed = (string)foc2; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc2, gOwner); - } - } - } else { - vector camPos2 = llGetCameraPos(); - rotation camRot2 = llGetCameraRot(); - vector foc3 = camPos2 + (llRot2Fwd(camRot2) * 10.0); - lockArgUsed = (string)foc3; - llMessageLinked(LINK_SET, CE_CMD_LOCK, "1|" + (string)foc3, gOwner); - } - - lockWrite(TRUE, lockArgUsed); - say("Lock ON"); - return; - } - - if (cmd == "follow" && n >= 2) { - string sw4 = llToLower(llList2String(t,1)); - integer on4 = (sw4 == "on" || sw4 == "1" || sw4 == "true"); - - key target = gOwner; - if (n >= 3) target = (key)llList2String(t,2); - - integer mode = FOLLOW_WORLD; - integer trans = 0; - - if (n >= 4) mode = followModeFrom(llList2String(t,3)); - if (n >= 5) trans = (integer)llList2String(t,4); - if (trans < 0) trans = 0; - - if (!on4) { - // OFF: do not touch lock - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, "0|" + (string)gOwner, gOwner); - followWrite(FALSE, NULL_KEY); - say("Follow OFF"); - return; - } - - // Mutual exclusion: Follow ON disables Lock - engineLockOff(); - - // capture-follow: offsets ZERO, engine captures - string payload = - "1|" + (string)target + "|" + (string)ZERO_VECTOR + "|" + (string)ZERO_VECTOR - + "|" + (string)mode + "|" + (string)trans; - - llMessageLinked(LINK_SET, CE_CMD_FOLLOW, payload, gOwner); - followWrite(TRUE, target); - - say("Follow ON mode=" + (string)mode + " trans=" + (string)trans); - return; - } - - // /88 fov (or degrees if >3.2) - if (cmd == "fov" && n >= 2) { - float v = (float)llList2String(t, 1); - - // Heuristic: if user typed "60", it's likely degrees - float rad = v; - if (v > 3.2) rad = v * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - // quiet=0 for manual commands, flags=1 (sync) - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - if (cmd == "fovdeg" && n >= 2) { - float deg = (float)llList2String(t, 1); - float rad = deg * PI / 180.0; - - rad = clampFovRad(rad); - gLastFovRad = rad; - - llMessageLinked(LINK_SET, CE_CMD_FOV, (string)rad + "|0|1", gOwner); - return; - } - - say("Unknown command. /88 help"); } } diff --git a/_bmad-output/implementation-artifacts/spec-preset-transfer-utility.md b/_bmad-output/implementation-artifacts/spec-preset-transfer-utility.md new file mode 100644 index 0000000..5f6c0ea --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-preset-transfer-utility.md @@ -0,0 +1,107 @@ +--- +title: 'Preset Transfer Utility' +type: 'feature' +created: '2026-05-13' +status: 'done' +baseline_commit: 'd979c9ee73947175fd246f1048759c74a833133e' +context: + - '{project-root}/project-context.md' + - '{project-root}/AGENTS.md' +--- + + + +## Intent + +**Problem:** HS_DollyCam users need a practical way to move saved camera positions between camera objects, including older versions, without permanently adding memory pressure or dependencies to the main camera scripts. + +**Approach:** Add one isolated drop-in LSL utility script that can be inserted when needed, exports existing `P` Linkset Data presets as copyable text, imports the same text back from a notecard, and can remove itself afterward. + +## Boundaries & Constraints + +**Always:** Preserve the existing preset payload exactly after the `P` key; use the current Linkset Data keys `P1`, `P2`, etc.; keep the script independent from controller, menu, playlist, engine, marker, and FOV scripts; restrict commands to the object owner or owner-owned attachments; use owner-visible feedback for all import/export operations. + +**Ask First:** Changing the persisted preset format; adding controller/menu integration; deleting or overwriting unrelated Linkset Data keys; adding a dependency on current-version-only linked-message protocols. + +**Never:** Do not move this into hot-path scripts; do not require edits to old camera versions; do not claim runtime notecard creation is possible in LSL; do not import malformed preset lines silently. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Export saved presets | `/88 xfer export 1 20` with `P1` and `P5` populated | Owner receives header, one `P=` line per populated preset, and end marker | If range has no presets, report that none were found | +| Import presets | `/88 xfer import My Presets` with notecard lines `P1=` | Script writes each valid line to matching Linkset Data key | Blank, comment, header, and end-marker lines are skipped | +| Replace import | `/88 xfer importreplace My Presets` with existing presets | Script clears existing `P1..P999`, then imports valid notecard lines | Missing notecard aborts before clearing | +| Malformed import line | Notecard contains `foo` or `P0=` | Line is rejected and counted as skipped | Owner sees skipped count at completion | +| Cleanup | `/88 xfer poof` | Script announces removal and removes its own inventory item | No other inventory or Linkset Data is touched | + + + +## Code Map + +- `HS_CamController.lsl` -- Existing owner chat and `P` Linkset Data storage behavior used for compatibility reference. +- `project-context.md` -- Project memory and parsing constraints. +- `HS_CamPresetTransfer.lsl` -- New isolated drop-in utility script for preset export, import, replace import, status, help, and self-removal. + +## Tasks & Acceptance + +**Execution:** +- [x] `HS_CamPresetTransfer.lsl` -- Add standalone transfer script -- Enables import/export without touching core camera scripts or increasing normal runtime memory. +- [x] `_bmad-output/implementation-artifacts/spec-preset-transfer-utility.md` -- Track Quick Dev specification and review order -- Keeps the feature reviewable. + +**Acceptance Criteria:** +- Given an object with populated `P` Linkset Data presets, when the owner runs `/88 xfer export [from] [count]`, then the script emits copyable `P=` lines that preserve the stored preset payload exactly. +- Given an object containing a transfer notecard, when the owner runs `/88 xfer import `, then valid `P=` lines are written to matching Linkset Data keys without requiring any other HS_DollyCam script. +- Given existing presets and a valid notecard, when the owner runs `/88 xfer importreplace `, then existing `P1..P999` keys are cleared before import and unrelated Linkset Data keys remain untouched. +- Given any command from a non-owner object or avatar, when it is heard on channel 88, then it is ignored. +- Given the owner runs `/88 xfer poof`, when the command is accepted, then only the transfer script removes itself. + +## Spec Change Log + +## Design Notes + +The export format intentionally mirrors the Linkset Data key/value pair instead of inventing a richer schema: + +```text +HS_DOLLYCAM_PRESETS_V1 +P1=px|py|pz|fx|fy|fz|rx|ry|rz|rs|fovRad +P2=px|py|pz|fx|fy|fz|rx|ry|rz|rs|fovRad +HS_DOLLYCAM_PRESETS_END +``` + +This keeps import compatible with older camera builds because the utility writes the same `P` keys and unchanged preset value strings those builds already read. + +## Verification + +**Commands:** +- `rg -n "llParseString2List" HS_CamPresetTransfer.lsl` -- expected: no output. +- Conflict marker scan on `HS_CamPresetTransfer.lsl` and this spec -- expected: no conflict marker output. + +**Manual checks:** +- In-world compile in Second Life Viewer or Firestorm; local repository has no official LSL compiler. + +## Suggested Review Order + +**Command Surface** + +- Entry point keeps transfer commands isolated under `/88 xfer`. + [`HS_CamPresetTransfer.lsl:240`](../../HS_CamPresetTransfer.lsl#L240) + +- Owner gate matches the existing controller trust model. + [`HS_CamPresetTransfer.lsl:304`](../../HS_CamPresetTransfer.lsl#L304) + +**Import And Export** + +- Export emits stable copyable `P=` lines. + [`HS_CamPresetTransfer.lsl:165`](../../HS_CamPresetTransfer.lsl#L165) + +- Import validates key shape and minimum preset fields before writing. + [`HS_CamPresetTransfer.lsl:103`](../../HS_CamPresetTransfer.lsl#L103) + +- Replace mode clears only preset keys before notecard import. + [`HS_CamPresetTransfer.lsl:122`](../../HS_CamPresetTransfer.lsl#L122) + +**Lifecycle** + +- Self-removal is explicit and limited to this script. + [`HS_CamPresetTransfer.lsl:274`](../../HS_CamPresetTransfer.lsl#L274)