FrameworkZ 10.8.3
Provides a framework for Project Zomboid with various systems.
Loading...
Searching...
No Matches
Tooltips.lua
Go to the documentation of this file.
1-- TODO also display character info variables (approx. age, height, weight, etc.) alongside physical description
3local Events = Events
5local getCell = getCell
25FrameworkZ.Tooltips.HoveredCharacterData = {
27 Texture = getTexture("media/textures/fz-selector.png"),
29 TextureAlpha = 0.8,
31 TooltipShowing = false,
37 TooltipCharacterNameColor = {r = 1, g = 1, b = 1, a = 1},
38 TooltipStickiness = 1.5, -- Multiplier advantage for tracking current tooltip player
39 TooltipSwitchThreshold = 0.3, -- Minimum score difference needed to switch to new character
41 TooltipDataLoaded = false,
46 TypewriterStartDelay = 2.5, -- Delay in seconds before typewriter effect starts (increased for more contemplative pacing)
47 TypewriterDelayStartTime = 0, -- When the delay period started
48 TypewriterBaseSpeed = 8, -- Scaling multiplier for how much the conditions will impact revealing text (increased for slower base speed)
50 TypewriterCharactersPerSecond = 6, -- Base line target of characters per second to reveal (decreased for more deliberate pacing)
51 -- Cache fields for performance
57 _cacheInterval = 16, -- Cache for 16ms (~60fps) for more responsive updates
58}
62-- Get normalized direction vector for a given IsoDirection (with caching)
63function FrameworkZ.Tooltips:GetDirectionVector(isoDirection)
64 -- Check cache first
66 if cached then
67 return cached.x, cached.y
68 end
70 local dirX, dirY = 0, 0
72 if isoDirection == IsoDirections.N then
73 dirX, dirY = 0, -1 -- North: up
74 elseif isoDirection == IsoDirections.NE then
75 dirX, dirY = 0.7071, -0.7071 -- Northeast: up-right (pre-normalized)
76 elseif isoDirection == IsoDirections.E then
77 dirX, dirY = 1, 0 -- East: right
78 elseif isoDirection == IsoDirections.SE then
79 dirX, dirY = 0.7071, 0.7071 -- Southeast: down-right (pre-normalized)
80 elseif isoDirection == IsoDirections.S then
81 dirX, dirY = 0, 1 -- South: down
82 elseif isoDirection == IsoDirections.SW then
83 dirX, dirY = -0.7071, 0.7071 -- Southwest: down-left (pre-normalized)
84 elseif isoDirection == IsoDirections.W then
85 dirX, dirY = -1, 0 -- West: left
86 elseif isoDirection == IsoDirections.NW then
87 dirX, dirY = -0.7071, -0.7071 -- Northwest: up-left (pre-normalized)
88 else
89 return nil, nil -- Unknown direction
90 end
92 -- Cache the result
94
95 return dirX, dirY
96end
98 -- Fast angle calculation using pre-computed lookup
99local ANGLE_LOOKUP = {}
100for i = 0, 7 do
101 local angle = i * math.pi / 4
102 ANGLE_LOOKUP[i] = {cos = math.cos(angle), sin = math.sin(angle)}
103end
104
105-- Pre-computed angle constants for performance optimization
106local ANGLE_CONSTANTS = {
107 PI_6 = 0.5236, -- math.pi / 6 (30 degrees)
108 PI_4 = 0.7854, -- math.pi / 4 (45 degrees)
109 PI_3 = 1.0472, -- math.pi / 3 (60 degrees)
110 PI_2 = 1.5708, -- math.pi / 2 (90 degrees)
111 PI_1_5 = 2.0944 -- math.pi / 1.5 (120 degrees)
112}
114-- Utility functions for common operations to reduce code duplication
115local Utils = {
116 -- Get grid coordinates from player position
118 return math.floor(player:getX()), math.floor(player:getY()), player:getZ()
119 end,
120
121 -- Calculate cache key for screen positions
122 getScreenPositionCacheKey = function(playerId, x, y, z)
123 return playerId .. "_" .. math.floor(x * 10) .. "_" .. math.floor(y * 10) .. "_" .. z
124 end,
126 -- Get adjacent square based on direction
127 getAdjacentSquare = function(cell, baseX, baseY, z, dx, dy)
128 return cell:getGridSquare(baseX + dx, baseY + dy, z)
129 end,
130
131 -- Calculate squared distance between two points
132 getDistanceSquared = function(x1, y1, x2, y2)
133 local dx = x2 - x1
134 local dy = y2 - y1
135 return dx * dx + dy * dy
136 end,
138 -- Find best candidate from candidates array (optimized loop)
139 findBestCandidate = function(candidates)
140 local bestCandidate = nil
141 local bestScore = 0
142 for i = 1, #candidates do
143 local candidate = candidates[i]
144 if candidate.score > bestScore then
146 bestScore = candidate.score
147 end
148 end
150 end,
151
152 -- Check if current player is still valid in candidates list
153 checkPlayerValidity = function(candidates, targetPlayer)
154 for i = 1, #candidates do
155 if candidates[i].player == targetPlayer then
156 return true, candidates[i].score
157 end
158 end
159 return false, 0
160 end
161}
162
163-- Check if a door has a window based on properties or sprite name
164function FrameworkZ.Tooltips:DoorHasWindow(door)
165 if not door then
166 return false
167 end
168
169 -- Method 1: Check properties for HasWindow or similar
170 local properties = door:getProperties()
171 if properties then
172
173 -- Check for common property names that might indicate a window using Is() method
174 if properties:Is("doorTrans") then
175 return true
176 end
177 end
178
179 return false
180end
181
182-- Centralized function to check if a wall/door/window combination allows passage
183-- This eliminates massive code duplication across directional checks
184function FrameworkZ.Tooltips:CheckWallPassage(door, window, wall, restrictive)
185 -- Check for door opening first (prioritize openings)
186 if door and door:IsOpen() then
187 return true -- Open door allows passage
188 elseif door and not door:IsOpen() then
189 -- Closed door - but check if it has a window we can see through
190 if self:DoorHasWindow(door) then
191 -- Door has a built-in window - check if there's also a separate window object for curtains
192 if window then
193 -- Both door with window AND separate window object - check curtain state
194 if restrictive then
195 local curtain = window:HasCurtains()
196 if curtain and not curtain:IsOpen() then
197 return false -- Door with window but closed curtains blocks
198 else
199 return true -- Door with window and open/no curtains allows passage
200 end
201 else
202 return true -- In permissive mode, door windows don't block regardless of curtains
203 end
204 else
205 -- Door has window but no separate window object (probably no curtains)
206 return true -- Door with uncurtained window allows passage
207 end
208 elseif window then
209 -- Closed solid door but separate window - check curtain state
210 if restrictive then
211 local curtain = window:HasCurtains()
212 if curtain and not curtain:IsOpen() then
213 return false -- Separate window with closed curtains blocks
214 else
215 return true -- Separate window with open/no curtains allows passage
216 end
217 else
218 return true -- In permissive mode, windows don't block regardless of curtains
219 end
220 else
221 return false -- Closed solid door without window blocks
222 end
223 elseif window then
224 -- Window without door - check for window opening
225 if restrictive then
226 local curtain = window:HasCurtains()
227 if curtain and not curtain:IsOpen() then
228 return false -- Window with closed curtains blocks in restrictive mode
229 else
230 return true -- Window with open/no curtains allows passage in restrictive mode
231 end
232 else
233 return true -- In permissive mode, windows don't block regardless of curtains
234 end
235 elseif wall then
236 return false -- Wall always blocks
237 else
238 return true -- No obstruction at all
239 end
240end
242-- Calculate angle between player's facing direction and target position using dot product (optimized)
243function FrameworkZ.Tooltips:CalculatePlayerTargetAngle(player, targetX, targetY)
244if not player then
245 return math.pi -- Return max angle if no player
246end
247
248local playerDir = player:getDir()
249local playerX = player:getX()
250local playerY = player:getY()
252-- Get normalized direction vector
253local dirX, dirY = self:GetDirectionVector(playerDir)
254if not dirX then
255 return math.pi -- Unknown direction, return max angle
256end
258-- Vector from player to target
259local toTargetX = targetX - playerX
260local toTargetY = targetY - playerY
261local toTargetLengthSqr = toTargetX * toTargetX + toTargetY * toTargetY
262
263if toTargetLengthSqr == 0 then
264 return 0 -- Same position, no angle
265end
266
267-- Normalize target vector using fast inverse square root approximation for better performance
268local toTargetLength = math.sqrt(toTargetLengthSqr)
269toTargetX = toTargetX / toTargetLength
270toTargetY = toTargetY / toTargetLength
272-- Calculate dot product to get cosine of angle between vectors
273local dotProduct = dirX * toTargetX + dirY * toTargetY
274
275-- Calculate angle from dot product with clamping
276local angle = math.acos(math.max(-1, math.min(1, dotProduct)))
277
278return angle
279end
280
281-- Check if player is facing towards a target within field of view (optimized)
282function FrameworkZ.Tooltips:IsPlayerFacingTarget(player, targetX, targetY, fieldOfViewAngle)
283 if not player then
284 return false
285 end
286
287 -- Calculate angle between player facing direction and target
288 local angle = self:CalculatePlayerTargetAngle(player, targetX, targetY)
289
290 if angle == math.pi then
291 return false -- Invalid calculation or unknown direction
292 end
293
294 -- Check if within field of view using pre-computed constant
295 return angle <= (fieldOfViewAngle or ANGLE_CONSTANTS.PI_1_5) -- 120° field of view
296end
297
298-- Check for obstructions in a square based on movement direction (centralized function)
299function FrameworkZ.Tooltips:CheckSquareObstructions(square, stepX, stepY, isTargetSquare, restrictive)
300 if not square then
301 return false -- No square means no obstruction
302 end
303
304 local hasOpening = true -- Start by assuming there's an opening, then check if blocked
305 local cell = getCell()
306
307 -- Project Zomboid wall system:
308 -- getWall(true) = North walls, getWall(false) = West walls
309 -- South walls are on the square to the south (Y+1), East walls are on the square to the east (X+1)
310
311 -- Check for north-side obstructions when moving south (stepY > 0)
312 if stepY > 0 then
313 local northDoor = square:getDoor(true)
314 local northWindow = square:getWindow(true)
315 local northWall = square:getWall(true)
316 hasOpening = self:CheckWallPassage(northDoor, northWindow, northWall, restrictive)
317 end
318
319 -- Check for west-side obstructions when moving east (stepX > 0)
320 if stepX > 0 and hasOpening then
321 local westDoor = square:getDoor(false)
322 local westWindow = square:getWindow(false)
323 local westWall = square:getWall(false)
324 hasOpening = self:CheckWallPassage(westDoor, westWindow, westWall, restrictive)
325 end
326
327 -- Check for south-side obstructions when moving north (stepY < 0)
328 -- South walls are stored as north walls of the square below
329 if stepY < 0 and hasOpening then
330 local southSquare = cell:getGridSquare(square:getX(), square:getY() + 1, square:getZ())
331 if southSquare then
332 local southDoor = southSquare:getDoor(true)
333 local southWindow = southSquare:getWindow(true)
334 local southWall = southSquare:getWall(true)
335 hasOpening = self:CheckWallPassage(southDoor, southWindow, southWall, restrictive)
336 end
337 end
338
339 -- Check for east-side obstructions when moving west (stepX < 0)
340 -- East walls are stored as west walls of the square to the right
341 if stepX < 0 and hasOpening then
342 local eastSquare = cell:getGridSquare(square:getX() + 1, square:getY(), square:getZ())
343 if eastSquare then
344 local eastDoor = eastSquare:getDoor(false)
345 local eastWindow = eastSquare:getWindow(false)
346 local eastWall = eastSquare:getWall(false)
347 hasOpening = self:CheckWallPassage(eastDoor, eastWindow, eastWall, restrictive)
348 end
349 end
350
351 return not hasOpening -- Return true if blocked (no opening found)
352end
353
354-- Check for valid openings between adjacent players (centralized function)
355-- Check for valid openings between adjacent players (optimized with centralized logic)
356function FrameworkZ.Tooltips:CheckAdjacentOpening(localSquare, targetSquare, dx, dy, restrictive)
357 local cell = getCell()
358
359 if dx == 1 and dy == 0 then
360 -- Moving east: check west walls between squares
361 -- Check west wall of target square
362 if targetSquare then
363 local door = targetSquare:getDoor(false)
364 local window = targetSquare:getWindow(false)
365 local wall = targetSquare:getWall(false)
366
367 if self:CheckWallPassage(door, window, wall, restrictive) then
368 return true
369 end
370 end
371
372 -- Also check east wall of local square (west wall of square to the right)
373 if localSquare then
374 local localX, localY = localSquare:getX(), localSquare:getY()
375 local eastSquare = cell:getGridSquare(localX + 1, localY, localSquare:getZ())
376 if eastSquare then
377 local door = eastSquare:getDoor(false)
378 local window = eastSquare:getWindow(false)
379 local wall = eastSquare:getWall(false)
380
381 return self:CheckWallPassage(door, window, wall, restrictive)
382 end
383 end
384
385 elseif dx == -1 and dy == 0 then
386 -- Moving west: check west wall of local square
387 if localSquare then
388 local door = localSquare:getDoor(false)
389 local window = localSquare:getWindow(false)
390 local wall = localSquare:getWall(false)
391
392 return self:CheckWallPassage(door, window, wall, restrictive)
393 end
394
395 elseif dx == 0 and dy == 1 then
396 -- Moving south: check north walls between squares
397 -- Check north wall of target square
398 if targetSquare then
399 local door = targetSquare:getDoor(true)
400 local window = targetSquare:getWindow(true)
401 local wall = targetSquare:getWall(true)
402
403 if self:CheckWallPassage(door, window, wall, restrictive) then
404 return true
405 end
406 end
407
408 -- Also check south wall of local square (north wall of square below)
409 if localSquare then
410 local localX, localY = localSquare:getX(), localSquare:getY()
411 local southSquare = cell:getGridSquare(localX, localY + 1, localSquare:getZ())
412 if southSquare then
413 local door = southSquare:getDoor(true)
414 local window = southSquare:getWindow(true)
415 local wall = southSquare:getWall(true)
416
417 return self:CheckWallPassage(door, window, wall, restrictive)
418 end
419 end
420
421 elseif dx == 0 and dy == -1 then
422 -- Moving north: check north wall of local square
423 if localSquare then
424 local door = localSquare:getDoor(true)
425 local window = localSquare:getWindow(true)
426 local wall = localSquare:getWall(true)
427
428 return self:CheckWallPassage(door, window, wall, restrictive)
429 end
430 end
431
432 return false
433end
434
435-- split a long description string into ~30-char lines
436function FrameworkZ.Tooltips:GetDescriptionLines(desc)
437 local lines, line, len = {}, "", 0
438 for word in string.gmatch(desc, "%S+") do
439 local wlen = #word
440 if len + wlen + 1 <= 30 then
441 if len > 0 then
442 line = line .. " "
443 len = len + 1
444 end
445 line = line .. word
446 len = len + wlen
447 else
448 table.insert(lines, line)
449 line, len = word, wlen
450 end
451 end
452 table.insert(lines, line)
453 return lines
454end
455
456-- calculate character selection score for weighted tracking (optimized)
457function FrameworkZ.Tooltips:CalculateCharacterScore(localPlayer, targetPlayer, mouseX, mouseY)
458 if not localPlayer or not targetPlayer then
459 return 0
460 end
461
462 -- Get screen position of target player with optimized caching
463 local targetId = targetPlayer:getPlayerNum()
464 local currentTime = getTimestampMs()
465 local px, py, pz = targetPlayer:getX(), targetPlayer:getY(), targetPlayer:getZ()
466 local cacheKey = Utils.getScreenPositionCacheKey(targetId, px, py, pz)
467
468 local sx, sy
471 local cached = self.HoveredCharacterData._cachedScreenPositions[cacheKey]
472 sx, sy = cached.x, cached.y
473 else
474 sx = isoToScreenX(targetId, px, py, pz)
475 sy = isoToScreenY(targetId, px, py, pz)
476
477 -- Cache the result with shorter lifetime for moving targets
478 self.HoveredCharacterData._cachedScreenPositions[cacheKey] = {x = sx, y = sy}
479 self.HoveredCharacterData._lastScreenCacheTime = currentTime
480 end
481
482 -- Distance from mouse cursor to character on screen (closer = higher score)
483 local screenDx = sx - mouseX
484 local screenDy = sy - mouseY
485 local screenDistanceSqr = screenDx * screenDx + screenDy * screenDy
486 local screenScore = math.max(0, 100 - math.sqrt(screenDistanceSqr) * 0.5)
487
488 -- World distance factor (closer = higher score) using utility function
489 local worldDistanceSqr = Utils.getDistanceSquared(px, py, localPlayer:getX(), localPlayer:getY())
490 local worldScore = math.max(0, 50 - math.sqrt(worldDistanceSqr) * 10)
491
492 -- Line of sight bonus (visible = higher score) - use centralized function
493 local hasLineOfSight = self:HasLineOfSight(localPlayer, targetPlayer)
494 local losScore = hasLineOfSight and 25 or 0
495
496 -- Apply stickiness bonus if this is the currently tracked player
497 local stickinessBonus = 0
498 if self.HoveredCharacterData.TooltipPlayer == targetPlayer then
499 stickinessBonus = (screenScore + worldScore + losScore) * (self.HoveredCharacterData.TooltipStickiness - 1.0)
500 end
501
502 return screenScore + worldScore + losScore + stickinessBonus
503end
504
505-- check line of sight between two players (optimized but more permissive)
506-- Centralized line of sight calculation with configurable strictness
507function FrameworkZ.Tooltips:CalculateLineOfSight(localPlayer, targetPlayer, restrictive, adjacentRange)
508 if not localPlayer or not targetPlayer then
509 return false
510 end
511
512 -- Check if both players are on the same Z level
513 if localPlayer:getZ() ~= targetPlayer:getZ() then
514 return false
515 end
516
517 -- Get starting and ending grid coordinates using utility function
518 local x1, y1, z = Utils.getPlayerGridCoords(localPlayer)
519 local x2, y2 = Utils.getPlayerGridCoords(targetPlayer)
520
521 -- If players are close, handle adjacent checking
522 local distanceSqr = Utils.getDistanceSquared(x1, y1, x2, y2)
523
524 if distanceSqr <= (adjacentRange or 1) then
525 -- Within adjacent range - use different logic
526 if distanceSqr <= 1 and restrictive then
527 -- Adjacent players with restrictive checking
528 local cell = getCell()
529 local localSquare = cell:getGridSquare(x1, y1, z)
530 local targetSquare = cell:getGridSquare(x2, y2, z)
531
532 -- Use centralized function to check for valid openings
533 local dx = x2 - x1
534 local dy = y2 - y1
535 local hasOpening = self:CheckAdjacentOpening(localSquare, targetSquare, dx, dy, restrictive)
536
537 return hasOpening
538 else
539 -- Close range, permissive or within range - assume line of sight
540 return true
541 end
542 end
543
544 -- Use Bresenham-like algorithm for longer distances
545 local dx = x2 - x1
546 local dy = y2 - y1
547 local absDx = math.abs(dx)
548 local absDy = math.abs(dy)
549 local stepX = x1 < x2 and 1 or -1
550 local stepY = y1 < y2 and 1 or -1
551 local err = absDx - absDy
552
553 local cell = getCell()
554 local checkX = x1
555 local checkY = y1
556
557 while true do
558 -- Check current square for obstructions using centralized function
559 local checkSquare = cell:getGridSquare(checkX, checkY, z)
560 local isTargetSquare = (checkX == x2 and checkY == y2)
561
562 if self:CheckSquareObstructions(checkSquare, stepX, stepY, isTargetSquare, restrictive) then
563 return false -- Blocked by obstruction
564 end
565
566 -- Check if we've reached the target
567 if checkX == x2 and checkY == y2 then
568 break
569 end
570
571 -- Move to next square using Bresenham algorithm
572 local err2 = 2 * err
573 if err2 > -absDy then
574 err = err - absDy
575 checkX = checkX + stepX
576 end
577 if err2 < absDx then
578 err = err + absDx
579 checkY = checkY + stepY
580 end
581 end
582
583 return true
584end
585
586-- Strict line of sight check for tooltip visibility
587function FrameworkZ.Tooltips:HasLineOfSight(localPlayer, targetPlayer)
588 return self:CalculateLineOfSight(localPlayer, targetPlayer, true, 1)
589end
590
591-- More permissive line of sight check specifically for typewriter speed calculation
592function FrameworkZ.Tooltips:HasLineOfSightForTypewriter(localPlayer, targetPlayer)
593 return self:CalculateLineOfSight(localPlayer, targetPlayer, false, 9) -- Within 3 tiles, very generous
594end
595
596-- calculate typewriter speed based on distance, line of sight, and facing direction (slowed and more variable for immersive roleplay)
597function FrameworkZ.Tooltips:CalculateTypewriterSpeed(localPlayer, targetPlayer)
598 if not localPlayer or not targetPlayer then
599 return self.HoveredCharacterData.TypewriterBaseSpeed * 2.5 -- Conservative fallback for measured pacing
600 end
601
602 -- Use utility function for distance calculation
603 local distanceSqr = Utils.getDistanceSquared(localPlayer:getX(), localPlayer:getY(), targetPlayer:getX(), targetPlayer:getY())
604
605 -- Start with slower, more contemplative base speed
606 local finalSpeed = self.HoveredCharacterData.TypewriterBaseSpeed * 1.0
607
608 -- Distance factor (much more variable and generally slower)
609 local distanceMultiplier = 1.0
610 if distanceSqr < 1.0 then -- < 1 tile - intimate range
611 distanceMultiplier = 0.7 -- Still fairly quick for close interaction, but not instant
612 elseif distanceSqr < 4.0 then -- < 2 tiles - conversation range
613 distanceMultiplier = 1.0 -- Normal speed for conversation distance
614 elseif distanceSqr < 9.0 then -- < 3 tiles - observation range
615 distanceMultiplier = 1.4 -- Slower for distant observation
616 elseif distanceSqr < 16.0 then -- < 4 tiles - recognition range
617 distanceMultiplier = 1.8 -- Much slower for distant recognition
618 else
619 distanceMultiplier = 2.5 -- Very slow for far targets
620 end
621 finalSpeed = finalSpeed * distanceMultiplier
622
623 -- Line of sight check (significant impact for realism)
624 local hasLineOfSight = self:HasLineOfSightForTypewriter(localPlayer, targetPlayer)
625 if not hasLineOfSight then
626 finalSpeed = finalSpeed * 3.5 -- Much slower without clear line of sight
627 else
628 finalSpeed = finalSpeed * 1.0 -- Normal speed with clear line of sight
629 end
630
631 -- Local player facing direction check (significant variability for immersion)
632 local localAngleDiff = self:CalculatePlayerTargetAngle(localPlayer, targetPlayer:getX(), targetPlayer:getY())
633
634 -- Local player facing factor (more dramatic differences for engagement)
635 if localAngleDiff < ANGLE_CONSTANTS.PI_6 then
636 finalSpeed = finalSpeed * 0.6 -- Faster when staring directly, but not instant
637 elseif localAngleDiff < ANGLE_CONSTANTS.PI_4 then
638 finalSpeed = finalSpeed * 0.8 -- Moderately fast when looking at target
639 elseif localAngleDiff < ANGLE_CONSTANTS.PI_3 then
640 finalSpeed = finalSpeed * 1.0 -- Normal speed when generally facing
641 elseif localAngleDiff < ANGLE_CONSTANTS.PI_2 then
642 finalSpeed = finalSpeed * 1.3 -- Slower when partially facing
643 else
644 finalSpeed = finalSpeed * 2.2 -- Much slower when not facing
645 end
646
647 -- Target player facing direction check (moderate but noticeable effect)
648 local targetAngleDiff = self:CalculatePlayerTargetAngle(targetPlayer, localPlayer:getX(), localPlayer:getY())
649
650 -- Target facing factor (creates natural interaction rhythms)
651 if targetAngleDiff < ANGLE_CONSTANTS.PI_6 then
652 finalSpeed = finalSpeed * 0.7 -- Faster when target stares back (mutual attention)
653 elseif targetAngleDiff < ANGLE_CONSTANTS.PI_4 then
654 finalSpeed = finalSpeed * 0.9 -- Slightly faster when target looks back
655 elseif targetAngleDiff < ANGLE_CONSTANTS.PI_3 then
656 finalSpeed = finalSpeed * 1.1 -- Slightly slower when target is somewhat attentive
657 elseif targetAngleDiff < ANGLE_CONSTANTS.PI_2 then
658 finalSpeed = finalSpeed * 1.3 -- Slower when target is partially facing
659 else
660 finalSpeed = finalSpeed * 1.7 -- Much slower when target isn't paying attention
661 end
662
663 -- Mutual attention bonus (rewarding but not overpowering)
664 if localAngleDiff < ANGLE_CONSTANTS.PI_4 and targetAngleDiff < ANGLE_CONSTANTS.PI_4 then
665 finalSpeed = finalSpeed * 0.5 -- Good bonus for mutual eye contact/attention
666 elseif localAngleDiff < ANGLE_CONSTANTS.PI_3 and targetAngleDiff < ANGLE_CONSTANTS.PI_3 then
667 finalSpeed = finalSpeed * 0.7 -- Moderate bonus for good mutual attention
668 elseif localAngleDiff < ANGLE_CONSTANTS.PI_2 and targetAngleDiff < ANGLE_CONSTANTS.PI_2 then
669 finalSpeed = finalSpeed * 0.9 -- Small bonus for casual mutual awareness
670 end
671
672 return finalSpeed
673end
674
675-- update typewriter progress (optimized)
676function FrameworkZ.Tooltips:UpdateTypewriterProgress()
677 local currentTime = getTimestampMs()
678
679 -- Check if we're still in the delay period
680 if (currentTime - self.HoveredCharacterData.TypewriterDelayStartTime) < (self.HoveredCharacterData.TypewriterStartDelay * 1000) then
681 return -- Don't start typewriter effect yet
682 end
683
684 -- If this is the first update after delay, reset the timer
687 return -- Skip this frame to avoid large deltaTime
688 end
689
690 local deltaTime = (currentTime - self.HoveredCharacterData.TypewriterLastUpdateTime) * 0.001 -- convert to seconds
691
692 -- Recalculate speed less frequently for better performance
693 local mp = getSpecificPlayer(0)
695 -- Only recalculate every few frames (more responsive: 100ms instead of 200ms)
696 if currentTime - self.HoveredCharacterData._lastTypewriterSpeedTime > 100 then -- Every 100ms for more dynamic updates
699 end
700 end
701
702 -- Get current FPS and normalize speed
703 local avgFPS = math.max(1, getAverageFPS()) -- prevent division by zero
704 local fpsNormalizer = 60 / avgFPS
705
706 -- Calculate frame-rate independent character reveal speed
708 local adjustedCharsPerSecond = baseCharsPerSecond / (self.HoveredCharacterData.TypewriterCurrentSpeed / self.HoveredCharacterData.TypewriterBaseSpeed)
709 local charactersToReveal = deltaTime * adjustedCharsPerSecond * fpsNormalizer
710
711 if charactersToReveal >= 1.0 then
712 local charsToAdd = math.floor(charactersToReveal)
713
714 -- Update description progress first (observing appearance)
715 local allDescriptionComplete = true
717 for i = 1, #descLines do
718 if charsToAdd <= 0 then break end
719
720 local line = descLines[i]
721 local lineLen = #line
722
725 end
726
727 -- Only start revealing description lines in order
728 if i == 1 or (self.HoveredCharacterData.TypewriterDescriptionProgress[i-1] or 0) >= #descLines[i-1] then
730 if currentProgress < lineLen then
731 local lineCharsToAdd = math.min(charsToAdd, lineLen - currentProgress)
732 self.HoveredCharacterData.TypewriterDescriptionProgress[i] = currentProgress + lineCharsToAdd
733 charsToAdd = charsToAdd - lineCharsToAdd
734 allDescriptionComplete = false
735 end
736 else
737 allDescriptionComplete = false
738 end
739 end
740
741 -- Check if all description lines are complete
742 if allDescriptionComplete then
743 for i = 1, #descLines do
745 if progress < #descLines[i] then
746 allDescriptionComplete = false
747 break
748 end
749 end
750 end
751
752 -- Update name progress (only after description is complete)
753 if allDescriptionComplete and charsToAdd > 0 then
756 local nameCharsToAdd = math.min(charsToAdd, nameLen - self.HoveredCharacterData.TypewriterNameProgress)
758 end
759 end
760
762 end
763end
764
765-- reset typewriter state for new character (optimized)
766function FrameworkZ.Tooltips:ResetTypewriterState()
769 self.HoveredCharacterData.TypewriterDelayStartTime = getTimestampMs() -- Start the delay timer
770 self.HoveredCharacterData.TypewriterLastUpdateTime = getTimestampMs()
771 -- Clear caches when resetting state
772 self.HoveredCharacterData._cachedScreenPositions = {}
773 self.HoveredCharacterData._lastScreenCacheTime = 0
774 self.HoveredCharacterData._lastTypewriterSpeedTime = 0
775end
776
777-- draw the selector + text each UI frame (optimized)
779 local tooltipData = FrameworkZ.Tooltips.HoveredCharacterData
780
781 if not tooltipData.TooltipPlayer or not tooltipData.UI then return end
782
783 -- Always recalculate screen position for smooth tracking of moving characters
784 local player = tooltipData.TooltipPlayer
785 local pidx = player:getPlayerNum()
786 local px, py, pz = player:getX(), player:getY(), player:getZ()
787 local sx = isoToScreenX(pidx, px, py, pz)
788 local sy = isoToScreenY(pidx, px, py, pz)
789
790 -- Always draw selector ring immediately
791 local texture = tooltipData.Texture
792 local w, h = texture:getWidth(), texture:getHeight()
793 local scale = tooltipData.TextureScale
794 local sw, sh = w * scale, h * scale
795 tooltipData.UI:drawTextureScaled(
796 texture,
797 sx - sw * 0.5,
798 sy - sh * 0.5 + (h * scale * tooltipData.TextureYOffset),
799 sw, sh,
800 tooltipData.TextureAlpha
801 )
802
803 -- Check if delay period has passed before showing text
804 local currentTime = getTimestampMs()
805 if (currentTime - tooltipData.TypewriterDelayStartTime) < (tooltipData.TypewriterStartDelay * 1000) then
806 return -- Don't show text yet, only selector
807 end
808
809 -- update typewriter progress (only after delay)
810 FrameworkZ.Tooltips:UpdateTypewriterProgress()
811
812 -- draw name + description with typewriter effect
813 local tm = getTextManager()
814 local font = UIFont.Dialogue
815 local lineH = tm:getFontFromEnum(font):getLineHeight()
816 local ty = sy + (sh * 0.5) + 6
817
818 -- name at top (with typewriter effect) - recognition after observation
819 local nameColor = tooltipData.TooltipCharacterNameColor
820 local visibleName = string.sub(tooltipData.TooltipCharacterName, 1, tooltipData.TypewriterNameProgress)
821 tm:DrawStringCentre(font, sx, ty, visibleName, nameColor.r, nameColor.g, nameColor.b, nameColor.a)
822 ty = ty + lineH
823
824 -- description below name (with typewriter effect) - observing appearance
825 local descLines = tooltipData.TooltipCharacterDescriptionLines
826 for i = 1, #descLines do
827 local line = descLines[i]
828 local visibleChars = tooltipData.TypewriterDescriptionProgress[i] or 0
829 local visibleLine = string.sub(line, 1, visibleChars)
830 tm:DrawStringCentre(font, sx, ty, visibleLine, 1, 1, 1, 1)
831 ty = ty + lineH
832 end
833end
834
835-- enable / disable the UI callback
836function FrameworkZ.Tooltips:EnableTooltip()
838 self.HoveredCharacterData.UI = ISUIElement:new(0, 0, 0, 0)
839 self.HoveredCharacterData.UI:initialise()
840 self.HoveredCharacterData.UI:addToUIManager()
842 Events.OnPreUIDraw.Add(FrameworkZ.Tooltips.DrawTooltip)
843 end
844end
845
846function FrameworkZ.Tooltips:DisableTooltip()
848 Events.OnPreUIDraw.Remove(FrameworkZ.Tooltips.DrawTooltip)
852 -- Reset character data state when disabling tooltip
856 FrameworkZ.Tooltips:ResetTypewriterState()
857 end
858end
859
860-- Cache cleanup function to prevent memory leaks
861function FrameworkZ.Tooltips:CleanupCaches()
862 local currentTime = getTimestampMs()
863
864 -- Clean up screen position cache if it's getting too old (every 10 seconds)
865 if currentTime - self.HoveredCharacterData._lastCacheCleanupTime > 10000 then
867 self.HoveredCharacterData._lastCacheCleanupTime = currentTime
868 end
869
870 -- Limit cache size to prevent memory bloat (increased from 50 to 100)
871 local cacheCount = 0
872 for _ in pairs(self.HoveredCharacterData._cachedScreenPositions) do
873 cacheCount = cacheCount + 1
874 end
875
876 if cacheCount > 100 then -- Limit to 100 cached positions
877 -- Clear oldest entries by recreating cache (simple but effective)
878 local newCache = {}
879 local count = 0
880 for key, value in pairs(self.HoveredCharacterData._cachedScreenPositions) do
881 if count < 50 then -- Keep newest 50
882 newCache[key] = value
883 count = count + 1
884 else
885 break
886 end
887 end
889 end
890end
891
892-- Main tick function (heavily optimized)
893function FrameworkZ.Tooltips.OnTick()
894 -- Cleanup caches periodically
895 FrameworkZ.Tooltips:CleanupCaches()
896
897 local mp = getPlayer()
898 if not mp then
899 FrameworkZ.Tooltips:DisableTooltip()
900 return
901 end
902
903 local mx, my = getMouseX(), getMouseY()
904 local pidx = mp:getPlayerNum()
905 local wx = screenToIsoX(pidx, mx, my, 0)
906 local wy = screenToIsoY(pidx, mx, my, 0)
907 local wz = mp:getZ()
908 local sq = getSquare(wx, wy, wz)
909
910 if not sq then
911 FrameworkZ.Tooltips:DisableTooltip()
912 return
913 end
914
915 -- scan a 3×3 grid for all IsoPlayers and score them (optimized)
916 local candidates = {}
917 local cell = getCell() -- Cache cell reference
918 local sqX, sqY = sq:getX(), sq:getY()
919
920 for ix = sqX - 1, sqX + 1 do
921 for iy = sqY - 1, sqY + 1 do
922 local sq2 = cell:getGridSquare(ix, iy, wz)
923 if sq2 then
924 local movingObjects = sq2:getMovingObjects()
925 local objCount = movingObjects:size()
926 for i = 0, objCount - 1 do
927 local o = movingObjects:get(i)
928 if instanceof(o, "IsoPlayer") then -- TODO uncomment when finalized: and o ~= mp
929 local score = FrameworkZ.Tooltips:CalculateCharacterScore(mp, o, mx, my)
930 if score > 0 then -- Only add candidates with positive scores
931 candidates[#candidates + 1] = {player = o, score = score}
932 end
933 end
934 end
935 end
936 end
937 end
938
939 -- Find the best candidate using utility function
940 local bestCandidate, bestScore = Utils.findBestCandidate(candidates)
941
942 -- Determine if we should switch to a new character
943 local shouldSwitch = false
944 local currentPlayerStillValid = false
945 local currentScore = 0
946 local tooltipData = FrameworkZ.Tooltips.HoveredCharacterData
947
948 -- Check if current player is still in candidates list using utility function
949 if tooltipData.TooltipPlayer then
950 currentPlayerStillValid, currentScore = Utils.checkPlayerValidity(candidates, tooltipData.TooltipPlayer)
951 end
952
953 if not tooltipData.TooltipPlayer then
954 -- No current player, switch to any valid candidate
955 shouldSwitch = bestCandidate ~= nil
956 elseif not bestCandidate then
957 -- No candidates found, disable tooltip
958 shouldSwitch = true
960 elseif not currentPlayerStillValid then
961 -- Current player is no longer valid, switch to best candidate
962 shouldSwitch = true
963 else
964 -- Check if the best candidate is different and significantly better
965 if bestCandidate ~= tooltipData.TooltipPlayer then
966 -- Only switch if the new candidate is significantly better
967 local scoreDifference = (bestScore - currentScore) / math.max(bestScore, 1)
968 shouldSwitch = scoreDifference > tooltipData.TooltipSwitchThreshold
969 end
970 end
971
972 if shouldSwitch then
973 if bestCandidate then
974 -- Check line of sight before showing tooltip - if no line of sight, don't show tooltip at all
975 if not FrameworkZ.Tooltips:HasLineOfSight(mp, bestCandidate) then
976 FrameworkZ.Tooltips:DisableTooltip()
977 return
978 end
979
980 -- Check if player is facing the target - if not, don't show tooltip at all
981 if not FrameworkZ.Tooltips:IsPlayerFacingTarget(mp, bestCandidate:getX(), bestCandidate:getY(), ANGLE_CONSTANTS.PI_1_5) then
982 FrameworkZ.Tooltips:DisableTooltip()
983 return
984 end
985
986 -- Update to new character
987 if bestCandidate ~= tooltipData.TooltipPlayer then
988 tooltipData.TooltipPlayer = bestCandidate
989
990 -- Reset character data state only if switching to a new character
991 local targetUsername = bestCandidate:getUsername()
992 if tooltipData.TooltipLastRequestedTarget ~= targetUsername then
993 tooltipData.TooltipDataLoaded = false
994 tooltipData.TooltipRequestSent = false
995 tooltipData.TooltipLastRequestedTarget = targetUsername
996 end
997
998 -- Show loading state immediately
999 tooltipData.TooltipCharacterName = "[Loading...]"
1000 tooltipData.TooltipCharacterNameColor = {r = 0.7, g = 0.7, b = 0.7, a = 1}
1001 tooltipData.TooltipCharacterDescriptionLines = {}
1002
1003 -- Request character data from server - only send if we haven't already sent for this specific player
1004 if not tooltipData.TooltipRequestSent then
1005 FrameworkZ.Foundation:SendFire(mp, "FrameworkZ.Tooltips.RequestCharacterData", function(data, responseData)
1006 -- Handle response from server
1007 FrameworkZ.Tooltips:OnReceiveCharacterData(responseData)
1008 end, mp:getUsername(), targetUsername)
1009 tooltipData.TooltipRequestSent = true
1010 elseif tooltipData.TooltipDataLoaded then
1011 -- If data is already loaded, show it immediately
1012 local character = FrameworkZ.Characters:GetCharacterByID(mp:getUsername())
1013 if not character then return end
1014 local targetCharacter = FrameworkZ.Characters:GetCharacterByID(targetUsername)
1015 if not targetCharacter then return end
1016
1017 tooltipData.TooltipCharacterName = character:GetRecognition(targetCharacter)
1018 tooltipData.TooltipCharacterNameColor = FrameworkZ.Factions:GetFactionByID(tooltipData.TooltipCharacterFaction):GetColor() or {r = 1, g = 1, b = 1, a = 1}
1019 tooltipData.TooltipCharacterDescriptionLines = FrameworkZ.Tooltips:GetDescriptionLines(tooltipData.TooltipCharacterDescription)
1020
1021 FrameworkZ.Tooltips:ResetTypewriterState()
1022 end
1023
1024 -- Calculate initial typewriter speed and reset state
1025 tooltipData.TypewriterCurrentSpeed = FrameworkZ.Tooltips:CalculateTypewriterSpeed(mp, bestCandidate)
1026 FrameworkZ.Tooltips:ResetTypewriterState()
1027 end
1028 FrameworkZ.Tooltips:EnableTooltip()
1029 else
1030 -- No valid candidate, disable tooltip
1031 tooltipData.TooltipPlayer = nil
1032 FrameworkZ.Tooltips:DisableTooltip()
1033 end
1034 elseif tooltipData.TooltipPlayer and currentPlayerStillValid then
1035 -- Check line of sight for current player before keeping tooltip
1036 if not FrameworkZ.Tooltips:HasLineOfSight(mp, tooltipData.TooltipPlayer) then
1037 -- Lost line of sight, disable tooltip
1038 FrameworkZ.Tooltips:DisableTooltip()
1039 return
1040 end
1041
1042 -- Also check if still facing the current target
1043 if not FrameworkZ.Tooltips:IsPlayerFacingTarget(mp, tooltipData.TooltipPlayer:getX(), tooltipData.TooltipPlayer:getY(), ANGLE_CONSTANTS.PI_1_5) then
1044 FrameworkZ.Tooltips:DisableTooltip()
1045 return
1046 end
1047
1048 -- Keep showing tooltip for current player
1049 FrameworkZ.Tooltips:EnableTooltip()
1050 else
1051 -- No valid player to show
1052 FrameworkZ.Tooltips:DisableTooltip()
1053 end
1054end
1055Events.OnTick.Add(FrameworkZ.Tooltips.OnTick)
1056
1057function FrameworkZ.Tooltips:OnReceiveCharacterData(responseData)
1058 if not responseData then
1059 return
1060 end
1061
1062 -- Only process if this response is for our current tooltip target
1063 if self.HoveredCharacterData.TooltipPlayer and self.HoveredCharacterData.TooltipPlayer:getUsername() == responseData.targetUsername then
1064 -- Store the character data
1065 self.HoveredCharacterData.TooltipDataLoaded = true
1066
1067 -- Update tooltip data with recognition logic
1068 self.HoveredCharacterData.TooltipCharacterName = responseData.targetData.name or "[Unknown]"
1069
1070 -- Get faction color
1071 local targetFaction = responseData.targetData.faction
1072 self.HoveredCharacterData.TooltipCharacterNameColor = targetFaction and FrameworkZ.Factions:GetFactionByID(targetFaction):GetColor() or {r = 1, g = 1, b = 1, a = 1}
1073
1074 -- Get description
1075 local descStr = responseData.targetData.description or ""
1076 self.HoveredCharacterData.TooltipCharacterDescriptionLines = FrameworkZ.Tooltips:GetDescriptionLines(descStr)
1077
1078 -- Reset typewriter state with new data
1079 FrameworkZ.Tooltips:ResetTypewriterState()
1080 end
1081end
1082
1083if isServer() then
1084 function FrameworkZ.Tooltips.RequestCharacterData(data, localUsername, targetUsername, requestingPlayer)
1085 -- Get both characters from server-side data
1086 local localCharacter = FrameworkZ.Characters:GetCharacterByID(localUsername)
1087 local targetCharacter = FrameworkZ.Characters:GetCharacterByID(targetUsername)
1088
1089 if localCharacter and targetCharacter then
1090 local responseData = {
1091 localUsername = localUsername,
1092 targetUsername = targetUsername,
1093 localRecognizes = localCharacter:GetRecognition(targetCharacter) or "[Unknown]",
1094 targetData = {
1095 name = localCharacter:GetRecognition(targetCharacter) or "[Unknown]",
1096 description = targetCharacter:GetDescription() or "[No Description]",
1097 faction = targetCharacter:GetFaction() or "[No Faction]",
1098 uid = targetCharacter:GetUID() or "[No UID]"
1099 }
1100 }
1101
1102 -- Return response data to client via callback
1103 return responseData
1104 end
1105
1106 -- Return empty response if something went wrong
1107 return {
1108 localUsername = localUsername or "unknown",
1109 targetUsername = targetUsername or "unknown",
1110 localRecognizes = {},
1111 targetData = {
1112 name = "[Error]",
1113 description = "[Error retrieving data]",
1114 faction = nil,
1115 uid = nil
1116 }
1117 }
1118 end
1119 FrameworkZ.Foundation:Subscribe("FrameworkZ.Tooltips.RequestCharacterData", FrameworkZ.Tooltips.RequestCharacterData)
1120end
void local y()
void local x()
void self Cache()
void self IsoPlayer()
void self faction()
void local height()
void local object()
void weight()
void description()
void self createCharacterButton font()
void self FrameworkZ UI self nil
Definition MainMenu.lua:95
void self self
Definition MainMenu.lua:89
void local stepY()
void local stepX
Definition MainMenu.lua:79
void local player()
void processingNotification backgroundColor a()
void local position()
void local speed()
void local fps()
void _cachedDirectionVectors()
void TextureScale()
void PI_3()
void _cacheInterval()
void local toTargetX()
void TypewriterCurrentSpeed()
void local playerY()
void local ANGLE_CONSTANTS()
void local instanceof()
void FrameworkZ Tooltips HoveredCharacterData()
void TooltipCharacterDescriptionLines()
void getPlayerGridCoords()
void local bestScore()
void local getTimestampMs()
void getScreenPositionCacheKey()
void local getTextManager()
void FrameworkZ Tooltips()
void TextureYOffset()
void TextureAlpha()
void findBestCandidate()
void PI_2()
void Texture()
void local dirX
Definition Tooltips.lua:153
void UI()
void local isClient()
void local angle()
void local getCell()
void local getMouseY()
void _lastCacheCleanupTime()
void TypewriterDescriptionProgress()
void _lastScreenCacheTime()
void local toTargetLength()
void local screenToIsoY()
void getAdjacentSquare()
void PI_1_5()
void local Events()
void local toTargetLengthSqr()
void TooltipDataLoaded()
void local dirY()
void local dy()
void local getAverageFPS()
void TooltipCharacterDescription()
void local Utils()
void TooltipStickiness()
void local bestCandidate()
void TooltipCharacterName()
void local curtain()
void local properties()
void local playerX()
void TypewriterStartDelay()
void local isoToScreenX()
void local isoToScreenY()
void if isoDirection()
void local candidate()
void checkPlayerValidity()
void _cachedScreenPositions()
void if candidates[i] player()
void TooltipCharacterFaction()
void local getSpecificPlayer()
void local getMouseX()
void local dx()
void PI_4()
void local screenToIsoX()
void getDistanceSquared()
void TooltipRequestSent()
void _lastTypewriterSpeedTime()
void TooltipPlayer()
void local playerDir()
void TypewriterDelayStartTime()
void local getTexture()
void local toTargetY()
void local dotProduct()
void TypewriterBaseSpeed()
void TooltipLastRequestedTarget()
void TooltipCharacterNameColor()
void local IsoDirections()
void local ANGLE_LOOKUP()
void PI_6()
void TypewriterLastUpdateTime()
void for i()
void FrameworkZ()
void local getSquare()
void TypewriterNameProgress()
void TooltipSwitchThreshold()
void TypewriterCharactersPerSecond()
void TooltipShowing()
void local name()
void current()
void local data()
void local getPlayer()
void key()
void local callback()
void local characters()
void value()
Foundation for FrameworkZ.
void HasLineOfSightForTypewriter(localPlayer, targetPlayer)
void UpdateTypewriterProgress()
void CheckAdjacentOpening(localSquare, targetSquare, dx, dy, restrictive)
void RequestCharacterData(data, localUsername, targetUsername, requestingPlayer)
void DoorHasWindow(door)
void GetDirectionVector(isoDirection)
void CheckWallPassage(door, window, wall, restrictive)
void IsPlayerFacingTarget(player, targetX, targetY, fieldOfViewAngle)
void CalculatePlayerTargetAngle(player, targetX, targetY)
void CalculateCharacterScore(localPlayer, targetPlayer, mouseX, mouseY)
void CheckSquareObstructions(square, stepX, stepY, isTargetSquare, restrictive)
void CalculateLineOfSight(localPlayer, targetPlayer, restrictive, adjacentRange)
void CalculateTypewriterSpeed(localPlayer, targetPlayer)
void OnReceiveCharacterData(responseData)
void HasLineOfSight(localPlayer, targetPlayer)
void GetDescriptionLines(desc)
Contains all of the User Interfaces for FrameworkZ.
void local word()