Example Scripts
Complete scripts taken from the V6.9 test set. Each example includes the scene setup it expects, the engine features it teaches, and source you can copy or download.
FPS Display
Updates a UI text object every tick and optionally caps the game at 120 FPS.
This shipped sample still derives from ModuBehaviour, which remains compatible. New V6.9 scripts should generally derive from ModuNode.
| 1 | add ModuCPP; |
| 2 | add ModuEngine; |
| 3 | public class FPSDisplay : ModuBehaviour { |
| 4 | public bool clampTo120 = false; |
| 5 | void TickUpdate() {SetFPSCap(clampTo120, 120.0f); obj.UILabel = "FPS: " + IntR(ModuEngine.FPS);} |
| 6 | } |
Top-Down 2D Movement
Moves a 2D character with normalized WASD input, directional animation clips, sprinting, acceleration, drag, and footsteps.
| 1 | add ModuCPP; |
| 2 | add ModuInput; |
| 3 | add ModuEngine; |
| 4 | |
| 5 | public enum FacingDirection {Down, Up, Right, Left} |
| 6 | public class TopDownMovement2D : ModuNode |
| 7 | { |
| 8 | [Header("Top Down Movement 2D")] |
| 9 | [Slider(0.0f, 50.0f)] public float walkSpeed = 4.0f; |
| 10 | [Slider(0.0f, 80.0f)] public float runSpeed = 7.0f; |
| 11 | [Slider(0.0f, 200.0f)] public float acceleration = 18.0f; |
| 12 | [Slider(0.0f, 200.0f)] public float drag = 8.0f; |
| 13 | [Slider(1.0f, 30.0f)] public float animationFps = 10.0f; |
| 14 | [Slider(1.0f, 60.0f)] public float runAnimationFps = 14.0f; |
| 15 | [Slider(0.01f, 10.0f)] public float movementThreshold = 0.15f; |
| 16 | [ClipGridPair] |
| 17 | private int[4] idleClips; |
| 18 | private int[4][4] walkClips; |
| 19 | [Separator] |
| 20 | [SoundSet("Footsteps")] |
| 21 | private string[6] stepSounds; |
| 22 | private FacingDirection facing = FacingDirection.Down; |
| 23 | private float animationTime = 0.0f; |
| 24 | private int lastWalkFrame = -1; |
| 25 | private int nextFootstepIndex = 0; |
| 26 | void Begin() to ResetAnimationState(); |
| 27 | void TickUpdate() |
| 28 | { |
| 29 | if (dt <= 0.0f) return; |
| 30 | Vector2 move = input.WASDNormalized(); bool running = input.sprint(); |
| 31 | float speed = running ? runSpeed : walkSpeed; |
| 32 | Vector2 targetVelocity = move * speed; Vector2 actualVelocity = targetVelocity; |
| 33 | TryMove(targetVelocity, dt, ref actualVelocity); |
| 34 | Vector2 motion = move.Dot(move) > 0.000001f ? targetVelocity : actualVelocity; |
| 35 | TickAnimation(motion, dt, running); |
| 36 | } |
| 37 | void TryMove(Vector2 targetVelocity, float dt, ref Vector2 actualVelocity) |
| 38 | { |
| 39 | actualVelocity = targetVelocity; |
| 40 | TryMoveRigidbody2D(ctx, targetVelocity, acceleration, drag, dt, ref actualVelocity); |
| 41 | } |
| 42 | void TickAnimation(Vector2 motion, float dt, bool running) |
| 43 | { |
| 44 | if (motion.Dot(motion) > movementThreshold * movementThreshold) {ApplyWalkingAnimation(motion, dt, running); return;} |
| 45 | ApplyIdleAnimation(); |
| 46 | } |
| 47 | void ApplyWalkingAnimation(Vector2 motion, float dt, bool running) |
| 48 | { |
| 49 | facing = ResolveFacing(motion, facing); animationTime += dt; |
| 50 | float fps = Math.Max(1.0f, running ? runAnimationFps : animationFps); |
| 51 | int frame = (int)(animationTime * fps) % 4; |
| 52 | int clip = SelectWalkClip(frame); if (clip >= 0) sprite.SetClip(clip); |
| 53 | PlayFootstepsBetweenFrames(lastWalkFrame, frame); lastWalkFrame = frame; |
| 54 | } |
| 55 | void ApplyIdleAnimation() |
| 56 | { |
| 57 | animationTime = 0.0f; lastWalkFrame = -1; |
| 58 | int clip = idleClips[FacingIndex(facing)]; if (IsValidClip(clip)) sprite.SetClip(clip); |
| 59 | } |
| 60 | void PlayFootstepsBetweenFrames(int previousFrame, int currentFrame) |
| 61 | { |
| 62 | int probe = previousFrame >= 0 ? previousFrame : 3; |
| 63 | while (probe != currentFrame) {probe = (probe + 1) % 4; if (probe == 0 || probe == 2) PlayRandomFootstep();} |
| 64 | } |
| 65 | void PlayRandomFootstep() |
| 66 | { |
| 67 | int[6] validIndices; int count = 0; |
| 68 | for (int i = 0; i < 6; i++) if (!stepSounds[i].IsEmpty()) validIndices[count++] = i; |
| 69 | if (count == 0) return; |
| 70 | int selected = validIndices[nextFootstepIndex % count]; |
| 71 | nextFootstepIndex = (nextFootstepIndex + 1) % count; |
| 72 | audio.PlayOneShot(stepSounds[selected]); |
| 73 | } |
| 74 | int SelectWalkClip(int frame) |
| 75 | { |
| 76 | int dir = FacingIndex(facing); |
| 77 | int[4] clips = walkClips[dir]; |
| 78 | int candidate = clips[Math.Clamp(frame, 0, 3)]; |
| 79 | if (IsValidClip(candidate)) return candidate; |
| 80 | for (int i = 0; i < 4; i++) if (IsValidClip(clips[i])) return clips[i]; |
| 81 | candidate = idleClips[dir]; |
| 82 | return IsValidClip(candidate) ? candidate : -1; |
| 83 | } |
| 84 | void ResetAnimationState() {facing = FacingDirection.Down; animationTime = 0.0f; lastWalkFrame = -1; nextFootstepIndex = 0;} |
| 85 | int FacingIndex(FacingDirection direction) to (int)direction; |
| 86 | bool IsValidClip(int clip) to clip >= 0 && clip < ctx.GetSpriteClipCount(); |
| 87 | FacingDirection ResolveFacing(Vector2 motion, FacingDirection fallback) |
| 88 | { |
| 89 | if (motion.Dot(motion) <= 0.000001f) return fallback; |
| 90 | if (Math.Abs(motion.x) > Math.Abs(motion.y)) return motion.x >= 0.0f ? FacingDirection.Right : FacingDirection.Left; |
| 91 | return motion.y >= 0.0f ? FacingDirection.Up : FacingDirection.Down; |
| 92 | } |
| 93 | } |
Standalone 3D Movement
A compact camera-relative 3D controller with acceleration, sprinting, ground checks, facing, and jumping.
| 1 | add ModuCPP; |
| 2 | add ModuEngine; |
| 3 | add ModuInput; |
| 4 | public class PlayerMovement : ModuNode { |
| 5 | public SceneObj camera; |
| 6 | public float walkSpeed = 4.0f; |
| 7 | public float sprintSpeed = 7.0f; |
| 8 | public float acceleration = 20.0f; |
| 9 | public float deceleration = 25.0f; |
| 10 | [Header("Ground Check & Jump")] |
| 11 | public float groundCheckDistance = 0.1f; |
| 12 | public float jumpForce = 6.5f; |
| 13 | private float verticalVelocity = 0.0f; |
| 14 | void Begin() { Ensure.obj; Ensure.Rigidbody3D(obj); } |
| 15 | void TickUpdate() { |
| 16 | Vector2 move = Input.WASDMovement(); |
| 17 | Vector3 direction = Movement.Direction(move, camera); |
| 18 | float speed = Input.ButtonHeld("Sprint") ? sprintSpeed : walkSpeed; |
| 19 | bool grounded = obj.Physics.IsGrounded(groundCheckDistance); |
| 20 | obj.Rigidbody3D.Accelerate(direction, speed, acceleration, deceleration); |
| 21 | obj.Transform.Face(direction); |
| 22 | if (grounded) { verticalVelocity = 0.0f; if (Input.ButtonDown("Jump")) then verticalVelocity = jumpForce; } |
| 23 | else then verticalVelocity = obj.Rigidbody3D.VelocityY; |
| 24 | obj.Rigidbody3D.AddVelocityY(verticalVelocity); |
| 25 | } |
| 26 | } |
Dialogue System
A localized typewriter dialogue runtime with per-line audio, text effects, mouth states, object toggles, and open/close animation hooks.
| 1 | add ModuCPP; |
| 2 | add ModuCPP.Experimental; |
| 3 | #include "DialoguePortShared.h" |
| 4 | using namespace DialoguePort; |
| 5 | |
| 6 | public enum MouthState {TalkingOpen, TalkingClosed, NotTalking} |
| 7 | public class DialogueSystem : ModuNode |
| 8 | { |
| 9 | [Header("Bindings")] |
| 10 | [ObjectRef] public string characterNameTextRef; |
| 11 | [ObjectRef] public string dialogueTextRef; |
| 12 | [ObjectRef] public string playerRef; |
| 13 | public Language currentLanguage = Language.English; |
| 14 | |
| 15 | [Header("Timing")] |
| 16 | [Slider(0.0f, 2.0f)] public float pitchVariation = 0.1f; |
| 17 | [Slider(0.0f, 10.0f)] public float openAnimationDelay = 0.3f; |
| 18 | [Slider(0.0f, 10.0f)] public float closeAnimationDelay = 0.3f; |
| 19 | [Slider(0.0f, 2.0f)] public float spacingFactor = 0.1f; |
| 20 | [Slider(0.1f, 10.0f)] public float sizeMultiplier = 2.0f; |
| 21 | |
| 22 | [Header("Audio")] |
| 23 | public string characterSoundClip; |
| 24 | public string triggerSoundClip; |
| 25 | public string enterSoundClip; |
| 26 | public string exitSoundClip; |
| 27 | public string skipSoundClip; |
| 28 | |
| 29 | [Header("Flags")] |
| 30 | public bool rigidSimulated = true; |
| 31 | public bool disableSelfOnEnd = true; |
| 32 | public bool autoOpenOnBegin = false; |
| 33 | [ObjectList] public string[] itemsToEnable; |
| 34 | [ObjectList] public string[] itemsToDisable; |
| 35 | |
| 36 | [Header("Dialogue")] |
| 37 | public DialoguePort.DialogueLine[] dialogueLines; |
| 38 | |
| 39 | private bool running = false; |
| 40 | private bool isTyping = false; |
| 41 | private bool isTextFullyDisplayed = false; |
| 42 | private bool autoOpened = false; |
| 43 | private int index = 0; |
| 44 | private size_t revealedCharacters = 0; |
| 45 | private float typeAccumulator = 0.0f; |
| 46 | private float holdDelay = 0.0f; |
| 47 | private float effectTimer = 0.0f; |
| 48 | private float soundCooldown = 0.0f; |
| 49 | private float openDelayRemaining = 0.0f; |
| 50 | private float closeDelayRemaining = 0.0f; |
| 51 | private bool pendingClose = false; |
| 52 | private int lastInteractionRequestSerial = 0; |
| 53 | private bool prevSubmitDown = false; |
| 54 | private string currentCleanSentence; |
| 55 | private string currentPlayerRef; |
| 56 | private DialoguePort.DialogueLine[] activeLines; |
| 57 | private string[] endItemsToEnable; |
| 58 | private string[] endItemsToDisable; |
| 59 | |
| 60 | inspector |
| 61 | { |
| 62 | Tabs { |
| 63 | Tab("Bindings") { |
| 64 | AutoFields(characterNameTextRef, dialogueTextRef, playerRef); |
| 65 | Run(DrawLanguageCombo("Language", currentLanguage)); |
| 66 | } |
| 67 | |
| 68 | Tab("Timing") { |
| 69 | AutoFields(pitchVariation, openAnimationDelay, closeAnimationDelay, spacingFactor, sizeMultiplier); |
| 70 | } |
| 71 | |
| 72 | Tab("Audio") { |
| 73 | AutoFields(characterSoundClip, triggerSoundClip, enterSoundClip, exitSoundClip, skipSoundClip); |
| 74 | } |
| 75 | |
| 76 | Tab("Flags") { |
| 77 | AutoFields(rigidSimulated, disableSelfOnEnd, autoOpenOnBegin, itemsToEnable, itemsToDisable); |
| 78 | } |
| 79 | |
| 80 | Tab("Dialogue") { |
| 81 | AutoFields(dialogueLines); |
| 82 | } |
| 83 | |
| 84 | Tab("Runtime") { |
| 85 | Run(ImGui::TextDisabled("Running: %s", running ? "Yes" : "No")); |
| 86 | Run(ImGui::TextDisabled("Typing: %s", isTyping ? "Yes" : "No")); |
| 87 | Run(ImGui::TextDisabled("Line: %d", index + 1)); |
| 88 | Run(ImGui::TextDisabled("Chars: %zu / %zu", revealedCharacters, currentCleanSentence.size())); |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | void Begin() |
| 94 | { |
| 95 | if (!ctx.object) return; |
| 96 | ResetRuntimeState(); |
| 97 | TryAutoOpenOnBegin(); |
| 98 | } |
| 99 | |
| 100 | void TickUpdate() |
| 101 | { |
| 102 | if (!ctx.object) return; |
| 103 | |
| 104 | const float step = Math.Max(0.0f, dt); |
| 105 | ConsumeInteractionRequest(); |
| 106 | TryAutoOpenOnBegin(); |
| 107 | if (!running) return; |
| 108 | |
| 109 | if (TickPendingClose(step)) return; |
| 110 | |
| 111 | const bool submitPressed = ConsumeSubmitPressed(); |
| 112 | if (TickOpenDelay(step)) return; |
| 113 | |
| 114 | tickTyping(step); |
| 115 | if (submitPressed) { |
| 116 | HandleSubmitPressed(); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | void ResetRuntimeState() |
| 121 | { |
| 122 | running = false; |
| 123 | isTyping = false; |
| 124 | isTextFullyDisplayed = false; |
| 125 | autoOpened = false; |
| 126 | index = 0; |
| 127 | revealedCharacters = 0; |
| 128 | typeAccumulator = 0.0f; |
| 129 | holdDelay = 0.0f; |
| 130 | effectTimer = 0.0f; |
| 131 | soundCooldown = 0.0f; |
| 132 | openDelayRemaining = 0.0f; |
| 133 | closeDelayRemaining = 0.0f; |
| 134 | pendingClose = false; |
| 135 | prevSubmitDown = false; |
| 136 | const int interactionSerial = ParseInt(ctx.GetSetting(kInteractionRequestSerial, "0"), 0); |
| 137 | const bool interactionPending = ParseBool(ctx.GetSetting(kInteractionRequestPending, "0"), false); |
| 138 | lastInteractionRequestSerial = interactionPending ? (interactionSerial - 1) : interactionSerial; |
| 139 | currentCleanSentence.clear(); |
| 140 | currentPlayerRef = playerRef; |
| 141 | activeLines.clear(); |
| 142 | endItemsToEnable.clear(); |
| 143 | endItemsToDisable.clear(); |
| 144 | resetTextWidgets(); |
| 145 | } |
| 146 | |
| 147 | void TryAutoOpenOnBegin() |
| 148 | { |
| 149 | if (running || autoOpened) return; |
| 150 | if (!autoOpenOnBegin || dialogueLines.empty()) return; |
| 151 | |
| 152 | openDialogue(dialogueLines, itemsToEnable, itemsToDisable, playerRef); |
| 153 | autoOpened = true; |
| 154 | } |
| 155 | |
| 156 | void ConsumeInteractionRequest() |
| 157 | { |
| 158 | const int interactionSerial = ParseInt(ctx.GetSetting(kInteractionRequestSerial, "0"), 0); |
| 159 | const bool interactionPending = ParseBool(ctx.GetSetting(kInteractionRequestPending, "0"), false); |
| 160 | if (interactionSerial == lastInteractionRequestSerial && !interactionPending) return; |
| 161 | |
| 162 | lastInteractionRequestSerial = interactionSerial; |
| 163 | if (interactionPending) { |
| 164 | ctx.SetSetting(kInteractionRequestPending, "0"); |
| 165 | } |
| 166 | |
| 167 | DialoguePort.DialogueLine[] overrideLines = DeserializeDialogueLines(ctx.GetSetting(kInteractionOverrideLines, "")); |
| 168 | string[] overrideEnable = DeserializeObjectRefs(ctx.GetSetting(kInteractionEndEnable, "")); |
| 169 | string[] overrideDisable = DeserializeObjectRefs(ctx.GetSetting(kInteractionEndDisable, "")); |
| 170 | std::string overridePlayerRef = ctx.GetSetting(kInteractionPlayerRef, ""); |
| 171 | |
| 172 | if (overrideLines.empty()) overrideLines = dialogueLines; |
| 173 | if (overrideEnable.empty()) overrideEnable = itemsToEnable; |
| 174 | if (overrideDisable.empty()) overrideDisable = itemsToDisable; |
| 175 | if (overridePlayerRef.empty()) overridePlayerRef = playerRef; |
| 176 | |
| 177 | openDialogue(overrideLines, overrideEnable, overrideDisable, overridePlayerRef); |
| 178 | } |
| 179 | |
| 180 | bool TickPendingClose(float step) |
| 181 | { |
| 182 | if (!pendingClose) return false; |
| 183 | |
| 184 | closeDelayRemaining -= step; |
| 185 | if (closeDelayRemaining <= 0.0f) { |
| 186 | finalizeDialogueEnd(); |
| 187 | } |
| 188 | return true; |
| 189 | } |
| 190 | |
| 191 | bool ConsumeSubmitPressed() |
| 192 | { |
| 193 | const bool submitDown = IsSubmitDown(); |
| 194 | const bool submitPressed = submitDown && !prevSubmitDown; |
| 195 | prevSubmitDown = submitDown; |
| 196 | return submitPressed; |
| 197 | } |
| 198 | |
| 199 | bool TickOpenDelay(float step) |
| 200 | { |
| 201 | if (openDelayRemaining <= 0.0f) return false; |
| 202 | |
| 203 | openDelayRemaining -= step; |
| 204 | if (openDelayRemaining <= 0.0f) { |
| 205 | if (!enterSoundClip.empty()) { |
| 206 | PlaySoundClip(enterSoundClip); |
| 207 | } |
| 208 | startLine(); |
| 209 | } |
| 210 | return true; |
| 211 | } |
| 212 | |
| 213 | void HandleSubmitPressed() |
| 214 | { |
| 215 | if (isTyping) { |
| 216 | completeTyping(); |
| 217 | if (!skipSoundClip.empty()) { |
| 218 | PlaySoundClip(skipSoundClip); |
| 219 | } |
| 220 | return; |
| 221 | } |
| 222 | |
| 223 | if (!isTextFullyDisplayed) return; |
| 224 | |
| 225 | if (index < static_cast<int>(activeLines.size()) - 1) { |
| 226 | ++index; |
| 227 | if (!enterSoundClip.empty()) { |
| 228 | PlaySoundClip(enterSoundClip); |
| 229 | } |
| 230 | startLine(); |
| 231 | return; |
| 232 | } |
| 233 | |
| 234 | if (!exitSoundClip.empty()) { |
| 235 | PlaySoundClip(exitSoundClip); |
| 236 | } |
| 237 | beginDialogueClose(); |
| 238 | } |
| 239 | |
| 240 | void resetTextWidgets() |
| 241 | { |
| 242 | SetUITextLabel(ctx, characterNameTextRef, ""); |
| 243 | SetUITextLabel(ctx, dialogueTextRef, ""); |
| 244 | SetUITextEffects(ctx, dialogueTextRef, TextEffectType.None, 1.0f, 0.0f); |
| 245 | } |
| 246 | |
| 247 | void revealDialogueText() |
| 248 | { |
| 249 | const size_t shown = std::min(revealedCharacters, currentCleanSentence.size()); |
| 250 | SetUITextLabel(ctx, dialogueTextRef, currentCleanSentence.substr(0, shown)); |
| 251 | } |
| 252 | |
| 253 | void startLine() |
| 254 | { |
| 255 | if (index < 0 || index >= static_cast<int>(activeLines.size())) { |
| 256 | running = false; |
| 257 | isTyping = false; |
| 258 | isTextFullyDisplayed = false; |
| 259 | return; |
| 260 | } |
| 261 | |
| 262 | const DialogueLine& line = activeLines[static_cast<size_t>(index)]; |
| 263 | SetObjectsEnabledState(ctx, line.itemsToDisable, false); |
| 264 | SetObjectsEnabledState(ctx, line.itemsToEnable, true); |
| 265 | |
| 266 | SetUITextLabel(ctx, characterNameTextRef, line.characterName); |
| 267 | |
| 268 | currentCleanSentence = ParseSentenceForDisplay(GetSentenceForLanguage(line, currentLanguage)); |
| 269 | revealedCharacters = 0; |
| 270 | typeAccumulator = 0.0f; |
| 271 | holdDelay = 0.0f; |
| 272 | soundCooldown = 0.0f; |
| 273 | isTyping = true; |
| 274 | isTextFullyDisplayed = false; |
| 275 | |
| 276 | revealDialogueText(); |
| 277 | SetUITextEffects(ctx, dialogueTextRef, line.textEffect, line.animationSpeed, line.effectIntensity); |
| 278 | applyMouthState(line, MouthState.TalkingClosed); |
| 279 | } |
| 280 | |
| 281 | void completeTyping() |
| 282 | { |
| 283 | if (index < 0 || index >= static_cast<int>(activeLines.size())) return; |
| 284 | |
| 285 | const DialogueLine& line = activeLines[static_cast<size_t>(index)]; |
| 286 | revealedCharacters = currentCleanSentence.size(); |
| 287 | isTyping = false; |
| 288 | isTextFullyDisplayed = true; |
| 289 | holdDelay = 0.0f; |
| 290 | typeAccumulator = 0.0f; |
| 291 | |
| 292 | revealDialogueText(); |
| 293 | applyMouthState(line, MouthState.NotTalking); |
| 294 | } |
| 295 | |
| 296 | void finalizeDialogueEnd() |
| 297 | { |
| 298 | SetObjectsEnabledState(ctx, endItemsToDisable, false); |
| 299 | SetObjectsEnabledState(ctx, endItemsToEnable, true); |
| 300 | SetRigidbody2DSimulated(ctx, currentPlayerRef, rigidSimulated); |
| 301 | |
| 302 | resetTextWidgets(); |
| 303 | |
| 304 | running = false; |
| 305 | pendingClose = false; |
| 306 | closeDelayRemaining = 0.0f; |
| 307 | isTyping = false; |
| 308 | isTextFullyDisplayed = false; |
| 309 | revealedCharacters = 0; |
| 310 | currentCleanSentence.clear(); |
| 311 | if (disableSelfOnEnd) { |
| 312 | autoOpened = false; |
| 313 | ctx.SetObjectEnabled(false); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | void beginDialogueClose() |
| 318 | { |
| 319 | if (pendingClose) return; |
| 320 | pendingClose = true; |
| 321 | closeDelayRemaining = std::max(0.0f, closeAnimationDelay); |
| 322 | TryPlayAnimationClipNamed(ctx, "DialogueStateClose"); |
| 323 | |
| 324 | if (closeDelayRemaining <= 0.0f) { |
| 325 | finalizeDialogueEnd(); |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | void openDialogue(ref DialoguePort.DialogueLine[] lines, |
| 330 | ref string[] itemsToEnableOnEnd, |
| 331 | ref string[] itemsToDisableOnEnd, |
| 332 | const std::string& playerRefOverride) |
| 333 | { |
| 334 | if (lines.empty()) { |
| 335 | ctx.AddConsoleMessage("DialogueSystem: no dialogue lines configured.", ConsoleMessageType.Warning); |
| 336 | return; |
| 337 | } |
| 338 | |
| 339 | activeLines = lines; |
| 340 | endItemsToEnable = itemsToEnableOnEnd; |
| 341 | endItemsToDisable = itemsToDisableOnEnd; |
| 342 | currentPlayerRef = playerRefOverride.empty() ? playerRef : playerRefOverride; |
| 343 | |
| 344 | running = true; |
| 345 | index = 0; |
| 346 | revealedCharacters = 0; |
| 347 | typeAccumulator = 0.0f; |
| 348 | holdDelay = 0.0f; |
| 349 | soundCooldown = 0.0f; |
| 350 | effectTimer = 0.0f; |
| 351 | isTyping = false; |
| 352 | isTextFullyDisplayed = false; |
| 353 | pendingClose = false; |
| 354 | closeDelayRemaining = 0.0f; |
| 355 | currentCleanSentence.clear(); |
| 356 | openDelayRemaining = openAnimationDelay; |
| 357 | |
| 358 | resetTextWidgets(); |
| 359 | SetRigidbody2DSimulated(ctx, currentPlayerRef, false); |
| 360 | TryPlayAnimationClipNamed(ctx, "DialogueStateOpen"); |
| 361 | |
| 362 | if (!triggerSoundClip.empty()) { |
| 363 | PlaySoundClip(triggerSoundClip); |
| 364 | } |
| 365 | |
| 366 | if (openDelayRemaining <= 0.0f) { |
| 367 | startLine(); |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | void tickTyping(float step) |
| 372 | { |
| 373 | if (!isTyping) return; |
| 374 | if (index < 0 || index >= static_cast<int>(activeLines.size())) return; |
| 375 | |
| 376 | const DialogueLine& line = activeLines[static_cast<size_t>(index)]; |
| 377 | |
| 378 | if (currentCleanSentence.empty()) { |
| 379 | completeTyping(); |
| 380 | return; |
| 381 | } |
| 382 | |
| 383 | const float typingSpeed = std::max(0.001f, line.typingSpeed); |
| 384 | effectTimer += step; |
| 385 | soundCooldown = std::max(0.0f, soundCooldown - step); |
| 386 | |
| 387 | float remaining = step; |
| 388 | while (remaining > 0.0f && isTyping) { |
| 389 | if (holdDelay > 0.0f) { |
| 390 | const float consume = std::min(remaining, holdDelay); |
| 391 | holdDelay -= consume; |
| 392 | remaining -= consume; |
| 393 | continue; |
| 394 | } |
| 395 | |
| 396 | typeAccumulator += remaining; |
| 397 | remaining = 0.0f; |
| 398 | |
| 399 | bool revealedAny = false; |
| 400 | while (typeAccumulator >= typingSpeed && revealedCharacters < currentCleanSentence.size()) { |
| 401 | typeAccumulator -= typingSpeed; |
| 402 | ++revealedCharacters; |
| 403 | revealedAny = true; |
| 404 | |
| 405 | const size_t charIndex = revealedCharacters - 1; |
| 406 | const char character = currentCleanSentence[charIndex]; |
| 407 | |
| 408 | if (std::isalnum(static_cast<unsigned char>(character)) != 0) { |
| 409 | const std::string& clip = line.characterSoundClip.empty() ? characterSoundClip : line.characterSoundClip; |
| 410 | if (!clip.empty() && soundCooldown <= 0.0f) { |
| 411 | PlaySoundClip(clip); |
| 412 | soundCooldown = std::max(0.01f, typingSpeed * 0.6f); |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | if ((charIndex % 2U) == 0U) { |
| 417 | applyMouthState(line, MouthState.TalkingOpen); |
| 418 | } else { |
| 419 | applyMouthState(line, MouthState.TalkingClosed); |
| 420 | } |
| 421 | |
| 422 | if (character == ',' || character == ';') { |
| 423 | holdDelay += typingSpeed * 3.0f; |
| 424 | } else if (character == '.' || character == '!' || character == '?') { |
| 425 | if (character == '.' && charIndex + 2 < currentCleanSentence.size() && |
| 426 | currentCleanSentence[charIndex + 1] == '.' && |
| 427 | currentCleanSentence[charIndex + 2] == '.') { |
| 428 | holdDelay += typingSpeed * 5.0f; |
| 429 | } else { |
| 430 | holdDelay += typingSpeed * 5.0f; |
| 431 | } |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | if (revealedAny) { |
| 436 | revealDialogueText(); |
| 437 | } |
| 438 | |
| 439 | if (revealedCharacters >= currentCleanSentence.size()) { |
| 440 | completeTyping(); |
| 441 | break; |
| 442 | } |
| 443 | |
| 444 | if (typeAccumulator < typingSpeed) { |
| 445 | break; |
| 446 | } |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | void applyMouthState(const DialogueLine& line, MouthState mouthState) |
| 451 | { |
| 452 | const bool isOpen = (mouthState == MouthState.TalkingOpen); |
| 453 | const bool isClosed = (mouthState == MouthState.TalkingClosed); |
| 454 | const bool isIdle = (mouthState == MouthState.NotTalking); |
| 455 | |
| 456 | auto openObj = ResolveObject(line.openMouthObjectRef); |
| 457 | auto closedObj = ResolveObject(line.closedMouthObjectRef); |
| 458 | auto idleObj = ResolveObject(line.notTalkingObjectRef); |
| 459 | |
| 460 | SetObjectEnabledState(ctx, openObj, isOpen); |
| 461 | SetObjectEnabledState(ctx, closedObj, isClosed); |
| 462 | SetObjectEnabledState(ctx, idleObj, isIdle); |
| 463 | } |
| 464 | } |
Interactable Object
Handles E-key interactions, optional player range checks, selection visuals, one-time use, object toggles, and dialogue handoff.
| 1 | add ModuCPP; |
| 2 | add ModuEngine; |
| 3 | add ModuInput; |
| 4 | add ModuCPP.Experimental; |
| 5 | #include "DialoguePortShared.h" |
| 6 | using namespace DialoguePort; |
| 7 | |
| 8 | public enum InteractableType {Dialogue, ToggleObjects} |
| 9 | |
| 10 | SubScript InteractionOption |
| 11 | { |
| 12 | public string optionName = "New Option"; |
| 13 | public int interactionType = 0; |
| 14 | public string dialogueSystemRef; |
| 15 | public DialoguePort.DialogueLine[] dialogueLines; |
| 16 | public string[] dialogueItemsToEnableOnEnd; |
| 17 | public string[] dialogueItemsToDisableOnEnd; |
| 18 | public string[] itemsToEnable; |
| 19 | public string[] itemsToDisable; |
| 20 | } |
| 21 | |
| 22 | public class InteractableObject : ModuNode |
| 23 | { |
| 24 | [Header("Basics")] |
| 25 | public bool canInteract = true; |
| 26 | public bool oneTimeUse = false; |
| 27 | public bool interactOnKeyPress = true; |
| 28 | public bool requirePlayerInRange = false; |
| 29 | [Slider(0.0f, 100.0f)] public float interactDistance = 2.5f; |
| 30 | public bool debugRange = false; |
| 31 | [ObjectRef] public string playerRef; |
| 32 | public string selectionNameOverride; |
| 33 | |
| 34 | [Header("Selection")] |
| 35 | public bool isSelected = false; |
| 36 | [ObjectList] public string[] selectedStateEnable; |
| 37 | [ObjectList] public string[] selectedStateDisable; |
| 38 | |
| 39 | [Header("Interaction")] |
| 40 | public int selectedOptionIndex = 0; |
| 41 | public InteractionOption[] options; |
| 42 | |
| 43 | private bool prevInteractDown = false; |
| 44 | private bool hasSelectionState = false; |
| 45 | private bool lastSelectionState = false; |
| 46 | private float lastRangeDistance = -1.0f; |
| 47 | private bool lastRangeInRange = false; |
| 48 | private bool lastRangeHasPlayer = false; |
| 49 | private vec3 lastPlayerWorldPos = vec3(0.0f); |
| 50 | private vec3 lastSelfWorldPos = vec3(0.0f); |
| 51 | |
| 52 | inspector |
| 53 | { |
| 54 | Tabs { |
| 55 | Tab("Basics") { |
| 56 | AutoFields(canInteract, oneTimeUse, interactOnKeyPress, requirePlayerInRange, |
| 57 | interactDistance, debugRange, playerRef, selectionNameOverride); |
| 58 | Run(DrawInteractNowButton()); |
| 59 | } |
| 60 | |
| 61 | Tab("Selection") { |
| 62 | AutoFields(isSelected, selectedStateEnable, selectedStateDisable); |
| 63 | Run(DrawSelectionNameInfo()); |
| 64 | } |
| 65 | |
| 66 | Tab("Options") { |
| 67 | AutoFields(selectedOptionIndex, options); |
| 68 | } |
| 69 | |
| 70 | Tab("Runtime") { |
| 71 | Run(DrawRuntimeStatus()); |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | void Begin() |
| 77 | { |
| 78 | if (!ctx.object) return; |
| 79 | |
| 80 | ResetRuntimeState(); |
| 81 | MigrateLegacyOptionsIfNeeded(); |
| 82 | applySelectedState(); |
| 83 | } |
| 84 | |
| 85 | void TickUpdate() |
| 86 | { |
| 87 | if (!ctx.object) return; |
| 88 | |
| 89 | SyncSelectedState(); |
| 90 | isPlayerInRange(); |
| 91 | |
| 92 | if (!interactOnKeyPress || !ConsumeInteractPressed()) { |
| 93 | return; |
| 94 | } |
| 95 | |
| 96 | interact(); |
| 97 | } |
| 98 | |
| 99 | void ResetRuntimeState() |
| 100 | { |
| 101 | prevInteractDown = false; |
| 102 | hasSelectionState = true; |
| 103 | lastSelectionState = isSelected; |
| 104 | lastRangeDistance = -1.0f; |
| 105 | lastRangeInRange = false; |
| 106 | lastRangeHasPlayer = false; |
| 107 | lastPlayerWorldPos = vec3(0.0f); |
| 108 | lastSelfWorldPos = ctx.object ? GetObjectReferencePosition(ctx, *ctx.object) : vec3(0.0f); |
| 109 | } |
| 110 | |
| 111 | void MigrateLegacyOptionsIfNeeded() |
| 112 | { |
| 113 | if (!options.empty()) return; |
| 114 | |
| 115 | string legacyBlob = ctx.GetSetting("optionsBlob", ""); |
| 116 | if (legacyBlob.empty()) return; |
| 117 | |
| 118 | options = DeserializeSubScriptArray<InteractionOption>(legacyBlob); |
| 119 | if (options.empty()) return; |
| 120 | |
| 121 | ctx.SetSetting("options", SerializeSubScriptArray(options)); |
| 122 | ctx.SetSetting("optionsBlob", ""); |
| 123 | // ctx.SetSetting already marks dirty; no manual MarkDirty needed here. |
| 124 | } |
| 125 | |
| 126 | void SyncSelectedState() |
| 127 | { |
| 128 | if (hasSelectionState && lastSelectionState == isSelected) { |
| 129 | return; |
| 130 | } |
| 131 | applySelectedState(); |
| 132 | hasSelectionState = true; |
| 133 | lastSelectionState = isSelected; |
| 134 | } |
| 135 | |
| 136 | bool ConsumeInteractPressed() |
| 137 | { |
| 138 | const bool interactDown = isInteractDown(); |
| 139 | const bool interactPressed = interactDown && !prevInteractDown; |
| 140 | prevInteractDown = interactDown; |
| 141 | return interactPressed; |
| 142 | } |
| 143 | |
| 144 | bool interact() |
| 145 | { |
| 146 | if (!canInteract || options.empty()) { |
| 147 | return false; |
| 148 | } |
| 149 | |
| 150 | if (!isPlayerInRange()) { |
| 151 | if (requirePlayerInRange && debugRange) { |
| 152 | if (lastRangeHasPlayer) { |
| 153 | char msg[256]; |
| 154 | std::snprintf(msg, sizeof(msg), |
| 155 | "InteractableObject: out of range (distance %.2f > %.2f).", |
| 156 | lastRangeDistance, |
| 157 | Math.Max(0.0f, interactDistance)); |
| 158 | ctx.AddConsoleMessage(msg, ConsoleMessageType::Info); |
| 159 | } else { |
| 160 | ctx.AddConsoleMessage("InteractableObject: player reference is missing or unresolved for range check.", |
| 161 | ConsoleMessageType::Warning); |
| 162 | } |
| 163 | } |
| 164 | return false; |
| 165 | } |
| 166 | |
| 167 | const int optionIndex = Math.Clamp(selectedOptionIndex, 0, (int)options.size() - 1); |
| 168 | selectedOptionIndex = optionIndex; |
| 169 | const InteractionOption option = options[optionIndex]; |
| 170 | |
| 171 | const bool executed = executeOption(option); |
| 172 | if (executed && oneTimeUse) { |
| 173 | canInteract = false; |
| 174 | ctx.SetSetting("canInteract", "0"); |
| 175 | } |
| 176 | |
| 177 | return executed; |
| 178 | } |
| 179 | |
| 180 | bool executeOption(InteractionOption option) |
| 181 | { |
| 182 | if (option.interactionType == 0) { |
| 183 | return executeDialogueOption(option); |
| 184 | } |
| 185 | |
| 186 | SetObjectsEnabledState(ctx, option.itemsToEnable, true); |
| 187 | SetObjectsEnabledState(ctx, option.itemsToDisable, false); |
| 188 | return true; |
| 189 | } |
| 190 | |
| 191 | bool executeDialogueOption(InteractionOption option) |
| 192 | { |
| 193 | auto dialogueObject = ResolveObject(option.dialogueSystemRef); |
| 194 | if (!dialogueObject) { |
| 195 | ctx.AddConsoleMessage("InteractableObject: dialogue option is missing a valid DialogueSystem object reference.", |
| 196 | ConsoleMessageType::Warning); |
| 197 | return false; |
| 198 | } |
| 199 | |
| 200 | const DialogueScriptTarget dialogueTarget = FindDialogueScriptTarget(ctx, dialogueObject); |
| 201 | if (!dialogueTarget.script || !dialogueTarget.object) { |
| 202 | ctx.AddConsoleMessage("InteractableObject: target DialogueSystem object has no DialogueSystem script in its hierarchy.", |
| 203 | ConsoleMessageType::Warning); |
| 204 | return false; |
| 205 | } |
| 206 | |
| 207 | bool changed = false; |
| 208 | changed |= WriteScriptSetting(dialogueTarget.script, kInteractionRequestPending, "1"); |
| 209 | changed |= WriteScriptSetting(dialogueTarget.script, kInteractionOverrideLines, |
| 210 | SerializeDialogueLines(option.dialogueLines)); |
| 211 | changed |= WriteScriptSetting(dialogueTarget.script, kInteractionEndEnable, |
| 212 | SerializeObjectRefs(option.dialogueItemsToEnableOnEnd)); |
| 213 | changed |= WriteScriptSetting(dialogueTarget.script, kInteractionEndDisable, |
| 214 | SerializeObjectRefs(option.dialogueItemsToDisableOnEnd)); |
| 215 | changed |= WriteScriptSetting(dialogueTarget.script, kInteractionPlayerRef, playerRef); |
| 216 | |
| 217 | const int currentSerial = ParseInt(GetScriptSetting(dialogueTarget.script, kInteractionRequestSerial, "0"), 0); |
| 218 | changed |= WriteScriptSetting(dialogueTarget.script, kInteractionRequestSerial, std::to_string(currentSerial + 1)); |
| 219 | changed |= SetObjectEnabledState(ctx, dialogueTarget.object, true); |
| 220 | |
| 221 | // WriteScriptSetting auto-dirties on change; SetObjectEnabledState dirties internally. |
| 222 | (void)changed; |
| 223 | |
| 224 | return true; |
| 225 | } |
| 226 | |
| 227 | void applySelectedState() |
| 228 | { |
| 229 | SetObjectsEnabledState(ctx, selectedStateEnable, isSelected); |
| 230 | SetObjectsEnabledState(ctx, selectedStateDisable, !isSelected); |
| 231 | } |
| 232 | |
| 233 | bool isInteractDown() |
| 234 | { |
| 235 | return IsRuntimeKeyDown(GLFW_KEY_E, ImGuiKey_E); |
| 236 | } |
| 237 | |
| 238 | bool isPlayerInRange() |
| 239 | { |
| 240 | if (!ctx.object) return false; |
| 241 | |
| 242 | auto player = ResolveObject(playerRef); |
| 243 | if (!player) { |
| 244 | lastRangeHasPlayer = false; |
| 245 | lastRangeInRange = false; |
| 246 | lastRangeDistance = -1.0f; |
| 247 | lastSelfWorldPos = GetObjectReferencePosition(ctx, *ctx.object); |
| 248 | lastPlayerWorldPos = vec3(0.0f); |
| 249 | return !requirePlayerInRange; |
| 250 | } |
| 251 | |
| 252 | const vec3 selfPos = GetObjectReferencePosition(ctx, *ctx.object); |
| 253 | const vec3 playerPos = GetObjectReferencePosition(ctx, *player); |
| 254 | const vec3 delta = playerPos - selfPos; |
| 255 | const float range = Math.Max(0.0f, interactDistance); |
| 256 | const float distanceSq = glm::dot(delta, delta); |
| 257 | const bool inRange = distanceSq <= (range * range); |
| 258 | |
| 259 | lastRangeHasPlayer = true; |
| 260 | lastRangeInRange = inRange; |
| 261 | lastRangeDistance = std::sqrt(Math.Max(0.0f, distanceSq)); |
| 262 | lastSelfWorldPos = selfPos; |
| 263 | lastPlayerWorldPos = playerPos; |
| 264 | |
| 265 | return !requirePlayerInRange || inRange; |
| 266 | } |
| 267 | |
| 268 | void DrawSelectionNameInfo() |
| 269 | { |
| 270 | ImGui::TextDisabled("Selection Name: %s", GetCurrentObjectName(ctx, "Interactable").c_str()); |
| 271 | } |
| 272 | |
| 273 | bool DrawInteractNowButton() |
| 274 | { |
| 275 | return ImGui::Button("Interact Now") && interact(); |
| 276 | } |
| 277 | |
| 278 | void DrawRuntimeStatus() |
| 279 | { |
| 280 | ImGui::Separator(); |
| 281 | ImGui::TextUnformatted("Runtime"); |
| 282 | ImGui::TextDisabled("Can Interact: %s", canInteract ? "Yes" : "No"); |
| 283 | ImGui::TextDisabled("Options: %zu", options.size()); |
| 284 | ImGui::TextDisabled("Selected Option: %d", selectedOptionIndex); |
| 285 | if (!requirePlayerInRange && !debugRange) return; |
| 286 | if (!lastRangeHasPlayer) { |
| 287 | ImGui::TextDisabled("Range: player not found"); |
| 288 | return; |
| 289 | } |
| 290 | |
| 291 | ImGui::TextDisabled("Range Distance: %.2f / %.2f", lastRangeDistance, Math.Max(0.0f, interactDistance)); |
| 292 | ImGui::TextDisabled("In Range: %s", lastRangeInRange ? "Yes" : "No"); |
| 293 | if (!debugRange) return; |
| 294 | ImGui::TextDisabled("Self Pos: (%.2f, %.2f, %.2f)", lastSelfWorldPos.x, lastSelfWorldPos.y, lastSelfWorldPos.z); |
| 295 | ImGui::TextDisabled("Player Pos: (%.2f, %.2f, %.2f)", lastPlayerWorldPos.x, lastPlayerWorldPos.y, lastPlayerWorldPos.z); |
| 296 | } |
| 297 | } |