From 56d77fe845ec3fd29d606a233b7afce34c549d61 Mon Sep 17 00:00:00 2001 From: Toontown Super <108632596+ToontownSuperForeverMVP@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:08:53 -0400 Subject: [PATCH] core: codebase fixes, Astron compatibility, and widescreen offset updates --- etc/Configrc.prc | 48 +- etc/toon.dc | 6 +- otp/avatar/LocalAvatar.py | 170 +-- otp/chat/ChatManager.py | 19 +- otp/distributed/OTPClientRepository.py | 231 +++- otp/login/AstronLoginManager.py | 15 +- otp/login/AstronLoginManagerUD.py | 28 +- otp/settings/Settings.py | 68 +- toontown/ai/ToontownAIRepository.py | 23 +- toontown/battle/BattleCalculatorAI.py | 44 +- toontown/battle/DistributedBattleBase.py | 8 +- toontown/battle/DistributedBattleBaseAI.py | 2 +- toontown/battle/FireCogPanel.py | 4 +- .../building/DistributedAnimBuildingAI.py | 2 +- toontown/building/DistributedAnimDoorAI.py | 2 +- .../building/DistributedAnimatedPropAI.py | 7 +- toontown/building/DistributedBuildingAI.py | 38 +- toontown/building/DistributedDoorAI.py | 18 +- .../building/DistributedKnockKnockDoorAI.py | 5 +- .../building/DistributedToonHallInteriorAI.py | 4 +- .../building/DistributedToonInteriorAI.py | 26 +- .../building/DistributedTutorialInteriorAI.py | 27 +- toontown/chat/ToontownChatManager.py | 55 +- toontown/coghq/CogHQExterior.py | 3 + toontown/coghq/CogHQLobby.py | 4 +- toontown/coghq/DistributedCashbotBossCrane.py | 67 +- .../coghq/DistributedCashbotBossCraneAI.py | 8 +- toontown/coghq/DistributedCashbotBossSafe.py | 5 +- .../coghq/DistributedCashbotBossSafeAI.py | 21 +- toontown/coghq/DistributedCogHQDoorAI.py | 2 +- toontown/coghq/DistributedSellbotHQDoorAI.py | 2 +- toontown/coghq/FactoryExterior.py | 4 + .../distributed/ToontownClientRepository.py | 344 ++++- toontown/estate/Estate.py | 5 +- toontown/launcher/QuickStartLauncher.py | 21 +- toontown/login/AvatarChoice.py | 49 +- toontown/login/AvatarChooser.py | 124 +- toontown/minigame/Purchase.py | 12 +- toontown/parties/Party.py | 5 +- toontown/quest/QuestManagerAI.py | 84 +- toontown/quest/QuestPoster.py | 2 +- toontown/quest/QuestRewardCounter.py | 6 +- toontown/quest/Quests.py | 222 +++ toontown/safezone/DistributedSZTreasure.py | 6 + toontown/safezone/GZPlayground.py | 3 + toontown/safezone/Playground.py | 76 +- toontown/safezone/SafeZoneLoader.py | 11 +- toontown/safezone/TTPlayground.py | 31 + toontown/safezone/TTSafeZoneLoader.py | 3 +- toontown/shtiker/DisplaySettingsDialog.py | 40 +- toontown/shtiker/OptionsPage.py | 1216 +++++++++++++++-- toontown/shtiker/QuestPage.py | 2 +- toontown/shtiker/ShtikerBook.py | 14 +- toontown/suit/DistributedFactorySuitAI.py | 2 +- toontown/suit/DistributedLawbotBossSuitAI.py | 2 +- toontown/suit/DistributedSuitAI.py | 99 +- toontown/suit/DistributedSuitBase.py | 2 +- toontown/suit/DistributedSuitBaseAI.py | 2 +- toontown/suit/DistributedTutorialSuitAI.py | 2 +- toontown/toon/CamRunner.py | 59 + toontown/toon/DistributedNPCBlockerAI.py | 2 +- toontown/toon/DistributedNPCClerkAI.py | 2 +- toontown/toon/DistributedNPCFishermanAI.py | 2 +- .../toon/DistributedNPCFlippyInToonHallAI.py | 2 +- toontown/toon/DistributedNPCKartClerkAI.py | 2 +- toontown/toon/DistributedNPCPartyPersonAI.py | 2 +- toontown/toon/DistributedNPCPetclerkAI.py | 2 +- toontown/toon/DistributedNPCScientistAI.py | 2 +- .../toon/DistributedNPCSpecialQuestGiverAI.py | 2 +- toontown/toon/DistributedNPCTailorAI.py | 2 +- toontown/toon/DistributedNPCToonAI.py | 2 +- toontown/toon/DistributedNPCToonBaseAI.py | 6 +- toontown/toon/DistributedToonAI.py | 119 +- toontown/toon/InventoryBase.py | 6 + toontown/toon/InventoryNew.py | 47 +- toontown/toon/LocalToon.py | 39 +- toontown/toon/NPCForceAcknowledge.py | 7 + toontown/toon/OrbitalCamera.py | 704 ++++++++++ toontown/toon/ParamObj.py | 239 ++++ toontown/toonbase/AssetCache.py | 1 - toontown/toonbase/DisplayOptions.py | 8 +- toontown/toonbase/TTLocalizerEnglish.py | 95 ++ .../toonbase/TTLocalizerEnglishProperty.py | 3 + toontown/toonbase/ToonBase.py | 95 +- toontown/toonbase/ToontownAccess.py | 6 + toontown/toonbase/ToontownAccessAI.py | 8 +- toontown/toonbase/ToontownGlobals.py | 79 ++ toontown/toonbase/ToontownStart.py | 131 +- toontown/town/Street.py | 234 +++- toontown/town/TownBattle.py | 20 +- toontown/town/TownBattleChooseAvatarPanel.py | 6 +- toontown/town/TownLoader.py | 69 +- toontown/tutorial/TutorialManager.py | 2 +- toontown/tutorial/TutorialManagerAI.py | 37 +- 94 files changed, 4698 insertions(+), 695 deletions(-) create mode 100644 toontown/toon/CamRunner.py create mode 100644 toontown/toon/OrbitalCamera.py create mode 100644 toontown/toon/ParamObj.py diff --git a/etc/Configrc.prc b/etc/Configrc.prc index a663654..f1e6ca5 100644 --- a/etc/Configrc.prc +++ b/etc/Configrc.prc @@ -89,12 +89,28 @@ allow-incomplete-render #t gl-compile-and-execute #t gl-use-display-lists #t sync-video #f -yield-timeslice #f +# Let the async loader yield to the render thread between I/O chunks (pairs with support-threads). +yield-timeslice #t +# Polished startup / zone-load overlay (gradient, progress, optional live asset preview). +want-modern-launcher-ui #t +want-loading-asset-preview #t +# After login: hide all zone bulk-load chrome (no compact bar, no legacy council screen). +want-zero-load-ui #t +# During quiet-zone fade, warm ModelPool entries in worker threads before sync loads hit disk. +want-async-zone-prefetch #t auto-flip #t gl-finish #f basic-shaders-only #f +# Animation smoothing +# This enables frame blending (interpolation) between animation frames so +# 24fps-authored animations still look smooth at high render framerates, +# without changing their real-time speed. +interpolate-frames #t + # Performance Optimizations +# Re-enable Panda threading so bulk zone/hood loads don't stall the main frame. +# Use a conservative threading model to avoid instability on Windows. support-threads #t pstats-gpu-timing 0 gl-check-errors 0 @@ -118,6 +134,26 @@ threading-model /Draw direct-wtext 0 on-screen-debug-font ImpressBT.ttf +# Event loop (shard entry / async loader can enqueue many C++ events in one frame) +# Default 5000 + drain-on-flood drops the rest and can break interest/loading. +# IMPORTANT: Each named C++ event is forwarded to Python (messenger.send). A very +# high cap (e.g. 2M) makes a *single* doEvents() call run for many seconds per frame +# (heartbeat mid-frame, multi-second stalls). A moderate cap spreads work across +# frames with short passes; keep drain-off so the backlog is not discarded. +eventmanager-max-events-per-frame 50000 +eventmanager-drain-on-flood #f +# Skip Python messenger for C++ events that have no listeners (TaskManager-*, adjust-pg*, etc.) +eventmanager-fast-cpp-only #t +# Sample every Nth C++ event name; on flood, log approximate top names. +# Use stride=1 so small persistent floods (~10-100 events) still show names. +eventmanager-flood-approx-stride 1 +# DNA loaders: geom.prepareScene walks the whole zone and can enqueue massive C++ task/event bursts. +# Disabled by default to avoid multi-second hitches; set #t if you need upfront GPU scene prep. +dna-want-prepare-scene #f + +# Streets: load the entire street (all visgroups) on entry. +street-load-whole 1 + # Misc Settings inactivity-timeout 180 # If require-window is true, it means that we should raise an exception if the window fails to open correctly. @@ -138,3 +174,13 @@ server-data-folder data # TEMPORARY skip-friend-quest true skip-phone-quest true + +# Local Astron / QuickLauncher: if MD + client agent (7198) are already open, UberDOG/AI may be skipped +# (local-servers-skip-python-if-ca-open, default on) to avoid duplicate sessions. After a crash, CA can +# still listen while UberDOG is dead — then login hits 10053 or error 100. Fix: kill stray ppython UD/AI +# or restart Astron; optionally set local-servers-force-python-spawn #t only after closing duplicates. +# local-servers-force-python-spawn #f +# local-servers-skip-python-if-ca-open #t + +# Optional local overrides (dev/debug). Create this file if needed. +load-prc-file etc/Configrc_dev.prc diff --git a/etc/toon.dc b/etc/toon.dc index 5c4ed03..eb3bbd7 100755 --- a/etc/toon.dc +++ b/etc/toon.dc @@ -22,6 +22,7 @@ from toontown.classicchars import DistributedDonaldDock/AI from toontown.classicchars import DistributedPluto/AI from toontown.classicchars import DistributedWesternPluto/AI from toontown.safezone import DistributedTrolley/AI +from toontown.safezone import DistributedTTCCraneSandbox/AI from toontown.safezone import DistributedPartyGate/AI from toontown.suit import DistributedSuitPlanner/AI from toontown.suit import DistributedSuitBase/AI @@ -530,7 +531,7 @@ dclass DistributedToon : DistributedPlayer { setQuests(uint32[] = []) required broadcast ownrecv db; setQuestHistory(uint16[] = []) required ownrecv db; setRewardHistory(uint8 = 0, uint16[] = []) required ownrecv db; - setQuestCarryLimit(uint8 = 1) required ownrecv db; + setQuestCarryLimit(uint8 = 4) required ownrecv db; requestDeleteQuest(uint32[]) ownsend airecv; setCheesyEffect(int16 = 0, uint32 = 0, uint32 = 0) required broadcast ownrecv db; setGhostMode(uint8) broadcast ownrecv ram; @@ -713,6 +714,9 @@ dclass DistributedTrolley : DistributedObject { setMinigameZone(uint32, uint16); }; +dclass DistributedTTCCraneSandbox : DistributedObject { +}; + dclass DistributedSuitPlanner : DistributedObject { setZoneId(uint32) required broadcast ram; suitListQuery() airecv clsend; diff --git a/otp/avatar/LocalAvatar.py b/otp/avatar/LocalAvatar.py index 86deddc..ba9153d 100644 --- a/otp/avatar/LocalAvatar.py +++ b/otp/avatar/LocalAvatar.py @@ -46,11 +46,17 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.controlManager = ControlManager.ControlManager(True, passMessagesThrough) self.initializeCollisions() self.initializeSmartCamera() + from toontown.toon.OrbitalCamera import OrbitalCamera + self.orbitalCamera = OrbitalCamera(self) self.cameraPositions = [] self.animMultiplier = 1.0 self.runTimeout = 2.5 self.customMessages = [] self.chatMgr = chatMgr + # Chat hooks can be started from multiple entry points (eg. quiet-zone + # completion, hood transitions, teleport flows). Make start/stop + # idempotent so we don't stack duplicate accepts and flood the event loop. + self._chatStarted = False base.talkAssistant = talkAssistant self.commonChatFlags = 0 self.garbleChat = 1 @@ -74,7 +80,7 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.sleepCallback = None self.accept('wakeup', self.wakeUp) self.jumpLandAnimFixTask = None - self.fov = OTPGlobals.DefaultCameraFov + self.fov = getattr(base, 'baseFov', OTPGlobals.DefaultCameraFov) self.accept('avatarMoving', self.clearPageUpDown) self.nametag2dNormalContents = Nametag.CSpeech self.showNametag2d() @@ -336,11 +342,13 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis def attachCamera(self): camera.reparentTo(self) - base.enableMouse() - base.setMouseOnNode(self.node()) + base.disableMouse() self.ignoreMouse = not self.wantMouse self.setWalkSpeedNormal() + def getGeom(self): + return getattr(self, '_LocalAvatar__geom', render) + def detachCamera(self): base.disableMouse() @@ -397,8 +405,6 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis return not self.sleepFlag and self.hp > 0 def enableSmartCameraViews(self): - self.accept('tab', self.nextCameraPos, [1]) - self.accept('shift-tab', self.nextCameraPos, [0]) self.accept('page_up', self.pageUp) self.accept('page_down', self.pageDown) @@ -415,6 +421,14 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.avatarControlsEnabled = 1 self.setupAnimationEvents() self.controlManager.enable() + # Walk.start enables the orbital camera before controlManager.enable(). + # setWASDTurn(False) may have no effect until enable() runs; re-apply + # here so A/D stay strafe (slide) for orbit cam, not tank turn. + if getattr(self, 'orbitalCamera', None) and self.orbitalCamera.isActive(): + try: + self.controlManager.setWASDTurn(False) + except Exception: + pass def disableAvatarControls(self): if not self.avatarControlsEnabled: @@ -424,11 +438,21 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.controlManager.disable() self.clearPageUpDown() + def _getWalkSpeedMult(self): + try: + pct = float(base.settings.getSetting('walk-speed-mult', 100)) + except (TypeError, ValueError): + pct = 100.0 + pct = max(75.0, min(125.0, pct)) + return pct / 100.0 + def setWalkSpeedNormal(self): - self.controlManager.setSpeeds(OTPGlobals.ToonForwardSpeed, OTPGlobals.ToonJumpForce, OTPGlobals.ToonReverseSpeed, OTPGlobals.ToonRotateSpeed) + m = self._getWalkSpeedMult() + self.controlManager.setSpeeds(OTPGlobals.ToonForwardSpeed * m, OTPGlobals.ToonJumpForce, OTPGlobals.ToonReverseSpeed * m, OTPGlobals.ToonRotateSpeed * m) def setWalkSpeedSlow(self): - self.controlManager.setSpeeds(OTPGlobals.ToonForwardSlowSpeed, OTPGlobals.ToonJumpSlowForce, OTPGlobals.ToonReverseSlowSpeed, OTPGlobals.ToonRotateSlowSpeed) + m = self._getWalkSpeedMult() + self.controlManager.setSpeeds(OTPGlobals.ToonForwardSlowSpeed * m, OTPGlobals.ToonJumpSlowForce, OTPGlobals.ToonReverseSlowSpeed * m, OTPGlobals.ToonRotateSlowSpeed * m) def pageUp(self): if not self.avatarControlsEnabled: @@ -696,17 +720,11 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.__disableSmartCam = 0 self.initializeSmartCameraCollisions() self._smartCamEnabled = False - - # Orbital camera controls - self.orbitalCameraEnabled = True - self.cameraOrbitH = 0.0 - self.cameraOrbitP = 0.0 - self.cameraDistance = 20.0 - self.lastMouseX = 0 - self.lastMouseY = 0 - self.isDraggingCamera = False def shutdownSmartCamera(self): + if getattr(self, 'orbitalCamera', None): + self.orbitalCamera.destroy() + self.orbitalCamera = None self.deleteSmartCameraCollisions() def setOnLevelGround(self, flag): @@ -723,106 +741,21 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis LocalAvatar.notify.warning('redundant call to startUpdateSmartCamera') return self._smartCamEnabled = True - self.__floorDetected = 0 - self.__cameraHasBeenMoved = 0 - self.recalcCameraSphere() self.initCameraPositions() - self.setCameraPositionByIndex(self.cameraIndex) - self.posCamera(0, 0.0) - self.__instantaneousCamPos = camera.getPos() - if push: - self.cTrav.addCollider(self.ccSphereNodePath, self.camPusher) - self.ccTravOnFloor.addCollider(self.ccRay2NodePath, self.camFloorCollisionBroadcaster) - self.__disableSmartCam = 0 - else: - self.__disableSmartCam = 1 - self.__lastPosWrtRender = camera.getPos(render) - self.__lastHprWrtRender = camera.getHpr(render) - taskName = self.taskName('updateSmartCamera') - taskMgr.remove(taskName) - taskMgr.add(self.updateSmartCamera, taskName, priority=47) + try: + self.setCameraPositionByIndex(self.cameraIndex) + except Exception: + pass + self.orbitalCamera.start() self.enableSmartCameraViews() - - # Setup orbital camera controls - if self.orbitalCameraEnabled: - self.accept('mouse2', self.__startCameraDrag) - self.accept('mouse2-up', self.__stopCameraDrag) - self.accept('wheel_up', self.__cameraZoomIn) - self.accept('wheel_down', self.__cameraZoomOut) - - def __startCameraDrag(self): - """Start dragging camera with right mouse button""" - if base.mouseWatcherNode.hasMouse(): - self.isDraggingCamera = True - self.lastMouseX = base.mouseWatcherNode.getMouseX() - self.lastMouseY = base.mouseWatcherNode.getMouseY() - taskMgr.add(self.__updateCameraDrag, 'updateCameraDrag') - - def __stopCameraDrag(self): - """Stop dragging camera""" - self.isDraggingCamera = False - taskMgr.remove('updateCameraDrag') - - def __updateCameraDrag(self, task): - """Update camera position while dragging""" - if not self.isDraggingCamera or not base.mouseWatcherNode.hasMouse(): - return task.cont - - mouseX = base.mouseWatcherNode.getMouseX() - mouseY = base.mouseWatcherNode.getMouseY() - - deltaX = mouseX - self.lastMouseX - deltaY = mouseY - self.lastMouseY - - # Update camera orbit angles - self.cameraOrbitH -= deltaX * 100.0 # Horizontal rotation - self.cameraOrbitP += deltaY * 50.0 # Vertical rotation - - # Clamp vertical rotation - self.cameraOrbitP = max(-80.0, min(80.0, self.cameraOrbitP)) - - # Apply camera rotation - self.__updateOrbitalCamera() - - self.lastMouseX = mouseX - self.lastMouseY = mouseY - - return task.cont - - def __cameraZoomIn(self): - """Zoom camera closer""" - self.cameraDistance = max(5.0, self.cameraDistance - 3.0) - self.__updateOrbitalCamera() - - def __cameraZoomOut(self): - """Zoom camera farther""" - self.cameraDistance = min(50.0, self.cameraDistance + 3.0) - self.__updateOrbitalCamera() - - def __updateOrbitalCamera(self): - """Update camera position based on orbital parameters""" - from panda3d.core import Point3 - import math - - # Calculate camera position in spherical coordinates - h = math.radians(self.cameraOrbitH) - p = math.radians(self.cameraOrbitP) - - x = self.cameraDistance * math.cos(p) * math.sin(h) - y = -self.cameraDistance * math.cos(p) * math.cos(h) - z = self.cameraDistance * math.sin(p) + self.getHeight() - - self.setIdealCameraPos(Point3(x, y, z)) def stopUpdateSmartCamera(self): if not self._smartCamEnabled: LocalAvatar.notify.warning('redundant call to stopUpdateSmartCamera') return self.disableSmartCameraViews() - self.cTrav.removeCollider(self.ccSphereNodePath) - self.ccTravOnFloor.removeCollider(self.ccRay2NodePath) - if not base.localAvatar.isEmpty(): - self.putCameraFloorRayOnAvatar() + if self.orbitalCamera: + self.orbitalCamera.stop() taskName = self.taskName('updateSmartCamera') taskMgr.remove(taskName) self._smartCamEnabled = False @@ -1181,7 +1114,20 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.lastNeedH = needH else: self.lastNeedH = None - action = self.setSpeed(speed, rotSpeed) + # GravityWalker reports forward/back motion in `speed` and strafing in + # `slideSpeed`. Toon animation selection (walk/run) is keyed off the + # first argument (forwardSpeed), so we must treat strafing as movement + # too or the toon will "neutral" while sliding. + # + # Preserve reverse intent for backpedal animations when applicable. + if abs(speed) > 0.001: + animSpeed = speed + elif abs(slideSpeed) > 0.001: + animSpeed = abs(slideSpeed) * (-1.0 if inputState.isSet('reverse') else 1.0) + else: + animSpeed = 0.0 + + action = self.setSpeed(animSpeed, rotSpeed) if action != self.lastAction: self.lastAction = action if self.emoteTrack: @@ -1216,6 +1162,9 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.stopSound() def startChat(self): + if getattr(self, '_chatStarted', False): + return + self._chatStarted = True self.chatMgr.start() self.accept(OTPGlobals.WhisperIncomingEvent, self.handlePlayerFriendWhisper) self.accept(OTPGlobals.ThinkPosHotkey, self.thinkPos) @@ -1224,6 +1173,9 @@ class LocalAvatar(DistributedAvatar.DistributedAvatar, DistributedSmoothNode.Dis self.accept(OTPGlobals.PlaceMarkerHotkey, self.__placeMarker) def stopChat(self): + if not getattr(self, '_chatStarted', False): + return + self._chatStarted = False self.chatMgr.stop() self.ignore(OTPGlobals.WhisperIncomingEvent) self.ignore(OTPGlobals.ThinkPosHotkey) diff --git a/otp/chat/ChatManager.py b/otp/chat/ChatManager.py index 86dfa80..c79a63a 100644 --- a/otp/chat/ChatManager.py +++ b/otp/chat/ChatManager.py @@ -42,11 +42,10 @@ class ChatManager(DirectObject.DirectObject): def __init__(self, cr, localAvatar): self.cr = cr self.localAvatar = localAvatar - # Check settings for T-key only chat option - # When T-key only is enabled, we still want to accept the T key specifically - # backgroundFocus controls whether any key activates chat (False = T-key only) - tKeyOnlyEnabled = base.settings.getSetting('tKeyOnlyChat', False) - self.wantBackgroundFocus = not tKeyOnlyEnabled + # Chat activation policy: + # - Fully disable "press any key to chat" + # - Only the 't' key should bring up the typed chat interface + self.wantBackgroundFocus = False self.__scObscured = 0 self.__normalObscured = 0 self.openChatWarning = None @@ -217,8 +216,10 @@ class ChatManager(DirectObject.DirectObject): def enterMainMenu(self): self.checkObscurred() if self.localAvatar.canChat() or self.cr.wantMagicWords: - if self.wantBackgroundFocus: - self.chatInputNormal.chatEntry['backgroundFocus'] = 1 + # Ensure chat does NOT steal focus from gameplay on random keypresses. + # Typed chat is explicitly opened via the 't' hotkey. + self.chatInputNormal.chatEntry['backgroundFocus'] = 0 + self.acceptOnce('t', self.fsm.request, ['normalChat']) self.acceptOnce('enterNormalChat', self.fsm.request, ['normalChat']) def checkObscurred(self): @@ -230,9 +231,9 @@ class ChatManager(DirectObject.DirectObject): def exitMainMenu(self): self.scButton.hide() self.normalButton.hide() + self.ignore('t') self.ignore('enterNormalChat') - if self.wantBackgroundFocus: - self.chatInputNormal.chatEntry['backgroundFocus'] = 0 + self.chatInputNormal.chatEntry['backgroundFocus'] = 0 def whisperTo(self, avatarName, avatarId, playerId = None): self.fsm.request('whisper', [avatarName, avatarId, playerId]) diff --git a/otp/distributed/OTPClientRepository.py b/otp/distributed/OTPClientRepository.py index f169ed1..5c186c9 100644 --- a/otp/distributed/OTPClientRepository.py +++ b/otp/distributed/OTPClientRepository.py @@ -3,6 +3,9 @@ import time import random import gc import os +import faulthandler +import threading +import traceback from panda3d.core import * from direct.gui.DirectGui import * from otp.distributed.OtpDoGlobals import * @@ -50,11 +53,46 @@ class OTPClientRepository(ClientRepositoryBase): 'Rejected'), start=0) def __init__(self, serverVersion, launcher = None, playGame = None): - ClientRepositoryBase.__init__(self) + # Force non-threaded net: threaded message handling can touch Panda + # objects from a non-main thread on some Win32 builds, causing C++ + # assertions (eg. threadWin32Impl) and apparent "freezes" with no + # Python traceback. + ClientRepositoryBase.__init__(self, threadedNet=False) self.handler = None self.launcher = launcher base.launcher = launcher self.__currentAvId = 0 + + # Hang diagnostics: enable Python-level stack dumping even if the + # engine wedges (eg. C++ assertion loops). This writes to stderr, + # which is captured by the client log. + try: + # By default, faulthandler writes to stderr, which is not always + # captured by the game's notify log. When shard-debug is enabled, + # mirror dumps into a dedicated .log file under ./logs/. + self._faultLog = None + if ConfigVariableBool('shard-debug', 0).value: + try: + os.makedirs('logs', exist_ok=True) + fn = time.strftime('logs/freeze-dump-%y%m%d_%H%M%S.log') + self._faultLog = open(fn, 'w', buffering=1, encoding='utf-8') + faulthandler.enable(file=self._faultLog, all_threads=True) + self.notify.info(f'[ShardDbg] faulthandler enabled -> {fn}') + except Exception: + faulthandler.enable() + else: + faulthandler.enable() + except Exception: + pass + + self._shardDbg = { + 'enabled': ConfigVariableBool('shard-debug', 0).value, + 'lastStep': None, + 'lastT': 0.0, + } + if self._shardDbg['enabled']: + taskMgr.doMethodLater(5.0, self._shardDebugWatchdog, 'shardDebugWatchdog') + self._startPythonHangWatchdog() self.productName = ConfigVariableString('product-name', 'DisneyOnline-US').value self.createAvatarClass = None self.systemMessageSfx = None @@ -501,6 +539,8 @@ class OTPClientRepository(ClientRepositoryBase): self.accept(self.loginDoneEvent, self.__handleLoginDone) self.loginScreen.load() self.loginScreen.enter() + if getattr(self, 'loginInterface', None) and hasattr(self.loginInterface, 'pollPendingLoginResponse'): + self.loginInterface.pollPendingLoginResponse() @report(types=['args', 'deltaStamp'], dConfigParam='teleport') def __handleLoginDone(self, doneStatus): @@ -1422,10 +1462,16 @@ class OTPClientRepository(ClientRepositoryBase): shardId = self.distributedDistrict.doId else: self.distributedDistrict = district + self._dbgShardProgress('enterWaitOnEnterResponses.begin', + shardId=shardId, hoodId=hoodId, zoneId=zoneId, avId=avId) self.notify.info('Entering shard %s' % shardId) localAvatar.setLocation(shardId, zoneId) base.localAvatar.defaultShard = shardId + self._dbgShardProgress('enterWaitOnEnterResponses.afterSetLocation', + shardId=shardId, zoneId=zoneId) self.waitForDatabaseTimeout(requestName='WaitOnEnterResponses') + self._dbgShardProgress('enterWaitOnEnterResponses.afterWaitForDatabaseTimeout', + requestName='WaitOnEnterResponses') self.handleSetShardComplete() return @@ -1449,15 +1495,51 @@ class OTPClientRepository(ClientRepositoryBase): hoodId = self.handlerArgs['hoodId'] zoneId = self.handlerArgs['zoneId'] avId = self.handlerArgs['avId'] - self.uberZoneInterest = self.addInterest(base.localAvatar.defaultShard, OTPGlobals.UberZone, 'uberZone', 'uberZoneInterestComplete') + self._dbgShardProgress('handleSetShardComplete.begin', + hoodId=hoodId, zoneId=zoneId, avId=avId, + shardId=getattr(base.localAvatar, 'defaultShard', None)) + + # If we wedge hard (taskMgr stops), our Task-based watchdog won't run. + # faulthandler.dump_traceback_later uses a watchdog thread and can still + # dump Python stacks into the log. + try: + if getattr(self, '_shardDbg', None) and self._shardDbg.get('enabled'): + faulthandler.cancel_dump_traceback_later() + faulthandler.dump_traceback_later(20.0, repeat=True) + self.notify.info('[ShardDbg] armed faulthandler.dump_traceback_later(20s, repeat=True) (writes to freeze-dump log if available)') + except Exception: + pass + + # Astron/OTP "management"/UberZone objects live under the game's + # globals parent (eg. OTP_DO_ID_TOONTOWN) in zone OTP_ZONE_ID_MANAGEMENT (2). + # Opening an interest on the district channel will never complete on this stack. + self.uberZoneInterest = self.addInterest(self.GameGlobalsId, OTP_ZONE_ID_MANAGEMENT, 'uberZone', 'uberZoneInterestComplete') + self._dbgShardProgress('handleSetShardComplete.afterAddInterest', + interest=getattr(self, 'uberZoneInterest', None)) self.acceptOnce('uberZoneInterestComplete', self.uberZoneInterestComplete) + self._dbgShardProgress('handleSetShardComplete.afterAcceptOnce', + event='uberZoneInterestComplete') + # Safety net: if the server never sends DONE_INTEREST for the UberZone + # interest, the client can get stuck at "Entering shard" forever. + # This keeps the client moving and provides a clear warning in logs. + timeout = ConfigVariableDouble('uberzone-interest-timeout', 15.0).value + taskMgr.doMethodLater(timeout, self._uberZoneInterestTimeout, 'uberZoneInterestTimeout') self.waitForDatabaseTimeout(20, requestName='waitingForUberZone') + self._dbgShardProgress('handleSetShardComplete.afterWaitForDatabaseTimeout', + requestName='waitingForUberZone') @report(types=['args', 'deltaStamp'], dConfigParam='teleport') def uberZoneInterestComplete(self): + self._dbgShardProgress('uberZoneInterestComplete.begin') + taskMgr.remove('uberZoneInterestTimeout') + try: + faulthandler.cancel_dump_traceback_later() + except Exception: + pass self.__gotTimeSync = 0 self.cleanupWaitingForDatabase() if self.timeManager == None: + self._dbgShardProgress('uberZoneInterestComplete.noTimeManager') self.notify.info('TimeManager is not present.') DistributedSmoothNode.globalActivateSmoothing(0, 0) self.gotTimeSync() @@ -1468,6 +1550,7 @@ class OTPClientRepository(ClientRepositoryBase): pyc = HashVal() if not __dev__: self.hashFiles(pyc) + self._dbgShardProgress('uberZoneInterestComplete.beforeTimeManagerSync') self.timeManager.d_setSignature(self.userSignature, h.asBin(), pyc.asBin()) self.timeManager.sendCpuInfo() if self.timeManager.synchronize('startup'): @@ -1478,6 +1561,131 @@ class OTPClientRepository(ClientRepositoryBase): self.gotTimeSync() return + def _uberZoneInterestTimeout(self, task): + # If the interest complete event never arrives, force the callback + # so we can at least proceed and capture the next failure point. + try: + self.notify.warning('[ShardDbg] UberZone interest timed out; forcing uberZoneInterestComplete') + except Exception: + pass + try: + messenger.send('uberZoneInterestComplete') + except Exception: + # Last resort: call directly. + try: + self.uberZoneInterestComplete() + except Exception: + pass + return Task.done + + # Extra instrumentation: log the actual interest wire parameters when + # shard-debug is enabled. + def addInterest(self, parentId, zoneIdList, description, event = None): + try: + if getattr(self, '_shardDbg', None) and self._shardDbg.get('enabled'): + self.notify.info(f'[ShardDbg] addInterest(parentId={parentId}, zoneIdList={zoneIdList}, desc={description!r}, event={event!r})') + except Exception: + pass + return super().addInterest(parentId, zoneIdList, description, event=event) + + def _dbgShardProgress(self, step, **fields): + if not getattr(self, '_shardDbg', None) or not self._shardDbg.get('enabled'): + return + t = 0.0 + try: + t = globalClock.getRealTime() + except Exception: + pass + self._shardDbg['lastStep'] = step + self._shardDbg['lastT'] = t + try: + details = ', '.join([f'{k}={v!r}' for k, v in fields.items()]) if fields else '' + self.notify.info(f'[ShardDbg] step={step} t={t:.3f}' + (f' {details}' if details else '')) + except Exception: + pass + + def _shardDebugWatchdog(self, task): + # If we're "stuck" at the same step for too long, dump all Python thread stacks. + try: + lastStep = self._shardDbg.get('lastStep') + lastT = float(self._shardDbg.get('lastT') or 0.0) + now = globalClock.getRealTime() + stuckFor = now - lastT + # Only trigger if we've started shard flow and haven't progressed. + if lastStep and stuckFor >= 15.0: + self.notify.warning(f'[ShardDbg] stuck step={lastStep} for {stuckFor:.1f}s; dumping stacks') + try: + faulthandler.dump_traceback(all_threads=True) + except Exception: + pass + # Bump timer so we don't spam every tick. + self._shardDbg['lastT'] = now + except Exception: + pass + return Task.again + + def _startPythonHangWatchdog(self): + # A pure-Python watchdog thread. Unlike Panda task-based timeouts, this + # can still run if the main thread is blocked waiting on a Python lock, + # because CPython releases the GIL while waiting. + if getattr(self, '_pyHangWatchdogStarted', False): + return + self._pyHangWatchdogStarted = True + + try: + os.makedirs('logs', exist_ok=True) + fn = time.strftime('logs/py-hang-watchdog-%y%m%d_%H%M%S.log') + except Exception: + fn = None + + def _thread_main(): + out = None + try: + if fn: + out = open(fn, 'w', buffering=1, encoding='utf-8') + out.write('Python hang watchdog started\n') + while True: + time.sleep(10.0) + try: + frames = sys._current_frames() + except Exception: + continue + step = None + try: + step = self._shardDbg.get('lastStep') + except Exception: + pass + header = f'\n=== watchdog tick t={time.time():.3f} lastStep={step!r} ===\n' + try: + if out: + out.write(header) + else: + self.notify.warning(header.strip()) + except Exception: + pass + for tid, frame in frames.items(): + try: + stack = ''.join(traceback.format_stack(frame)) + if out: + out.write(f'\n--- thread {tid} ---\n') + out.write(stack) + except Exception: + pass + finally: + try: + if out: + out.close() + except Exception: + pass + + t = threading.Thread(target=_thread_main, name='PyHangWatchdog', daemon=True) + t.start() + try: + if fn: + self.notify.info(f'[ShardDbg] python watchdog -> {fn}') + except Exception: + pass + @report(types=['args', 'deltaStamp'], dConfigParam='teleport') def exitWaitOnEnterResponses(self): self.ignore('uberZoneInterestComplete') @@ -1561,8 +1769,14 @@ class OTPClientRepository(ClientRepositoryBase): self.accept(self.gameDoneEvent, self.handleGameDone) base.transitions.noFade() self.playGame.load() + cr = base.cr + if getattr(cr, '_localAvatarPlayGameBulkLoadActive', True): + try: + loader.endBulkLoad('localAvatarPlayGame') + except: + pass try: - loader.endBulkLoad('localAvatarPlayGame') + cr._localAvatarPlayGameBulkLoadActive = False except: pass @@ -1605,6 +1819,7 @@ class OTPClientRepository(ClientRepositoryBase): @report(types=['args', 'deltaStamp'], dConfigParam='teleport') def gotTimeSync(self): self.notify.info('gotTimeSync') + self._dbgShardProgress('gotTimeSync') self.ignore('gotTimeSync') self.__gotTimeSync = 1 self.moveOnFromUberZone() @@ -1614,6 +1829,7 @@ class OTPClientRepository(ClientRepositoryBase): if not self.__gotTimeSync: self.notify.info('Waiting for time sync.') return + self._dbgShardProgress('moveOnFromUberZone') hoodId = self.handlerArgs['hoodId'] zoneId = self.handlerArgs['zoneId'] avId = self.handlerArgs['avId'] @@ -2016,6 +2232,15 @@ class OTPClientRepository(ClientRepositoryBase): currentGameStateName = 'None' def gotInterestDoneMessage(self, di): + # Extra breadcrumb: confirms whether DONE_INTEREST is arriving at all. + try: + if getattr(self, '_shardDbg', None) and self._shardDbg.get('enabled'): + di2 = DatagramIterator(di.getDatagram(), di.getCurrentIndex()) + ctx = di2.getUint32() + handle = di2.getUint16() + self.notify.info(f'[ShardDbg] recv CLIENT_DONE_INTEREST_RESP ctx={ctx} handle={handle}') + except Exception: + pass if self.deferredGenerates: dg = Datagram(di.getDatagram()) di = DatagramIterator(dg, di.getCurrentIndex()) diff --git a/otp/login/AstronLoginManager.py b/otp/login/AstronLoginManager.py index 626de28..ad35ad9 100644 --- a/otp/login/AstronLoginManager.py +++ b/otp/login/AstronLoginManager.py @@ -8,6 +8,7 @@ class AstronLoginManager(DistributedObjectGlobal): def __init__(self, cr): DistributedObjectGlobal.__init__(self, cr) self._callback = None + self._pendingLoginResponse = None def handleRequestLogin(self): playToken = self.cr.playToken or 'dev' @@ -17,7 +18,19 @@ class AstronLoginManager(DistributedObjectGlobal): self.sendUpdate('requestLogin', [playToken]) def loginResponse(self, responseBlob): - self.cr.loginScreen.handleLoginToontownResponse(responseBlob) + # The launcher can tear down / transition UI while the login response is in flight. + # Buffer until the LoginScreen exists again (or is created). + if getattr(self.cr, 'loginScreen', None): + self.cr.loginScreen.handleLoginToontownResponse(responseBlob) + else: + self._pendingLoginResponse = responseBlob + self.notify.debug('loginResponse received before loginScreen was ready; buffering.') + + def pollPendingLoginResponse(self): + if self._pendingLoginResponse is not None and getattr(self.cr, 'loginScreen', None): + blob = self._pendingLoginResponse + self._pendingLoginResponse = None + self.cr.loginScreen.handleLoginToontownResponse(blob) def sendRequestAvatarList(self): self.sendUpdate('requestAvatarList') diff --git a/otp/login/AstronLoginManagerUD.py b/otp/login/AstronLoginManagerUD.py index 3f905c9..b289619 100644 --- a/otp/login/AstronLoginManagerUD.py +++ b/otp/login/AstronLoginManagerUD.py @@ -226,13 +226,16 @@ class LoginOperation(GameOperation): self.__handleSetAccount() def __handleSetAccount(self): - # if somebody's already logged into this account, disconnect them - datagram = PyDatagram() - datagram.addServerHeader(self.loginManager.GetAccountConnectionChannel(self.accountId), - self.loginManager.air.ourChannel, CLIENTAGENT_EJECT) - datagram.addUint16(100) - datagram.addString('This account has been logged in elsewhere.') - self.loginManager.air.send(datagram) + # If somebody's already logged into this account, disconnect them. + # IMPORTANT: Do NOT eject the entire account connection channel; doing so + # can race with channel open and eject the newly logging-in client. + oldSender = self.loginManager.accountId2sender.get(self.accountId) + if oldSender and oldSender != self.sender: + datagram = PyDatagram() + datagram.addServerHeader(oldSender, self.loginManager.air.ourChannel, CLIENTAGENT_EJECT) + datagram.addUint16(100) + datagram.addString('This account has been logged in elsewhere.') + self.loginManager.air.send(datagram) # add connection to account channel datagram = PyDatagram() @@ -249,6 +252,9 @@ class LoginOperation(GameOperation): # set client state to established, thus un-sandboxing the sender self.loginManager.air.setClientState(self.sender, 2) + # Record the current sender as the active connection for this account. + self.loginManager.accountId2sender[self.accountId] = self.sender + # Update the last login timestamp. self.loginManager.air.dbInterface.updateObject(self.loginManager.air.dbId, self.accountId, self.loginManager.air.dclassesByName['AstronAccountUD'], @@ -845,6 +851,8 @@ class AstronLoginManagerUD(DistributedObjectGlobalUD): self.accountDb = None self.sender2loginOperation = {} self.account2operation = {} + # Tracks the active connection channel (sender) per accountId. + self.accountId2sender = {} def announceGenerate(self): DistributedObjectGlobalUD.announceGenerate(self) @@ -876,8 +884,14 @@ class AstronLoginManagerUD(DistributedObjectGlobalUD): if isAccount: # Closes the account's connection. datagram.addServerHeader(self.GetAccountConnectionChannel(connectionId), self.air.ourChannel, CLIENTAGENT_EJECT) + # Also clear any tracked active sender for this account. + self.accountId2sender.pop(connectionId, None) else: datagram.addServerHeader(connectionId, self.air.ourChannel, CLIENTAGENT_EJECT) + # If this sender was tracked as active for any account, clear it. + for accId, sender in list(self.accountId2sender.items()): + if sender == connectionId: + del self.accountId2sender[accId] datagram.addUint32(122) if forOperations and not reason: datagram.addString('An operation is already running: %s' % operation.__class__.__name__) diff --git a/otp/settings/Settings.py b/otp/settings/Settings.py index 9918750..99f9749 100644 --- a/otp/settings/Settings.py +++ b/otp/settings/Settings.py @@ -1,33 +1,91 @@ import json import os +_WRITE_TASK = 'persist-useropt-json' + class Settings: def __init__(self): self.__settings = {} - self.__filename = 'useropt.json' + self.__filename = self._resolveFilename() + + def _resolveFilename(self): + """Always store next to the game executable / main dir, not the process cwd.""" + try: + from panda3d.core import ExecutionEnvironment, Filename + md = ExecutionEnvironment.getEnvironmentVariable('MAIN_DIR') + if md: + return Filename(md, 'useropt.json').to_os_specific() + except Exception: + pass + return os.path.abspath(os.path.join(os.getcwd(), 'useropt.json')) def doSavedSettingsExist(self): return os.path.exists(self.__filename) def readSettings(self): if not self.doSavedSettingsExist(): - self.__settings = {} + legacy = os.path.abspath(os.path.join(os.getcwd(), 'useropt.json')) + if legacy != os.path.abspath(self.__filename) and os.path.isfile(legacy): + try: + with open(legacy, 'r') as f: + self.__settings = json.load(f) + self.writeSettings() + except Exception: + self.__settings = {} + else: + self.__settings = {} return try: with open(self.__filename, 'r') as f: self.__settings = json.load(f) - except: + except Exception: self.__settings = {} def writeSettings(self): - with open(self.__filename, 'w+') as f: - json.dump(self.__settings, f, indent=4) + try: + from direct.task.TaskManagerGlobal import taskMgr + taskMgr.remove(_WRITE_TASK) + except Exception: + pass + dn = os.path.dirname(self.__filename) + if dn and not os.path.isdir(dn): + try: + os.makedirs(dn, exist_ok=True) + except Exception: + pass + tmp = self.__filename + '.tmp' + try: + with open(tmp, 'w') as f: + json.dump(self.__settings, f, indent=4) + if os.path.exists(self.__filename): + os.replace(tmp, self.__filename) + else: + os.rename(tmp, self.__filename) + except Exception: + try: + if os.path.exists(tmp): + os.remove(tmp) + except Exception: + pass + + def _schedulePersist(self): + try: + from direct.task.TaskManagerGlobal import taskMgr + taskMgr.remove(_WRITE_TASK) + taskMgr.doMethodLater(0.15, self._persistTask, _WRITE_TASK) + except Exception: + self.writeSettings() + + def _persistTask(self, task): + self.writeSettings() + return task.done def updateSetting(self, setting, value): self.__settings[setting] = value + self._schedulePersist() def getSetting(self, setting, default=None): return self.__settings.get(setting, default) diff --git a/toontown/ai/ToontownAIRepository.py b/toontown/ai/ToontownAIRepository.py index 19e549c..105e8d7 100644 --- a/toontown/ai/ToontownAIRepository.py +++ b/toontown/ai/ToontownAIRepository.py @@ -236,11 +236,13 @@ class ToontownAIRepository(ToontownInternalRepository): # Bossbot HQ doesn't use DNA, so we skip over that. if zoneId != ToontownGlobals.BossbotHQ: self.dnaStoreMap[zoneId] = DNAStorage() - self.dnaDataMap[zoneId] = loadDNAFileAI(self.dnaStoreMap[zoneId], self.genDNAFileName(zoneId)) + hoodDna = self.lookupDNAFileName(self.genDNAFileName(zoneId)) or self.genDNAFileName(zoneId) + self.dnaDataMap[zoneId] = loadDNAFileAI(self.dnaStoreMap[zoneId], hoodDna) if zoneId in ToontownGlobals.HoodHierarchy: for streetId in ToontownGlobals.HoodHierarchy[zoneId]: self.dnaStoreMap[streetId] = DNAStorage() - self.dnaDataMap[streetId] = loadDNAFileAI(self.dnaStoreMap[streetId], self.genDNAFileName(streetId)) + streetDna = self.lookupDNAFileName(self.genDNAFileName(streetId)) or self.genDNAFileName(streetId) + self.dnaDataMap[streetId] = loadDNAFileAI(self.dnaStoreMap[streetId], streetDna) hood = hoodConstructor(self, zoneId) hood.startup() @@ -357,6 +359,14 @@ class ToontownAIRepository(ToontownInternalRepository): return 'phase_%s/dna/%s_%s.dna' % (phase, hood, canonicalZoneId) def lookupDNAFileName(self, dnaFileName): + # In this project, DNA lives under resources/phase_*/dna, and callers + # typically pass "phase_X/dna/foo.dna". Resolve that relative to resources/. + rel = dnaFileName.replace('\\', '/') + candidate = os.path.join('resources', rel) + if os.path.exists(candidate): + return candidate + + # Fallback: older callsites might pass just the basename. searchPath = DSearchPath() searchPath.appendDirectory(Filename('resources/phase_3.5/dna')) searchPath.appendDirectory(Filename('resources/phase_4/dna')) @@ -369,13 +379,12 @@ class ToontownAIRepository(ToontownInternalRepository): searchPath.appendDirectory(Filename('resources/phase_11/dna')) searchPath.appendDirectory(Filename('resources/phase_12/dna')) searchPath.appendDirectory(Filename('resources/phase_13/dna')) - filename = Filename(dnaFileName) + filename = Filename(os.path.basename(rel)) found = vfs.resolveFilename(filename, searchPath) if not found: - self.notify.warning('lookupDNAFileName - %s not found on:' % dnaFileName) - print(searchPath) - else: - return filename.getFullpath() + self.notify.warning('lookupDNAFileName - %s not found.' % dnaFileName) + return None + return filename.getFullpath() def loadDNAFileAI(self, dnaStore, dnaFileName): return loadDNAFileAI(dnaStore, dnaFileName) diff --git a/toontown/battle/BattleCalculatorAI.py b/toontown/battle/BattleCalculatorAI.py index e64d715..f446de6 100644 --- a/toontown/battle/BattleCalculatorAI.py +++ b/toontown/battle/BattleCalculatorAI.py @@ -427,16 +427,15 @@ class BattleCalculatorAI: if self.notify.getDebug(): self.notify.debug('Suit lured, but no trap exists') if self.SUITS_UNLURED_IMMEDIATELY: - if not self.__suitIsLured(targetId, prevRound=1): - if not self.__combatantDead(targetId, toon=toonTarget): - validTargetAvail = 1 - rounds = self.NumRoundsLured[atkLevel] - wakeupChance = 100 - atkAcc * 2 - npcLurer = attack[TOON_TRACK_COL] == NPCSOS - currLureId = self.__addLuredSuitInfo(targetId, -1, rounds, wakeupChance, toonId, atkLevel, lureId=currLureId, npc=npcLurer) - if self.notify.getDebug(): - self.notify.debug('Suit lured for ' + str(rounds) + ' rounds max with ' + str(wakeupChance) + '% chance to wake up each round') - targetLured = 1 + if not self.__combatantDead(targetId, toon=toonTarget): + validTargetAvail = 1 + rounds = self.NumRoundsLured[atkLevel] + wakeupChance = 100 - atkAcc * 2 + npcLurer = attack[TOON_TRACK_COL] == NPCSOS + currLureId = self.__addLuredSuitInfo(targetId, -1, rounds, wakeupChance, toonId, atkLevel, lureId=currLureId, npc=npcLurer) + if self.notify.getDebug(): + self.notify.debug('Suit lured for ' + str(rounds) + ' rounds max with ' + str(wakeupChance) + '% chance to wake up each round') + targetLured = 1 else: attackTrack = TRAP if targetId in self.traps: @@ -457,16 +456,15 @@ class BattleCalculatorAI: validTargetAvail = 1 targetLured = 1 if not self.SUITS_UNLURED_IMMEDIATELY: - if not self.__suitIsLured(targetId, prevRound=1): - if not self.__combatantDead(targetId, toon=toonTarget): - validTargetAvail = 1 - rounds = self.NumRoundsLured[atkLevel] - wakeupChance = 100 - atkAcc * 2 - npcLurer = attack[TOON_TRACK_COL] == NPCSOS - currLureId = self.__addLuredSuitInfo(targetId, -1, rounds, wakeupChance, toonId, atkLevel, lureId=currLureId, npc=npcLurer) - if self.notify.getDebug(): - self.notify.debug('Suit lured for ' + str(rounds) + ' rounds max with ' + str(wakeupChance) + '% chance to wake up each round') - targetLured = 1 + if not self.__combatantDead(targetId, toon=toonTarget): + validTargetAvail = 1 + rounds = self.NumRoundsLured[atkLevel] + wakeupChance = 100 - atkAcc * 2 + npcLurer = attack[TOON_TRACK_COL] == NPCSOS + currLureId = self.__addLuredSuitInfo(targetId, -1, rounds, wakeupChance, toonId, atkLevel, lureId=currLureId, npc=npcLurer) + if self.notify.getDebug(): + self.notify.debug('Suit lured for ' + str(rounds) + ' rounds max with ' + str(wakeupChance) + '% chance to wake up each round') + targetLured = 1 if attackLevel != -1: self.__addLuredSuitsDelayed(toonId, targetId) if targetLured and (targetId not in self.successfulLures or targetId in self.successfulLures and self.successfulLures[targetId][1] < atkLevel): @@ -1498,6 +1496,12 @@ class BattleCalculatorAI: lureInfo[2] = wakeChance lureInfo[3][lurer] = [ lureLvl, availLureId, credit] + else: + lureInfo[1] += maxRounds + if wakeChance < lureInfo[2]: + lureInfo[2] = wakeChance + lureInfo[3][lurer][0] = lureLvl + lureInfo[3][lurer][2] = credit else: lurerInfo = {lurer: [lureLvl, availLureId, credit]} self.currentlyLuredSuits[suitId] = [ diff --git a/toontown/battle/DistributedBattleBase.py b/toontown/battle/DistributedBattleBase.py index b2daaaa..b945e94 100644 --- a/toontown/battle/DistributedBattleBase.py +++ b/toontown/battle/DistributedBattleBase.py @@ -1185,7 +1185,7 @@ class DistributedBattleBase(DistributedNode, BattleBase): target = -1 if len(self.luredSuits) > 0: - if track == TRAP or track == LURE and not levelAffectsGroup(LURE, level): + if track == TRAP: if target != -1: suit = self.findSuit(targetId) if self.luredSuits.count(suit) != 0: @@ -1193,12 +1193,6 @@ class DistributedBattleBase(DistributedNode, BattleBase): track = -1 level = -1 targetId = -1 - elif track == LURE: - if levelAffectsGroup(LURE, level) and len(self.activeSuits) == len(self.luredSuits): - self.notify.warning('All suits are lured!') - track = -1 - level = -1 - targetId = -1 if track == TRAP: if target != -1: diff --git a/toontown/battle/DistributedBattleBaseAI.py b/toontown/battle/DistributedBattleBaseAI.py index eb88aa7..fcd6a24 100644 --- a/toontown/battle/DistributedBattleBaseAI.py +++ b/toontown/battle/DistributedBattleBaseAI.py @@ -1079,7 +1079,7 @@ class DistributedBattleBaseAI(DistributedObjectAI.DistributedObjectAI, BattleBas self.toonAttacks[toonId] = getToonAttack(toonId) return if track == HEAL: - if self.runningToons.count(av) == 1 or attackAffectsGroup(track, level) and len(self.activeToons) < 2: + if self.runningToons.count(av) == 1: self.toonAttacks[toonId] = getToonAttack(toonId, track=UN_ATTACK) validResponse = 0 else: diff --git a/toontown/battle/FireCogPanel.py b/toontown/battle/FireCogPanel.py index 078ef5c..4d9e086 100644 --- a/toontown/battle/FireCogPanel.py +++ b/toontown/battle/FireCogPanel.py @@ -54,7 +54,7 @@ class FireCogPanel(StateData.StateData): invalidTargets = [] if not self.toon: if len(luredIndices) > 0: - if track == BattleBase.TRAP or track == BattleBase.LURE: + if track == BattleBase.TRAP: invalidTargets += luredIndices if len(trappedIndices) > 0: if track == BattleBase.TRAP: @@ -76,7 +76,7 @@ class FireCogPanel(StateData.StateData): def adjustCogs(self, numAvatars, luredIndices, trappedIndices, track): invalidTargets = [] if len(luredIndices) > 0: - if track == BattleBase.TRAP or track == BattleBase.LURE: + if track == BattleBase.TRAP: invalidTargets += luredIndices if len(trappedIndices) > 0: if track == BattleBase.TRAP: diff --git a/toontown/building/DistributedAnimBuildingAI.py b/toontown/building/DistributedAnimBuildingAI.py index 08b973d..e2ad708 100644 --- a/toontown/building/DistributedAnimBuildingAI.py +++ b/toontown/building/DistributedAnimBuildingAI.py @@ -5,7 +5,7 @@ from toontown.building import DoorTypes class DistributedAnimBuildingAI(DistributedBuildingAI.DistributedBuildingAI): - def __init__(self, air, blockNumber, zoneId, trophyMgr): + def __init__(self, air, blockNumber=None, zoneId=None, trophyMgr=None): DistributedBuildingAI.DistributedBuildingAI.__init__(self, air, blockNumber, zoneId, trophyMgr) def createExteriorDoor(self): diff --git a/toontown/building/DistributedAnimDoorAI.py b/toontown/building/DistributedAnimDoorAI.py index ca3d644..a9af430 100644 --- a/toontown/building/DistributedAnimDoorAI.py +++ b/toontown/building/DistributedAnimDoorAI.py @@ -4,5 +4,5 @@ from toontown.building import DistributedDoorAI class DistributedAnimDoorAI(DistributedDoorAI.DistributedDoorAI): notify = DirectNotifyGlobal.directNotify.newCategory('DistributedAnimDoorAI') - def __init__(self, air, blockNumber, doorType, doorIndex=0, lockValue=0, swing=3): + def __init__(self, air, blockNumber=0, doorType=0, doorIndex=0, lockValue=0, swing=3): DistributedDoorAI.DistributedDoorAI.__init__(self, air, blockNumber, doorType, doorIndex, lockValue, swing) diff --git a/toontown/building/DistributedAnimatedPropAI.py b/toontown/building/DistributedAnimatedPropAI.py index 973ab42..5e71c23 100644 --- a/toontown/building/DistributedAnimatedPropAI.py +++ b/toontown/building/DistributedAnimatedPropAI.py @@ -7,7 +7,7 @@ from direct.fsm import State class DistributedAnimatedPropAI(DistributedObjectAI.DistributedObjectAI): - def __init__(self, air, propId): + def __init__(self, air): DistributedObjectAI.DistributedObjectAI.__init__(self, air) self.fsm = ClassicFSM.ClassicFSM('DistributedAnimatedPropAI', [ State.State('off', self.enterOff, self.exitOff, [ @@ -17,9 +17,12 @@ class DistributedAnimatedPropAI(DistributedObjectAI.DistributedObjectAI): State.State('playing', self.enterPlaying, self.exitPlaying, [ 'attract'])], 'off', 'off') self.fsm.enterInitialState() - self.propId = propId + self.propId = 0 self.avatarId = 0 + def setPropId(self, propId): + self.propId = propId + def delete(self): self.fsm.requestFinalState() del self.fsm diff --git a/toontown/building/DistributedBuildingAI.py b/toontown/building/DistributedBuildingAI.py index 2f675c6..0f93057 100644 --- a/toontown/building/DistributedBuildingAI.py +++ b/toontown/building/DistributedBuildingAI.py @@ -18,12 +18,14 @@ from toontown.cogdominium.DistributedCogdoElevatorExtAI import DistributedCogdoE class DistributedBuildingAI(DistributedObjectAI.DistributedObjectAI): FieldOfficeNumFloors = 1 - def __init__(self, air, blockNumber, zoneId, trophyMgr): + def __init__(self, air, blockNumber=None, zoneId=None, trophyMgr=None): DistributedObjectAI.DistributedObjectAI.__init__(self, air) - self.block = blockNumber - self.zoneId = zoneId - self.canonicalZoneId = ZoneUtil.getCanonicalZoneId(zoneId) - self.trophyMgr = trophyMgr + # NOTE: Astron constructs AI-side DistributedObjects with only (air). + # Block/zone are provided via the required DC method setBlock(). + self.block = 0 if blockNumber is None else blockNumber + self.zoneId = 0 if zoneId is None else zoneId + self.canonicalZoneId = ZoneUtil.getCanonicalZoneId(self.zoneId) if self.zoneId else 0 + self.trophyMgr = trophyMgr if trophyMgr is not None else getattr(air, 'trophyMgr', None) self.victorResponses = None self.fsm = ClassicFSM.ClassicFSM('DistributedBuildingAI', [ State.State('off', self.enterOff, self.exitOff, [ @@ -63,6 +65,14 @@ class DistributedBuildingAI(DistributedObjectAI.DistributedObjectAI): self.fSkipElevatorOpening = False return + # DC required field initializer (see etc/toon.dc: DistributedBuilding.setBlock) + def setBlock(self, blockNumber, zoneId): + self.block = blockNumber + self.zoneId = zoneId + self.canonicalZoneId = ZoneUtil.getCanonicalZoneId(zoneId) + if self.trophyMgr is None: + self.trophyMgr = getattr(self.air, 'trophyMgr', None) + def cleanup(self): if self.isDeleted(): return @@ -196,7 +206,16 @@ class DistributedBuildingAI(DistributedObjectAI.DistributedObjectAI): def getExteriorAndInteriorZoneId(self): blockNumber = self.block - dnaStore = self.air.dnaStoreMap[self.canonicalZoneId] + # DNA block maps are stored per "branch" (street/safezone) zone. + # Using the hood DNA store will cause block->zone lookups to fail/spam. + canonicalBranchId = ZoneUtil.getCanonicalBranchZone(self.zoneId) + dnaStore = self.air.dnaStoreMap.get(canonicalBranchId) + if dnaStore is None: + # Fallback to hood if branch store is unavailable (should be rare). + canonicalHoodId = ZoneUtil.getCanonicalHoodId(self.zoneId) + dnaStore = self.air.dnaStoreMap.get(canonicalHoodId) + if dnaStore is None: + raise KeyError('No DNA store for zoneId=%s (branch=%s)' % (self.zoneId, canonicalBranchId)) zoneId = dnaStore.getZoneFromBlockNumber(blockNumber) zoneId = ZoneUtil.getTrueZoneId(zoneId, self.zoneId) interiorZoneId = zoneId - zoneId % 100 + 500 + blockNumber @@ -291,13 +310,13 @@ class DistributedBuildingAI(DistributedObjectAI.DistributedObjectAI): return None def updateSavedBy(self, savedBy): - if self.savedBy: + if self.savedBy and self.trophyMgr is not None: for avId, name, dna in self.savedBy: if not ZoneUtil.isWelcomeValley(self.zoneId): self.trophyMgr.removeTrophy(avId, self.numFloors) self.savedBy = savedBy - if self.savedBy: + if self.savedBy and self.trophyMgr is not None: for avId, name, dna in self.savedBy: if not ZoneUtil.isWelcomeValley(self.zoneId): self.trophyMgr.addTrophy(avId, name, self.numFloors) @@ -428,7 +447,8 @@ class DistributedBuildingAI(DistributedObjectAI.DistributedObjectAI): self.door = door self.insideDoor = insideDoor self.becameSuitTime = 0 - self.knockKnock = DistributedKnockKnockDoorAI.DistributedKnockKnockDoorAI(self.air, self.block) + self.knockKnock = DistributedKnockKnockDoorAI.DistributedKnockKnockDoorAI(self.air) + self.knockKnock.setPropId(self.block) self.knockKnock.generateWithRequired(exteriorZoneId) self.air.writeServerEvent('building-toon', self.doId, '%s|%s' % (self.zoneId, self.block)) diff --git a/toontown/building/DistributedDoorAI.py b/toontown/building/DistributedDoorAI.py index 2c7ff58..7b63b64 100644 --- a/toontown/building/DistributedDoorAI.py +++ b/toontown/building/DistributedDoorAI.py @@ -9,7 +9,7 @@ from toontown.toonbase import ToontownAccessAI class DistributedDoorAI(DistributedObjectAI.DistributedObjectAI): - def __init__(self, air, blockNumber, doorType, doorIndex=0, lockValue=0, swing=3): + def __init__(self, air, blockNumber=0, doorType=0, doorIndex=0, lockValue=0, swing=3): DistributedObjectAI.DistributedObjectAI.__init__(self, air) self.block = blockNumber self.swing = swing @@ -47,6 +47,22 @@ class DistributedDoorAI(DistributedObjectAI.DistributedObjectAI): self.avatarsWhoAreExiting = {} return + def setZoneIdAndBlock(self, zoneId, block): + self.zoneId = zoneId + self.block = block + + def setDoorType(self, doorType): + self.doorType = doorType + + def setDoorIndex(self, doorIndex): + self.doorIndex = doorIndex + + def setState(self, state, timestamp=0): + self.fsm.request(state) + + def setExitDoorState(self, state, timestamp=0): + self.exitDoorFSM.request(state) + def delete(self): taskMgr.remove(self.uniqueName('door_opening-timer')) taskMgr.remove(self.uniqueName('door_open-timer')) diff --git a/toontown/building/DistributedKnockKnockDoorAI.py b/toontown/building/DistributedKnockKnockDoorAI.py index 9fdcc19..1de2fe4 100644 --- a/toontown/building/DistributedKnockKnockDoorAI.py +++ b/toontown/building/DistributedKnockKnockDoorAI.py @@ -8,10 +8,9 @@ from direct.fsm import State class DistributedKnockKnockDoorAI(DistributedAnimatedPropAI.DistributedAnimatedPropAI): - def __init__(self, air, propId): - DistributedAnimatedPropAI.DistributedAnimatedPropAI.__init__(self, air, propId) + def __init__(self, air): + DistributedAnimatedPropAI.DistributedAnimatedPropAI.__init__(self, air) self.fsm.setName('DistributedKnockKnockDoor') - self.propId = propId self.doLaterTask = None return diff --git a/toontown/building/DistributedToonHallInteriorAI.py b/toontown/building/DistributedToonHallInteriorAI.py index 23a9252..265e769 100644 --- a/toontown/building/DistributedToonHallInteriorAI.py +++ b/toontown/building/DistributedToonHallInteriorAI.py @@ -3,8 +3,8 @@ from toontown.toonbase import ToontownGlobals class DistributedToonHallInteriorAI(DistributedToonInteriorAI): - def __init__(self, block, air, zoneId, building): - DistributedToonInteriorAI.__init__(self, block, air, zoneId, building) + def __init__(self, *args): + DistributedToonInteriorAI.__init__(self, *args) self.accept('ToonEnteredZone', self.logToonEntered) self.accept('ToonLeftZone', self.logToonLeft) diff --git a/toontown/building/DistributedToonInteriorAI.py b/toontown/building/DistributedToonInteriorAI.py index 89b39b3..5b91aac 100644 --- a/toontown/building/DistributedToonInteriorAI.py +++ b/toontown/building/DistributedToonInteriorAI.py @@ -11,12 +11,24 @@ from toontown.toon.ToonDNA import ToonDNA class DistributedToonInteriorAI(DistributedObjectAI.DistributedObjectAI): - def __init__(self, block, air, zoneId, building): + def __init__(self, *args): + """ + Astron constructs AI-side distributed objects with only (air). + Game code also constructs this object manually as (block, air, zoneId, building). + Support both. + """ + if len(args) == 1: + air = args[0] + block = 0 + zoneId = 0 + building = None + else: + block, air, zoneId, building = args[:4] DistributedObjectAI.DistributedObjectAI.__init__(self, air) self.block = block self.zoneId = zoneId self.building = building - self.npcs = NPCToons.createNpcsInZone(air, zoneId) + self.npcs = NPCToons.createNpcsInZone(air, zoneId) if zoneId else [] self.fsm = ClassicFSM.ClassicFSM('DistributedToonInteriorAI', [ State.State('toon', self.enterToon, self.exitToon, [ 'beingTakenOver']), @@ -24,6 +36,13 @@ class DistributedToonInteriorAI(DistributedObjectAI.DistributedObjectAI): State.State('off', self.enterOff, self.exitOff, [])], 'toon', 'off') self.fsm.enterInitialState() + # DC required field initializer (see etc/toon.dc: DistributedToonInterior.setZoneIdAndBlock) + def setZoneIdAndBlock(self, zoneId, block): + self.zoneId = zoneId + self.block = block + if not getattr(self, 'npcs', None): + self.npcs = NPCToons.createNpcsInZone(self.air, zoneId) if zoneId else [] + def delete(self): self.ignoreAll() for npc in self.npcs: @@ -52,7 +71,8 @@ class DistributedToonInteriorAI(DistributedObjectAI.DistributedObjectAI): self.fsm.getCurrentState().getName(), globalClockDelta.getRealNetworkTime()] return r - def setState(self, state): + def setState(self, state, timestamp=0): + # Timestamp is supplied by the DC field signature; AI doesn't need it. self.sendUpdate('setState', [state, globalClockDelta.getRealNetworkTime()]) self.fsm.request(state) diff --git a/toontown/building/DistributedTutorialInteriorAI.py b/toontown/building/DistributedTutorialInteriorAI.py index 49274a6..f77aaf2 100644 --- a/toontown/building/DistributedTutorialInteriorAI.py +++ b/toontown/building/DistributedTutorialInteriorAI.py @@ -11,19 +11,32 @@ class DistributedTutorialInteriorAI(DistributedObjectAI.DistributedObjectAI): if __debug__: notify = DirectNotifyGlobal.directNotify.newCategory('DistributedTutorialInteriorAI') - def __init__(self, block, air, zoneId, building, npcId): - """blockNumber: the landmark building number (from the name)""" - #self.air=air + def __init__(self, *args): + """Supports both Astron (air) construction and manual (block, air, zoneId, building, npcId) construction.""" + if len(args) == 1: + air = args[0] + block = 0 + zoneId = 0 + building = None + npcId = 0 + else: + block, air, zoneId, building, npcId = args[:5] DistributedObjectAI.DistributedObjectAI.__init__(self, air) - self.block=block - self.zoneId=zoneId - self.building=building + self.block = block + self.zoneId = zoneId + self.building = building self.tutorialNpcId = npcId # Make any npcs that may be in this interior zone # If there are none specified, this will just be an empty list - self.npcs = NPCToons.createNpcsInZone(air, zoneId) + self.npcs = NPCToons.createNpcsInZone(air, zoneId) if zoneId else [] + + def setZoneIdAndBlock(self, zoneId, block): + self.zoneId = zoneId + self.block = block + if not getattr(self, 'npcs', None): + self.npcs = NPCToons.createNpcsInZone(self.air, zoneId) if zoneId else [] def delete(self): self.ignoreAll() diff --git a/toontown/chat/ToontownChatManager.py b/toontown/chat/ToontownChatManager.py index b027930..b6fadad 100644 --- a/toontown/chat/ToontownChatManager.py +++ b/toontown/chat/ToontownChatManager.py @@ -32,36 +32,53 @@ class ToontownChatManager(ChatManager.ChatManager): def __init__(self, cr, localAvatar): gui = loader.loadModel('phase_3.5/models/gui/chat_input_gui') - # Widescreen support - maintain distance from left edge - baseNormalButtonXPos = -1.2647 - normalButtonXPos = base.getWidescreenXOffset(baseNormalButtonXPos, 'left') if hasattr(base, 'getWidescreenXOffset') else baseNormalButtonXPos - self.normalButton = DirectButton(image=(gui.find('**/ChtBx_ChtBtn_UP'), gui.find('**/ChtBx_ChtBtn_DN'), gui.find('**/ChtBx_ChtBtn_RLVR')), pos=(normalButtonXPos, 0, 0.928), scale=1.179, relief=None, image_color=Vec4(1, 1, 1, 1), text=('', OTPLocalizer.ChatManagerChat, OTPLocalizer.ChatManagerChat), text_align=TextNode.ALeft, text_scale=TTLocalizer.TCMnormalButton, text_fg=Vec4(1, 1, 1, 1), text_shadow=Vec4(0, 0, 0, 1), text_pos=(-0.0525, -0.09), textMayChange=0, sortOrder=DGG.FOREGROUND_SORT_INDEX, command=self.__normalButtonPressed) + + def _safe_find(model, pattern): + try: + if model is None or model.isEmpty(): + return None + except Exception: + return None + try: + n = model.find(pattern) + if n is None or n.isEmpty(): + return None + return n + except Exception: + return None + + cht_up = _safe_find(gui, '**/ChtBx_ChtBtn_UP') + cht_dn = _safe_find(gui, '**/ChtBx_ChtBtn_DN') + cht_rl = _safe_find(gui, '**/ChtBx_ChtBtn_RLVR') + if cht_up is None or cht_dn is None or cht_rl is None: + self.notify.warning('chat_input_gui missing expected ChtBx_ChtBtn_* nodes; using fallback button visuals') + self.normalButton = DirectButton(image=(cht_up, cht_dn, cht_rl), pos=(-1.2647, 0, 0.928), scale=1.179, relief=None, image_color=Vec4(1, 1, 1, 1), text=('', OTPLocalizer.ChatManagerChat, OTPLocalizer.ChatManagerChat), text_align=TextNode.ALeft, text_scale=TTLocalizer.TCMnormalButton, text_fg=Vec4(1, 1, 1, 1), text_shadow=Vec4(0, 0, 0, 1), text_pos=(-0.0525, -0.09), textMayChange=0, sortOrder=DGG.FOREGROUND_SORT_INDEX, command=self.__normalButtonPressed) self.normalButton.hide() self.openScSfx = loader.loadSfx('phase_3.5/audio/sfx/GUI_quicktalker.ogg') self.openScSfx.setVolume(0.6) - # Speedchat button - maintain distance from left edge - scButtonPos = TTLocalizer.TCMscButtonPos - if isinstance(scButtonPos, tuple) and len(scButtonPos) == 3: - baseSCXPos = scButtonPos[0] - adjustedSCXPos = base.getWidescreenXOffset(baseSCXPos, 'left') if hasattr(base, 'getWidescreenXOffset') else baseSCXPos - scButtonPos = (adjustedSCXPos, scButtonPos[1], scButtonPos[2]) - self.scButton = DirectButton(image=(gui.find('**/ChtBx_ChtBtn_UP'), gui.find('**/ChtBx_ChtBtn_DN'), gui.find('**/ChtBx_ChtBtn_RLVR')), pos=scButtonPos, scale=1.179, relief=None, image_color=Vec4(0.75, 1, 0.6, 1), text=('', OTPLocalizer.GlobalSpeedChatName, OTPLocalizer.GlobalSpeedChatName), text_scale=TTLocalizer.TCMscButton, text_fg=Vec4(1, 1, 1, 1), text_shadow=Vec4(0, 0, 0, 1), text_pos=(0, -0.09), textMayChange=0, sortOrder=DGG.FOREGROUND_SORT_INDEX, command=self.__scButtonPressed, clickSound=self.openScSfx) + self.scButton = DirectButton(image=(cht_up, cht_dn, cht_rl), pos=TTLocalizer.TCMscButtonPos, scale=1.179, relief=None, image_color=Vec4(0.75, 1, 0.6, 1), text=('', OTPLocalizer.GlobalSpeedChatName, OTPLocalizer.GlobalSpeedChatName), text_scale=TTLocalizer.TCMscButton, text_fg=Vec4(1, 1, 1, 1), text_shadow=Vec4(0, 0, 0, 1), text_pos=(0, -0.09), textMayChange=0, sortOrder=DGG.FOREGROUND_SORT_INDEX, command=self.__scButtonPressed, clickSound=self.openScSfx) self.scButton.hide() - # Whisper frame - maintain distance from left edge - baseWhisperFrameXPos = -0.4 - whisperFrameXPos = base.getWidescreenXOffset(baseWhisperFrameXPos, 'left') if hasattr(base, 'getWidescreenXOffset') else baseWhisperFrameXPos - self.whisperFrame = DirectFrame(parent=aspect2dp, relief=None, image=DGG.getDefaultDialogGeom(), image_scale=(0.45, 0.45, 0.45), image_color=OTPGlobals.GlobalDialogColor, pos=(whisperFrameXPos, 0, 0.754), text=OTPLocalizer.ChatManagerWhisperTo, text_wordwrap=7.0, text_scale=TTLocalizer.TCMwhisperFrame, text_fg=Vec4(0, 0, 0, 1), text_pos=(0, 0.14), textMayChange=1, sortOrder=DGG.FOREGROUND_SORT_INDEX) + self.whisperFrame = DirectFrame(parent=aspect2dp, relief=None, image=DGG.getDefaultDialogGeom(), image_scale=(0.45, 0.45, 0.45), image_color=OTPGlobals.GlobalDialogColor, pos=(-0.4, 0, 0.754), text=OTPLocalizer.ChatManagerWhisperTo, text_wordwrap=7.0, text_scale=TTLocalizer.TCMwhisperFrame, text_fg=Vec4(0, 0, 0, 1), text_pos=(0, 0.14), textMayChange=1, sortOrder=DGG.FOREGROUND_SORT_INDEX) self.whisperFrame.hide() - self.whisperButton = DirectButton(parent=self.whisperFrame, image=(gui.find('**/ChtBx_ChtBtn_UP'), gui.find('**/ChtBx_ChtBtn_DN'), gui.find('**/ChtBx_ChtBtn_RLVR')), pos=(-0.125, 0, -0.1), scale=1.179, relief=None, image_color=Vec4(1, 1, 1, 1), text=('', + self.whisperButton = DirectButton(parent=self.whisperFrame, image=(cht_up, cht_dn, cht_rl), pos=(-0.125, 0, -0.1), scale=1.179, relief=None, image_color=Vec4(1, 1, 1, 1), text=('', OTPLocalizer.ChatManagerChat, OTPLocalizer.ChatManagerChat, ''), image3_color=Vec4(0.6, 0.6, 0.6, 0.6), text_scale=TTLocalizer.TCMwhisperButton, text_fg=(0, 0, 0, 1), text_pos=(0, -0.09), textMayChange=0, command=self.__whisperButtonPressed) - self.whisperScButton = DirectButton(parent=self.whisperFrame, image=(gui.find('**/ChtBx_ChtBtn_UP'), gui.find('**/ChtBx_ChtBtn_DN'), gui.find('**/ChtBx_ChtBtn_RLVR')), pos=(0.0, 0, -0.1), scale=1.179, relief=None, image_color=Vec4(0.75, 1, 0.6, 1), text=('', + self.whisperScButton = DirectButton(parent=self.whisperFrame, image=(cht_up, cht_dn, cht_rl), pos=(0.0, 0, -0.1), scale=1.179, relief=None, image_color=Vec4(0.75, 1, 0.6, 1), text=('', OTPLocalizer.GlobalSpeedChatName, OTPLocalizer.GlobalSpeedChatName, ''), image3_color=Vec4(0.6, 0.6, 0.6, 0.6), text_scale=TTLocalizer.TCMwhisperScButton, text_fg=(0, 0, 0, 1), text_pos=(0, -0.09), textMayChange=0, command=self.__whisperScButtonPressed) - self.whisperCancelButton = DirectButton(parent=self.whisperFrame, image=(gui.find('**/CloseBtn_UP'), gui.find('**/CloseBtn_DN'), gui.find('**/CloseBtn_Rllvr')), pos=(0.125, 0, -0.1), scale=1.179, relief=None, text=('', OTPLocalizer.ChatManagerCancel, OTPLocalizer.ChatManagerCancel), text_scale=0.05, text_fg=(0, 0, 0, 1), text_pos=(0, -0.09), textMayChange=0, command=self.__whisperCancelPressed) - gui.removeNode() + close_up = _safe_find(gui, '**/CloseBtn_UP') + close_dn = _safe_find(gui, '**/CloseBtn_DN') + close_rl = _safe_find(gui, '**/CloseBtn_Rllvr') + if close_up is None or close_dn is None or close_rl is None: + self.notify.warning('chat_input_gui missing expected CloseBtn_* nodes; using fallback button visuals') + self.whisperCancelButton = DirectButton(parent=self.whisperFrame, image=(close_up, close_dn, close_rl), pos=(0.125, 0, -0.1), scale=1.179, relief=None, text=('', OTPLocalizer.ChatManagerCancel, OTPLocalizer.ChatManagerCancel), text_scale=0.05, text_fg=(0, 0, 0, 1), text_pos=(0, -0.09), textMayChange=0, command=self.__whisperCancelPressed) + try: + if gui is not None and not gui.isEmpty(): + gui.removeNode() + except Exception: + pass ChatManager.ChatManager.__init__(self, cr, localAvatar) self.defaultToWhiteList = base.config.GetBool('white-list-is-default', 1) self.chatInputSpeedChat = TTChatInputSpeedChat(self) diff --git a/toontown/coghq/CogHQExterior.py b/toontown/coghq/CogHQExterior.py index cd1ec7c..e187685 100644 --- a/toontown/coghq/CogHQExterior.py +++ b/toontown/coghq/CogHQExterior.py @@ -3,6 +3,7 @@ from toontown.battle import BattlePlace from direct.fsm import ClassicFSM, State from direct.fsm import State from toontown.toonbase import ToontownGlobals +from toontown.hood import OutdoorLighting from toontown.hood import ZoneUtil from panda3d.core import * from panda3d.otp import * @@ -68,6 +69,7 @@ class CogHQExterior(BattlePlace.BattlePlace): self.fsm.enterInitialState() base.playMusic(self.loader.music, looping=1, volume=0.8) self.loader.geom.reparentTo(render) + OutdoorLighting.begin(self.loader.geom, 'cog') self.nodeList = [self.loader.geom] self._telemLimiter = TLGatherAllAvs('CogHQExterior', RotationLimitToH) self.accept('doorDoneEvent', self.handleDoorDoneEvent) @@ -88,6 +90,7 @@ class CogHQExterior(BattlePlace.BattlePlace): del self.tunnelOriginList if self.loader.geom: self.loader.geom.reparentTo(hidden) + OutdoorLighting.end(self.loader.geom) self.ignoreAll() BattlePlace.BattlePlace.exit(self) diff --git a/toontown/coghq/CogHQLobby.py b/toontown/coghq/CogHQLobby.py index f28af14..146a18f 100644 --- a/toontown/coghq/CogHQLobby.py +++ b/toontown/coghq/CogHQLobby.py @@ -1,7 +1,7 @@ from direct.directnotify import DirectNotifyGlobal from direct.fsm import ClassicFSM, State from direct.fsm import State -from toontown.hood import Place +from toontown.hood import OutdoorLighting, Place from toontown.building import Elevator from toontown.toonbase import ToontownGlobals from panda3d.core import * @@ -48,6 +48,7 @@ class CogHQLobby(Place.Place): self.fsm.enterInitialState() base.playMusic(self.loader.music, looping=1, volume=0.8) self.loader.geom.reparentTo(render) + OutdoorLighting.begin(self.loader.geom, 'cog') self.accept('doorDoneEvent', self.handleDoorDoneEvent) self.accept('DistributedDoor_doorTrigger', self.handleDoorTrigger) NametagGlobals.setMasterArrowsOn(1) @@ -63,6 +64,7 @@ class CogHQLobby(Place.Place): self.loader.music.stop() if self.loader.geom != None: self.loader.geom.reparentTo(hidden) + OutdoorLighting.end(self.loader.geom) Place.Place.exit(self) return diff --git a/toontown/coghq/DistributedCashbotBossCrane.py b/toontown/coghq/DistributedCashbotBossCrane.py index 8f10715..63dd64a 100644 --- a/toontown/coghq/DistributedCashbotBossCrane.py +++ b/toontown/coghq/DistributedCashbotBossCrane.py @@ -80,13 +80,51 @@ class DistributedCashbotBossCrane(DistributedObject.DistributedObject, FSM.FSM): self.magnetSoundInterval = Parallel(SoundInterval(self.magnetOnSfx), Sequence(Wait(0.5), Func(base.playSfx, self.magnetLoopSfx, looping=1))) self.craneMoveSfx = base.loader.loadSfx('phase_9/audio/sfx/CHQ_FACT_elevator_up_down.ogg') self.fadeTrack = None + self._bossRequest = None + self._craneAnnounceCompleted = False + self.bossCogId = None return - def announceGenerate(self): - DistributedObject.DistributedObject.announceGenerate(self) + def _abortBossRequest(self): + req = getattr(self, '_bossRequest', None) + if not req: + return + try: + self.cr.relatedObjectMgr.abortRequest(req) + except Exception: + pass + self._bossRequest = None + + def _bindBoss(self, boss): + self.boss = boss + self._completeCraneAnnounceIfReady() + + def _bossResolved(self, objects): + self._bossRequest = None + boss = objects[0] if objects else None + if boss is None: + self.notify.warning('Crane %s: boss %s missing from repository' % (self.doId, self.bossCogId)) + return + self._bindBoss(boss) + + def _resolveBossReference(self): + if not self.bossCogId: + return + if self.bossCogId in self.cr.doId2do: + self._bindBoss(self.cr.doId2do[self.bossCogId]) + return + self._abortBossRequest() + self._bossRequest = self.cr.relatedObjectMgr.requestObjects([self.bossCogId], allCallback=self._bossResolved) + + def _completeCraneAnnounceIfReady(self): + if self._craneAnnounceCompleted: + return + if self.boss is None or self.index is None: + return + self._craneAnnounceCompleted = True self.name = 'crane-%s' % self.doId self.root.setName(self.name) - self.root.setPosHpr(*ToontownGlobals.CashbotBossCranePosHprs[self.index]) + self.root.setPosHpr(*self._craneArenaPosHpr()) self.rotateLinkName = self.uniqueName('rotateLink') self.snifferEvent = self.uniqueName('sniffer') self.triggerName = self.uniqueName('trigger') @@ -130,15 +168,33 @@ class DistributedCashbotBossCrane(DistributedObject.DistributedObject, FSM.FSM): arm = self.boss.craneArm.copyTo(self.crane) self.boss.cranes[self.index] = self + def _craneArenaPosHpr(self): + if self.boss and getattr(self.boss, 'ttcCraneSandbox', False): + return ToontownGlobals.TTCCraneSandboxCranePosHprs[self.index] + return ToontownGlobals.CashbotBossCranePosHprs[self.index] + + def announceGenerate(self): + DistributedObject.DistributedObject.announceGenerate(self) + self._completeCraneAnnounceIfReady() + def disable(self): + self._abortBossRequest() + if self.boss is not None and self.index is not None: + try: + cranes = getattr(self.boss, 'cranes', None) + if cranes is not None and cranes.get(self.index) == self: + del cranes[self.index] + except Exception: + pass DistributedObject.DistributedObject.disable(self) - del self.boss.cranes[self.index] self.cleanup() def cleanup(self): if self.state != 'Off': self.demand('Off') + self._abortBossRequest() self.boss = None + self._craneAnnounceCompleted = False return def accomodateToon(self, toon): @@ -670,10 +726,11 @@ class DistributedCashbotBossCrane(DistributedObject.DistributedObject, FSM.FSM): def setBossCogId(self, bossCogId): self.bossCogId = bossCogId - self.boss = base.cr.doId2do[bossCogId] + self._resolveBossReference() def setIndex(self, index): self.index = index + self._completeCraneAnnounceIfReady() def setState(self, state, avId): if state == 'C': diff --git a/toontown/coghq/DistributedCashbotBossCraneAI.py b/toontown/coghq/DistributedCashbotBossCraneAI.py index 45ff12d..878f3d7 100644 --- a/toontown/coghq/DistributedCashbotBossCraneAI.py +++ b/toontown/coghq/DistributedCashbotBossCraneAI.py @@ -15,7 +15,10 @@ class DistributedCashbotBossCraneAI(DistributedObjectAI.DistributedObjectAI, FSM cs = CollisionSphere(0, -6, 0, 6) cn.addSolid(cs) self.goonShield = NodePath(cn) - self.goonShield.setPosHpr(*ToontownGlobals.CashbotBossCranePosHprs[self.index]) + if getattr(boss, 'craneSandbox', False): + self.goonShield.setPosHpr(*ToontownGlobals.TTCCraneSandboxCranePosHprs[self.index]) + else: + self.goonShield.setPosHpr(*ToontownGlobals.CashbotBossCranePosHprs[self.index]) self.avId = 0 self.objectId = 0 @@ -30,7 +33,8 @@ class DistributedCashbotBossCraneAI(DistributedObjectAI.DistributedObjectAI, FSM def requestControl(self): avId = self.air.getAvatarIdFromSender() - if avId in self.boss.involvedToons and self.avId == 0: + allowed = getattr(self.boss, 'craneSandbox', False) or avId in self.boss.involvedToons + if allowed and self.avId == 0: craneId = self.__getCraneId(avId) if craneId == 0: self.request('Controlled', avId) diff --git a/toontown/coghq/DistributedCashbotBossSafe.py b/toontown/coghq/DistributedCashbotBossSafe.py index 712b555..e1e0b6e 100644 --- a/toontown/coghq/DistributedCashbotBossSafe.py +++ b/toontown/coghq/DistributedCashbotBossSafe.py @@ -60,7 +60,10 @@ class DistributedCashbotBossSafe(DistributedCashbotBossObject.DistributedCashbot goon.b_destroyGoon() def resetToInitialPosition(self): - posHpr = ToontownGlobals.CashbotBossSafePosHprs[self.index] + if self.boss and getattr(self.boss, 'ttcCraneSandbox', False): + posHpr = ToontownGlobals.TTCCraneSandboxSafePosHprs[self.index] + else: + posHpr = ToontownGlobals.CashbotBossSafePosHprs[self.index] self.setPosHpr(*posHpr) self.physicsObject.setVelocity(0, 0, 0) diff --git a/toontown/coghq/DistributedCashbotBossSafeAI.py b/toontown/coghq/DistributedCashbotBossSafeAI.py index 2b141c0..be6d684 100644 --- a/toontown/coghq/DistributedCashbotBossSafeAI.py +++ b/toontown/coghq/DistributedCashbotBossSafeAI.py @@ -16,7 +16,10 @@ class DistributedCashbotBossSafeAI(DistributedCashbotBossObjectAI.DistributedCas self.attachNewNode(cn) def resetToInitialPosition(self): - posHpr = ToontownGlobals.CashbotBossSafePosHprs[self.index] + if getattr(self.boss, 'craneSandbox', False): + posHpr = ToontownGlobals.TTCCraneSandboxSafePosHprs[self.index] + else: + posHpr = ToontownGlobals.CashbotBossSafePosHprs[self.index] self.setPosHpr(*posHpr) def getIndex(self): @@ -25,6 +28,8 @@ class DistributedCashbotBossSafeAI(DistributedCashbotBossObjectAI.DistributedCas def hitBoss(self, impact): avId = self.air.getAvatarIdFromSender() self.validate(avId, impact <= 1.0, 'invalid hitBoss impact %s' % impact) + if getattr(self.boss, 'craneSandbox', False): + return if avId not in self.boss.involvedToons: return if self.state != 'Dropped' and self.state != 'Grabbed': @@ -65,7 +70,19 @@ class DistributedCashbotBossSafeAI(DistributedCashbotBossObjectAI.DistributedCas def exitInitial(self): if self.index == 0: - self.unstash() + # Defensive: during AI cleanup / delete, this NodePath may already be + # empty or otherwise invalid. NodePath.unstash() can assert in C++. + try: + if hasattr(self, 'isEmpty') and self.isEmpty(): + return + if hasattr(self, 'isSingleton') and self.isSingleton(): + return + except Exception: + return + try: + self.unstash() + except Exception: + return def enterFree(self): DistributedCashbotBossObjectAI.DistributedCashbotBossObjectAI.enterFree(self) diff --git a/toontown/coghq/DistributedCogHQDoorAI.py b/toontown/coghq/DistributedCogHQDoorAI.py index afa71d7..7202e8f 100644 --- a/toontown/coghq/DistributedCogHQDoorAI.py +++ b/toontown/coghq/DistributedCogHQDoorAI.py @@ -13,7 +13,7 @@ from toontown.toonbase import ToontownAccessAI class DistributedCogHQDoorAI(DistributedDoorAI.DistributedDoorAI): notify = DirectNotifyGlobal.directNotify.newCategory('DistributedCogHQDoorAI') - def __init__(self, air, blockNumber, doorType, destinationZone, doorIndex=0, lockValue=FADoorCodes.SB_DISGUISE_INCOMPLETE, swing=3): + def __init__(self, air, blockNumber=0, doorType=0, destinationZone=0, doorIndex=0, lockValue=FADoorCodes.SB_DISGUISE_INCOMPLETE, swing=3): DistributedDoorAI.DistributedDoorAI.__init__(self, air, blockNumber, doorType, doorIndex, lockValue, swing) self.destinationZone = destinationZone diff --git a/toontown/coghq/DistributedSellbotHQDoorAI.py b/toontown/coghq/DistributedSellbotHQDoorAI.py index 991c7c1..162ff11 100644 --- a/toontown/coghq/DistributedSellbotHQDoorAI.py +++ b/toontown/coghq/DistributedSellbotHQDoorAI.py @@ -9,7 +9,7 @@ from otp.otpbase import OTPGlobals class DistributedSellbotHQDoorAI(DistributedCogHQDoorAI.DistributedCogHQDoorAI): notify = DirectNotifyGlobal.directNotify.newCategory('DistributedSellbotHQDoorAI') - def __init__(self, air, blockNumber, doorType, destinationZone, doorIndex = 0, lockValue = FADoorCodes.SB_DISGUISE_INCOMPLETE, swing = 3): + def __init__(self, air, blockNumber=0, doorType=0, destinationZone=0, doorIndex=0, lockValue=FADoorCodes.SB_DISGUISE_INCOMPLETE, swing=3): self.notify.debugStateCall(self) DistributedCogHQDoorAI.DistributedCogHQDoorAI.__init__(self, air, blockNumber, doorType, destinationZone, doorIndex, lockValue, swing) diff --git a/toontown/coghq/FactoryExterior.py b/toontown/coghq/FactoryExterior.py index f0fbedc..ff530ad 100644 --- a/toontown/coghq/FactoryExterior.py +++ b/toontown/coghq/FactoryExterior.py @@ -4,6 +4,7 @@ from direct.fsm import ClassicFSM, State from direct.fsm import State from otp.distributed.TelemetryLimiter import RotationLimitToH, TLGatherAllAvs from toontown.toonbase import ToontownGlobals +from toontown.hood import OutdoorLighting from toontown.hood import ZoneUtil from toontown.building import Elevator from panda3d.core import * @@ -65,6 +66,7 @@ class FactoryExterior(BattlePlace.BattlePlace): self.fsm.enterInitialState() base.playMusic(self.loader.music, looping=1, volume=0.8) self.loader.geom.reparentTo(render) + OutdoorLighting.begin(self.loader.geom, 'cog') self.nodeList = [self.loader.geom] self.loader.hood.startSky() self._telemLimiter = TLGatherAllAvs('FactoryExterior', RotationLimitToH) @@ -85,6 +87,8 @@ class FactoryExterior(BattlePlace.BattlePlace): node.removeNode() del self.tunnelOriginList + if self.loader.geom: + OutdoorLighting.end(self.loader.geom) del self.nodeList self.ignoreAll() BattlePlace.BattlePlace.exit(self) diff --git a/toontown/distributed/ToontownClientRepository.py b/toontown/distributed/ToontownClientRepository.py index dbadca9..062f8db 100644 --- a/toontown/distributed/ToontownClientRepository.py +++ b/toontown/distributed/ToontownClientRepository.py @@ -38,6 +38,7 @@ from . import HoodMgr from . import PlayGame from toontown.toontowngui import ToontownLoadingBlocker from toontown.hood import StreetSign +import faulthandler class ToontownClientRepository(OTPClientRepository.OTPClientRepository): SupportTutorial = 1 @@ -51,6 +52,11 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): def __init__(self, serverVersion, launcher = None): OTPClientRepository.OTPClientRepository.__init__(self, serverVersion, launcher, playGame=PlayGame.PlayGame) + # Pick-a-Toon TTC revamp: keep a preloaded TTC backdrop alive across + # the set-avatar transition, then hand it off to PlayGame. + self._keepPickAToonBackdrop = False + # Paired with OTPClientRepository.enterPlayGame: only endBulkLoad if we began. + self._localAvatarPlayGameBulkLoadActive = False self._playerAvDclass = self.dclassesByName['DistributedToon'] setInterfaceFont(TTLocalizer.InterfaceFont) setSignFont(TTLocalizer.SignFont) @@ -102,6 +108,7 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): self.hoodMgr = HoodMgr.HoodMgr(self) self.setZonesEmulated = 0 self.old_setzone_interest_handle = None + self._setZoneOpSerial = 0 self.setZoneQueue = Queue() self.accept(ToontownClientRepository.SetZoneDoneEvent, self._handleEmuSetZoneDone) self.previousInterestZones = None @@ -203,6 +210,18 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): TexturePool.garbageCollect() self.sendSetAvatarIdMsg(0) self.clearFriendState() + self.__preloadPickAToonTTCBackdrop() + # Modern launcher transition: keep the new loading screen up through + # server connect, then transition out into Pick-a-Toon. + try: + ml = getattr(base, 'modernLoading', None) + if ml: + ml.set_title('Toontown', 'Pick-a-Toon') + ml.set_status('Loading Pick-a-Toon…') + ml.set_progress(72) + base.graphicsEngine.renderFrame() + except Exception: + pass if self.music == None and base.musicManagerIsValid: self.music = base.musicManager.getSound('phase_3/audio/bgm/tt_theme.ogg') if self.music: @@ -216,13 +235,26 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): self.avChoice.load(self.isPaid()) self.avChoice.enter() self.accept(self.avChoiceDoneEvent, self.__handleAvatarChooserDone, [avList]) - if ConfigVariableBool('want-gib-loader', 1).value: - self.loadingBlocker = ToontownLoadingBlocker.ToontownLoadingBlocker(avList) + # Legacy download blocker can still be used for phase downloads, but + # default to the modern overlay if present. + try: + ml = getattr(base, 'modernLoading', None) + if ml: + ml.set_status('Ready.') + ml.set_progress(100) + ml.transition_out() + else: + if ConfigVariableBool('want-gib-loader', 1).value: + self.loadingBlocker = ToontownLoadingBlocker.ToontownLoadingBlocker(avList) + except Exception: + if ConfigVariableBool('want-gib-loader', 1).value: + self.loadingBlocker = ToontownLoadingBlocker.ToontownLoadingBlocker(avList) return def __handleAvatarChooserDone(self, avList, doneStatus): done = doneStatus['mode'] if done == 'exit': + self.cleanupPickAToonTTCBackdrop() if not launcher.isDummy(): if not self.isPaid(): self.loginFSM.request('shutdown', [OTPLauncherGlobals.ExitUpsell]) @@ -251,20 +283,31 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): if done == 'chose': self.avChoice.exit() if avatarChoice.approvedName != '': + self.cleanupPickAToonTTCBackdrop() self.congratulations(avatarChoice) avatarChoice.approvedName = '' elif avatarChoice.rejectedName != '': + self.cleanupPickAToonTTCBackdrop() avatarChoice.rejectedName = '' self.betterlucknexttime(avList, index) else: + # Keep the preloaded TTC backdrop so entering PlayGame can reuse + # it and spawn immediately into the already-loaded scene. + self._keepPickAToonBackdrop = True self.loginFSM.request('waitForSetAvatarResponse', [avatarChoice]) elif done == 'nameIt': + self._keepPickAToonBackdrop = False + self.cleanupPickAToonTTCBackdrop() self.accept('downloadAck-response', self.__handleDownloadAck, [avList, index]) self.downloadAck = DownloadForceAcknowledge('downloadAck-response') self.downloadAck.enter(4) elif done == 'create': + self._keepPickAToonBackdrop = False + self.cleanupPickAToonTTCBackdrop() self.loginFSM.request('createAvatar', [avList, index]) elif done == 'delete': + self._keepPickAToonBackdrop = False + self.cleanupPickAToonTTCBackdrop() self.loginFSM.request('waitForDeleteAvatarResponse', [avatarChoice]) def __handleDownloadAck(self, avList, index, doneStatus): @@ -283,8 +326,167 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): self.avChoice.unload() self.avChoice = None self.ignore(self.avChoiceDoneEvent) + # If the user picked a toon, keep the backdrop alive across the + # set-avatar transition so PlayGame can reuse it. + if not getattr(self, '_keepPickAToonBackdrop', False): + self.cleanupPickAToonTTCBackdrop() return + def __preloadPickAToonTTCBackdrop(self): + """ + Revamp: Render the Pick-a-Toon UI on top of a live Toontown Central + safezone backdrop. This intentionally loads only the hood geometry/sky, + and does NOT enter the playground state (which requires a localAvatar). + """ + try: + if not ConfigVariableBool('want-pick-a-toon-ttc-backdrop', 0).value: + return + except Exception: + return + # Track whether the backdrop could not be created, so Pick-a-Toon can + # fall back to legacy background art instead of a blank/grey screen. + try: + self._pickAToonTTCBackdropFailed = False + except Exception: + pass + if getattr(self, '_pickAToonTTCBackdrop', None): + return + try: + from toontown.toonbase import ToontownGlobals + from toontown.hood import TTHood + except Exception: + return + + try: + # Ensure PlayGame has a DNA store ready for hood loading. + self.playGame.loadDnaStore() + except Exception: + # If this fails, just skip the backdrop (picker still works). + return + + hoodId = ToontownGlobals.ToontownCentral + requestStatus = { + 'loader': 'safeZoneLoader', + 'where': 'playground', + 'how': 'teleportIn', + 'hoodId': hoodId, + 'zoneId': hoodId, + 'shardId': None, + 'avId': -1, + } + + # Important: some Panda3D NodePath operations can assert in C++ (not raise + # Python exceptions). Be very defensive here to avoid hard crashes or + # "Assertion failed: !is_empty()" spew. If anything is missing/empty, + # mark failed and let Pick-a-Toon fall back to legacy art. + hood = None + try: + hood = TTHood.TTHood(self.playGame.fsm, self.playGame.hoodDoneEvent, self.playGame.dnaStore, hoodId) + hood.load() + try: + hood.startSky() + except Exception: + pass + hood.loadLoader(requestStatus) + + geom = None + try: + if hasattr(hood, 'loader') and hasattr(hood.loader, 'geom'): + geom = hood.loader.geom + except Exception: + geom = None + + if not geom or geom.isEmpty(): + try: + self._pickAToonTTCBackdropFailed = True + except Exception: + pass + try: + hood.unload() + except Exception: + pass + return + + try: + geom.reparentTo(render) + except Exception: + try: + self._pickAToonTTCBackdropFailed = True + except Exception: + pass + try: + hood.unload() + except Exception: + pass + return + + # Apply outdoor lighting only if enabled and safe. + self._pickAToonTTCBackdropHasLighting = False + try: + if ConfigVariableBool('pick-a-toon-ttc-backdrop-want-lighting', 0).value: + from toontown.hood import OutdoorLighting + OutdoorLighting.begin(geom, 'playground', hoodId=hoodId) + self._pickAToonTTCBackdropHasLighting = True + except Exception: + self._pickAToonTTCBackdropHasLighting = False + + # Camera pose while picking (safe best-effort). + try: + base.disableMouse() + if getattr(base, 'camera', None) and not base.camera.isEmpty(): + base.camera.reparentTo(render) + base.camera.setPos(0, -70, 28) + base.camera.setHpr(0, -10, 0) + except Exception: + pass + + self._pickAToonTTCBackdrop = hood + self._pickAToonTTCBackdropRequestStatus = requestStatus + except Exception: + try: + self._pickAToonTTCBackdropFailed = True + except Exception: + pass + try: + if hood: + hood.unload() + except Exception: + pass + self._pickAToonTTCBackdrop = None + self._pickAToonTTCBackdropRequestStatus = None + self._pickAToonTTCBackdropHasLighting = False + + def cleanupPickAToonTTCBackdrop(self): + hood = getattr(self, '_pickAToonTTCBackdrop', None) + if not hood: + return + try: + if getattr(self, '_pickAToonTTCBackdropHasLighting', False) and hasattr(hood, 'loader') and hasattr(hood.loader, 'geom'): + from toontown.hood import OutdoorLighting + OutdoorLighting.end(hood.loader.geom) + except Exception: + pass + try: + hood.stopSky() + except Exception: + pass + try: + hood.unload() + except Exception: + pass + self._pickAToonTTCBackdrop = None + self._pickAToonTTCBackdropRequestStatus = None + self._pickAToonTTCBackdropHasLighting = False + # The backdrop TTHood used playGame.dnaStore. hood.unload() calls + # dnaStore.resetHood() and drops the Hood's reference, but PlayGame still + # keeps the same DNAStorage. loadDnaStore() only runs when dnaStore is + # missing — so the next real hood load would reuse a half-reset store + # and mix neighborhoods (assertions / instant exit). Force a full reset. + try: + self.playGame.unloadDnaStore() + except Exception: + pass + def goToPickAName(self, avList, index): self.avChoice.exit() self.loginFSM.request('createAvatar', [avList, index]) @@ -359,7 +561,13 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): if returnCode == 0: dclass = self.dclassesByName['DistributedToon'] NametagGlobals.setMasterArrowsOn(0) - loader.beginBulkLoad('localAvatarPlayGame', OTPLocalizer.CREnteringToontown, 400, 1, TTLocalizer.TIP_GENERAL) + # Revamp: when using the Pick-a-Toon TTC backdrop, avoid putting + # up a blocking loading screen while generating localAvatar. + if not getattr(self, '_keepPickAToonBackdrop', False): + loader.beginBulkLoad('localAvatarPlayGame', OTPLocalizer.CREnteringToontown, 400, 1, TTLocalizer.TIP_GENERAL) + self._localAvatarPlayGameBulkLoadActive = True + else: + self._localAvatarPlayGameBulkLoadActive = False localAvatar = LocalToon.LocalToon(self) localAvatar.dclass = dclass base.localAvatar = localAvatar @@ -385,7 +593,13 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): self.cleanupWaitingForDatabase() dclass = self.dclassesByName['DistributedToon'] NametagGlobals.setMasterArrowsOn(0) - loader.beginBulkLoad('localAvatarPlayGame', OTPLocalizer.CREnteringToontown, 400, 1, TTLocalizer.TIP_GENERAL) + # Revamp: when using the Pick-a-Toon TTC backdrop, avoid putting + # up a blocking loading screen while generating localAvatar. + if not getattr(self, '_keepPickAToonBackdrop', False): + loader.beginBulkLoad('localAvatarPlayGame', OTPLocalizer.CREnteringToontown, 400, 1, TTLocalizer.TIP_GENERAL) + self._localAvatarPlayGameBulkLoadActive = True + else: + self._localAvatarPlayGameBulkLoadActive = False localAvatar = LocalToon.LocalToon(self) localAvatar.dclass = dclass base.localAvatar = localAvatar @@ -456,10 +670,16 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): def enterPlayingGame(self, *args, **kArgs): OTPClientRepository.OTPClientRepository.enterPlayingGame(self, *args, **kArgs) - self.gameFSM.request('waitOnEnterResponses', [None, - base.localAvatar.defaultZone, - base.localAvatar.defaultZone, - -1]) + # Once we are in-game, we no longer need the "keep" latch. + self._keepPickAToonBackdrop = False + # Use the correct hoodId and avatarId when entering the shard. + # Passing -1 here can break downstream zone-load flow and make it + # look like the client "freezes" during shard entry. + from toontown.hood import ZoneUtil + zoneId = base.localAvatar.defaultZone + hoodId = ZoneUtil.getHoodId(zoneId) + avId = base.localAvatar.getDoId() + self.gameFSM.request('waitOnEnterResponses', [None, hoodId, zoneId, avId]) self._userLoggingOut = False if self.wantStreetSign and not self.streetSign: @@ -500,6 +720,20 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): def enterWaitOnEnterResponses(self, shardId, hoodId, zoneId, avId): self.resetDeletedSubShardDoIds() + # Pick-a-Toon preloads TTC behind the chooser. If the avatar's last + # location was not TTC playground, we must drop that backdrop before + # shard entry — otherwise TTC and the real hood both load (assertions). + try: + from toontown.hood import ZoneUtil + pre = getattr(self, '_pickAToonTTCBackdrop', None) + if pre: + canon = ZoneUtil.getCanonicalZoneId(zoneId) + loaderName = ZoneUtil.getLoaderName(zoneId) + reuseBackdrop = canon == ToontownCentral and loaderName == 'safeZoneLoader' + if not reuseBackdrop: + self.cleanupPickAToonTTCBackdrop() + except Exception: + pass OTPClientRepository.OTPClientRepository.enterWaitOnEnterResponses(self, shardId, hoodId, zoneId, avId) def enterSkipTutorialRequest(self, hoodId, zoneId, avId): @@ -1012,13 +1246,15 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): def sendSetZoneMsg(self, zoneId, visibleZoneList = None): event = self.getNextSetZoneDoneEvent() self.setZonesEmulated += 1 + self._setZoneOpSerial += 1 parentId = base.localAvatar.defaultShard self.sendSetLocation(base.localAvatar.doId, parentId, zoneId) localAvatar.setLocation(parentId, zoneId) interestZones = zoneId if visibleZoneList is not None: interestZones = visibleZoneList - self._addInterestOpToQueue(ToontownClientRepository.SetInterest, [parentId, interestZones, 'OldSetZoneEmulator'], event) + # Include a serial so timeout tasks can be uniquely named/cancelled. + self._addInterestOpToQueue(ToontownClientRepository.SetInterest, [parentId, interestZones, 'OldSetZoneEmulator', self._setZoneOpSerial], event) return def resetInterestStateForConnectionLoss(self): @@ -1039,7 +1275,7 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): def _sendNextSetZone(self): op, args, event = self.setZoneQueue.top() if op == ToontownClientRepository.SetInterest: - parentId, interestZones, name = args + parentId, interestZones, name, serial = args if self.old_setzone_interest_handle == None: if interestZones == []: # Empty zones at startup, don't do anything to save bandwidth, just send the event. @@ -1056,6 +1292,46 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): else: self.alterInterest(self.old_setzone_interest_handle, parentId, interestZones, name, ToontownClientRepository.SetZoneDoneEvent) self.previousInterestZones = interestZones + + # Diagnostic escape hatch: if the server never responds to the + # set-zone interest (DONE_INTEREST), forcibly advance instead of + # hard-freezing in quiet zone. This is opt-in via PRC. + if ConfigVariableBool('force-setzone-done', 0).value: + try: + self.notify.warning('[ShardDbg] force-setzone-done=1; bypassing DONE_INTEREST wait for set-zone interest') + except Exception: + pass + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info(f'[ShardDbg] force-setzone-done calling _handleEmuSetZoneDone; event={event!r} serial={serial} parentId={parentId} zones={interestZones!r}') + except Exception: + pass + self._handleEmuSetZoneDone() + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info('[ShardDbg] force-setzone-done returned from _handleEmuSetZoneDone') + except Exception: + pass + return + + # Safety net: if the server never sends DONE_INTEREST for this + # zone interest, don't hard-freeze the client. We'll force the + # set-zone completion event after a timeout. + try: + timeout = ConfigVariableDouble('setzone-interest-timeout', 15.0).value + except Exception: + timeout = 15.0 + taskName = f'setZoneInterestTimeout-{serial}' + taskMgr.remove(taskName) + taskMgr.doMethodLater(timeout, self._forceEmuSetZoneDone, taskName, extraArgs=[taskName]) + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info(f'[ShardDbg] armed setZone timeout {timeout:.1f}s task={taskName}') + # If we hard-wedge, try to get Python stacks anyway. + # Note: output destination is controlled by OTPClientRepository's faulthandler setup. + faulthandler.dump_traceback_later(timeout + 5.0, repeat=False) + except Exception: + pass elif op == ToontownClientRepository.ClearInterest: self.removeInterest(self.old_setzone_interest_handle, ToontownClientRepository.SetZoneDoneEvent) self.old_setzone_interest_handle = None @@ -1064,12 +1340,55 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): self.notify.error('unknown setZone op: %s' % op) return + def _forceEmuSetZoneDone(self, taskName): + # If we already advanced, ignore. + if self.setZoneQueue.isEmpty(): + return Task.done + try: + self.notify.warning(f'[ShardDbg] setZone interest timed out ({taskName}); forcing {ToontownClientRepository.SetZoneDoneEvent}') + except Exception: + pass + self._handleEmuSetZoneDone() + return Task.done + def _handleEmuSetZoneDone(self): + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info('[ShardDbg] _handleEmuSetZoneDone.enter') + except Exception: + pass + # This can be invoked both by the normal DONE_INTEREST event path + # and by our force-setzone-done/timeout safety nets. If the queue was + # already advanced, ignore duplicate callbacks. + if self.setZoneQueue.isEmpty(): + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.warning('[ShardDbg] _handleEmuSetZoneDone called but setZoneQueue is empty; ignoring') + except Exception: + pass + return + op, args, event = self.setZoneQueue.pop() queueIsEmpty = self.setZoneQueue.isEmpty() + # Cancel any pending timeout for the setzone operation we just completed. + try: + if op == ToontownClientRepository.SetInterest and args and len(args) >= 4: + taskMgr.remove(f'setZoneInterestTimeout-{args[3]}') + except Exception: + pass if event is not None: if not base.killInterestResponse: + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info(f'[ShardDbg] _handleEmuSetZoneDone sending {event!r}') + except Exception: + pass messenger.send(event) + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info(f'[ShardDbg] _handleEmuSetZoneDone sent {event!r}') + except Exception: + pass elif not hasattr(self, '_dontSendSetZoneDone'): import random if random.random() < 0.05: @@ -1078,6 +1397,11 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): messenger.send(event) if not queueIsEmpty: self._sendNextSetZone() + try: + if ConfigVariableBool('shard-debug', 0).value: + self.notify.info('[ShardDbg] _handleEmuSetZoneDone.exit') + except Exception: + pass return def _isPlayerDclass(self, dclass): diff --git a/toontown/estate/Estate.py b/toontown/estate/Estate.py index cec60b3..e898ee9 100644 --- a/toontown/estate/Estate.py +++ b/toontown/estate/Estate.py @@ -4,14 +4,13 @@ from toontown.toonbase.ToonBaseGlobal import * from toontown.toonbase.ToontownGlobals import * from direct.gui.DirectGui import * from direct.distributed.ClockDelta import * -from toontown.hood import Place +from toontown.hood import OutdoorLighting, Place from direct.directnotify import DirectNotifyGlobal from direct.fsm import ClassicFSM, State from direct.task.Task import Task from toontown.toonbase import TTLocalizer import random from direct.showbase import PythonUtil -from toontown.hood import Place from toontown.hood import SkyUtil from toontown.pets import PetTutorial from direct.controls.GravityWalker import GravityWalker @@ -130,6 +129,7 @@ class Estate(Place.Place): self.loader.enterAnimatedProps(i) self.loader.geom.reparentTo(render) + OutdoorLighting.begin(self.loader.geom, 'estate') if hasattr(base.cr, 'newsManager') and base.cr.newsManager: holidayIds = base.cr.newsManager.getHolidayIdList() if ToontownGlobals.APRIL_FOOLS_COSTUMES in holidayIds or ToontownGlobals.SILLYMETER_EXT_HOLIDAY in holidayIds: @@ -157,6 +157,7 @@ class Estate(Place.Place): if hasattr(self, 'fsm'): self.fsm.requestFinalState() self.loader.geom.reparentTo(hidden) + OutdoorLighting.end(self.loader.geom) for i in self.loader.nodeList: self.loader.exitAnimatedProps(i) diff --git a/toontown/launcher/QuickStartLauncher.py b/toontown/launcher/QuickStartLauncher.py index f629cbc..aed7ff2 100644 --- a/toontown/launcher/QuickStartLauncher.py +++ b/toontown/launcher/QuickStartLauncher.py @@ -1,6 +1,25 @@ if __debug__: - from panda3d.core import loadPrcFile + import os + from panda3d.core import loadPrcFile, loadPrcFileData loadPrcFile('etc/Configrc.prc') + # Optional local overrides (shard-debug, notify levels, etc.); not loaded automatically before. + _devPrc = os.path.join('etc', 'Configrc_dev.prc') + if os.path.isfile(_devPrc): + loadPrcFile(_devPrc) + + # Freeze-capture mode: set env var TTBTN_FREEZE_CAPTURE=1 before launch. + # This turns on shard flow breadcrumbs + watchdog stack dumps without + # requiring any local PRC override files to be present/loaded. + if os.environ.get('TTBTN_FREEZE_CAPTURE') == '1': + loadPrcFileData('', '\n'.join([ + 'shard-debug 1', + 'eventmanager-debug-flood 1', + 'eventmanager-drain-on-flood 1', + # force-setzone-done is a last-resort escape hatch; leave it off + # for normal testing now that interests are completing. + 'default-directnotify-level info', + 'notify-level-OTPClientRepository info', + ])) else: import sys sys.path = [''] diff --git a/toontown/login/AvatarChoice.py b/toontown/login/AvatarChoice.py index 4c418f5..17e0498 100644 --- a/toontown/login/AvatarChoice.py +++ b/toontown/login/AvatarChoice.py @@ -24,6 +24,22 @@ DELETE_POSITIONS = ((0.187, 0, -0.26), class AvatarChoice(DirectButton): notify = DirectNotifyGlobal.directNotify.newCategory('AvatarChoice') + + def __reparentToButtonStateNodes(self, np): + """Parent GUI geometry to this DirectButton's per-state roots, or to self if those are invalid.""" + try: + snp = self.stateNodePath + except Exception: + np.reparentTo(self) + return + if len(snp) < 3 or snp[0].isEmpty() or snp[1].isEmpty() or snp[2].isEmpty(): + self.notify.warning( + 'AvatarChoice slot %s: stateNodePath invalid; parenting to button root' % self.position) + np.reparentTo(self) + return + np.reparentTo(snp[0], 20) + np.instanceTo(snp[1], 20) + np.instanceTo(snp[2], 20) NEW_TRIALER_OPEN_POS = (1,) OLD_TRIALER_OPEN_POS = (1, 4) MODE_CREATE = 0 @@ -65,6 +81,17 @@ class AvatarChoice(DirectButton): self.buttonBgs.append(self.pickAToonGui.find('**/tt_t_gui_pat_squareBlue')) self.buttonBgs.append(self.pickAToonGui.find('**/tt_t_gui_pat_squarePink')) self.buttonBgs.append(self.pickAToonGui.find('**/tt_t_gui_pat_squareYellow')) + fallbackBg = None + for bg in self.buttonBgs: + if bg and not bg.isEmpty(): + fallbackBg = bg + break + if fallbackBg is None: + self.notify.error('pick_a_toon_gui is missing all button background textures') + else: + for i in range(len(self.buttonBgs)): + if self.buttonBgs[i].isEmpty(): + self.buttonBgs[i] = fallbackBg self['image'] = self.buttonBgs[position] self.setScale(1.01) if self.mode is AvatarChoice.MODE_LOCKED: @@ -79,12 +106,13 @@ class AvatarChoice(DirectButton): self['text_pos'] = (0, 0.19) upsellModel = loader.loadModel('phase_3/models/gui/tt_m_gui_ups_mainGui') upsellTex = upsellModel.find('**/tt_t_gui_ups_logo_noBubbles') - self.logoModelImage = loader.loadModel('phase_3/models/gui/members_only_gui').find('**/MembersOnly') - logo = DirectFrame(state=DGG.DISABLED, parent=self, relief=None, image=upsellTex, image_scale=(0.9, 0, 0.9), image_pos=(0, 0, 0), scale=0.45) - logo.reparentTo(self.stateNodePath[0], 20) - logo.instanceTo(self.stateNodePath[1], 20) - logo.instanceTo(self.stateNodePath[2], 20) - self.logo = logo + self.logo = None + if not upsellTex.isEmpty(): + logo = DirectFrame(state=DGG.DISABLED, parent=self, relief=None, image=upsellTex, image_scale=(0.9, 0, 0.9), image_pos=(0, 0, 0), scale=0.45) + self.__reparentToButtonStateNodes(logo) + self.logo = logo + else: + self.notify.warning('AvatarChoice locked slot %s: missing upsell texture' % self.position) upsellModel.removeNode() elif self.mode is AvatarChoice.MODE_CREATE: self['command'] = self.__handleCreate @@ -125,9 +153,7 @@ class AvatarChoice(DirectButton): self.statusText['text'] = '' self.head = hidden.attachNewNode('head') self.head.setPosHprScale(0, 5, -0.1, 180, 0, 0, 0.24, 0.24, 0.24) - self.head.reparentTo(self.stateNodePath[0], 20) - self.head.instanceTo(self.stateNodePath[1], 20) - self.head.instanceTo(self.stateNodePath[2], 20) + self.__reparentToButtonStateNodes(self.head) self.headModel = ToonHead.ToonHead() self.headModel.setupHead(self.dna, forGui=1) self.headModel.reparentTo(self.head) @@ -153,7 +179,10 @@ class AvatarChoice(DirectButton): del self.pickAToonGui del self.dna if self.mode in (AvatarChoice.MODE_CREATE, AvatarChoice.MODE_LOCKED): - pass + logo = getattr(self, 'logo', None) + if logo is not None: + logo.destroy() + del self.logo else: self.headModel.stopBlink() self.headModel.stopLookAroundNow() diff --git a/toontown/login/AvatarChooser.py b/toontown/login/AvatarChooser.py index 5c21235..71c5fb6 100644 --- a/toontown/login/AvatarChooser.py +++ b/toontown/login/AvatarChooser.py @@ -13,7 +13,7 @@ from direct.directnotify import DirectNotifyGlobal from direct.interval.IntervalGlobal import * import random MAX_AVATARS = 6 -POSITIONS = (Vec3(-0.840167, 0, 0.359333), +BASE_POSITIONS = (Vec3(-0.840167, 0, 0.359333), Vec3(0.00933349, 0, 0.306533), Vec3(0.862, 0, 0.3293), Vec3(-0.863554, 0, -0.445659), @@ -55,14 +55,55 @@ class AvatarChooser(StateData.StateData): self.quitButton.show() if base.cr.loginInterface.supportsRelogin(): self.logoutButton.show() - self.pickAToonBG.reparentTo(base.camera) + # Revamp: do not show the pick-a-toon background panel; the chooser UI + # should overlay the in-world backdrop (Toontown Central). + if getattr(self, 'pickAToonBG', None): + self.pickAToonBG.reparentTo(hidden) choice = base.config.GetInt('auto-avatar-choice', -1) for panel in self.panelList: + # Ensure panels are in the visible GUI graph and render on top. + panel.reparentTo(aspect2d) + panel.setBin('gui-popup', 10) panel.show() self.accept(panel.doneEvent, self.__handlePanelDone) if panel.position == choice and panel.mode == AvatarChoice.AvatarChoice.MODE_CHOOSE: self.__handlePanelDone('chose', panelChoice=choice) + # A "cool transition" into Pick-a-Toon: quick pop + fade for the panels and title. + try: + if getattr(self, '_enterIval', None): + self._enterIval.finish() + items = [] + items.append(self.title) + for p in self.panelList: + items.append(p) + # Buttons too, for a unified entrance. + items.append(self.quitButton) + if self.logoutButton: + items.append(self.logoutButton) + + for item in items: + try: + item.setColorScale(1, 1, 1, 0) + except Exception: + pass + + def _fade(alpha): + for item in items: + try: + item.setColorScale(1, 1, 1, alpha) + except Exception: + pass + + self._enterIval = Sequence( + Parallel( + LerpFunc(_fade, fromData=0.0, toData=1.0, duration=0.28, blendType='easeOut'), + ), + ) + self._enterIval.start() + except Exception: + pass + def exit(self): if self.isLoaded == 0: return None @@ -73,7 +114,8 @@ class AvatarChooser(StateData.StateData): self.title.reparentTo(hidden) self.quitButton.hide() self.logoutButton.hide() - self.pickAToonBG.reparentTo(hidden) + if getattr(self, 'pickAToonBG', None): + self.pickAToonBG.reparentTo(hidden) return None def load(self, isPaid): @@ -82,21 +124,65 @@ class AvatarChooser(StateData.StateData): self.isPaid = isPaid gui = loader.loadModel('phase_3/models/gui/pick_a_toon_gui') gui2 = loader.loadModel('phase_3/models/gui/quit_button') - newGui = loader.loadModel('phase_3/models/gui/tt_m_gui_pat_mainGui') - self.pickAToonBG = newGui.find('**/tt_t_gui_pat_background') - self.pickAToonBG.reparentTo(hidden) - self.pickAToonBG.setPos(0.0, 2.73, 0.0) - self.pickAToonBG.setScale(1, 1, 1) + # Prefer the live TTC backdrop. If it failed (or is disabled), fall back + # to the legacy pick-a-toon background art to avoid a blank/grey screen. + self.pickAToonBG = None + try: + failed = bool(getattr(base.cr, '_pickAToonTTCBackdropFailed', False)) + haveBackdrop = bool(getattr(base.cr, '_pickAToonTTCBackdrop', None)) + except Exception: + failed = False + haveBackdrop = False + guiOk = False + try: + guiOk = gui is not None and not gui.isEmpty() + except Exception: + guiOk = False + + if (failed or not haveBackdrop) and guiOk: + try: + self.pickAToonBG = DirectFrame( + parent=aspect2d, + relief=None, + image=gui, + image_scale=(1.33, 1.0, 1.0), + pos=(0, 0, 0), + ) + self.pickAToonBG.setBin('fixed', -10) + except Exception: + self.pickAToonBG = None + elif failed or not haveBackdrop: + chooser_notify.warning('pick_a_toon_gui failed to load; skipping legacy background image') self.title = OnscreenText(TTLocalizer.AvatarChooserPickAToon, scale=TTLocalizer.ACtitle, parent=hidden, font=ToontownGlobals.getSignFont(), fg=(1, 0.9, 0.1, 1), pos=(0.0, 0.82)) - quitHover = gui.find('**/QuitBtn_RLVR') + # Some forks remove/rename assets; guard against missing models so we + # don't trip Panda NodePath empty assertions. + quitHover = None + try: + if guiOk: + q = gui.find('**/QuitBtn_RLVR') + if q is not None and not q.isEmpty(): + quitHover = q + except Exception: + quitHover = None + if quitHover is None: + # Safe fallback: DirectButton allows image=None. + chooser_notify.warning('pick_a_toon_gui missing QuitBtn_RLVR; using fallback button visuals') self.quitButton = DirectButton(image=(quitHover, quitHover, quitHover), relief=None, text=TTLocalizer.AvatarChooserQuit, text_font=ToontownGlobals.getSignFont(), text_fg=(0.977, 0.816, 0.133, 1), text_pos=TTLocalizer.ACquitButtonPos, text_scale=TTLocalizer.ACquitButton, image_scale=1, image1_scale=1.05, image2_scale=1.05, scale=1.05, pos=(1.08, 0, -0.907), command=self.__handleQuit) self.logoutButton = DirectButton(relief=None, image=(quitHover, quitHover, quitHover), text=TTLocalizer.OptionsPageLogout, text_font=ToontownGlobals.getSignFont(), text_fg=(0.977, 0.816, 0.133, 1), text_scale=TTLocalizer.AClogoutButton, text_pos=(0, -0.035), pos=(-1.17, 0, -0.914), image_scale=1.15, image1_scale=1.15, image2_scale=1.18, scale=0.5, command=self.__handleLogoutWithoutConfirm) self.logoutButton.hide() - gui.removeNode() - gui2.removeNode() - newGui.removeNode() + try: + if gui is not None and not gui.isEmpty(): + gui.removeNode() + except Exception: + pass + try: + if gui2 is not None and not gui2.isEmpty(): + gui2.removeNode() + except Exception: + pass self.panelList = [] used_position_indexs = [] + positions = BASE_POSITIONS for av in self.avatarList: if base.cr.isPaid(): okToLockout = 0 @@ -105,20 +191,21 @@ class AvatarChooser(StateData.StateData): if av.position in AvatarChoice.AvatarChoice.OLD_TRIALER_OPEN_POS: okToLockout = 0 panel = AvatarChoice.AvatarChoice(av, position=av.position, paid=isPaid, okToLockout=okToLockout) - panel.setPos(POSITIONS[av.position]) + panel.setPos(positions[av.position]) used_position_indexs.append(av.position) self.panelList.append(panel) for panelNum in range(0, MAX_AVATARS): if panelNum not in used_position_indexs: panel = AvatarChoice.AvatarChoice(position=panelNum, paid=isPaid) - panel.setPos(POSITIONS[panelNum]) + panel.setPos(positions[panelNum]) self.panelList.append(panel) if len(self.avatarList) > 0: self.initLookAtInfo() self.isLoaded = 1 + def getLookAtPosition(self, toonHead, toonidx): lookAtChoice = random.random() if len(self.used_panel_indexs) == 1: @@ -174,9 +261,9 @@ class AvatarChooser(StateData.StateData): return def getLookAtToPosVec(self, fromIdx, toIdx): - x = -(POSITIONS[toIdx][0] - POSITIONS[fromIdx][0]) - y = POSITIONS[toIdx][1] - POSITIONS[fromIdx][1] - z = POSITIONS[toIdx][2] - POSITIONS[fromIdx][2] + x = -(BASE_POSITIONS[toIdx][0] - BASE_POSITIONS[fromIdx][0]) + y = BASE_POSITIONS[toIdx][1] - BASE_POSITIONS[fromIdx][1] + z = BASE_POSITIONS[toIdx][2] - BASE_POSITIONS[fromIdx][2] return Vec3(x, y, z) def initLookAtInfo(self): @@ -211,7 +298,8 @@ class AvatarChooser(StateData.StateData): del self.quitButton self.logoutButton.destroy() del self.logoutButton - self.pickAToonBG.removeNode() + if getattr(self, 'pickAToonBG', None): + self.pickAToonBG.removeNode() del self.pickAToonBG del self.avatarList self.parentFSM.getCurrentState().removeChild(self.fsm) diff --git a/toontown/minigame/Purchase.py b/toontown/minigame/Purchase.py index d7147a9..a8eb5c4 100644 --- a/toontown/minigame/Purchase.py +++ b/toontown/minigame/Purchase.py @@ -435,7 +435,11 @@ class Purchase(PurchaseBase): def countUp(self): totalDelay = 0 - if base.cr.newsManager.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY) or base.cr.newsManager.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY_MONTH): + newsMgr = getattr(getattr(base, 'cr', None), 'newsManager', None) + if newsMgr and ( + newsMgr.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY) or + newsMgr.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY_MONTH) + ): self.rewardDoubledJellybeanLabel.show() countUpTask = taskMgr.add(self._countUpTask, 'countUp') countUpTask.duration = COUNT_UP_DURATION @@ -496,7 +500,11 @@ class Purchase(PurchaseBase): def countVotesUp(self): totalDelay = 0 self.convertingVotesToBeansLabel.show() - if base.cr.newsManager.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY) or base.cr.newsManager.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY_MONTH): + newsMgr = getattr(getattr(base, 'cr', None), 'newsManager', None) + if newsMgr and ( + newsMgr.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY) or + newsMgr.isHolidayRunning(ToontownGlobals.JELLYBEAN_TROLLEY_HOLIDAY_MONTH) + ): self.rewardDoubledJellybeanLabel.show() counterIndex = 0 for index in range(len(self.ids)): diff --git a/toontown/parties/Party.py b/toontown/parties/Party.py index 54d12b6..6cdd42b 100644 --- a/toontown/parties/Party.py +++ b/toontown/parties/Party.py @@ -3,7 +3,7 @@ from toontown.toonbase.ToonBaseGlobal import * from toontown.toonbase.ToontownGlobals import * from direct.gui.DirectGui import * from direct.distributed.ClockDelta import * -from toontown.hood import Place +from toontown.hood import OutdoorLighting, Place from direct.directnotify import DirectNotifyGlobal from direct.fsm import ClassicFSM, State from direct.task.Task import Task @@ -11,7 +11,6 @@ from toontown.toonbase import TTLocalizer import random from direct.showbase import PythonUtil from otp.distributed.TelemetryLimiter import RotationLimitToH, TLGatherAllAvs, TLNull -from toontown.hood import Place from toontown.hood import SkyUtil from toontown.parties import PartyPlanner from toontown.parties.DistributedParty import DistributedParty @@ -109,6 +108,7 @@ class Party(Place.Place): self.loader.enterAnimatedProps(i) self.loader.geom.reparentTo(render) + OutdoorLighting.begin(self.loader.geom, 'playground') self.fsm.request(requestStatus['how'], [requestStatus]) self.playMusic() @@ -121,6 +121,7 @@ class Party(Place.Place): if hasattr(self, 'fsm'): self.fsm.requestFinalState() self.loader.geom.reparentTo(hidden) + OutdoorLighting.end(self.loader.geom) for i in self.loader.nodeList: self.loader.exitAnimatedProps(i) diff --git a/toontown/quest/QuestManagerAI.py b/toontown/quest/QuestManagerAI.py index 8a7d8b7..c477a27 100644 --- a/toontown/quest/QuestManagerAI.py +++ b/toontown/quest/QuestManagerAI.py @@ -95,10 +95,24 @@ class QuestManagerAI: needsQuestButNoneLeft = 0 if (self.needsQuest(av) and npc.getGivesQuests()): + if Quests.wantRandomTaskSystem() and npc.getHq(): + assignedAny = 0 + while self.needsQuest(av): + quests = self.getNextQuestIds(npc, av) + if not quests: + if not assignedAny: + needsQuestButNoneLeft = 1 + break + fromNpcId = Quests.ToonHQ + self.assignQuest(avId, fromNpcId, *quests[0]) + npc.assignQuest(av.getDoId(), *quests[0]) + assignedAny = 1 + if assignedAny: + return # bestQuests is a nested list of [questId, rewardId, toNpcId] lists quests = self.getNextQuestIds(npc, av) if quests: - if (Quests.getNumChoices(av.getRewardTier()) == 0): + if Quests.wantRandomTaskSystem() or Quests.getNumChoices(av.getRewardTier()) == 0: assert(len(quests) == 1) # There should only be one if npc.getHq(): fromNpcId = Quests.ToonHQ @@ -149,7 +163,7 @@ class QuestManagerAI: # quests is a nested list of [questId, rewardId, toNpcId] lists quests = self.getNextQuestIds(npc, av) if quests: - if (Quests.getNumChoices(av.getRewardTier()) == 0): + if Quests.wantRandomTaskSystem() or Quests.getNumChoices(av.getRewardTier()) == 0: assert(len(quests) == 1) # There should only be one if npc.getHq(): fromNpcId = Quests.ToonHQ @@ -267,6 +281,16 @@ class QuestManagerAI: # This happens in avatarChoseTrack return + # See if this quest is part of a multiquest. If it is, we assign + # the next part of the multiquest. + nextQuestId, nextToNpcId = Quests.getNextQuest(questId, npc, av) + + # Random task "spaghetti": sometimes send the player to another shopkeeper + # instead of paying out (must run before DeliverGag side effects). + if nextQuestId == Quests.NA and Quests.tryTerminalSpaghettiRedirect(self.air, av, npc, questId): + npc.freeAvatar(av.getDoId()) + return + # If this is a deliver gag quest, we need to actually remove the # gags delivered from the player's inventory if questClass == Quests.DeliverGagQuest: @@ -279,15 +303,15 @@ class QuestManagerAI: av.inventory.useItem(track, level) av.d_setInventory(av.inventory.makeNetString()) - - # See if this quest is part of a multiquest. If it is, we assign - # the next part of the multiquest. - nextQuestId, nextToNpcId = Quests.getNextQuest(questId, npc, av) eventLogMessage = "%s|%s|%s|%s" % ( questId, npc.getNpcId(), questClass.__name__, nextQuestId) if nextQuestId == Quests.NA: - rewardId = Quests.getAvatarRewardId(av, questId) + if Quests.wantRandomTaskSystem(): + baseRewardId = Quests.chooseRandomCompletionReward(av) + rewardId = Quests.transformReward(baseRewardId, av) + else: + rewardId = Quests.getAvatarRewardId(av, questId) # Update the toon with the reward reward = Quests.getReward(rewardId) @@ -301,6 +325,9 @@ class QuestManagerAI: # Nope, this is the end, dish out the reward av.removeQuest(questId) + if Quests.wantRandomTaskSystem(): + tier, hist = av.getRewardHistory() + av.b_setRewardHistory(tier, hist + [rewardId]) # TODO: put this in the movie reward.sendRewardAI(av) # Full heal for completing a quest @@ -308,8 +335,11 @@ class QuestManagerAI: # Tell the npc to deliver the movie which will # complete the quest, display the reward, and do nothing else npc.completeQuest(av.getDoId(), questId, rewardId) - # Bump the reward - self.incrementReward(av) + if Quests.wantRandomTaskSystem(): + self._assignRandomFollowupQuest(av, npc) + else: + # Bump the reward + self.incrementReward(av) eventLogMessage += "|%s|%s" % ( reward.__class__.__name__, reward.getAmount()) @@ -432,11 +462,17 @@ class QuestManagerAI: # Update the toon with the reward rewardId = Quests.getRewardIdFromTrackId(trackId) reward = Quests.getReward(rewardId) + if Quests.wantRandomTaskSystem(): + tier, hist = av.getRewardHistory() + av.b_setRewardHistory(tier, hist + [rewardId]) reward.sendRewardAI(av) # Tell the npc to deliver the movie which will # complete the quest, display the reward, and do nothing else npc.completeQuest(av.getDoId(), questId, rewardId) - self.incrementReward(av) + if Quests.wantRandomTaskSystem(): + self._assignRandomFollowupQuest(av, npc) + else: + self.incrementReward(av) else: self.notify.warning("avatarChoseTrack: av is gone.") @@ -483,6 +519,8 @@ class QuestManagerAI: # count the reward twice else: finalRewardId = None + if Quests.wantRandomTaskSystem() and startingQuest: + finalRewardId = None # 0 for initial progress initialProgress = 0 # To make it easy for testing purposes. @@ -500,7 +538,10 @@ class QuestManagerAI: recordHistory = 0 else: recordHistory = 1 - av.addQuest((questId, npcId, toNpcId, rewardId, initialProgress), finalRewardId, recordHistory) + questRow = [questId, npcId, toNpcId, rewardId, initialProgress] + if Quests.wantRandomTaskSystem(): + Quests.spaghettiRandomizeNewQuestEndpoints(av, questRow) + av.addQuest(tuple(questRow), finalRewardId, recordHistory) # if this was a requested quest, clear it if self.NextQuestDict.get(avId) == questId: del self.NextQuestDict[avId] @@ -966,8 +1007,8 @@ class QuestManagerAI: # now but it may in the future, so it is the right thing to do reward = Quests.getReward(rewardId) reward.sendRewardAI(av) - # Bump the reward - self.incrementReward(av) + if not Quests.wantRandomTaskSystem(): + self.incrementReward(av) return 1 else: # Reward was not a clothing ticket @@ -981,6 +1022,23 @@ class QuestManagerAI: # Did not find it, avId does not have clothing ticket on this tailor return 0 + def _assignRandomFollowupQuest(self, av, npc): + if not Quests.wantRandomTaskSystem(): + return + if not self.needsQuest(av): + return + if not npc.getGivesQuests(): + return + quests = self.getNextQuestIds(npc, av) + if not quests: + return + if npc.getHq(): + fromNpcId = Quests.ToonHQ + else: + fromNpcId = npc.getNpcId() + self.assignQuest(av.getDoId(), fromNpcId, *quests[0]) + npc.assignQuest(av.getDoId(), *quests[0]) + def setNextQuest(self, avId, questId): # for ~nextQuest: queue up a quest for this avatar self.NextQuestDict[avId] = questId diff --git a/toontown/quest/QuestPoster.py b/toontown/quest/QuestPoster.py index cd8d761..55d626b 100644 --- a/toontown/quest/QuestPoster.py +++ b/toontown/quest/QuestPoster.py @@ -278,7 +278,7 @@ class QuestPoster(DirectFrame): reward = Quests.getReward(transformedReward) else: reward = Quests.getReward(rewardId) - if reward and questId not in Quests.NoRewardTierZeroQuests: + if reward and (rewardId != Quests.NA or questId not in Quests.NoRewardTierZeroQuests): rewardString = reward.getPosterString() else: rewardString = '' diff --git a/toontown/quest/QuestRewardCounter.py b/toontown/quest/QuestRewardCounter.py index 58b1466..3eff872 100644 --- a/toontown/quest/QuestRewardCounter.py +++ b/toontown/quest/QuestRewardCounter.py @@ -18,7 +18,7 @@ class QuestRewardCounter: self.maxHp = 15 self.maxCarry = 20 self.maxMoney = 40 - self.questCarryLimit = 1 + self.questCarryLimit = 4 self.teleportAccess = [] self.trackAccess = [0, 0, @@ -125,7 +125,7 @@ class QuestRewardCounter: self.notify.info('Changed avatar %d to have maxHp %d instead of %d' % (av.doId, self.maxHp, av.maxHp)) av.b_setMaxHp(self.maxHp) anyChanged = 1 - if self.maxCarry != av.maxCarry: + if not ToontownGlobals.WantUnlimitedGags and self.maxCarry != av.maxCarry: self.notify.info('Changed avatar %d to have maxCarry %d instead of %d' % (av.doId, self.maxCarry, av.maxCarry)) av.b_setMaxCarry(self.maxCarry) anyChanged = 1 @@ -141,7 +141,7 @@ class QuestRewardCounter: self.notify.info('Changed avatar %d to have teleportAccess %s instead of %s' % (av.doId, self.teleportAccess, av.teleportZoneArray)) av.b_setTeleportAccess(self.teleportAccess) anyChanged = 1 - if self.trackAccess != av.trackArray: + if not ToontownGlobals.WantUnlimitedGags and self.trackAccess != av.trackArray: self.notify.info('Changed avatar %d to have trackAccess %s instead of %s' % (av.doId, self.trackAccess, av.trackArray)) av.b_setTrackAccess(self.trackAccess) anyChanged = 1 diff --git a/toontown/quest/Quests.py b/toontown/quest/Quests.py index 655a25b..4f171e8 100644 --- a/toontown/quest/Quests.py +++ b/toontown/quest/Quests.py @@ -70,6 +70,8 @@ ELDER_TIER = 49 LOOPING_FINAL_TIER = ELDER_TIER VISIT_QUEST_ID = 1000 TROLLEY_QUEST_ID = 110 +# Old skip-tutorial / bootstrap used these; strip on login migrate for tutorialAck toons. +LegacyTutorialQuestIds = frozenset((101, TROLLEY_QUEST_ID)) FIRST_COG_QUEST_ID = 145 FRIEND_QUEST_ID = 150 PHONE_QUEST_ID = 175 @@ -79,6 +81,15 @@ CASHBOT_HQ_NEWBIE_HP = 85 from toontown.toonbase.ToontownGlobals import FT_FullSuit, FT_Leg, FT_Arm, FT_Torso QuestRandGen = random.Random() + +def wantRandomTaskSystem(): + try: + from otp.ai.AIBaseGlobal import simbase + return simbase.config.GetBool('want-random-task-system', True) + except Exception: + return True + + def seedRandomGen(npcId, avId, tier, rewardHistory): QuestRandGen.seed(npcId * 100 + avId + tier + len(rewardHistory)) @@ -17864,6 +17875,87 @@ def filterQuests(entireQuestPool, currentNpc, av): return finalQuestPool +def filterQuestsForRandomSystem(entireQuestPool, currentNpc, av): + if notify.getDebug(): + notify.debug('filterQuestsForRandomSystem: entireQuestPool: %s' % entireQuestPool) + validQuestPool = dict([ (questId, 1) for questId in entireQuestPool ]) + if isLoopingFinalTier(av.getRewardTier()): + history = [questDesc[0] for questDesc in av.quests] + else: + history = av.getQuestHistory() + currentQuests = av.quests + hqBypass = currentNpc.getHq() + for questId in entireQuestPool: + if questId in history: + validQuestPool[questId] = 0 + continue + if not hqBypass: + potentialFromNpc = getQuestFromNpcId(questId) + if not npcMatches(potentialFromNpc, currentNpc): + validQuestPool[questId] = 0 + continue + potentialToNpc = getQuestToNpcId(questId) + if currentNpc.getNpcId() == potentialToNpc: + validQuestPool[questId] = 0 + continue + if not getQuestClass(questId).filterFunc(av): + validQuestPool[questId] = 0 + continue + if not (hqBypass and wantRandomTaskSystem()): + for quest in currentQuests: + toNpcId = quest[2] + if potentialToNpc == toNpcId and toNpcId != ToonHQ: + validQuestPool[questId] = 0 + break + + finalQuestPool = [key for key in list(validQuestPool.keys()) if validQuestPool[key]] + if notify.getDebug(): + notify.debug('filterQuestsForRandomSystem: finalQuestPool: %s' % finalQuestPool) + return finalQuestPool + + +def chooseRandomQuestOffers(tier, currentNpc, av): + seedRandomGen(currentNpc.getNpcId(), av.getDoId(), tier, av.getRewardHistory()[1]) + entirePool = getStartingQuests() + validPool = filterQuestsForRandomSystem(entirePool, currentNpc, av) + if not validPool: + return [] + byTier = {} + for qid in validPool: + t = QuestDict[qid][QuestDictTierIndex] + byTier.setdefault(t, []).append(qid) + tierCounts = {} + for qdesc in av.quests: + qid0 = qdesc[0] + if qid0 in QuestDict: + t0 = QuestDict[qid0][QuestDictTierIndex] + tierCounts[t0] = tierCounts.get(t0, 0) + 1 + bestTiers = [] + minCount = None + for t in byTier.keys(): + c = tierCounts.get(t, 0) + if minCount is None or c < minCount: + minCount = c + bestTiers = [t] + elif c == minCount: + bestTiers.append(t) + pickTier = random.choice(bestTiers) + questId = random.choice(byTier[pickTier]) + bestQuestToNpcId = getQuestToNpcId(questId) + if bestQuestToNpcId == Any: + bestQuestToNpcId = 2003 + elif bestQuestToNpcId == Same: + if currentNpc.getHq(): + bestQuestToNpcId = ToonHQ + else: + bestQuestToNpcId = currentNpc.getNpcId() + elif bestQuestToNpcId == ToonHQ: + bestQuestToNpcId = ToonHQ + placeholderReward = chooseRandomCompletionReward(av) + placeholderReward = transformReward(placeholderReward, av) + return [[questId, placeholderReward, bestQuestToNpcId]] + + def chooseTrackChoiceQuest(tier, av, fixed = 0): def fixAndCallAgain(): @@ -18000,6 +18092,8 @@ def transformReward(baseRewardId, av): def chooseBestQuests(tier, currentNpc, av): + if wantRandomTaskSystem(): + return chooseRandomQuestOffers(tier, currentNpc, av) if isLoopingFinalTier(tier): rewardHistory = [questDesc[3] for questDesc in av.quests] else: @@ -19699,6 +19793,134 @@ OptionalRewardTrackDict = {TT_TIER: (), 2970, 2971)} + +def chooseRandomCompletionReward(av): + pool = [] + for tier in RequiredRewardTrackDict.keys(): + pool.extend(list(getRewardsInTier(tier))) + pool.extend(list(getOptionalRewardsInTier(tier))) + pool = list(set(pool)) + filtered = [] + for rid in pool: + if rid in (Any,): + continue + rc = getRewardClass(rid) + if rc is None: + continue + if rc in (ClothingTicketReward, TIPClothingTicketReward): + continue + if rid == 400: + continue + rew = getReward(rid) + if rew is None: + continue + if rc == CogSuitPartReward: + deptStr = RewardDict.get(rid)[1] + cogPart = RewardDict.get(rid)[2] + dept = ToontownGlobals.cogDept2index[deptStr] + if av.hasCogPart(cogPart, dept): + continue + filtered.append(rid) + if not filtered: + return 604 + return random.choice(filtered) + + +_SPAGHETTI_NPC_POOL = None +SPAGHETTI_REDIRECT_CHANCE = 0.15 + + +def wantSpaghettiTaskRandomizer(): + try: + from otp.ai.AIBaseGlobal import simbase + return simbase.config.GetBool('want-spaghetti-tasks', True) + except Exception: + return True + + +def _getSpaghettiNpcPool(): + global _SPAGHETTI_NPC_POOL + if _SPAGHETTI_NPC_POOL is not None: + return _SPAGHETTI_NPC_POOL + from toontown.toon import NPCToons + pool = [] + for npcId, desc in list(NPCToons.NPCToonDict.items()): + if not isinstance(npcId, int): + continue + if npcId in (20001,): + continue + try: + ntype = desc[-1] + except (IndexError, TypeError): + continue + if ntype in (NPCToons.NPC_REGULAR, NPCToons.NPC_CLERK, NPCToons.NPC_FISHERMAN, NPCToons.NPC_TAILOR): + pool.append(npcId) + pool.append(ToonHQ) + _SPAGHETTI_NPC_POOL = tuple(pool) + return _SPAGHETTI_NPC_POOL + + +def pickRandomSpaghettiNpc(exclude=None): + exclude = exclude or frozenset() + choices = [n for n in _getSpaghettiNpcPool() if n not in exclude] + if not choices: + choices = [n for n in _getSpaghettiNpcPool()] + return random.choice(choices) + + +def questClassSupportsSpaghettiShuffle(qc): + if qc in (VisitQuest, DeliverItemQuest, DeliverGagQuest): + return True + try: + return issubclass(qc, LocationBasedQuest) + except TypeError: + return False + + +def spaghettiRandomizeNewQuestEndpoints(av, questRow): + if not wantRandomTaskSystem() or not wantSpaghettiTaskRandomizer(): + return + qid = questRow[0] + qc = getQuestClass(qid) + if not questClassSupportsSpaghettiShuffle(qc): + return + ex = frozenset(x for x in (questRow[1], questRow[2]) if x is not None) + questRow[2] = pickRandomSpaghettiNpc(exclude=ex) + questRow[3] = transformReward(chooseRandomCompletionReward(av), av) + + +def tryTerminalSpaghettiRedirect(air, av, npc, questId): + if not wantRandomTaskSystem() or not wantSpaghettiTaskRandomizer(): + return False + if random.random() >= SPAGHETTI_REDIRECT_CHANCE: + return False + questDesc = None + for q in av.quests: + if q[0] == questId: + questDesc = q + break + if not questDesc: + return False + qc = getQuestClass(questId) + if not questClassSupportsSpaghettiShuffle(qc): + return False + curNpc = npc.getNpcId() + exclude = frozenset((curNpc, questDesc[2])) + newTo = questDesc[2] + for _ in range(12): + cand = pickRandomSpaghettiNpc(exclude=exclude) + if cand != questDesc[2]: + newTo = cand + break + if newTo == questDesc[2]: + return False + questDesc[2] = newTo + questDesc[3] = transformReward(chooseRandomCompletionReward(av), av) + av.b_setQuests(av.quests) + air.writeServerEvent('questSpaghettiRedirect', av.getDoId(), '%s|%s|%s' % (questId, curNpc, newTo)) + return True + + def isRewardOptional(tier, rewardId): return tier in OptionalRewardTrackDict and rewardId in OptionalRewardTrackDict[tier] diff --git a/toontown/safezone/DistributedSZTreasure.py b/toontown/safezone/DistributedSZTreasure.py index 1546c01..2fa7561 100644 --- a/toontown/safezone/DistributedSZTreasure.py +++ b/toontown/safezone/DistributedSZTreasure.py @@ -27,6 +27,10 @@ class DistributedSZTreasure(DistributedTreasure.DistributedTreasure): def setHolidayModelPath(self): self.defaultModelPath = self.modelPath + # newsManager can be missing during early zone load or misconfigured servers. + # Fall back to default treasure model rather than crashing the client. + if not getattr(base, 'cr', None) or not getattr(base.cr, 'newsManager', None): + return holidayIds = base.cr.newsManager.getHolidayIdList() if ToontownGlobals.VALENTINES_DAY in holidayIds: self.modelPath = 'phase_4/models/props/tt_m_ara_ext_heart' @@ -72,6 +76,8 @@ class DistributedSZTreasure(DistributedTreasure.DistributedTreasure): return def startAnimation(self): + if not getattr(base, 'cr', None) or not getattr(base.cr, 'newsManager', None): + return holidayIds = base.cr.newsManager.getHolidayIdList() if ToontownGlobals.VALENTINES_DAY in holidayIds: originalScale = self.nodePath.getScale() diff --git a/toontown/safezone/GZPlayground.py b/toontown/safezone/GZPlayground.py index 5c1c3c6..df1d8f1 100644 --- a/toontown/safezone/GZPlayground.py +++ b/toontown/safezone/GZPlayground.py @@ -7,6 +7,7 @@ from toontown.toontowngui import TTDialog from toontown.toonbase import TTLocalizer from toontown.racing import RaceGlobals from direct.fsm import State +from toontown.hood import OutdoorLighting from toontown.safezone import GolfKart class GZPlayground(Playground.Playground): @@ -35,6 +36,7 @@ class GZPlayground(Playground.Playground): def enter(self, requestStatus): Playground.Playground.enter(self, requestStatus) + OutdoorLighting.shadeExtraSubtree(self.hub) blimp = base.cr.playGame.hood.loader.geom.find('**/GS_blimp') if blimp.isEmpty(): return @@ -51,6 +53,7 @@ class GZPlayground(Playground.Playground): self.rotateBlimp.loop() def exit(self): + OutdoorLighting.clearExtraSubtree(self.hub) Playground.Playground.exit(self) if hasattr(self, 'rotateBlimp'): self.rotateBlimp.finish() diff --git a/toontown/safezone/Playground.py b/toontown/safezone/Playground.py index 5484176..9716ccc 100644 --- a/toontown/safezone/Playground.py +++ b/toontown/safezone/Playground.py @@ -11,6 +11,7 @@ from direct.showbase.MessengerGlobal import messenger from otp.distributed.TelemetryLimiter import RotationLimitToH, TLGatherAllAvs from toontown.classicchars import CCharPaths +from toontown.hood import OutdoorLighting from toontown.hood.Place import Place from toontown.quest import Quests from toontown.toon.DeathForceAcknowledge import DeathForceAcknowledge @@ -205,8 +206,11 @@ class Playground(Place): messenger.send('enterPlayground') self.accept('doorDoneEvent', self.handleDoorDoneEvent) self.accept('DistributedDoor_doorTrigger', self.handleDoorTrigger) - base.playMusic(self.loader.music, looping=1, volume=0.8) + if self.loader.music is not None: + base.playMusic(self.loader.music, looping=1, volume=0.8) self.loader.geom.reparentTo(base.render) + _hoodId = getattr(getattr(self.loader, 'hood', None), 'id', None) + OutdoorLighting.begin(self.loader.geom, 'playground', hoodId=_hoodId) for i in self.loader.nodeList: self.loader.enterAnimatedProps(i) @@ -254,6 +258,7 @@ class Playground(Place): del self.tunnelOriginList self.loader.geom.reparentTo(base.hidden) + OutdoorLighting.end(self.loader.geom) def __lightDecorationOff__(): for light in self.loader.hood.halloweenLights: @@ -264,7 +269,8 @@ class Playground(Place): self.loader.exitAnimatedProps(i) self.loader.hood.stopSky() - self.loader.music.stop() + if self.loader.music is not None: + self.loader.music.stop() def load(self): Place.load(self) @@ -407,7 +413,13 @@ class Playground(Place): del self.dfa ds = doneStatus['mode'] if ds == 'complete': - self.fsm.request('NPCFA', [requestStatus]) + # The TTC newbie quest gates (trolley/friend/first cog/etc.) can hard-lock + # "stuck tutorial" toons from leaving via map teleport/tunnels. If a toon + # has not acknowledged the tutorial, allow leaving without NPC gating. + if getattr(base.localAvatar, 'tutorialAck', 1) == 0: + self.fsm.request('HFA', [requestStatus]) + else: + self.fsm.request('NPCFA', [requestStatus]) elif ds == 'incomplete': self.fsm.request('DFAReject') else: @@ -495,61 +507,9 @@ class Playground(Place): x, y, z, h, p, r = base.cr.hoodMgr.getPlaygroundCenterFromId(self.loader.hood.id) self.accept('deathAck', self.__handleDeathAck, extraArgs=[requestStatus]) self.deathAckBox = DeathForceAcknowledge(doneEvent='deathAck') - elif base.localAvatar.hp > 0 and (Quests.avatarHasTrolleyQuest(base.localAvatar) or Quests.avatarHasFirstCogQuest(base.localAvatar) or Quests.avatarHasFriendQuest(base.localAvatar) or Quests.avatarHasPhoneQuest(base.localAvatar) and Quests.avatarHasCompletedPhoneQuest(base.localAvatar)) and self.loader.hood.id == ToontownGlobals.ToontownCentral: - requestStatus['nextState'] = 'popup' - imageModel = base.loader.loadModel('phase_4/models/gui/tfa_images') - if base.localAvatar.quests[0][0] == Quests.TROLLEY_QUEST_ID: - if not Quests.avatarHasCompletedTrolleyQuest(base.localAvatar): - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralInitialDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage3 - imgNodePath = imageModel.find('**/trolley-dialog-image') - imgPos = (0, 0, 0.04) - imgScale = 0.5 - else: - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralHQDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage4 - imgNodePath = imageModel.find('**/hq-dialog-image') - imgPos = (0, 0, -0.02) - imgScale = 0.5 - elif base.localAvatar.quests[0][0] == Quests.FIRST_COG_QUEST_ID: - if not Quests.avatarHasCompletedFirstCogQuest(base.localAvatar): - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralTunnelDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage5 - imgNodePath = imageModel.find('**/tunnelSignA') - imgPos = (0, 0, 0.04) - imgScale = 0.5 - else: - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralHQDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage6 - imgNodePath = imageModel.find('**/hq-dialog-image') - imgPos = (0, 0, 0.05) - imgScale = 0.5 - elif base.localAvatar.quests[0][0] == Quests.FRIEND_QUEST_ID: - if not Quests.avatarHasCompletedFriendQuest(base.localAvatar): - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralInitialDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage7 - gui = base.loader.loadModel('phase_3.5/models/gui/friendslist_gui') - imgNodePath = gui.find('**/FriendsBox_Closed') - imgPos = (0, 0, 0.04) - imgScale = 1.0 - gui.removeNode() - else: - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralHQDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage8 - imgNodePath = imageModel.find('**/hq-dialog-image') - imgPos = (0, 0, 0.05) - imgScale = 0.5 - elif base.localAvatar.quests[0][0] == Quests.PHONE_QUEST_ID: - if Quests.avatarHasCompletedPhoneQuest(base.localAvatar): - x, y, z, h, p, r = base.cr.hoodMgr.getDropPoint(base.cr.hoodMgr.ToontownCentralHQDropPoints) - msg = TTLocalizer.NPCForceAcknowledgeMessage9 - imgNodePath = imageModel.find('**/hq-dialog-image') - imgPos = (0, 0, 0.05) - imgScale = 0.5 - - self.dialog = TTDialog.TTDialog(text=msg, command=self.__cleanupDialog, style=TTDialog.Acknowledge) - imgLabel = DirectLabel(parent=self.dialog, relief=None, pos=imgPos, scale=TTLocalizer.PimgLabel, image=imgNodePath, image_scale=imgScale) - imageModel.removeNode() + # Disable TTC "newbie quest" force-acknowledge popups (trolley/friend/phone/first cog). + # These popups are what display "You must ride the trolley before leaving" and can + # hard-lock travel via tunnels or the map teleport. else: requestStatus['nextState'] = 'walk' x, y, z, h, p, r = base.cr.hoodMgr.getPlaygroundCenterFromId(self.loader.hood.id) diff --git a/toontown/safezone/SafeZoneLoader.py b/toontown/safezone/SafeZoneLoader.py index 5d1735b..3ec96f6 100644 --- a/toontown/safezone/SafeZoneLoader.py +++ b/toontown/safezone/SafeZoneLoader.py @@ -37,7 +37,11 @@ class SafeZoneLoader(StateData.StateData): def load(self): self.music = base.loader.loadMusic(self.musicFile) + if self.music is None: + self.notify.warning('Missing music file %s (incomplete resources).' % self.musicFile) self.activityMusic = base.loader.loadMusic(self.activityMusicFile) + if self.activityMusic is None: + self.notify.warning('Missing music file %s (incomplete resources).' % self.activityMusicFile) self.createSafeZone(self.dnaFile) self.parentFSMState.addChild(self.fsm) @@ -97,7 +101,9 @@ class SafeZoneLoader(StateData.StateData): # Skip flattenMedium for faster loading # self.geom.flattenMedium() gsg = base.win.getGsg() - if gsg: + # prepareScene walks the entire zone graph and can trigger huge C++ task/event + # bursts on large DNA scenes. Optional skip: safezone-want-prepare-scene #f + if gsg and base.config.GetBool('dna-want-prepare-scene', True): def prepareSceneTask(task, geom=self.geom, gsg=gsg): geom.prepareScene(gsg) return task.done @@ -110,7 +116,8 @@ class SafeZoneLoader(StateData.StateData): groupName = base.cr.hoodMgr.extractGroupName(groupFullName) groupNode = self.geom.find('**/' + groupFullName) if groupNode.isEmpty(): - self.notify.error('Could not find visgroup') + self.notify.warning('Could not find visgroup %s; using placeholder (check phase/DNA resources).' % groupFullName) + groupNode = self.geom.attachNewNode('missingVis_' + str(i)) self.nodeList.append(groupNode) self.removeLandmarkBlockNodes() diff --git a/toontown/safezone/TTPlayground.py b/toontown/safezone/TTPlayground.py index 236201f..61411c4 100644 --- a/toontown/safezone/TTPlayground.py +++ b/toontown/safezone/TTPlayground.py @@ -4,12 +4,22 @@ from . import Playground import random from toontown.launcher import DownloadForceAcknowledge from direct.task.Task import Task +from direct.fsm import State from toontown.hood import ZoneUtil class TTPlayground(Playground.Playground): def __init__(self, loader, parentFSM, doneEvent): Playground.Playground.__init__(self, loader, parentFSM, doneEvent) + self.fsm.addState(State.State('crane', self.enterCraneTTC, self.exitCraneTTC, ['finalBattle'])) + self.fsm.addState(State.State('finalBattle', self.enterFinalBattleTTC, self.exitFinalBattleTTC, ['walk', + 'crane'])) + for name in ('walk', 'stickerBook', 'fishing', 'trolley', 'quest', 'purchase', 'stopped', 'DFA', 'HFA', 'TFA', 'teleportIn', 'popup', 'doorIn', 'doorOut', 'deathAck', 'NPCFA', 'NPCFAReject', 'trialerFA'): + try: + self.fsm.getStateNamed(name).addTransition('crane') + self.fsm.getStateNamed(name).addTransition('finalBattle') + except KeyError: + pass def load(self): Playground.Playground.load(self) @@ -26,6 +36,8 @@ class TTPlayground(Playground.Playground): taskMgr.remove('TT-birds') def __birds(self, task): + if not self.loader.birdSound: + return Task.done base.playSfx(random.choice(self.loader.birdSound)) t = random.random() * 20.0 + 1 taskMgr.doMethodLater(t, self.__birds, 'TT-birds') @@ -48,6 +60,25 @@ class TTPlayground(Playground.Playground): else: self.dfa.enter(5) + def enterCraneTTC(self): + base.localAvatar.setTeleportAvailable(0) + base.localAvatar.laffMeter.start() + base.localAvatar.collisionsOn() + + def exitCraneTTC(self): + base.localAvatar.collisionsOff() + base.localAvatar.laffMeter.stop() + + def enterFinalBattleTTC(self, *args): + taskMgr.doMethodLater(0.0, self.__requestWalkAfterCrane, 'TTPlayground-finalBattle') + + def __requestWalkAfterCrane(self, task): + self.fsm.request('walk', [0]) + return Task.done + + def exitFinalBattleTTC(self): + taskMgr.remove('TTPlayground-finalBattle') + def showPaths(self): from toontown.classicchars import CCharPaths from toontown.toonbase import TTLocalizer diff --git a/toontown/safezone/TTSafeZoneLoader.py b/toontown/safezone/TTSafeZoneLoader.py index e9919ca..50113c3 100644 --- a/toontown/safezone/TTSafeZoneLoader.py +++ b/toontown/safezone/TTSafeZoneLoader.py @@ -16,7 +16,8 @@ class TTSafeZoneLoader(SafeZoneLoader.SafeZoneLoader): def load(self): SafeZoneLoader.SafeZoneLoader.load(self) - self.birdSound = list(map(base.loader.loadSfx, ['phase_4/audio/sfx/SZ_TC_bird1.ogg', 'phase_4/audio/sfx/SZ_TC_bird2.ogg', 'phase_4/audio/sfx/SZ_TC_bird3.ogg'])) + birdPaths = ['phase_4/audio/sfx/SZ_TC_bird1.ogg', 'phase_4/audio/sfx/SZ_TC_bird2.ogg', 'phase_4/audio/sfx/SZ_TC_bird3.ogg'] + self.birdSound = [s for s in (base.loader.loadSfx(p) for p in birdPaths) if s is not None] def unload(self): del self.birdSound diff --git a/toontown/shtiker/DisplaySettingsDialog.py b/toontown/shtiker/DisplaySettingsDialog.py index d98ae1a..5f7544f 100644 --- a/toontown/shtiker/DisplaySettingsDialog.py +++ b/toontown/shtiker/DisplaySettingsDialog.py @@ -16,6 +16,17 @@ class DisplaySettingsDialog(DirectFrame, StateData.StateData): EmbeddedMode = 2 notify = DirectNotifyGlobal.directNotify.newCategory('DisplaySettingsDialog') + class _Layout: + def __init__(self): + self.leftX = -0.62 + self.labelRightX = -0.05 + self.controlLeftX = 0.05 + self.topY = 0.20 + self.rowH = 0.11 + + def rowY(self, i): + return self.topY - i * self.rowH + def __init__(self): DirectFrame.__init__(self, pos=(0, 0, 0.3), relief=None, image=DGG.getDefaultDialogGeom(), image_scale=(1.6, 1, 1.2), image_pos=(0, 0, -0.05), image_color=ToontownGlobals.GlobalDialogColor, text=TTLocalizer.DisplaySettingsTitle, text_scale=0.12, text_pos=(0, 0.4), borderWidth=(0.01, 0.01)) StateData.StateData.__init__(self, 'display-settings-done') @@ -37,6 +48,7 @@ class DisplaySettingsDialog(DirectFrame, StateData.StateData): self.isLoaded = 1 self.anyChanged = 0 self.apiChanged = 0 + layout = self._Layout() # Standard resolutions including widescreen (16:9 and 16:10) screenSizes = [(640, 480), # 4:3 (800, 600), # 4:3 @@ -66,30 +78,30 @@ class DisplaySettingsDialog(DirectFrame, StateData.StateData): innerCircle.setPos(0, 0, 0.2) self.c1b = circle.copyTo(self, -1) self.c1b.setColor(0, 0, 0, 1) - self.c1b.setPos(0.044, 0, -0.21) + self.c1b.setPos(layout.controlLeftX + 0.03, 0, layout.rowY(3) + 0.01) self.c1b.setScale(0.4) c1f = circle.copyTo(self.c1b) c1f.setColor(1, 1, 1, 1) c1f.setScale(0.8) self.c2b = circle.copyTo(self, -2) self.c2b.setColor(0, 0, 0, 1) - self.c2b.setPos(0.044, 0, -0.3) + self.c2b.setPos(layout.controlLeftX + 0.03, 0, layout.rowY(4) + 0.01) self.c2b.setScale(0.4) c2f = circle.copyTo(self.c2b) c2f.setColor(1, 1, 1, 1) c2f.setScale(0.8) self.c3b = circle.copyTo(self, -2) self.c3b.setColor(0, 0, 0, 1) - self.c3b.setPos(0.044, 0, -0.4) + self.c3b.setPos(layout.controlLeftX + 0.03, 0, layout.rowY(5) + 0.01) self.c3b.setScale(0.4) c3f = circle.copyTo(self.c3b) c3f.setColor(1, 1, 1, 1) c3f.setScale(0.8) - self.introText = DirectLabel(parent=self, relief=None, scale=TTLocalizer.DSDintroText, text=TTLocalizer.DisplaySettingsIntro, text_wordwrap=TTLocalizer.DSDintroTextWordwrap, text_align=TextNode.ALeft, pos=(-0.725, 0, 0.3)) - self.introTextSimple = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsIntroSimple, text_wordwrap=25, text_align=TextNode.ALeft, pos=(-0.725, 0, 0.3)) - self.apiLabel = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsApi, text_align=TextNode.ARight, pos=(-0.08, 0, 0)) - self.apiMenu = DirectOptionMenu(parent=self, relief=DGG.RAISED, scale=0.06, items=['x'], pos=(0, 0, 0)) - self.screenSizeLabel = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsResolution, text_align=TextNode.ARight, pos=(-0.08, 0, -0.1)) + self.introText = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsIntro, text_wordwrap=30, text_align=TextNode.ALeft, pos=(layout.leftX, 0, layout.rowY(0) + 0.08)) + self.introTextSimple = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsIntroSimple, text_wordwrap=30, text_align=TextNode.ALeft, pos=(layout.leftX, 0, layout.rowY(0) + 0.08)) + self.apiLabel = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsApi, text_align=TextNode.ARight, pos=(layout.labelRightX, 0, layout.rowY(1))) + self.apiMenu = DirectOptionMenu(parent=self, relief=DGG.RAISED, scale=0.06, items=['x'], pos=(layout.controlLeftX, 0, layout.rowY(1))) + self.screenSizeLabel = DirectLabel(parent=self, relief=None, scale=0.06, text=TTLocalizer.DisplaySettingsResolution, text_align=TextNode.ARight, pos=(layout.labelRightX, 0, layout.rowY(2))) self.screenSizeLeftArrow = DirectButton(parent=self, relief=None, image=(gui.find('**/Horiz_Arrow_UP'), gui.find('**/Horiz_Arrow_DN'), gui.find('**/Horiz_Arrow_Rllvr'), @@ -98,12 +110,14 @@ class DisplaySettingsDialog(DirectFrame, StateData.StateData): gui.find('**/Horiz_Arrow_DN'), gui.find('**/Horiz_Arrow_Rllvr'), gui.find('**/Horiz_Arrow_UP')), pos=(0.54, 0, -0.085), command=self.__doScreenSizeRight) - self.screenSizeValueText = DirectLabel(parent=self, relief=None, text='x', text_align=TextNode.ACenter, text_scale=0.06, pos=(0.29, 0, -0.1)) - self.windowedButton = DirectCheckButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsWindowed, text_align=TextNode.ALeft, text_scale=0.6, scale=0.1, boxImage=innerCircle, boxImageScale=2.5, boxImageColor=VBase4(0, 0.25, 0.5, 1), boxRelief=None, pos=TTLocalizer.DSDwindowedButtonPos, command=self.__doWindowed) - self.fullscreenButton = DirectCheckButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsFullscreen, text_align=TextNode.ALeft, text_scale=0.6, scale=0.1, boxImage=innerCircle, boxImageScale=2.5, boxImageColor=VBase4(0, 0.25, 0.5, 1), boxRelief=None, pos=TTLocalizer.DSDfullscreenButtonPos, command=self.__doFullscreen) - self.embeddedButton = DirectCheckButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsEmbedded, text_align=TextNode.ALeft, text_scale=0.6, scale=0.1, boxImage=innerCircle, boxImageScale=2.5, boxImageColor=VBase4(0, 0.25, 0.5, 1), boxRelief=None, pos=TTLocalizer.DSDembeddedButtonPos, command=self.__doEmbedded) + self.screenSizeLeftArrow.setPos(layout.controlLeftX + 0.03, 0, layout.rowY(2) + 0.015) + self.screenSizeRightArrow.setPos(layout.controlLeftX + 0.53, 0, layout.rowY(2) + 0.015) + self.screenSizeValueText = DirectLabel(parent=self, relief=None, text='x', text_align=TextNode.ACenter, text_scale=0.06, pos=(layout.controlLeftX + 0.28, 0, layout.rowY(2))) + self.windowedButton = DirectCheckButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsWindowed, text_align=TextNode.ALeft, text_scale=0.6, scale=0.1, boxImage=innerCircle, boxImageScale=2.5, boxImageColor=VBase4(0, 0.25, 0.5, 1), boxRelief=None, pos=(layout.controlLeftX, 0, layout.rowY(3)), command=self.__doWindowed) + self.fullscreenButton = DirectCheckButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsFullscreen, text_align=TextNode.ALeft, text_scale=0.6, scale=0.1, boxImage=innerCircle, boxImageScale=2.5, boxImageColor=VBase4(0, 0.25, 0.5, 1), boxRelief=None, pos=(layout.controlLeftX, 0, layout.rowY(4)), command=self.__doFullscreen) + self.embeddedButton = DirectCheckButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsEmbedded, text_align=TextNode.ALeft, text_scale=0.6, scale=0.1, boxImage=innerCircle, boxImageScale=2.5, boxImageColor=VBase4(0, 0.25, 0.5, 1), boxRelief=None, pos=(layout.controlLeftX, 0, layout.rowY(5)), command=self.__doEmbedded) self.apply = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=(0.6, 1, 1), text=TTLocalizer.DisplaySettingsApply, text_scale=0.06, text_pos=(0, -0.02), pos=(0.52, 0, -0.53), command=self.__apply) - self.cancel = DirectButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsCancel, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=(0.6, 1, 1), text_scale=TTLocalizer.DSDcancel, text_pos=TTLocalizer.DSDcancelPos, pos=(0.2, 0, -0.53), command=self.__cancel) + self.cancel = DirectButton(parent=self, relief=None, text=TTLocalizer.DisplaySettingsCancel, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=(0.6, 1, 1), text_scale=0.06, text_pos=(0, -0.02), pos=(0.2, 0, -0.53), command=self.__cancel) guiButton.removeNode() gui.removeNode() nameShopGui.removeNode() diff --git a/toontown/shtiker/OptionsPage.py b/toontown/shtiker/OptionsPage.py index dc5d84e..47087aa 100644 --- a/toontown/shtiker/OptionsPage.py +++ b/toontown/shtiker/OptionsPage.py @@ -3,6 +3,9 @@ from . import ShtikerPage from toontown.toontowngui import TTDialog from direct.gui.DirectGui import * from toontown.toonbase import TTLocalizer +from toontown.toonbase import ToontownGlobals +from toontown.toonbase import ToontownBattleGlobals +from toontown.hood import OutdoorLighting from . import DisplaySettingsDialog from direct.task import Task from otp.speedchat import SpeedChat @@ -51,6 +54,7 @@ speedChatStyles = ((2000, (165 / 255.0, 120 / 255.0, 50 / 255.0), (210 / 255.0, 200 / 255.0, 180 / 255.0))) PageMode = IntEnum('PageMode', ('Options', 'Codes'), start=0) +OptionsSubTab = IntEnum('OptionsSubTab', ('Audio', 'Social', 'DisplayChat', 'Advanced', 'Gameplay', 'Lighting'), start=0) class OptionsPage(ShtikerPage.ShtikerPage): notify = DirectNotifyGlobal.directNotify.newCategory('OptionsPage') @@ -86,6 +90,7 @@ class OptionsPage(ShtikerPage.ShtikerPage): def unload(self): self.optionsTabPage.unload() + self.codesTabPage.unload() del self.title ShtikerPage.ShtikerPage.unload(self) @@ -117,10 +122,42 @@ class OptionsPage(ShtikerPage.ShtikerPage): class OptionsTabPage(DirectFrame): notify = DirectNotifyGlobal.directNotify.newCategory('OptionsTabPage') DisplaySettingsTaskName = 'save-display-settings' - DisplaySettingsDelay = 60 ChangeDisplaySettings = ConfigVariableBool('change-display-settings', 1).value ChangeDisplayAPI = ConfigVariableBool('change-display-api', 0).value + class _Layout: + def __init__(self): + self.topY = 0.38 + self.helpY = self.topY - 0.065 + self.rowHeight = 0.125 + # Controls start below the help paragraph. + self.firstRowOffset = 0.17 + self.leftX = -0.72 + self.controlX = 0.35 + self.sliderX = self.controlX + 0.2 + self.valueX = self.controlX + 0.5 + + def row(self, n): + return self.topY - self.firstRowOffset - n * self.rowHeight + + def headerPos(self): + return (self.leftX, 0, self.topY) + + def helpPos(self): + return (self.leftX, 0, self.helpY) + + def labelPos(self, n, zOffset=0.0): + return (self.leftX, 0, self.row(n) + zOffset) + + def controlPos(self, n, xOffset=0.0, zOffset=0.0): + return (self.controlX + xOffset, 0, self.row(n) + zOffset) + + def sliderPos(self, n): + return (self.sliderX, 0.0, self.row(n)) + + def valuePos(self, n): + return (self.valueX, 0.0, self.row(n)) + def __init__(self, parent = aspect2d): self._parent = parent self.currentSizeIndex = None @@ -141,79 +178,547 @@ class OptionsTabPage(DirectFrame): self.displaySettingsEmbedded = None self.displaySettingsApi = None self.displaySettingsApiChanged = 0 + self.subTabButtons = {} + self.subPanels = {} + self.subTabMode = OptionsSubTab.Audio + self._preMuteMusic = None + self._preMuteSfx = None guiButton = loader.loadModel('phase_3/models/gui/quit_button') gui = loader.loadModel('phase_3.5/models/gui/friendslist_gui') - titleHeight = 0.61 - textStartHeight = 0.45 - textRowHeight = 0.145 - leftMargin = -0.72 - buttonbase_xcoord = 0.35 - buttonbase_ycoord = 0.45 + tabGui = loader.loadModel('phase_3.5/models/gui/fishingBook') + layout = self._Layout() button_image_scale = (0.7, 1, 1) button_textpos = (0, -0.02) options_text_scale = 0.052 - disabled_arrow_color = Vec4(0.6, 0.6, 0.6, 1.0) self.speed_chat_scale = 0.055 - self.Music_Label = DirectLabel(parent=self, relief=None, text=TTLocalizer.OptionsPageMusicVolumeLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, pos=(leftMargin, 0, textStartHeight)) - self.SoundFX_Label = DirectLabel(parent=self, relief=None, text=TTLocalizer.OptionsPageSFXVolumeLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=(leftMargin, 0, textStartHeight - textRowHeight)) - self.Friends_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=(leftMargin, 0, textStartHeight - 3 * textRowHeight)) - self.Whispers_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=(leftMargin, 0, textStartHeight - 4 * textRowHeight)) - self.DisplaySettings_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=10, pos=(leftMargin, 0, textStartHeight - 5 * textRowHeight)) - self.SpeedChatStyle_Label = DirectLabel(parent=self, relief=None, text=TTLocalizer.OptionsPageSpeedChatStyleLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=10, pos=(leftMargin, 0, textStartHeight - 6 * textRowHeight)) - self.ToonChatSounds_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 2 * textRowHeight + 0.025)) + accentFg = Vec4(0.12, 0.35, 0.62, 1) + helpFg = Vec4(0.28, 0.22, 0.14, 0.85) + normalColor = (1, 1, 1, 1) + clickColor = (0.8, 0.8, 0, 1) + rolloverColor = (0.15, 0.82, 1.0, 1) + disabledColor = (1.0, 0.98, 0.15, 1) + # Sub-tab art (background) alignment. The tab geometry is rotated -90, + # so we keep it centered and *higher* to avoid overlapping content. + subTabImagePos = (0.0, 1, -0.62) + subTabImageScale = (0.022, 0.022, 0.024) + subTabZ = 0.62 + tabGeomCycle = ('**/tabs/polySurface1', '**/tabs/polySurface2') + tabSpecs = ( + (OptionsSubTab.Audio, TTLocalizer.OptionsPageSubTabAudio, -0.50), + (OptionsSubTab.Social, TTLocalizer.OptionsPageSubTabSocial, -0.30), + (OptionsSubTab.DisplayChat, TTLocalizer.OptionsPageSubTabDisplayChat, -0.10), + (OptionsSubTab.Advanced, TTLocalizer.OptionsPageSubTabAdvanced, 0.10), + (OptionsSubTab.Gameplay, TTLocalizer.OptionsPageSubTabGameplay, 0.30), + (OptionsSubTab.Lighting, TTLocalizer.OptionsPageSubTabLighting, 0.50), + ) + for idx, (mode, tabLabel, xpos) in enumerate(tabSpecs): + geom = tabGeomCycle[idx % len(tabGeomCycle)] + btn = DirectButton( + parent=self, + relief=None, + text=tabLabel, + text_scale=TTLocalizer.OPsubTab, + text_align=TextNode.ACenter, + text_pos=(0.0, -0.015, 0.0), + image=tabGui.find(geom), + image_pos=subTabImagePos, + image_hpr=(0, 0, -90), + image_scale=subTabImageScale, + image_color=normalColor, + image1_color=clickColor, + image2_color=rolloverColor, + image3_color=disabledColor, + text_fg=Vec4(0.2, 0.1, 0, 1), + command=self.setSubTab, + extraArgs=[mode], + pos=(xpos, 0, subTabZ), + ) + self.subTabButtons[mode] = btn + tabGui.removeNode() + pAudio = DirectFrame(parent=self, relief=None, pos=(0, 0, -0.015)) + pSocial = DirectFrame(parent=self, relief=None, pos=(0, 0, -0.015)) + pDisplay = DirectFrame(parent=self, relief=None, pos=(0, 0, -0.015)) + pAdvanced = DirectFrame(parent=self, relief=None, pos=(0, 0, -0.015)) + pGameplay = DirectFrame(parent=self, relief=None, pos=(0, 0, -0.015)) + pLighting = DirectFrame(parent=self, relief=None, pos=(0, 0, -0.015)) + self.subPanels = { + OptionsSubTab.Audio: pAudio, + OptionsSubTab.Social: pSocial, + OptionsSubTab.DisplayChat: pDisplay, + OptionsSubTab.Advanced: pAdvanced, + OptionsSubTab.Gameplay: pGameplay, + OptionsSubTab.Lighting: pLighting, + } + topY = layout.topY + helpY = layout.helpY + + DirectLabel( + parent=pAudio, + relief=None, + text=TTLocalizer.OptionsPageSectionAudioTitle, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsectionTitle, + text_fg=accentFg, + pos=layout.headerPos(), + ) + DirectLabel( + parent=pAudio, + relief=None, + text=TTLocalizer.OptionsPageSectionAudioHelp, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsubTabHelp, + text_fg=helpFg, + text_wordwrap=22, + pos=layout.helpPos(), + ) + self.MuteAll_Button = DirectButton( + parent=pAudio, + relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=(0.52, 1, 0.95), + text=TTLocalizer.OptionsPageMuteAll, + text_scale=options_text_scale * 0.9, + text_pos=button_textpos, + pos=layout.controlPos(0, xOffset=-0.16), + command=self.__doMuteAllAudio, + ) + self.RestoreAudio_Button = DirectButton( + parent=pAudio, + relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=(0.52, 1, 0.95), + text=TTLocalizer.OptionsPageRestoreAudio, + text_scale=options_text_scale * 0.9, + text_pos=button_textpos, + pos=layout.controlPos(0, xOffset=0.16), + command=self.__doRestoreAudio, + ) + self.Music_Label = DirectLabel(parent=pAudio, relief=None, text=TTLocalizer.OptionsPageMusicVolumeLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, pos=layout.labelPos(1)) + self.Music_toggleSlider = DirectSlider(parent=pAudio, relief=DGG.FLAT, range=(0, 100), value=100, pageSize=5, pos=layout.sliderPos(1), scale=0.5, command=self.__setMusicVolume) + self.Music_volumeLabel = DirectLabel(parent=pAudio, relief=None, text='100%', text_scale=options_text_scale * 0.8, pos=layout.valuePos(1)) + self.SoundFX_Label = DirectLabel(parent=pAudio, relief=None, text=TTLocalizer.OptionsPageSFXVolumeLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=layout.labelPos(2)) + self.SoundFX_toggleSlider = DirectSlider(parent=pAudio, relief=DGG.FLAT, range=(0, 100), value=100, pageSize=5, pos=layout.sliderPos(2), scale=0.5, command=self.__setSfxVolume) + self.SoundFX_volumeLabel = DirectLabel(parent=pAudio, relief=None, text='100%', text_scale=options_text_scale * 0.8, pos=layout.valuePos(2)) + self.ToonChatSounds_Label = DirectLabel(parent=pAudio, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=layout.labelPos(3, zOffset=0.02)) self.ToonChatSounds_Label.setScale(0.9) - self.TKeyOnlyChat_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 7 * textRowHeight)) - # New QOL options - self.ShowFPS_Label = DirectLabel(parent=self, relief=None, text='Show FPS Counter', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 8 * textRowHeight)) - self.MouseSensitivity_Label = DirectLabel(parent=self, relief=None, text='Mouse Sensitivity', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 9 * textRowHeight)) - self.CameraFOV_Label = DirectLabel(parent=self, relief=None, text='Camera FOV', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 10 * textRowHeight)) - self.SmoothAnimations_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 11 * textRowHeight)) - self.ShowNametags_Label = DirectLabel(parent=self, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=(leftMargin, 0, textStartHeight - 12 * textRowHeight)) - # Controls info - WASD and Arrow keys both work - self.Controls_Label = DirectLabel(parent=self, relief=None, text='Controls: WASD and Arrow Keys, F9 for Screenshot', text_align=TextNode.ALeft, text_scale=options_text_scale * 0.75, text_wordwrap=25, pos=(leftMargin, 0, textStartHeight - 13 * textRowHeight)) - # Music volume slider - self.Music_toggleSlider = DirectSlider(parent=self, relief=DGG.FLAT, range=(0, 100), value=100, pageSize=5, pos=(buttonbase_xcoord + 0.2, 0.0, buttonbase_ycoord), scale=0.5, command=self.__setMusicVolume) - self.Music_volumeLabel = DirectLabel(parent=self, relief=None, text='100%', text_scale=options_text_scale * 0.8, pos=(buttonbase_xcoord + 0.5, 0.0, buttonbase_ycoord)) - # SFX volume slider - self.SoundFX_toggleSlider = DirectSlider(parent=self, relief=DGG.FLAT, range=(0, 100), value=100, pageSize=5, pos=(buttonbase_xcoord + 0.2, 0.0, buttonbase_ycoord - textRowHeight), scale=0.5, command=self.__setSfxVolume) - self.SoundFX_volumeLabel = DirectLabel(parent=self, relief=None, text='100%', text_scale=options_text_scale * 0.8, pos=(buttonbase_xcoord + 0.5, 0.0, buttonbase_ycoord - textRowHeight)) - self.Friends_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 3), command=self.__doToggleAcceptFriends) - self.Whispers_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 4), command=self.__doToggleAcceptWhispers) - self.DisplaySettingsButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image3_color=Vec4(0.5, 0.5, 0.5, 0.5), image_scale=button_image_scale, text=TTLocalizer.OptionsPageChange, text3_fg=(0.5, 0.5, 0.5, 0.75), text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 5), command=self.__doDisplaySettings) - self.speedChatStyleLeftArrow = DirectButton(parent=self, relief=None, image=(gui.find('**/Horiz_Arrow_UP'), - gui.find('**/Horiz_Arrow_DN'), - gui.find('**/Horiz_Arrow_Rllvr'), - gui.find('**/Horiz_Arrow_UP')), image3_color=Vec4(1, 1, 1, 0.5), scale=(-1.0, 1.0, 1.0), pos=(0.25, 0, buttonbase_ycoord - textRowHeight * 6), command=self.__doSpeedChatStyleLeft) - self.speedChatStyleRightArrow = DirectButton(parent=self, relief=None, image=(gui.find('**/Horiz_Arrow_UP'), - gui.find('**/Horiz_Arrow_DN'), - gui.find('**/Horiz_Arrow_Rllvr'), - gui.find('**/Horiz_Arrow_UP')), image3_color=Vec4(1, 1, 1, 0.5), pos=(0.65, 0, buttonbase_ycoord - textRowHeight * 6), command=self.__doSpeedChatStyleRight) - self.ToonChatSounds_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), - guiButton.find('**/QuitBtn_DN'), - guiButton.find('**/QuitBtn_RLVR'), - guiButton.find('**/QuitBtn_UP')), image3_color=Vec4(0.5, 0.5, 0.5, 0.5), image_scale=button_image_scale, text='', text3_fg=(0.5, 0.5, 0.5, 0.75), text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 2 + 0.025), command=self.__doToggleToonChatSounds) + self.ToonChatSounds_toggleButton = DirectButton( + parent=pAudio, + relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR'), guiButton.find('**/QuitBtn_UP')), + image3_color=Vec4(0.5, 0.5, 0.5, 0.5), + image_scale=button_image_scale, + text='', + text3_fg=(0.5, 0.5, 0.5, 0.75), + text_scale=options_text_scale, + text_pos=button_textpos, + pos=layout.controlPos(3), + command=self.__doToggleToonChatSounds, + ) self.ToonChatSounds_toggleButton.setScale(0.8) - self.TKeyOnlyChat_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 7), command=self.__doToggleTKeyOnlyChat) - # New QOL option controls - self.ShowFPS_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 8), command=self.__doToggleShowFPS) - self.MouseSensitivity_Slider = DirectSlider(parent=self, relief=DGG.FLAT, range=(0.5, 2.0), value=1.0, pageSize=0.1, pos=(buttonbase_xcoord + 0.2, 0.0, buttonbase_ycoord - textRowHeight * 9), scale=0.5, command=self.__setMouseSensitivity) - self.MouseSensitivity_valueLabel = DirectLabel(parent=self, relief=None, text='1.0x', text_scale=options_text_scale * 0.8, pos=(buttonbase_xcoord + 0.5, 0.0, buttonbase_ycoord - textRowHeight * 9)) - self.CameraFOV_Slider = DirectSlider(parent=self, relief=DGG.FLAT, range=(40, 90), value=52.5, pageSize=1, pos=(buttonbase_xcoord + 0.2, 0.0, buttonbase_ycoord - textRowHeight * 10), scale=0.5, command=self.__setCameraFOV) - self.CameraFOV_valueLabel = DirectLabel(parent=self, relief=None, text='52.5°', text_scale=options_text_scale * 0.8, pos=(buttonbase_xcoord + 0.5, 0.0, buttonbase_ycoord - textRowHeight * 10)) - self.SmoothAnimations_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 11), command=self.__doToggleSmoothAnimations) - self.ShowNametags_toggleButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=(buttonbase_xcoord, 0.0, buttonbase_ycoord - textRowHeight * 12), command=self.__doToggleShowNametags) + DirectLabel( + parent=pSocial, + relief=None, + text=TTLocalizer.OptionsPageSectionSocialTitle, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsectionTitle, + text_fg=accentFg, + pos=layout.headerPos(), + ) + DirectLabel( + parent=pSocial, + relief=None, + text=TTLocalizer.OptionsPageSectionSocialHelp, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsubTabHelp, + text_fg=helpFg, + text_wordwrap=22, + pos=layout.helpPos(), + ) + self.Friends_Label = DirectLabel(parent=pSocial, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=18, pos=layout.labelPos(0)) + self.Friends_toggleButton = DirectButton(parent=pSocial, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(0), command=self.__doToggleAcceptFriends) + self.Whispers_Label = DirectLabel(parent=pSocial, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=18, pos=layout.labelPos(1)) + self.Whispers_toggleButton = DirectButton(parent=pSocial, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(1), command=self.__doToggleAcceptWhispers) + self.TKeyOnlyChat_Label = DirectLabel(parent=pSocial, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=18, pos=layout.labelPos(2)) + self.TKeyOnlyChat_toggleButton = DirectButton(parent=pSocial, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(2), command=self.__doToggleTKeyOnlyChat) + DirectLabel( + parent=pDisplay, + relief=None, + text=TTLocalizer.OptionsPageSectionDisplayTitle, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsectionTitle, + text_fg=accentFg, + pos=layout.headerPos(), + ) + DirectLabel( + parent=pDisplay, + relief=None, + text=TTLocalizer.OptionsPageSectionDisplayHelp, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsubTabHelp, + text_fg=helpFg, + text_wordwrap=22, + pos=layout.helpPos(), + ) + self.Particles_Label = DirectLabel(parent=pDisplay, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=18, pos=layout.labelPos(1)) + self.Particles_toggleButton = DirectButton(parent=pDisplay, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(1), command=self.__doToggleParticles) + self.DynamicShadows_Label = DirectLabel(parent=pDisplay, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=18, pos=layout.labelPos(2)) + self.DynamicShadows_toggleButton = DirectButton(parent=pDisplay, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(2), command=self.__doToggleDynamicShadows) + self.DisplaySettings_Label = DirectLabel(parent=pDisplay, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=26, pos=layout.labelPos(3)) + self.DisplaySettingsButton = DirectButton(parent=pDisplay, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image3_color=Vec4(0.5, 0.5, 0.5, 0.5), image_scale=button_image_scale, text=TTLocalizer.OptionsPageChange, text3_fg=(0.5, 0.5, 0.5, 0.75), text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(4), command=self.__doDisplaySettings) + self.SpeedChatStyle_Label = DirectLabel(parent=pDisplay, relief=None, text=TTLocalizer.OptionsPageSpeedChatStyleLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=12, pos=layout.labelPos(5, zOffset=0.04)) + scY = layout.row(6) + 0.02 + self.speedChatStyleLeftArrow = DirectButton(parent=pDisplay, relief=None, image=(gui.find('**/Horiz_Arrow_UP'), gui.find('**/Horiz_Arrow_DN'), gui.find('**/Horiz_Arrow_Rllvr'), gui.find('**/Horiz_Arrow_UP')), image3_color=Vec4(1, 1, 1, 0.5), scale=(-1.0, 1.0, 1.0), pos=(0.25, 0, scY), command=self.__doSpeedChatStyleLeft) + self.speedChatStyleRightArrow = DirectButton(parent=pDisplay, relief=None, image=(gui.find('**/Horiz_Arrow_UP'), gui.find('**/Horiz_Arrow_DN'), gui.find('**/Horiz_Arrow_Rllvr'), gui.find('**/Horiz_Arrow_UP')), image3_color=Vec4(1, 1, 1, 0.5), pos=(0.65, 0, scY), command=self.__doSpeedChatStyleRight) self.speedChatStyleText = SpeedChat.SpeedChat(name='OptionsPageStyleText', structure=[2000], backgroundModelName='phase_3/models/gui/ChatPanel', guiModelName='phase_3.5/models/gui/speedChatGui') self.speedChatStyleText.setScale(self.speed_chat_scale) - self.speedChatStyleText.setPos(0.37, 0, buttonbase_ycoord - textRowHeight * 6 + 0.03) - self.speedChatStyleText.reparentTo(self, DGG.FOREGROUND_SORT_INDEX) - self.exitButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=1.15, text=TTLocalizer.OptionsPageExitToontown, text_scale=options_text_scale, text_pos=button_textpos, textMayChange=0, pos=(0.45, 0, -0.6), command=self.__handleExitShowWithConfirm) + self.speedChatStyleText.setPos(0.37, 0, scY + 0.03) + self.speedChatStyleText.reparentTo(pDisplay, DGG.FOREGROUND_SORT_INDEX) + DirectLabel( + parent=pGameplay, + relief=None, + text=TTLocalizer.OptionsPageSectionGameplayTitle, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsectionTitle, + text_fg=accentFg, + pos=layout.headerPos(), + ) + DirectLabel( + parent=pGameplay, + relief=None, + text=TTLocalizer.OptionsPageSectionGameplayHelp, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsubTabHelp, + text_fg=helpFg, + text_wordwrap=22, + pos=layout.helpPos(), + ) + self.SkipBattleMovies_Label = DirectLabel(parent=pGameplay, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=20, pos=layout.labelPos(0)) + self.SkipBattleMovies_toggleButton = DirectButton(parent=pGameplay, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(0), command=self.__doToggleSkipBattleMovies) + self.WalkSpeed_Label = DirectLabel(parent=pGameplay, relief=None, text=TTLocalizer.OptionsPageWalkSpeedLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=layout.labelPos(1)) + self.WalkSpeed_Slider = DirectSlider(parent=pGameplay, relief=DGG.FLAT, range=(75, 125), value=100, pageSize=5, pos=layout.sliderPos(1), scale=0.5, command=self.__setWalkSpeedMult) + self.WalkSpeed_valueLabel = DirectLabel(parent=pGameplay, relief=None, text='100%', text_scale=options_text_scale * 0.8, pos=layout.valuePos(1)) + self.CameraDistance_Label = DirectLabel(parent=pGameplay, relief=None, text=TTLocalizer.OptionsPageCameraDistanceLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=layout.labelPos(2)) + self.CameraDistance_Slider = DirectSlider(parent=pGameplay, relief=DGG.FLAT, range=(4, 25), value=14, pageSize=1, pos=layout.sliderPos(2), scale=0.5, command=self.__setCameraDistance) + self.CameraDistance_valueLabel = DirectLabel(parent=pGameplay, relief=None, text='14', text_scale=options_text_scale * 0.8, pos=layout.valuePos(2)) + self.CameraYInvert_Label = DirectLabel(parent=pGameplay, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=20, pos=layout.labelPos(3)) + self.CameraYInvert_toggleButton = DirectButton(parent=pGameplay, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(3), command=self.__doToggleCameraYInvert) + DirectLabel( + parent=pAdvanced, + relief=None, + text=TTLocalizer.OptionsPageSectionAdvancedTitle, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsectionTitle, + text_fg=accentFg, + pos=layout.headerPos(), + ) + DirectLabel( + parent=pAdvanced, + relief=None, + text=TTLocalizer.OptionsPageSectionAdvancedHelp, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsubTabHelp, + text_fg=helpFg, + text_wordwrap=22, + pos=layout.helpPos(), + ) + self.ShowFPS_Label = DirectLabel(parent=pAdvanced, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=layout.labelPos(0)) + self.ShowFPS_toggleButton = DirectButton(parent=pAdvanced, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(0), command=self.__doToggleShowFPS) + self.MouseSensitivity_Label = DirectLabel(parent=pAdvanced, relief=None, text=TTLocalizer.OptionsPageMouseSensitivityLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=layout.labelPos(1)) + self.MouseSensitivity_Slider = DirectSlider(parent=pAdvanced, relief=DGG.FLAT, range=(0.5, 2.0), value=1.0, pageSize=0.1, pos=layout.sliderPos(1), scale=0.5, command=self.__setMouseSensitivity) + self.MouseSensitivity_valueLabel = DirectLabel(parent=pAdvanced, relief=None, text='1.0x', text_scale=options_text_scale * 0.8, pos=layout.valuePos(1)) + self.CameraFOV_Label = DirectLabel(parent=pAdvanced, relief=None, text=TTLocalizer.OptionsPageCameraFovLabel, text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=15, pos=layout.labelPos(2)) + self.CameraFOV_Slider = DirectSlider(parent=pAdvanced, relief=DGG.FLAT, range=(40, 90), value=ToontownGlobals.DefaultCameraFov, pageSize=1, pos=layout.sliderPos(2), scale=0.5, command=self.__setCameraFOV) + self.CameraFOV_valueLabel = DirectLabel(parent=pAdvanced, relief=None, text='%s°' % ToontownGlobals.DefaultCameraFov, text_scale=options_text_scale * 0.8, pos=layout.valuePos(2)) + self.SmoothAnimations_Label = DirectLabel(parent=pAdvanced, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=layout.labelPos(3)) + self.SmoothAnimations_toggleButton = DirectButton(parent=pAdvanced, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(3), command=self.__doToggleSmoothAnimations) + self.ShowNametags_Label = DirectLabel(parent=pAdvanced, relief=None, text='', text_align=TextNode.ALeft, text_scale=options_text_scale, text_wordwrap=16, pos=layout.labelPos(4)) + self.ShowNametags_toggleButton = DirectButton(parent=pAdvanced, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=button_image_scale, text='', text_scale=options_text_scale, text_pos=button_textpos, pos=layout.controlPos(4), command=self.__doToggleShowNametags) + self.Controls_Label = DirectLabel(parent=pAdvanced, relief=None, text=TTLocalizer.OptionsPageControlsHint, text_align=TextNode.ALeft, text_scale=options_text_scale * 0.78, text_fg=helpFg, text_wordwrap=28, pos=layout.labelPos(5, zOffset=-0.02)) + + # ── Lighting panel ──────────────────────────────────────────────── + DirectLabel( + parent=pLighting, + relief=None, + text=TTLocalizer.OptionsPageSectionLightingTitle, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsectionTitle, + text_fg=accentFg, + pos=layout.headerPos(), + ) + DirectLabel( + parent=pLighting, + relief=None, + text=TTLocalizer.OptionsPageSectionLightingHelp, + text_align=TextNode.ALeft, + text_scale=TTLocalizer.OPsubTabHelp, + text_fg=helpFg, + text_wordwrap=22, + pos=layout.helpPos(), + ) + self.AdvancedLighting_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(0), + ) + self.AdvancedLighting_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(0), + command=self.__doToggleAdvancedLighting, + ) + self.GodRays_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(1), + ) + self.GodRays_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(1), + command=self.__doToggleGodRays, + ) + self.LightingFog_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(2), + ) + self.LightingFog_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(2), + command=self.__doToggleLightingFog, + ) + self.LightingIntensity_Label = DirectLabel( + parent=pLighting, relief=None, + text=TTLocalizer.OptionsPageLightingIntensityLabel, + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=16, pos=layout.labelPos(3), + ) + self.LightingIntensity_Slider = DirectSlider( + parent=pLighting, relief=DGG.FLAT, + range=(0.4, 1.8), value=1.0, pageSize=0.1, + pos=layout.sliderPos(3), scale=0.5, + command=self.__setLightingIntensity, + ) + self.LightingIntensity_valueLabel = DirectLabel( + parent=pLighting, relief=None, text='1.0x', + text_scale=options_text_scale * 0.8, pos=layout.valuePos(3), + ) + self.ColorTemp_Label = DirectLabel( + parent=pLighting, relief=None, + text=TTLocalizer.OptionsPageColorTempLabel, + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=16, pos=layout.labelPos(4), + ) + self.ColorTemp_Slider = DirectSlider( + parent=pLighting, relief=DGG.FLAT, + range=(-1.0, 1.0), value=0.0, pageSize=0.1, + pos=layout.sliderPos(4), scale=0.5, + command=self.__setColorTemp, + ) + self.ColorTemp_valueLabel = DirectLabel( + parent=pLighting, relief=None, text='Neutral', + text_scale=options_text_scale * 0.8, pos=layout.valuePos(4), + ) + + # Row 5 – Tonemapping + self.Tonemap_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(5), + ) + self.Tonemap_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(5), + command=self.__doToggleTonemap, + ) + + # Row 6 – Day / Night Cycle + self.DayNight_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(6), + ) + self.DayNight_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(6), + command=self.__doToggleDayNight, + ) + + # Row 7 – Procedural Sky + self.ProceduralSky_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(7), + ) + self.ProceduralSky_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(7), + command=self.__doToggleProceduralSky, + ) + + # Row 8 – Bloom + self.Bloom_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(8), + ) + self.Bloom_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(8), + command=self.__doToggleBloom, + ) + + # Row 9 – Water Reflections + self.WaterRefl_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(9), + ) + self.WaterRefl_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(9), + command=self.__doToggleWaterReflections, + ) + + # Row 10 – Shadow Quality + self.ShadowQuality_Label = DirectLabel( + parent=pLighting, relief=None, + text=TTLocalizer.OptionsPageShadowQualityLabel, + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=16, pos=layout.labelPos(10), + ) + self.ShadowQuality_valueLabel = DirectLabel( + parent=pLighting, relief=None, text='High', + text_scale=options_text_scale * 0.8, pos=layout.valuePos(10), + ) + self.ShadowQuality_cycleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text=TTLocalizer.OptionsPageCycleButton, + text_scale=options_text_scale, text_pos=button_textpos, + pos=layout.controlPos(10), + command=self.__cycleShadowQuality, + ) + + # Row 11 – Fog Density + self.FogDensity_Label = DirectLabel( + parent=pLighting, relief=None, + text=TTLocalizer.OptionsPageFogDensityLabel, + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=16, pos=layout.labelPos(11), + ) + self.FogDensity_Slider = DirectSlider( + parent=pLighting, relief=DGG.FLAT, + range=(0.2, 2.5), value=1.0, pageSize=0.1, + pos=layout.sliderPos(11), scale=0.5, + command=self.__setFogDensity, + ) + self.FogDensity_valueLabel = DirectLabel( + parent=pLighting, relief=None, text='1.0x', + text_scale=options_text_scale * 0.8, pos=layout.valuePos(11), + ) + + # Row 12 – Vignette + self.Vignette_Label = DirectLabel( + parent=pLighting, relief=None, text='', + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=18, pos=layout.labelPos(12), + ) + self.Vignette_toggleButton = DirectButton( + parent=pLighting, relief=None, + image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), + image_scale=button_image_scale, text='', text_scale=options_text_scale, + text_pos=button_textpos, pos=layout.controlPos(12), + command=self.__doToggleVignette, + ) + + # Row 13 – Day/Night Speed + self.DayNightSpeed_Label = DirectLabel( + parent=pLighting, relief=None, + text=TTLocalizer.OptionsPageDayNightSpeedLabel, + text_align=TextNode.ALeft, text_scale=options_text_scale, + text_wordwrap=16, pos=layout.labelPos(13), + ) + self.DayNightSpeed_Slider = DirectSlider( + parent=pLighting, relief=DGG.FLAT, + range=(0.1, 5.0), value=1.0, pageSize=0.1, + pos=layout.sliderPos(13), scale=0.5, + command=self.__setDayNightSpeed, + ) + self.DayNightSpeed_valueLabel = DirectLabel( + parent=pLighting, relief=None, text='1.0x', + text_scale=options_text_scale * 0.8, pos=layout.valuePos(13), + ) + + self.exitButton = DirectButton(parent=self, relief=None, image=(guiButton.find('**/QuitBtn_UP'), guiButton.find('**/QuitBtn_DN'), guiButton.find('**/QuitBtn_RLVR')), image_scale=1.15, text=TTLocalizer.OptionsPageExitToontown, text_scale=options_text_scale, text_pos=button_textpos, textMayChange=0, pos=(0.45, 0, -0.62), command=self.__handleExitShowWithConfirm) guiButton.removeNode() gui.removeNode() + for panel in self.subPanels.values(): + panel.hide() return + def setSubTab(self, mode, force = 0): + messenger.send('wakeup') + if not force and self.subTabMode == mode: + return + self.subTabMode = mode + base.settings.updateSetting('options-page-subtab', int(mode)) + self.settingsChanged = 1 + for m, panel in self.subPanels.items(): + if m == mode: + panel.show() + else: + panel.hide() + for m, btn in self.subTabButtons.items(): + btn['state'] = DGG.DISABLED if m == mode else DGG.NORMAL + + def __doMuteAllAudio(self): + messenger.send('wakeup') + self._preMuteMusic = int(self.Music_toggleSlider['value']) + self._preMuteSfx = int(self.SoundFX_toggleSlider['value']) + self.Music_toggleSlider['value'] = 0 + self.SoundFX_toggleSlider['value'] = 0 + self.__setMusicVolume() + self.__setSfxVolume() + + def __doRestoreAudio(self): + messenger.send('wakeup') + m = self._preMuteMusic + s = self._preMuteSfx + if m is None or s is None: + self.__setMusicSlider() + self.__setSoundFXSlider() + return + self.Music_toggleSlider['value'] = m + self.SoundFX_toggleSlider['value'] = s + self.__setMusicVolume() + self.__setSfxVolume() + def enter(self): self.show() taskMgr.remove(self.DisplaySettingsTaskName) + raw = base.settings.getSetting('options-page-subtab', int(OptionsSubTab.Audio)) + try: + tab = OptionsSubTab(int(raw)) + except (ValueError, TypeError): + tab = OptionsSubTab.Audio + if tab not in self.subPanels: + tab = OptionsSubTab.Audio + self.setSubTab(tab, force=1) self.settingsChanged = 0 self.__setMusicSlider() self.__setSoundFXSlider() @@ -222,12 +727,31 @@ class OptionsTabPage(DirectFrame): self.__setDisplaySettings() self.__setToonChatSoundsButton() self.__setTKeyOnlyChatButton() - # Initialize new QOL settings self.__setShowFPSButton() self.__setMouseSensitivitySlider() self.__setCameraFOVSlider() self.__setSmoothAnimationsButton() self.__setShowNametagsButton() + self.__setParticlesButton() + self.__setDynamicShadowsButton() + self.__setSkipBattleMoviesButton() + self.__setWalkSpeedSlider() + self.__setCameraDistanceSlider() + self.__setCameraYInvertButton() + self.__setAdvancedLightingButton() + self.__setGodRaysButton() + self.__setLightingFogButton() + self.__setLightingIntensitySlider() + self.__setColorTempSlider() + self.__setTonemapButton() + self.__setDayNightButton() + self.__setProceduralSkyButton() + self.__setBloomButton() + self.__setWaterReflectionsButton() + self.__setShadowQualityLabel() + self.__setFogDensitySlider() + self.__setVignetteButton() + self.__setDayNightSpeedSlider() self.speedChatStyleText.enter() self.speedChatStyleIndex = base.localAvatar.getSpeedChatStyleIndex() self.updateSpeedChatStyle() @@ -239,11 +763,12 @@ class OptionsTabPage(DirectFrame): def exit(self): self.ignore('confirmDone') self.hide() - if self.settingsChanged != 0: + if self.displaySettingsChanged: + taskMgr.remove(self.DisplaySettingsTaskName) + self.writeDisplaySettings() + elif self.settingsChanged != 0: base.settings.writeSettings() self.speedChatStyleText.exit() - if self.displaySettingsChanged: - taskMgr.doMethodLater(self.DisplaySettingsDelay, self.writeDisplaySettings, self.DisplaySettingsTaskName) def unload(self): self.writeDisplaySettings() @@ -252,56 +777,17 @@ class OptionsTabPage(DirectFrame): self.ignore(self.displaySettings.doneEvent) self.displaySettings.unload() self.displaySettings = None - self.exitButton.destroy() - self.Music_toggleSlider.destroy() - self.Music_volumeLabel.destroy() - self.SoundFX_toggleSlider.destroy() - self.SoundFX_volumeLabel.destroy() - self.Friends_toggleButton.destroy() - self.Whispers_toggleButton.destroy() - self.TKeyOnlyChat_toggleButton.destroy() - self.DisplaySettingsButton.destroy() - self.speedChatStyleLeftArrow.destroy() - self.speedChatStyleRightArrow.destroy() - del self.exitButton - del self.SoundFX_Label - del self.Music_Label - del self.Friends_Label - del self.Whispers_Label - del self.SpeedChatStyle_Label - del self.SoundFX_toggleSlider - del self.SoundFX_volumeLabel - del self.Music_toggleSlider - del self.Music_volumeLabel - del self.Friends_toggleButton - del self.Whispers_toggleButton - del self.TKeyOnlyChat_toggleButton - del self.speedChatStyleLeftArrow - del self.speedChatStyleRightArrow + for btn in list(self.subTabButtons.values()): + btn.destroy() + self.subTabButtons.clear() self.speedChatStyleText.exit() self.speedChatStyleText.destroy() del self.speedChatStyleText - # Clean up new QOL widgets - self.ShowFPS_toggleButton.destroy() - del self.ShowFPS_toggleButton - del self.ShowFPS_Label - self.MouseSensitivity_Slider.destroy() - self.MouseSensitivity_valueLabel.destroy() - del self.MouseSensitivity_Slider - del self.MouseSensitivity_valueLabel - del self.MouseSensitivity_Label - self.CameraFOV_Slider.destroy() - self.CameraFOV_valueLabel.destroy() - del self.CameraFOV_Slider - del self.CameraFOV_valueLabel - del self.CameraFOV_Label - self.SmoothAnimations_toggleButton.destroy() - del self.SmoothAnimations_toggleButton - del self.SmoothAnimations_Label - self.ShowNametags_toggleButton.destroy() - del self.ShowNametags_toggleButton - del self.ShowNametags_Label - del self.Controls_Label + for panel in list(self.subPanels.values()): + panel.destroy() + self.subPanels.clear() + self.exitButton.destroy() + del self.exitButton self.currentSizeIndex = None return @@ -317,6 +803,7 @@ class OptionsTabPage(DirectFrame): base.enableMusic(1) base.musicManager.setVolume(volumeFloat) base.settings.updateSetting('musicVolume', volume) + base.settings.updateSetting('music', volume > 0) self.settingsChanged = 1 def __setMusicSlider(self): @@ -337,6 +824,7 @@ class OptionsTabPage(DirectFrame): for sfxManager in base.sfxManagerList: sfxManager.setVolume(volumeFloat) base.settings.updateSetting('sfxVolume', volume) + base.settings.updateSetting('sfx', volume > 0) self.settingsChanged = 1 def __setSoundFXSlider(self): @@ -416,10 +904,10 @@ class OptionsTabPage(DirectFrame): def __setTKeyOnlyChatButton(self): if base.settings.getSetting('tKeyOnlyChat', False): - self.TKeyOnlyChat_Label['text'] = 'T-Key Only Chat: ON' + self.TKeyOnlyChat_Label['text'] = TTLocalizer.OptionsPageTKeyOnlyOn self.TKeyOnlyChat_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff else: - self.TKeyOnlyChat_Label['text'] = 'T-Key Only Chat: OFF' + self.TKeyOnlyChat_Label['text'] = TTLocalizer.OptionsPageTKeyOnlyOff self.TKeyOnlyChat_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn def __doToggleShowFPS(self): @@ -433,11 +921,11 @@ class OptionsTabPage(DirectFrame): def __setShowFPSButton(self): showFPS = base.settings.getSetting('show-fps', False) if showFPS: - self.ShowFPS_Label['text'] = 'FPS Counter: ON' + self.ShowFPS_Label['text'] = TTLocalizer.OptionsPageShowFpsOn self.ShowFPS_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff base.setFrameRateMeter(True) else: - self.ShowFPS_Label['text'] = 'FPS Counter: OFF' + self.ShowFPS_Label['text'] = TTLocalizer.OptionsPageShowFpsOff self.ShowFPS_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn base.setFrameRateMeter(False) @@ -446,77 +934,229 @@ class OptionsTabPage(DirectFrame): sensitivity = round(self.MouseSensitivity_Slider['value'], 1) self.MouseSensitivity_valueLabel['text'] = '%.1fx' % sensitivity base.settings.updateSetting('mouse-sensitivity', sensitivity) - # Apply to camera control if it exists - if hasattr(base, 'mouseSensitivity'): - base.mouseSensitivity = sensitivity + if hasattr(base, 'applyMouseSensitivity'): + base.applyMouseSensitivity(sensitivity) self.settingsChanged = 1 def __setMouseSensitivitySlider(self): sensitivity = base.settings.getSetting('mouse-sensitivity', 1.0) self.MouseSensitivity_Slider['value'] = sensitivity self.MouseSensitivity_valueLabel['text'] = '%.1fx' % sensitivity - if hasattr(base, 'mouseSensitivity'): - base.mouseSensitivity = sensitivity + if hasattr(base, 'applyMouseSensitivity'): + base.applyMouseSensitivity(sensitivity) def __setCameraFOV(self): messenger.send('wakeup') fov = round(self.CameraFOV_Slider['value'], 1) self.CameraFOV_valueLabel['text'] = '%.1f°' % fov base.settings.updateSetting('camera-fov', fov) - # Apply FOV to camera - if hasattr(base, 'camLens'): + if hasattr(base, 'baseFov'): + base.baseFov = fov + if hasattr(base, 'updateFovForAspectRatio'): + base.updateFovForAspectRatio() + elif hasattr(base, 'camLens'): base.camLens.setFov(fov) + if hasattr(base, 'localAvatar') and base.localAvatar: + base.localAvatar.fov = fov self.settingsChanged = 1 def __setCameraFOVSlider(self): - fov = base.settings.getSetting('camera-fov', 52.5) + fov = base.settings.getSetting('camera-fov', ToontownGlobals.DefaultCameraFov) self.CameraFOV_Slider['value'] = fov self.CameraFOV_valueLabel['text'] = '%.1f°' % fov - if hasattr(base, 'camLens'): + if hasattr(base, 'baseFov'): + base.baseFov = fov + if hasattr(base, 'updateFovForAspectRatio'): + base.updateFovForAspectRatio() + elif hasattr(base, 'camLens'): base.camLens.setFov(fov) + if hasattr(base, 'localAvatar') and base.localAvatar: + base.localAvatar.fov = fov def __doToggleSmoothAnimations(self): messenger.send('wakeup') smoothAnims = base.settings.getSetting('smooth-animations', True) base.settings.updateSetting('smooth-animations', not smoothAnims) - # Apply smooth animations setting - if hasattr(base, 'transitions'): + if hasattr(base, 'transitions') and base.transitions and hasattr(base.transitions, 'setUseBlend'): base.transitions.setUseBlend(not smoothAnims) self.settingsChanged = 1 self.__setSmoothAnimationsButton() + def __doToggleParticles(self): + messenger.send('wakeup') + on = base.settings.getSetting('particles-enabled', True) + newVal = not on + base.settings.updateSetting('particles-enabled', newVal) + if newVal: + base.enableParticles() + else: + base.disableParticles() + self.settingsChanged = 1 + self.__setParticlesButton() + + def __setParticlesButton(self): + cfg = base.config.GetBool('want-particles', True) + on = base.settings.getSetting('particles-enabled', True) + if cfg: + if on: + base.enableParticles() + else: + base.disableParticles() + if on: + self.Particles_Label['text'] = TTLocalizer.OptionsPageParticlesOn + self.Particles_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.Particles_Label['text'] = TTLocalizer.OptionsPageParticlesOff + self.Particles_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + if not cfg: + self.Particles_Label['text'] = TTLocalizer.OptionsPageParticlesOff + self.Particles_toggleButton['state'] = DGG.DISABLED + self.Particles_Label.setColorScale(0.55, 0.55, 0.55, 1.0) + else: + self.Particles_toggleButton['state'] = DGG.NORMAL + self.Particles_Label.setColorScale(1.0, 1.0, 1.0, 1.0) + + def __doToggleDynamicShadows(self): + messenger.send('wakeup') + on = base.settings.getSetting('dynamic-shadows', False) + base.settings.updateSetting('dynamic-shadows', not on) + base.wantDynamicShadows = 0 if on else 1 + self.settingsChanged = 1 + self.__setDynamicShadowsButton() + + def __setDynamicShadowsButton(self): + on = base.settings.getSetting('dynamic-shadows', False) + if on: + self.DynamicShadows_Label['text'] = TTLocalizer.OptionsPageShadowsOn + self.DynamicShadows_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.DynamicShadows_Label['text'] = TTLocalizer.OptionsPageShadowsOff + self.DynamicShadows_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + base.wantDynamicShadows = 1 if on else 0 + + def __doToggleSkipBattleMovies(self): + messenger.send('wakeup') + if base.config.GetBool('skip-battle-movies', False): + return + on = base.settings.getSetting('skip-battle-movies', False) + newVal = not on + base.settings.updateSetting('skip-battle-movies', newVal) + ToontownBattleGlobals.SkipMovie = 1 if newVal else 0 + self.settingsChanged = 1 + self.__setSkipBattleMoviesButton() + + def __setSkipBattleMoviesButton(self): + if base.config.GetBool('skip-battle-movies', False): + ToontownBattleGlobals.SkipMovie = 1 + self.SkipBattleMovies_Label['text'] = TTLocalizer.OptionsPageSkipBattleMoviesForced + self.SkipBattleMovies_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + self.SkipBattleMovies_toggleButton['state'] = DGG.DISABLED + self.SkipBattleMovies_Label.setColorScale(0.55, 0.55, 0.55, 1.0) + return + self.SkipBattleMovies_toggleButton['state'] = DGG.NORMAL + self.SkipBattleMovies_Label.setColorScale(1.0, 1.0, 1.0, 1.0) + on = base.settings.getSetting('skip-battle-movies', False) + ToontownBattleGlobals.SkipMovie = 1 if on else 0 + if on: + self.SkipBattleMovies_Label['text'] = TTLocalizer.OptionsPageSkipBattleMoviesOn + self.SkipBattleMovies_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.SkipBattleMovies_Label['text'] = TTLocalizer.OptionsPageSkipBattleMoviesOff + self.SkipBattleMovies_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + + def __setWalkSpeedMult(self): + messenger.send('wakeup') + pct = int(self.WalkSpeed_Slider['value']) + pct = max(75, min(125, pct)) + self.WalkSpeed_Slider['value'] = pct + self.WalkSpeed_valueLabel['text'] = TTLocalizer.OptionsPageWalkSpeedValue % pct + base.settings.updateSetting('walk-speed-mult', pct) + if hasattr(base, 'localAvatar') and base.localAvatar: + if base.localAvatar.avatarControlsEnabled: + base.localAvatar.setWalkSpeedNormal() + self.settingsChanged = 1 + + def __setWalkSpeedSlider(self): + try: + pct = int(base.settings.getSetting('walk-speed-mult', 100)) + except (TypeError, ValueError): + pct = 100 + pct = max(75, min(125, pct)) + self.WalkSpeed_Slider['value'] = pct + self.WalkSpeed_valueLabel['text'] = TTLocalizer.OptionsPageWalkSpeedValue % pct + if hasattr(base, 'localAvatar') and base.localAvatar: + if base.localAvatar.avatarControlsEnabled: + base.localAvatar.setWalkSpeedNormal() + + def __setCameraDistance(self): + messenger.send('wakeup') + dist = round(self.CameraDistance_Slider['value']) + self.CameraDistance_Slider['value'] = dist + self.CameraDistance_valueLabel['text'] = TTLocalizer.OptionsPageCameraDistanceValue % dist + base.settings.updateSetting('cam-distance', float(dist)) + if hasattr(base, 'localAvatar') and base.localAvatar: + cam = getattr(base.localAvatar, 'orbitalCamera', None) + if cam and cam.isActive(): + cam.onSettingsChanged() + self.settingsChanged = 1 + + def __setCameraDistanceSlider(self): + try: + dist = float(base.settings.getSetting('cam-distance', 14.0)) + except (TypeError, ValueError): + dist = 14.0 + dist = max(4.0, min(25.0, dist)) + self.CameraDistance_Slider['value'] = dist + self.CameraDistance_valueLabel['text'] = TTLocalizer.OptionsPageCameraDistanceValue % dist + + def __doToggleCameraYInvert(self): + messenger.send('wakeup') + invertY = base.settings.getSetting('cam-invert-y', False) + base.settings.updateSetting('cam-invert-y', not invertY) + self.settingsChanged = 1 + self.__setCameraYInvertButton() + + def __setCameraYInvertButton(self): + invertY = base.settings.getSetting('cam-invert-y', False) + if invertY: + self.CameraYInvert_Label['text'] = TTLocalizer.OptionsPageCameraYInvertOn + self.CameraYInvert_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.CameraYInvert_Label['text'] = TTLocalizer.OptionsPageCameraYInvertOff + self.CameraYInvert_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + def __setSmoothAnimationsButton(self): smoothAnims = base.settings.getSetting('smooth-animations', True) if smoothAnims: - self.SmoothAnimations_Label['text'] = 'Smooth Animations: ON' + self.SmoothAnimations_Label['text'] = TTLocalizer.OptionsPageSmoothAnimsOn self.SmoothAnimations_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff else: - self.SmoothAnimations_Label['text'] = 'Smooth Animations: OFF' + self.SmoothAnimations_Label['text'] = TTLocalizer.OptionsPageSmoothAnimsOff self.SmoothAnimations_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn def __doToggleShowNametags(self): messenger.send('wakeup') showNametags = base.settings.getSetting('show-nametags', True) - base.settings.updateSetting('show-nametags', not showNametags) - # Apply nametag visibility + newShow = not showNametags + base.settings.updateSetting('show-nametags', newShow) if hasattr(base, 'cr') and hasattr(base.cr, 'doFindAll'): avatars = base.cr.doFindAll('DistributedToon') for avatar in avatars: if hasattr(avatar, 'nametag3d'): - if not showNametags: - avatar.nametag3d.hide() - else: + if newShow: avatar.nametag3d.show() + else: + avatar.nametag3d.hide() self.settingsChanged = 1 self.__setShowNametagsButton() def __setShowNametagsButton(self): showNametags = base.settings.getSetting('show-nametags', True) if showNametags: - self.ShowNametags_Label['text'] = 'Show Nametags: ON' + self.ShowNametags_Label['text'] = TTLocalizer.OptionsPageShowNametagsOn self.ShowNametags_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff else: - self.ShowNametags_Label['text'] = 'Show Nametags: OFF' + self.ShowNametags_Label['text'] = TTLocalizer.OptionsPageShowNametagsOff self.ShowNametags_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn def __doDisplaySettings(self): @@ -558,11 +1198,11 @@ class OptionsTabPage(DirectFrame): 'api': api} if self.ChangeDisplayAPI: OptionsPage.notify.debug('change display settings...') - text = TTLocalizer.OptionsPageDisplaySettings % settings + detail = TTLocalizer.OptionsPageDisplaySettings % settings else: OptionsPage.notify.debug('no change display settings...') - text = TTLocalizer.OptionsPageDisplaySettingsNoApi % settings - self.DisplaySettings_Label['text'] = text + detail = TTLocalizer.OptionsPageDisplaySettingsNoApi % settings + self.DisplaySettings_Label['text'] = '%s\n%s' % (TTLocalizer.OptionsPageDisplaySummaryIntro, detail) def __doSpeedChatStyleLeft(self): if self.speedChatStyleIndex > 0: @@ -604,16 +1244,310 @@ class OptionsTabPage(DirectFrame): base.settings.updateSetting('resolution', (self.displaySettingsSize[0], self.displaySettingsSize[1])) base.settings.updateSetting('windowed-mode', not self.displaySettingsFullscreen) #base.settings.updateSetting('embedded-mode', self.displaySettingsEmbedded) - if self.displaySettingsApiChanged: - api = self.DisplaySettingsApiMap.get(self.displaySettingsApi) - if api == None: - self.notify.warning('Cannot save unknown display API: %s' % self.displaySettingsApi) - else: - Settings.setDisplayDriver(api) + if self.displaySettingsApiChanged and self.displaySettingsApi: + base.settings.updateSetting('graphics-api-interface', self.displaySettingsApi) base.settings.writeSettings() self.displaySettingsChanged = 0 return Task.done + def __doToggleAdvancedLighting(self): + messenger.send('wakeup') + on = base.settings.getSetting('want-modern-outdoor-lighting', True) + base.settings.updateSetting('want-modern-outdoor-lighting', not on) + self.settingsChanged = 1 + self.__setAdvancedLightingButton() + OutdoorLighting.refreshSettings() + + def __setAdvancedLightingButton(self): + on = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.AdvancedLighting_Label['text'] = TTLocalizer.OptionsPageAdvancedLightingOn + self.AdvancedLighting_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.AdvancedLighting_Label['text'] = TTLocalizer.OptionsPageAdvancedLightingOff + self.AdvancedLighting_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + + def __doToggleGodRays(self): + messenger.send('wakeup') + on = base.settings.getSetting('lighting-god-rays', True) + base.settings.updateSetting('lighting-god-rays', not on) + self.settingsChanged = 1 + self.__setGodRaysButton() + OutdoorLighting.refreshSettings() + + def __setGodRaysButton(self): + on = base.settings.getSetting('lighting-god-rays', True) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.GodRays_Label['text'] = TTLocalizer.OptionsPageGodRaysOn + self.GodRays_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.GodRays_Label['text'] = TTLocalizer.OptionsPageGodRaysOff + self.GodRays_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + if not adv: + self.GodRays_Label.setColorScale(0.55, 0.55, 0.55, 1.0) + self.GodRays_toggleButton['state'] = DGG.DISABLED + else: + self.GodRays_Label.setColorScale(1.0, 1.0, 1.0, 1.0) + self.GodRays_toggleButton['state'] = DGG.NORMAL + + def __doToggleLightingFog(self): + messenger.send('wakeup') + on = base.settings.getSetting('lighting-fog-enabled', True) + base.settings.updateSetting('lighting-fog-enabled', not on) + self.settingsChanged = 1 + self.__setLightingFogButton() + OutdoorLighting.refreshSettings() + + def __setLightingFogButton(self): + on = base.settings.getSetting('lighting-fog-enabled', True) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.LightingFog_Label['text'] = TTLocalizer.OptionsPageLightingFogOn + self.LightingFog_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.LightingFog_Label['text'] = TTLocalizer.OptionsPageLightingFogOff + self.LightingFog_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + if not adv: + self.LightingFog_Label.setColorScale(0.55, 0.55, 0.55, 1.0) + self.LightingFog_toggleButton['state'] = DGG.DISABLED + else: + self.LightingFog_Label.setColorScale(1.0, 1.0, 1.0, 1.0) + self.LightingFog_toggleButton['state'] = DGG.NORMAL + + def __setLightingIntensity(self): + messenger.send('wakeup') + val = round(self.LightingIntensity_Slider['value'], 2) + self.LightingIntensity_valueLabel['text'] = '%.1fx' % val + base.settings.updateSetting('lighting-intensity', val) + self.settingsChanged = 1 + OutdoorLighting.refreshSettings() + + def __setLightingIntensitySlider(self): + val = float(base.settings.getSetting('lighting-intensity', 1.0)) + val = max(0.4, min(1.8, val)) + self.LightingIntensity_Slider['value'] = val + self.LightingIntensity_valueLabel['text'] = '%.1fx' % val + + def __setColorTemp(self): + messenger.send('wakeup') + val = round(self.ColorTemp_Slider['value'], 2) + if val > 0.1: + label = TTLocalizer.OptionsPageColorTempWarm + elif val < -0.1: + label = TTLocalizer.OptionsPageColorTempCool + else: + label = 'Neutral' + self.ColorTemp_valueLabel['text'] = label + base.settings.updateSetting('lighting-color-temp', val) + self.settingsChanged = 1 + OutdoorLighting.refreshSettings() + + def __setColorTempSlider(self): + val = float(base.settings.getSetting('lighting-color-temp', 0.0)) + val = max(-1.0, min(1.0, val)) + self.ColorTemp_Slider['value'] = val + if val > 0.1: + label = TTLocalizer.OptionsPageColorTempWarm + elif val < -0.1: + label = TTLocalizer.OptionsPageColorTempCool + else: + label = 'Neutral' + self.ColorTemp_valueLabel['text'] = label + + # ── Tonemapping ─────────────────────────────────────────────────────────── + def __doToggleTonemap(self): + messenger.send('wakeup') + on = base.settings.getSetting('lighting-tonemap-enabled', True) + base.settings.updateSetting('lighting-tonemap-enabled', not on) + self.settingsChanged = 1 + self.__setTonemapButton() + OutdoorLighting.refreshSettings() + + def __setTonemapButton(self): + on = base.settings.getSetting('lighting-tonemap-enabled', True) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.Tonemap_Label['text'] = TTLocalizer.OptionsPageTonemapOn + self.Tonemap_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.Tonemap_Label['text'] = TTLocalizer.OptionsPageTonemapOff + self.Tonemap_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + shade = 1.0 if adv else 0.55 + self.Tonemap_Label.setColorScale(shade, shade, shade, 1.0) + self.Tonemap_toggleButton['state'] = DGG.NORMAL if adv else DGG.DISABLED + + # ── Day / Night cycle ───────────────────────────────────────────────────── + def __doToggleDayNight(self): + messenger.send('wakeup') + on = base.settings.getSetting('want-day-night-cycle', False) + base.settings.updateSetting('want-day-night-cycle', not on) + self.settingsChanged = 1 + self.__setDayNightButton() + OutdoorLighting.refreshSettings() + + def __setDayNightButton(self): + on = base.settings.getSetting('want-day-night-cycle', False) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.DayNight_Label['text'] = TTLocalizer.OptionsPageDayNightOn + self.DayNight_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.DayNight_Label['text'] = TTLocalizer.OptionsPageDayNightOff + self.DayNight_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + shade = 1.0 if adv else 0.55 + self.DayNight_Label.setColorScale(shade, shade, shade, 1.0) + self.DayNight_toggleButton['state'] = DGG.NORMAL if adv else DGG.DISABLED + + # ── Procedural Sky ──────────────────────────────────────────────────────── + def __doToggleProceduralSky(self): + messenger.send('wakeup') + on = base.settings.getSetting('want-procedural-sky', True) + base.settings.updateSetting('want-procedural-sky', not on) + self.settingsChanged = 1 + self.__setProceduralSkyButton() + OutdoorLighting.refreshSettings() + + def __setProceduralSkyButton(self): + on = base.settings.getSetting('want-procedural-sky', True) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.ProceduralSky_Label['text'] = TTLocalizer.OptionsPageProceduralSkyOn + self.ProceduralSky_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.ProceduralSky_Label['text'] = TTLocalizer.OptionsPageProceduralSkyOff + self.ProceduralSky_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + shade = 1.0 if adv else 0.55 + self.ProceduralSky_Label.setColorScale(shade, shade, shade, 1.0) + self.ProceduralSky_toggleButton['state'] = DGG.NORMAL if adv else DGG.DISABLED + + # ── Bloom ───────────────────────────────────────────────────────────────── + def __doToggleBloom(self): + messenger.send('wakeup') + on = base.settings.getSetting('lighting-bloom-enabled', True) + base.settings.updateSetting('lighting-bloom-enabled', not on) + self.settingsChanged = 1 + self.__setBloomButton() + OutdoorLighting.refreshSettings() + + def __setBloomButton(self): + on = base.settings.getSetting('lighting-bloom-enabled', True) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.Bloom_Label['text'] = TTLocalizer.OptionsPageBloomOn + self.Bloom_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.Bloom_Label['text'] = TTLocalizer.OptionsPageBloomOff + self.Bloom_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + shade = 1.0 if adv else 0.55 + self.Bloom_Label.setColorScale(shade, shade, shade, 1.0) + self.Bloom_toggleButton['state'] = DGG.NORMAL if adv else DGG.DISABLED + + # ── Water Reflections ───────────────────────────────────────────────────── + def __doToggleWaterReflections(self): + messenger.send('wakeup') + on = base.settings.getSetting('want-water-reflections', True) + base.settings.updateSetting('want-water-reflections', not on) + self.settingsChanged = 1 + self.__setWaterReflectionsButton() + OutdoorLighting.refreshSettings() + + def __setWaterReflectionsButton(self): + on = base.settings.getSetting('want-water-reflections', True) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.WaterRefl_Label['text'] = TTLocalizer.OptionsPageWaterReflOn + self.WaterRefl_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.WaterRefl_Label['text'] = TTLocalizer.OptionsPageWaterReflOff + self.WaterRefl_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + shade = 1.0 if adv else 0.55 + self.WaterRefl_Label.setColorScale(shade, shade, shade, 1.0) + self.WaterRefl_toggleButton['state'] = DGG.NORMAL if adv else DGG.DISABLED + + # ── Shadow Quality ──────────────────────────────────────────────────────── + _SHADOW_QUALITY_CYCLE = ('off', 'low', 'medium', 'high') + _SHADOW_QUALITY_LABELS = { + 'off': TTLocalizer.OptionsPageShadowQualityOff if hasattr(TTLocalizer, 'OptionsPageShadowQualityOff') else 'Off', + 'low': TTLocalizer.OptionsPageShadowQualityLow if hasattr(TTLocalizer, 'OptionsPageShadowQualityLow') else 'Low', + 'medium': TTLocalizer.OptionsPageShadowQualityMedium if hasattr(TTLocalizer, 'OptionsPageShadowQualityMedium') else 'Medium', + 'high': TTLocalizer.OptionsPageShadowQualityHigh if hasattr(TTLocalizer, 'OptionsPageShadowQualityHigh') else 'High', + } + + def __cycleShadowQuality(self): + messenger.send('wakeup') + cur = base.settings.getSetting('shadow-quality', 'high') + cycle = self._SHADOW_QUALITY_CYCLE + idx = cycle.index(cur) if cur in cycle else len(cycle) - 1 + nxt = cycle[(idx + 1) % len(cycle)] + base.settings.updateSetting('shadow-quality', nxt) + self.settingsChanged = 1 + self.__setShadowQualityLabel() + OutdoorLighting.refreshSettings() + + def __setShadowQualityLabel(self): + q = base.settings.getSetting('shadow-quality', 'high') + self.ShadowQuality_valueLabel['text'] = self._SHADOW_QUALITY_LABELS.get( + q, q.capitalize()) + + # ── Fog Density ─────────────────────────────────────────────────────────── + def __setFogDensity(self): + messenger.send('wakeup') + val = round(self.FogDensity_Slider['value'], 2) + self.FogDensity_valueLabel['text'] = '%.1fx' % val + base.settings.updateSetting('fog-density-multiplier', val) + self.settingsChanged = 1 + OutdoorLighting.refreshSettings() + + def __setFogDensitySlider(self): + val = float(base.settings.getSetting('fog-density-multiplier', 1.0)) + val = max(0.2, min(2.5, val)) + self.FogDensity_Slider['value'] = val + self.FogDensity_valueLabel['text'] = '%.1fx' % val + + # ── Vignette ────────────────────────────────────────────────────────────── + def __doToggleVignette(self): + messenger.send('wakeup') + on = base.settings.getSetting('lighting-vignette-enabled', False) + base.settings.updateSetting('lighting-vignette-enabled', not on) + self.settingsChanged = 1 + self.__setVignetteButton() + OutdoorLighting.refreshSettings() + + def __setVignetteButton(self): + on = base.settings.getSetting('lighting-vignette-enabled', False) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + if on: + self.Vignette_Label['text'] = TTLocalizer.OptionsPageVignetteOn + self.Vignette_toggleButton['text'] = TTLocalizer.OptionsPageToggleOff + else: + self.Vignette_Label['text'] = TTLocalizer.OptionsPageVignetteOff + self.Vignette_toggleButton['text'] = TTLocalizer.OptionsPageToggleOn + shade = 1.0 if adv else 0.55 + self.Vignette_Label.setColorScale(shade, shade, shade, 1.0) + self.Vignette_toggleButton['state'] = DGG.NORMAL if adv else DGG.DISABLED + + # ── Day/Night Speed ─────────────────────────────────────────────────────── + def __setDayNightSpeed(self): + messenger.send('wakeup') + val = round(self.DayNightSpeed_Slider['value'], 2) + self.DayNightSpeed_valueLabel['text'] = '%.1fx' % val + base.settings.updateSetting('day-night-speed', val) + self.settingsChanged = 1 + OutdoorLighting.refreshSettings() + + def __setDayNightSpeedSlider(self): + val = float(base.settings.getSetting('day-night-speed', 1.0)) + val = max(0.1, min(5.0, val)) + self.DayNightSpeed_Slider['value'] = val + self.DayNightSpeed_valueLabel['text'] = '%.1fx' % val + dn = base.settings.getSetting('want-day-night-cycle', False) + adv = base.settings.getSetting('want-modern-outdoor-lighting', True) + shade = 1.0 if (adv and dn) else 0.55 + self.DayNightSpeed_Label.setColorScale(shade, shade, shade, 1.0) + self.DayNightSpeed_Slider['state'] = ( + DGG.NORMAL if (adv and dn) else DGG.DISABLED) + def __handleExitShowWithConfirm(self): self.confirm = TTDialog.TTGlobalDialog(doneEvent='confirmDone', message=TTLocalizer.OptionsPageExitConfirm, style=TTDialog.TwoChoice) self.confirm.show() diff --git a/toontown/shtiker/QuestPage.py b/toontown/shtiker/QuestPage.py index a306a07..781652d 100644 --- a/toontown/shtiker/QuestPage.py +++ b/toontown/shtiker/QuestPage.py @@ -169,7 +169,7 @@ class QuestPage(ShtikerPage.ShtikerPage): self.hide() def canDeleteQuest(self, questDesc): - return Quests.isQuestJustForFun(questDesc[0], questDesc[3]) and self.onscreen == 0 + return self.onscreen == 0 def __deleteQuest(self, questDesc): base.localAvatar.d_requestDeleteQuest(questDesc) diff --git a/toontown/shtiker/ShtikerBook.py b/toontown/shtiker/ShtikerBook.py index bca00fc..2459773 100644 --- a/toontown/shtiker/ShtikerBook.py +++ b/toontown/shtiker/ShtikerBook.py @@ -45,6 +45,7 @@ class ShtikerBook(DirectFrame, StateData.StateData): TTLocalizer.GardenPageTitle, TTLocalizer.GolfPageTitle, TTLocalizer.EventsPageName, + TTLocalizer.AutoerPageTitle, TTLocalizer.NewsPageName] return @@ -123,11 +124,8 @@ class ShtikerBook(DirectFrame, StateData.StateData): self['image'] = bookModel.find('**/big_book') self['image_scale'] = (2, 1, 1.5) self.resetFrameSize() - # Widescreen support - maintain distance from right edge - baseXPos = 1.175 - adjustedXPos = base.getWidescreenXOffset(baseXPos, 'right') if hasattr(base, 'getWidescreenXOffset') else baseXPos - self.bookOpenButton = DirectButton(image=(bookModel.find('**/BookIcon_CLSD'), bookModel.find('**/BookIcon_OPEN'), bookModel.find('**/BookIcon_RLVR')), relief=None, pos=(adjustedXPos, 0, -0.83), scale=0.305, command=self.__open) - self.bookCloseButton = DirectButton(image=(bookModel.find('**/BookIcon_OPEN'), bookModel.find('**/BookIcon_CLSD'), bookModel.find('**/BookIcon_RLVR2')), relief=None, pos=(adjustedXPos, 0, -0.83), scale=0.305, command=self.__close) + self.bookOpenButton = DirectButton(image=(bookModel.find('**/BookIcon_CLSD'), bookModel.find('**/BookIcon_OPEN'), bookModel.find('**/BookIcon_RLVR')), relief=None, pos=(1.175, 0, -0.83), scale=0.305, command=self.__open) + self.bookCloseButton = DirectButton(image=(bookModel.find('**/BookIcon_OPEN'), bookModel.find('**/BookIcon_CLSD'), bookModel.find('**/BookIcon_RLVR2')), relief=None, pos=(1.175, 0, -0.83), scale=0.305, command=self.__close) self.bookOpenButton.hide() self.bookCloseButton.hide() self.nextArrow = DirectButton(parent=self, relief=None, image=(bookModel.find('**/arrow_button'), bookModel.find('**/arrow_down'), bookModel.find('**/arrow_rollover')), scale=(0.1, 0.1, 0.1), pos=(0.838, 0, -0.661), command=self.__pageChange, extraArgs=[1]) @@ -270,6 +268,10 @@ class ShtikerBook(DirectFrame, StateData.StateData): iconModels = loader.loadModel('phase_4/models/parties/partyStickerbook') iconGeom = iconModels.find('**/Stickerbook_PartyIcon') iconModels.detachNode() + elif pageName == TTLocalizer.AutoerPageTitle: + iconModels = loader.loadModel('phase_3.5/models/gui/sos_textures') + iconGeom = iconModels.find('**/gui_gear') + iconModels.detachNode() elif pageName == TTLocalizer.NewsPageName: iconModels = loader.loadModel('phase_3.5/models/gui/sos_textures') iconGeom = iconModels.find('**/tt_t_gui_sbk_newsPageTab') @@ -278,6 +280,8 @@ class ShtikerBook(DirectFrame, StateData.StateData): extraArgs = [page] if pageName == TTLocalizer.OptionsPageTitle: pageName = TTLocalizer.OptionsTabTitle + elif pageName == TTLocalizer.AutoerPageTitle: + pageName = TTLocalizer.AutoerPageTabTitle pageTab = DirectButton(parent=self.pageTabFrame, relief=DGG.RAISED, frameSize=(-0.575, 0.575, -0.575, diff --git a/toontown/suit/DistributedFactorySuitAI.py b/toontown/suit/DistributedFactorySuitAI.py index 30f6714..74f6517 100644 --- a/toontown/suit/DistributedFactorySuitAI.py +++ b/toontown/suit/DistributedFactorySuitAI.py @@ -7,7 +7,7 @@ from direct.showbase.PythonUtil import StackTrace class DistributedFactorySuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): notify = DirectNotifyGlobal.directNotify.newCategory('DistributedFactorySuitAI') - def __init__(self, air, suitPlanner): + def __init__(self, air, suitPlanner=None): DistributedSuitBaseAI.DistributedSuitBaseAI.__init__(self, air, suitPlanner) self.blocker = None self.battleCellIndex = None diff --git a/toontown/suit/DistributedLawbotBossSuitAI.py b/toontown/suit/DistributedLawbotBossSuitAI.py index 43bc074..83b6ed8 100644 --- a/toontown/suit/DistributedLawbotBossSuitAI.py +++ b/toontown/suit/DistributedLawbotBossSuitAI.py @@ -11,7 +11,7 @@ from direct.fsm import State class DistributedLawbotBossSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): notify = DirectNotifyGlobal.directNotify.newCategory('DistributedLawbotBossSuitAI') - def __init__(self, air, suitPlanner): + def __init__(self, air, suitPlanner=None): DistributedSuitBaseAI.DistributedSuitBaseAI.__init__(self, air, suitPlanner) self.stunned = False self.timeToRelease = 3.15 diff --git a/toontown/suit/DistributedSuitAI.py b/toontown/suit/DistributedSuitAI.py index 1ceefec..6b42b95 100644 --- a/toontown/suit/DistributedSuitAI.py +++ b/toontown/suit/DistributedSuitAI.py @@ -20,8 +20,9 @@ class DistributedSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): myId = 0 notify = DirectNotifyGlobal.directNotify.newCategory('DistributedSuitAI') - def __init__(self, air, suitPlanner): + def __init__(self, air, suitPlanner=None): DistributedSuitBaseAI.DistributedSuitBaseAI.__init__(self, air, suitPlanner) + self.spDoId = 0 self.bldgTrack = None self.branchId = None if suitPlanner: @@ -32,9 +33,14 @@ class DistributedSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): self.maxPathLen = 0 self.pathPositionIndex = 0 self.pathPositionTimestamp = 0.0 + # These fields can be set via required updates before we have enough + # information to build a legList. Initialize them defensively so + # required-field ordering can't crash the AI process. self.pathState = 0 + self.pathStartTime = 0.0 self.currentLeg = 0 self.legType = SuitLeg.TOff + self.legList = None self.flyInSuit = 0 self.buildingSuit = 0 self.attemptingTakeover = 0 @@ -43,19 +49,71 @@ class DistributedSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): self.buildingDestinationIsCogdo = False return + # DC required field initializer (see etc/toon.dc: DistributedSuit.setSPDoId) + def setSPDoId(self, doId): + self.spDoId = doId + self.sp = self.air.doId2do.get(doId, None) + if self.sp is None and doId != 0: + taskMgr.doMethodLater(0.25, self.__retryResolveSuitPlanner, self.taskName('resolveSuitPlanner')) + else: + taskMgr.remove(self.taskName('resolveSuitPlanner')) + self.__maybeStartMovingAfterPlannerResolved() + + def __retryResolveSuitPlanner(self, task): + if self.spDoId == 0 or self.isDeleted(): + return Task.done + self.sp = self.air.doId2do.get(self.spDoId, None) + if self.sp is None: + return task.again + self.__maybeStartMovingAfterPlannerResolved() + return Task.done + + def __maybeStartMovingAfterPlannerResolved(self): + # If we were put onto a path before required fields finished arriving, + # try again now that we have a suit planner reference. + if self.pathState == 1 and not getattr(self, 'legList', None): + try: + self.initializePath() + except Exception: + return + if self.pathState == 1 and getattr(self, 'legList', None): + try: + self.moveToNextLeg(None) + except Exception: + pass + + def taskName(self, taskString): + """ + DistributedObjectAI.taskName assumes self.doId exists. + During early lifecycle (before generateWithRequired), some code paths + may still schedule/clear tasks (eg. failed suit creation cleanup). + Use a stable fallback so delete/cleanup can't crash the AI process. + """ + doId = getattr(self, 'doId', None) + if doId is None: + return '%s-tmp-%s' % (taskString, id(self)) + return '%s-%s' % (taskString, doId) + def stopTasks(self): + # If we were never generated, we may not have a doId. taskName() handles that. taskMgr.remove(self.taskName('flyAwayNow')) taskMgr.remove(self.taskName('danceNowFlyAwayLater')) taskMgr.remove(self.taskName('move')) + taskMgr.remove(self.taskName('resolveSuitPlanner')) + + def delete(self): + self.stopTasks() + DistributedSuitBaseAI.DistributedSuitBaseAI.delete(self) def pointInMyPath(self, point, elapsedTime): if self.pathState != 1: return 0 + if not getattr(self, 'legList', None) or not self.sp: + return 0 then = globalClock.getFrameTime() + elapsedTime elapsed = then - self.pathStartTime - if not self.sp: - pass - return self.legList.isPointInRange(point, elapsed - self.sp.PATH_COLLISION_BUFFER, elapsed + self.sp.PATH_COLLISION_BUFFER) + buf = getattr(self.sp, 'PATH_COLLISION_BUFFER', 5) + return self.legList.isPointInRange(point, elapsed - buf, elapsed + buf) def requestBattle(self, x, y, z, h, p, r): toonId = self.air.getAvatarIdFromSender() @@ -171,7 +229,20 @@ class DistributedSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): if state == 0: self.stopPathNow() elif state == 1: - self.moveToNextLeg(None) + # When this arrives as a required field, other required fields + # (path endpoints, dna, etc.) may not have been processed yet. + # Only start moving once we have a valid legList. + try: + if not getattr(self, 'legList', None): + # Only attempt path init if endpoints are set. + if getattr(self, 'pathEndpointStart', None) is not None and getattr(self, 'pathEndpointEnd', None) is not None: + self.initializePath() + if getattr(self, 'legList', None): + self.moveToNextLeg(None) + except Exception: + # If we can't initialize yet, stay in pathState=1 and wait + # for subsequent required updates to fill in what we need. + pass elif state == 2: self.stopPathNow() elif state == 3: @@ -211,8 +282,16 @@ class DistributedSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): self.b_setPathPosition(self.currentLeg, self.pathStartTime + self.legList.getStartTime(self.currentLeg)) def moveToNextLeg(self, task): + if self.isDeleted() or self.air is None: + return Task.done + if not getattr(self, 'legList', None): + return Task.done now = globalClock.getFrameTime() - elapsed = now - self.pathStartTime + try: + elapsed = now - self.pathStartTime + except Exception: + self.pathStartTime = now + elapsed = 0.0 nextLeg = self.legList.getLegIndexAtTime(elapsed, self.currentLeg) numLegs = self.legList.getNumLegs() if self.currentLeg != nextLeg: @@ -249,12 +328,16 @@ class DistributedSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): taskMgr.remove(self.taskName('move')) def __enterZone(self, zoneId): + if self.air is None: + return if zoneId != self.zoneId: - self.sp.zoneChange(self, self.zoneId, zoneId) + if self.sp: + self.sp.zoneChange(self, self.zoneId, zoneId) self.air.sendSetZone(self, zoneId) self.zoneId = zoneId if self.pathState == 1: - self.sp.checkForBattle(zoneId, self) + if self.sp: + self.sp.checkForBattle(zoneId, self) def __beginLegType(self, legType): self.legType = legType diff --git a/toontown/suit/DistributedSuitBase.py b/toontown/suit/DistributedSuitBase.py index 62e2501..2a90e5f 100644 --- a/toontown/suit/DistributedSuitBase.py +++ b/toontown/suit/DistributedSuitBase.py @@ -366,7 +366,7 @@ class DistributedSuitBase(DistributedAvatar.DistributedAvatar, Suit.Suit, SuitBa self.HpTextGenerator.setFont(OTPGlobals.getSignFont()) if number < 0: self.HpTextGenerator.setText(str(number)) - if base.cr.newsManager.isHolidayRunning(ToontownGlobals.SILLY_SURGE_HOLIDAY): + if base.cr.newsManager and base.cr.newsManager.isHolidayRunning(ToontownGlobals.SILLY_SURGE_HOLIDAY): self.sillySurgeText = True absNum = abs(number) if absNum > 0 and absNum <= 10: diff --git a/toontown/suit/DistributedSuitBaseAI.py b/toontown/suit/DistributedSuitBaseAI.py index 1251671..5141566 100644 --- a/toontown/suit/DistributedSuitBaseAI.py +++ b/toontown/suit/DistributedSuitBaseAI.py @@ -7,7 +7,7 @@ from toontown.battle import SuitBattleGlobals class DistributedSuitBaseAI(DistributedAvatarAI.DistributedAvatarAI, SuitBase.SuitBase): notify = DirectNotifyGlobal.directNotify.newCategory('DistributedSuitBaseAI') - def __init__(self, air, suitPlanner): + def __init__(self, air, suitPlanner=None): DistributedAvatarAI.DistributedAvatarAI.__init__(self, air) SuitBase.SuitBase.__init__(self) self.sp = suitPlanner diff --git a/toontown/suit/DistributedTutorialSuitAI.py b/toontown/suit/DistributedTutorialSuitAI.py index c92d64d..eea74b3 100644 --- a/toontown/suit/DistributedTutorialSuitAI.py +++ b/toontown/suit/DistributedTutorialSuitAI.py @@ -10,7 +10,7 @@ class DistributedTutorialSuitAI(DistributedSuitBaseAI.DistributedSuitBaseAI): notify = DirectNotifyGlobal.directNotify.newCategory( 'DistributedTutorialSuitAI') - def __init__(self, air, suitPlanner): + def __init__(self, air, suitPlanner=None): """__init__(air, suitPlanner)""" DistributedSuitBaseAI.DistributedSuitBaseAI.__init__(self, air, suitPlanner) diff --git a/toontown/toon/CamRunner.py b/toontown/toon/CamRunner.py new file mode 100644 index 0000000..f904c28 --- /dev/null +++ b/toontown/toon/CamRunner.py @@ -0,0 +1,59 @@ +from direct.showbase.DirectObject import DirectObject +from direct.showbase.InputStateGlobal import inputState + + +class CamRunner(DirectObject): + def __init__(self): + self.orbitMode = False + self.toonWasWalking = False + self.usingOrbitalRun = False + self.acceptingMovement = True + self.forwardInput = None + self._lmbToken = None + + def startInput(self): + self.forwardInput = base.controls.MOVE_UP + self.orbitMode = True + + self._lmbToken = inputState.watchWithModifiers('LMB', 'mouse1') + self.accept('mouse1', self.startOrbitalMovement) + self.accept('mouse1-up', self.stopOrbitalMovement) + + def stopInput(self): + self.stopOrbitalMovement() + self.ignore('mouse1') + self.ignore('mouse1-up') + self.orbitMode = False + + self.toonWasWalking = False + self.acceptingMovement = True + if self._lmbToken is not None: + self._lmbToken.release() + self._lmbToken = None + + def startOrbitalMovement(self): + self.usingOrbitalRun = True + self.toonWasWalking = base.walking + messenger.send(self.forwardInput) + self.accept(f'{self.forwardInput}-up', self.triggerToonWalk, extraArgs=[False]) + self.accept(self.forwardInput, self.triggerToonWalk, extraArgs=[True]) + + def stopOrbitalMovement(self): + if not self.usingOrbitalRun: + return + self.usingOrbitalRun = False + self.triggerToonWalk(False) + self.ignore(f'{self.forwardInput}-up') + self.ignore(self.forwardInput) + if not self.toonWasWalking: + messenger.send(f'{self.forwardInput}-up') + self.toonWasWalking = None + self.acceptingMovement = True + + def triggerToonWalk(self, walking): + if self.acceptingMovement: + self.toonWasWalking = walking + + self.acceptingMovement = walking + if not walking: + messenger.send(self.forwardInput) diff --git a/toontown/toon/DistributedNPCBlockerAI.py b/toontown/toon/DistributedNPCBlockerAI.py index a79042f..eab3f6b 100644 --- a/toontown/toon/DistributedNPCBlockerAI.py +++ b/toontown/toon/DistributedNPCBlockerAI.py @@ -6,7 +6,7 @@ from direct.task.Task import Task class DistributedNPCBlockerAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.tutorial = 0 diff --git a/toontown/toon/DistributedNPCClerkAI.py b/toontown/toon/DistributedNPCClerkAI.py index 39e452b..d1d253f 100644 --- a/toontown/toon/DistributedNPCClerkAI.py +++ b/toontown/toon/DistributedNPCClerkAI.py @@ -5,7 +5,7 @@ from .DistributedNPCToonBaseAI import * class DistributedNPCClerkAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.timedOut = 0 diff --git a/toontown/toon/DistributedNPCFishermanAI.py b/toontown/toon/DistributedNPCFishermanAI.py index b1f2bd2..f880172 100644 --- a/toontown/toon/DistributedNPCFishermanAI.py +++ b/toontown/toon/DistributedNPCFishermanAI.py @@ -8,7 +8,7 @@ from direct.task import Task class DistributedNPCFishermanAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.givesQuests = 0 self.busy = 0 diff --git a/toontown/toon/DistributedNPCFlippyInToonHallAI.py b/toontown/toon/DistributedNPCFlippyInToonHallAI.py index 9c450a0..92cc340 100644 --- a/toontown/toon/DistributedNPCFlippyInToonHallAI.py +++ b/toontown/toon/DistributedNPCFlippyInToonHallAI.py @@ -2,5 +2,5 @@ from .DistributedNPCToonAI import * class DistributedNPCFlippyInToonHallAI(DistributedNPCToonAI): - def __init__(self, air, npcId, questCallback = None, hq = 0): + def __init__(self, air, npcId=None, questCallback = None, hq = 0): DistributedNPCToonAI.__init__(self, air, npcId, questCallback) diff --git a/toontown/toon/DistributedNPCKartClerkAI.py b/toontown/toon/DistributedNPCKartClerkAI.py index 364fa52..239791d 100644 --- a/toontown/toon/DistributedNPCKartClerkAI.py +++ b/toontown/toon/DistributedNPCKartClerkAI.py @@ -8,7 +8,7 @@ from toontown.racing.KartDNA import * class DistributedNPCKartClerkAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.givesQuests = 0 self.busy = 0 diff --git a/toontown/toon/DistributedNPCPartyPersonAI.py b/toontown/toon/DistributedNPCPartyPersonAI.py index f14de22..56ea095 100644 --- a/toontown/toon/DistributedNPCPartyPersonAI.py +++ b/toontown/toon/DistributedNPCPartyPersonAI.py @@ -8,7 +8,7 @@ from toontown.parties import PartyGlobals class DistributedNPCPartyPersonAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.givesQuests = 0 self.busy = 0 diff --git a/toontown/toon/DistributedNPCPetclerkAI.py b/toontown/toon/DistributedNPCPetclerkAI.py index 9ff2d0a..6fade69 100644 --- a/toontown/toon/DistributedNPCPetclerkAI.py +++ b/toontown/toon/DistributedNPCPetclerkAI.py @@ -8,7 +8,7 @@ from toontown.pets import PetUtil, PetDNA, PetConstants class DistributedNPCPetclerkAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.givesQuests = 0 self.busy = 0 diff --git a/toontown/toon/DistributedNPCScientistAI.py b/toontown/toon/DistributedNPCScientistAI.py index 4b2fe52..9c8a46d 100644 --- a/toontown/toon/DistributedNPCScientistAI.py +++ b/toontown/toon/DistributedNPCScientistAI.py @@ -5,7 +5,7 @@ from direct.task.Task import Task class DistributedNPCScientistAI(DistributedNPCToonBaseAI.DistributedNPCToonBaseAI): - def __init__(self, air, npcId, questCallback = None, hq = 0): + def __init__(self, air, npcId=None, questCallback = None, hq = 0): DistributedNPCToonBaseAI.DistributedNPCToonBaseAI.__init__(self, air, npcId, questCallback) self.scientistFSM = ClassicFSM.ClassicFSM('Scientist', [ State.State('Neutral', diff --git a/toontown/toon/DistributedNPCSpecialQuestGiverAI.py b/toontown/toon/DistributedNPCSpecialQuestGiverAI.py index 1cc67e1..e83bc83 100644 --- a/toontown/toon/DistributedNPCSpecialQuestGiverAI.py +++ b/toontown/toon/DistributedNPCSpecialQuestGiverAI.py @@ -6,7 +6,7 @@ from toontown.quest import Quests class DistributedNPCSpecialQuestGiverAI(DistributedNPCToonBaseAI): - def __init__(self, air, npcId, questCallback = None, hq = 0): + def __init__(self, air, npcId=None, questCallback = None, hq = 0): DistributedNPCToonBaseAI.__init__(self, air, npcId, questCallback) self.hq = hq self.tutorial = 0 diff --git a/toontown/toon/DistributedNPCTailorAI.py b/toontown/toon/DistributedNPCTailorAI.py index c5b39f7..0eaa8c7 100644 --- a/toontown/toon/DistributedNPCTailorAI.py +++ b/toontown/toon/DistributedNPCTailorAI.py @@ -10,7 +10,7 @@ class DistributedNPCTailorAI(DistributedNPCToonBaseAI): freeClothes = simbase.config.GetBool('free-clothes', 0) housingEnabled = simbase.config.GetBool('want-housing', 1) - def __init__(self, air, npcId): + def __init__(self, air, npcId=None): DistributedNPCToonBaseAI.__init__(self, air, npcId) self.timedOut = 0 self.givesQuests = 0 diff --git a/toontown/toon/DistributedNPCToonAI.py b/toontown/toon/DistributedNPCToonAI.py index 44a8ecd..425e4c2 100644 --- a/toontown/toon/DistributedNPCToonAI.py +++ b/toontown/toon/DistributedNPCToonAI.py @@ -7,7 +7,7 @@ from toontown.quest import Quests class DistributedNPCToonAI(DistributedNPCToonBaseAI): FourthGagVelvetRopeBan = config.GetBool('want-ban-fourth-gag-velvet-rope', 0) - def __init__(self, air, npcId, questCallback = None, hq = 0): + def __init__(self, air, npcId=None, questCallback = None, hq = 0): DistributedNPCToonBaseAI.__init__(self, air, npcId, questCallback) self.hq = hq self.tutorial = 0 diff --git a/toontown/toon/DistributedNPCToonBaseAI.py b/toontown/toon/DistributedNPCToonBaseAI.py index 8ac54cf..0b6bba9 100644 --- a/toontown/toon/DistributedNPCToonBaseAI.py +++ b/toontown/toon/DistributedNPCToonBaseAI.py @@ -11,10 +11,12 @@ from toontown.quest import Quests class DistributedNPCToonBaseAI(DistributedToonAI.DistributedToonAI): - def __init__(self, air, npcId, questCallback = None): + def __init__(self, air, npcId=None, questCallback = None): DistributedToonAI.DistributedToonAI.__init__(self, air) self.air = air - self.npcId = npcId + # AstronInternalRepository instantiates distributed AI objects with only `air`. + # npcId comes in via required fields afterwards, so allow a safe default. + self.npcId = 0 if npcId is None else npcId self.busy = 0 self.questCallback = questCallback self.givesQuests = 1 diff --git a/toontown/toon/DistributedToonAI.py b/toontown/toon/DistributedToonAI.py index b5489fe..13ac8bb 100644 --- a/toontown/toon/DistributedToonAI.py +++ b/toontown/toon/DistributedToonAI.py @@ -214,8 +214,19 @@ class DistributedToonAI(DistributedPlayerAI.DistributedPlayerAI, DistributedSmoo DistributedPlayerAI.DistributedPlayerAI.announceGenerate(self) DistributedSmoothNodeAI.DistributedSmoothNodeAI.announceGenerate(self) if self.isPlayerControlled(): + # Grant global teleport access immediately on login. + # This sets both the teleport destinations and the "visited hoods" + # list so the ShtikerBook map can offer teleports everywhere. + try: + allHoods = list(ToontownGlobals.HoodsForTeleportAll) + self.b_setTeleportAccess(allHoods) + self.b_setHoodsVisited(allHoods) + except Exception: + self.notify.warning('Failed to grant global teleport access on login.') + self.grantFullGagUnlock() if self.WantOldGMNameBan: self._checkOldGMName() + self.maybeMigrateLegacyTutorialQuestState() messenger.send('avatarEntered', [self]) if __astron__: self.sendUpdate('setDefaultShard', [self.air.districtId]) @@ -1672,10 +1683,6 @@ class DistributedToonAI(DistributedPlayerAI.DistributedPlayerAI, DistributedSmoo self.air.writeServerEvent('suspicious', self.doId, "Toon tried to delete quest they don't have %s" % str(questDesc)) self.notify.warning("%s.requestDeleteQuest(%s) -- Toon doesn't have that quest" % (self, str(questDesc))) return - if not Quests.isQuestJustForFun(questId, rewardId): - self.air.writeServerEvent('suspicious', self.doId, 'Toon tried to delete non-Just For Fun quest %s' % str(questDesc)) - self.notify.warning('%s.requestDeleteQuest(%s) -- Tried to cancel non-Just For Fun quest' % (self, str(questDesc))) - return removedStatus = self.removeAllTracesOfQuest(questId, rewardId) if 0 in removedStatus: self.notify.warning('%s.requestDeleteQuest(%s) -- Failed to remove quest, status=%s' % (self, str(questDesc), removedStatus)) @@ -1979,6 +1986,49 @@ class DistributedToonAI(DistributedPlayerAI.DistributedPlayerAI, DistributedSmoo def getRewardTier(self): return self.rewardTier + def grantFullGagUnlock(self): + if not ToontownGlobals.WantUnlimitedGags: + return + self.b_setTrackAccess([1, 1, 1, 1, 1, 1, 1]) + self.b_setMaxCarry(ToontownGlobals.MaxCarryLimit) + if self.experience: + self.experience.maxOutExp() + self.b_setExperience(self.getExperience()) + if self.inventory: + self.inventory.zeroInv() + self.inventory.maxOutInv(0, 0) + self.d_setInventory(self.getInventory()) + + def maybeMigrateLegacyTutorialQuestState(self): + """Players who finished the tutorial under old rules may still have carry + limit 1 and/or legacy bootstrap quests (101/110). Clean that up on login.""" + if not self.isPlayerControlled(): + return + if not self.getTutorialAck(): + return + limit = ToontownGlobals.MaxQuestCarryLimit + legacy = Quests.LegacyTutorialQuestIds + changed = 0 + newQuests = [q for q in self.quests if q[0] not in legacy] + if len(newQuests) != len(self.quests): + self.notify.info( + 'maybeMigrateLegacyTutorialQuestState: removed legacy tutorial quests for avatar %s' + % self.doId) + self.b_setQuests(newQuests) + changed = 1 + newHist = [qid for qid in self.questHistory if qid not in legacy] + if len(newHist) != len(self.questHistory): + self.b_setQuestHistory(newHist) + changed = 1 + if self.questCarryLimit != limit: + self.notify.info( + 'maybeMigrateLegacyTutorialQuestState: questCarryLimit %s -> %s for avatar %s' + % (self.questCarryLimit, limit, self.doId)) + self.b_setQuestCarryLimit(limit) + changed = 1 + if changed: + self.air.writeServerEvent('legacyTutorialQuestMigrate', self.doId, '') + def fixAvatar(self): anyChanged = 0 qrc = QuestRewardCounter.QuestRewardCounter() @@ -1990,42 +2040,43 @@ class DistributedToonAI(DistributedPlayerAI.DistributedPlayerAI, DistributedSmoo self.b_setHp(self.maxHp) anyChanged = 1 inventoryChanged = 0 - carry = self.maxCarry - for track in range(len(ToontownBattleGlobals.Tracks)): - if not self.hasTrackAccess(track): - for level in range(len(ToontownBattleGlobals.Levels[track])): - count = self.inventory.inventory[track][level] - if count != 0: - self.notify.info('Changed avatar %d to throw away %d items in track %d level %d; no access to track.' % (self.doId, - count, - track, - level)) - self.inventory.inventory[track][level] = 0 - inventoryChanged = 1 - - else: - curSkill = self.experience.getExp(track) - for level in range(len(ToontownBattleGlobals.Levels[track])): - count = self.inventory.inventory[track][level] - if curSkill < ToontownBattleGlobals.Levels[track][level]: + if not ToontownGlobals.WantUnlimitedGags: + carry = self.maxCarry + for track in range(len(ToontownBattleGlobals.Tracks)): + if not self.hasTrackAccess(track): + for level in range(len(ToontownBattleGlobals.Levels[track])): + count = self.inventory.inventory[track][level] if count != 0: - self.notify.info('Changed avatar %d to throw away %d items in track %d level %d; no access to level.' % (self.doId, + self.notify.info('Changed avatar %d to throw away %d items in track %d level %d; no access to track.' % (self.doId, count, track, level)) self.inventory.inventory[track][level] = 0 inventoryChanged = 1 - else: - newCount = min(count, carry) - newCount = min(count, self.inventory.getMax(track, level)) - if count != newCount: - self.notify.info('Changed avatar %d to throw away %d items in track %d level %d; too many gags.' % (self.doId, - count - newCount, - track, - level)) - self.inventory.inventory[track][level] = newCount - inventoryChanged = 1 - carry -= newCount + + else: + curSkill = self.experience.getExp(track) + for level in range(len(ToontownBattleGlobals.Levels[track])): + count = self.inventory.inventory[track][level] + if curSkill < ToontownBattleGlobals.Levels[track][level]: + if count != 0: + self.notify.info('Changed avatar %d to throw away %d items in track %d level %d; no access to level.' % (self.doId, + count, + track, + level)) + self.inventory.inventory[track][level] = 0 + inventoryChanged = 1 + else: + newCount = min(count, carry) + newCount = min(count, self.inventory.getMax(track, level)) + if count != newCount: + self.notify.info('Changed avatar %d to throw away %d items in track %d level %d; too many gags.' % (self.doId, + count - newCount, + track, + level)) + self.inventory.inventory[track][level] = newCount + inventoryChanged = 1 + carry -= newCount self.inventory.calcTotalProps() if inventoryChanged: diff --git a/toontown/toon/InventoryBase.py b/toontown/toon/InventoryBase.py index f4af9c7..5b21bb9 100644 --- a/toontown/toon/InventoryBase.py +++ b/toontown/toon/InventoryBase.py @@ -131,6 +131,12 @@ class InventoryBase(DirectObject.DirectObject): def useItem(self, track, level): if type(track) == type(''): track = Tracks.index(track) + if ToontownGlobals.WantUnlimitedGags: + if self.numItem(track, level) > 0: + return + if self.numItem(track, level) == -1: + return -1 + return if self.numItem(track, level) > 0: self.inventory[track][level] -= 1 self.calcTotalProps() diff --git a/toontown/toon/InventoryNew.py b/toontown/toon/InventoryNew.py index c46d937..267f8e2 100644 --- a/toontown/toon/InventoryNew.py +++ b/toontown/toon/InventoryNew.py @@ -9,6 +9,10 @@ from direct.directnotify import DirectNotifyGlobal from toontown.toonbase import ToontownGlobals from otp.otpbase import OTPGlobals +def _invDetailRightX(x): + return x + + class InventoryNew(InventoryBase.InventoryBase, DirectFrame): notify = DirectNotifyGlobal.directNotify.newCategory('InventoryNew') PressableTextColor = Vec4(1, 1, 1, 1) @@ -126,7 +130,10 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): DirectFrame.hide(self) def updateTotalPropsText(self): - textTotal = TTLocalizer.InventoryTotalGags % (self.totalProps, self.toon.getMaxCarry()) + if ToontownGlobals.WantUnlimitedGags: + textTotal = '' + else: + textTotal = TTLocalizer.InventoryTotalGags % (self.totalProps, self.toon.getMaxCarry()) if localAvatar.getPinkSlips() > 1: textTotal = textTotal + '\n\n' + TTLocalizer.InventroyPinkSlips % localAvatar.getPinkSlips() elif localAvatar.getPinkSlips() == 1: @@ -287,8 +294,11 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.detailNameLabel.configure(text=AvPropStrings[track][level], image_image=self.invModels[track][level]) self.detailNameLabel.configure(image_scale=20, image_pos=(-0.2, 0, -2.2)) self.detailAmountLabel.show() - self.detailAmountLabel.configure(text=TTLocalizer.InventoryDetailAmount % {'numItems': self.numItem(track, level), - 'maxItems': self.getMax(track, level)}) + if ToontownGlobals.WantUnlimitedGags: + self.detailAmountLabel.configure(text='') + else: + self.detailAmountLabel.configure(text=TTLocalizer.InventoryDetailAmount % {'numItems': self.numItem(track, level), + 'maxItems': self.getMax(track, level)}) self.detailDataLabel.show() damage = getAvPropDamage(track, level, self.toon.experience.getExp(track)) organicBonus = self.toon.checkGagBonus(track, level) @@ -451,10 +461,10 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.detailFrame.setPos(0.1, 0, -0.855) self.detailFrame.setScale(0.75) self.deleteEnterButton.hide() - self.deleteEnterButton.setPos(1.029, 0, -0.639) + self.deleteEnterButton.setPos(_invDetailRightX(1.029), 0, -0.639) self.deleteEnterButton.setScale(0.75) self.deleteExitButton.hide() - self.deleteExitButton.setPos(1.029, 0, -0.639) + self.deleteExitButton.setPos(_invDetailRightX(1.029), 0, -0.639) self.deleteExitButton.setScale(0.75) self.invFrame.reparentTo(self) self.invFrame.setPos(0, 0, 0) @@ -486,10 +496,10 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.setPos(-0.2, 0, 0.4) self.setScale(0.8) self.deleteEnterButton.hide() - self.deleteEnterButton.setPos(1.029, 0, -0.639) + self.deleteEnterButton.setPos(_invDetailRightX(1.029), 0, -0.639) self.deleteEnterButton.setScale(0.75) self.deleteExitButton.show() - self.deleteExitButton.setPos(1.029, 0, -0.639) + self.deleteExitButton.setPos(_invDetailRightX(1.029), 0, -0.639) self.deleteExitButton.setScale(0.75) self.deleteHelpText.show() self.invFrame.reparentTo(self) @@ -529,7 +539,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.purchaseFrame) self.invFrame.setPos(-0.235, 0, 0.52) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.17, 0, -0.02) + self.detailFrame.setPos(_invDetailRightX(1.17), 0, -0.02) self.detailFrame.setScale(1.25) self.deleteEnterButton.hide() self.deleteEnterButton.setPos(-0.441, 0, -0.917) @@ -589,7 +599,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.storePurchaseFrame) self.invFrame.setPos(-0.23, 0, 0.505) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.175, 0, 0) + self.detailFrame.setPos(_invDetailRightX(1.175), 0, 0) self.detailFrame.setScale(1.25) self.deleteEnterButton.hide() self.deleteEnterButton.setPos(-0.55, 0, -0.91) @@ -633,7 +643,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.storePurchaseFrame) self.invFrame.setPos(-0.23, 0, 0.505) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.175, 0, 0) + self.detailFrame.setPos(_invDetailRightX(1.175), 0, 0) self.detailFrame.setScale(1.25) self.deleteEnterButton.show() self.deleteEnterButton.setPos(-0.55, 0, -0.91) @@ -702,7 +712,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.purchaseFrame) self.invFrame.setPos(-0.235, 0, 0.52) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.17, 0, -0.02) + self.detailFrame.setPos(_invDetailRightX(1.17), 0, -0.02) self.detailFrame.setScale(1.25) totalProps = self.totalProps maxProps = self.toon.getMaxCarry() @@ -757,7 +767,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.storePurchaseFrame) self.invFrame.setPos(-0.23, 0, 0.505) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.175, 0, 0) + self.detailFrame.setPos(_invDetailRightX(1.175), 0, 0) self.detailFrame.setScale(1.25) totalProps = self.totalProps maxProps = self.toon.getMaxCarry() @@ -810,7 +820,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.purchaseFrame) self.invFrame.setPos(-0.235, 0, 0.52) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.17, 0, -0.02) + self.detailFrame.setPos(_invDetailRightX(1.17), 0, -0.02) self.detailFrame.setScale(1.25) self.deleteEnterButton.show() self.deleteEnterButton.setPos(-0.441, 0, -0.917) @@ -851,7 +861,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.purchaseFrame) self.invFrame.setPos(-0.235, 0, 0.52) self.invFrame.setScale(0.81) - self.detailFrame.setPos(1.17, 0, -0.02) + self.detailFrame.setPos(_invDetailRightX(1.17), 0, -0.02) self.detailFrame.setScale(1.25) self.deleteEnterButton.show() self.deleteEnterButton.setPos(-0.441, 0, -0.917) @@ -892,7 +902,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.battleFrame) self.invFrame.setPos(-0.26, 0, 0.35) self.invFrame.setScale(1) - self.detailFrame.setPos(1.125, 0, -0.08) + self.detailFrame.setPos(_invDetailRightX(1.125), 0, -0.08) self.detailFrame.setScale(1) self.deleteEnterButton.hide() self.deleteExitButton.hide() @@ -957,7 +967,7 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): self.invFrame.reparentTo(self.battleFrame) self.invFrame.setPos(-0.25, 0, 0.35) self.invFrame.setScale(1) - self.detailFrame.setPos(1.125, 0, -0.08) + self.detailFrame.setPos(_invDetailRightX(1.125), 0, -0.08) self.detailFrame.setScale(1) self.deleteEnterButton.hide() self.deleteExitButton.hide() @@ -1129,7 +1139,10 @@ class InventoryNew(InventoryBase.InventoryBase, DirectFrame): def updateButton(self, track, level): button = self.buttons[track][level] - button['text'] = str(self.numItem(track, level)) + if ToontownGlobals.WantUnlimitedGags: + button['text'] = '' + else: + button['text'] = str(self.numItem(track, level)) organicBonus = self.toon.checkGagBonus(track, level) propBonus = self.checkPropBonus(track) bonus = organicBonus or propBonus diff --git a/toontown/toon/LocalToon.py b/toontown/toon/LocalToon.py index 4a00e2f..64c00df 100644 --- a/toontown/toon/LocalToon.py +++ b/toontown/toon/LocalToon.py @@ -86,9 +86,22 @@ class LocalToon(DistributedToon.DistributedToon, LocalAvatar.LocalAvatar): self.soundSystemMessage = base.loader.loadSfx('phase_3/audio/sfx/clock03.ogg') self.positionExaminer = PositionExaminer.PositionExaminer() friendsGui = loader.loadModel('phase_3.5/models/gui/friendslist_gui') - friendsButtonNormal = friendsGui.find('**/FriendsBox_Closed') - friendsButtonPressed = friendsGui.find('**/FriendsBox_Rollover') - friendsButtonRollover = friendsGui.find('**/FriendsBox_Rollover') + friendsButtonNormal = friendsButtonPressed = friendsButtonRollover = None + try: + if friendsGui is not None and not friendsGui.isEmpty(): + n = friendsGui.find('**/FriendsBox_Closed') + p = friendsGui.find('**/FriendsBox_Rollover') + r = friendsGui.find('**/FriendsBox_Rollover') + if n is not None and not n.isEmpty(): + friendsButtonNormal = n + if p is not None and not p.isEmpty(): + friendsButtonPressed = p + if r is not None and not r.isEmpty(): + friendsButtonRollover = r + except Exception: + friendsButtonNormal = friendsButtonPressed = friendsButtonRollover = None + if friendsButtonNormal is None or friendsButtonPressed is None or friendsButtonRollover is None: + self.notify.warning('friendslist_gui missing expected FriendsBox_* nodes; using fallback button visuals') newScale = oldScale = 0.8 if WantNewsPage: newScale = oldScale * ToontownGlobals.NewsPageScaleAdjust @@ -101,7 +114,11 @@ class LocalToon(DistributedToon.DistributedToon, LocalAvatar.LocalAvatar): self.friendsListButtonObscured = 0 self.moveFurnitureButtonObscured = 0 self.clarabelleButtonObscured = 0 - friendsGui.removeNode() + try: + if friendsGui is not None and not friendsGui.isEmpty(): + friendsGui.removeNode() + except Exception: + pass self.__furnitureGui = None self.__clarabelleButton = None self.__clarabelleFlash = None @@ -431,6 +448,9 @@ class LocalToon(DistributedToon.DistributedToon, LocalAvatar.LocalAvatar): self.accept('InputState-turnRight', self.__toonMoved) self.accept('InputState-slide', self.__toonMoved) QuestParser.init() + if base.config.GetBool('want-autoer-sticker-page', True): + from toontown.quest import AutoerManager + AutoerManager.attachEmergencyStopListener() return def __handlePurchase(self): @@ -1848,6 +1868,17 @@ class LocalToon(DistributedToon.DistributedToon, LocalAvatar.LocalAvatar): self.eventsPage = EventsPage.EventsPage() self.eventsPage.load() self.book.addPage(self.eventsPage, pageName=TTLocalizer.EventsPageName) + if base.config.GetBool('want-autoer-sticker-page', True): + self.addAutoerPage() + return + + def addAutoerPage(self): + if hasattr(self, 'autoerPage') and self.autoerPage != None: + return + from toontown.shtiker import AutoerPage + self.autoerPage = AutoerPage.AutoerPage() + self.autoerPage.load() + self.book.addPage(self.autoerPage, pageName=TTLocalizer.AutoerPageTitle) return def addNewsPage(self): diff --git a/toontown/toon/NPCForceAcknowledge.py b/toontown/toon/NPCForceAcknowledge.py index 3d5e8f8..ae6baef 100644 --- a/toontown/toon/NPCForceAcknowledge.py +++ b/toontown/toon/NPCForceAcknowledge.py @@ -12,6 +12,13 @@ class NPCForceAcknowledge: return def enter(self): + # This class historically enforced the TTC newbie quest sequence by + # blocking travel with a popup like "You must ride the trolley before leaving." + # This project disables tutorial/newbie travel locking entirely. + doneStatus = {'mode': 'complete'} + messenger.send(self.doneEvent, [doneStatus]) + return + doneStatus = {} questHistory = base.localAvatar.getQuestHistory() imgScale = 0.5 diff --git a/toontown/toon/OrbitalCamera.py b/toontown/toon/OrbitalCamera.py new file mode 100644 index 0000000..b8247f1 --- /dev/null +++ b/toontown/toon/OrbitalCamera.py @@ -0,0 +1,704 @@ +from panda3d.core import (BitMask32, CollisionHandlerFloor, + CollisionHandlerQueue, CollisionNode, CollisionRay, + CollisionSegment, CollisionTraverser, NodePath, + Vec3, WindowProperties) +from direct.directnotify import DirectNotifyGlobal +from direct.fsm.FSM import FSM +from direct.showbase.InputStateGlobal import inputState +from direct.showbase.PythonUtil import fitSrcAngle2Dest, reduceAngle +from direct.task import Task +from direct.task.TaskManagerGlobal import taskMgr + +from otp.otpbase import OTPGlobals +from toontown.toon.CamRunner import CamRunner +from toontown.toon.ParamObj import ParamObj + + +def _angleLerpToward(current, target, alpha): + """Lerp between two headings (degrees) taking the shortest arc.""" + diff = ((target - current) + 180.0) % 360.0 - 180.0 + return current + diff * alpha + + +class OrbitalCamera(FSM, NodePath, ParamObj): + """ + Modern third-person orbital camera for Toontown. + + Architecture + ------------ + The orbit rig is a NodePath reparented to the local toon. Its pivot is + at head height. The actual camera sits at (0, -distance, 0) in rig-local + space. Heading (H) and pitch (P) of the rig control where the camera + orbits; distance controls how far back it sits. + + Three independent desired values track what the player *wants*: + _desiredH, _desiredP, _desiredDistance + + Three smoothed actual values chase the desired values each frame: + _actualH, _actualP, _actualDistance + + A wall-collision ray (fixed in rig-local space, swept by H/P) determines + _collisionDistance, which caps _actualDistance from above so the camera + never clips through geometry. Pull-in is fast; release is slow so the + camera eases back out instead of snapping. + """ + + notify = DirectNotifyGlobal.directNotify.newCategory("OrbitalCamera") + + # ParamObj compatibility – not used in core logic, kept for API compat + class ParamSet(ParamObj.ParamSet): + Params = {"camOffset": Vec3(0, -14, 0)} + + # Task name constants + UpdateTaskName = "OrbitCamUpdateTask" + ReadMouseTaskName = "OrbitCamReadMouseTask" + AvatarFacingTaskName = "OrbitCamAvatarFacingTask" + + # Orbit angle limits (degrees) + MinP = -50.0 + MaxP = 20.0 + + # Optional heading lock (set by external code; None = free) + baseH = None + minH = None + maxH = None + + # Tab-key presets [{dist, p}] + presets = [ + {"dist": 14.0, "p": -20.0}, + {"dist": 24.0, "p": -10.0}, + {"dist": 5.0, "p": -5.0}, + ] + + TopNodeName = "OrbitCam" + _MinCamDistance = 1.5 + _MaxCamDistance = 30.0 + _MaxMouseDelta = 100.0 + _CollisionBuffer = 0.35 + + # Smoothing speeds expressed as "fraction closed per frame at 60 fps". + # Frame-rate-independent via: alpha = 1 - (1 - speed)^(dt * 60) + _OrbitSmoothSpeed = 0.25 # H / P orbit follow speed + _DistancePullSpeed = 0.88 # fast pull-in when wall is found + _DistanceReleaseSpeed = 0.05 # slow ease-out when wall clears + + # ------------------------------------------------------------------ # + # Construction / destruction # + # ------------------------------------------------------------------ # + + def __init__(self, subject): + ParamObj.__init__(self) + NodePath.__init__(self, self.TopNodeName) + FSM.__init__(self, "OrbitalCamera") + + self.subject = subject + self._paramStack = [] + self.setDefaultParams() + self.presetPos = 0 + + self.__inputEnabled = False + self.mouseControl = False + self.mouseDelta = (0, 0) + self.lastMousePos = (0.0, 0.0) + self.origMousePos = (0, 0) + + self._rmbToken = inputState.watchWithModifiers("RMB", "mouse3") + self.firstPerson = False + self.ignoreRMB = False + self.cam_toggled = False + self.runner = CamRunner() + self._oldWASDTurn = None + + # ---- Desired state (user input) -------------------------------- + self._desiredH = 0.0 + self._desiredP = -20.0 + self._desiredDistance = 14.0 + + # ---- Smoothed actual state ------------------------------------- + self._actualH = 0.0 + self._actualP = -20.0 + self._actualDistance = 14.0 + + # Maximum distance allowed by collision this frame + self._collisionDistance = self._MaxCamDistance + + self._active = False + self.request('Off') + + self.initializeCollisions() + + def destroy(self): + if self.isActive(): + self.request('Off') + self.destroyCollisions() + self._rmbToken.release() + del self._rmbToken + del self.subject + FSM.cleanup(self) + ParamObj.destroy(self) + self.ignoreAll() + if not self.isEmpty(): + self.removeNode() + + # ------------------------------------------------------------------ # + # Floor-ray collision nodes (zone on/off-floor signals) # + # ------------------------------------------------------------------ # + + def initializeCollisions(self): + self.cTravOnFloor = CollisionTraverser("CamMode.cTravOnFloor") + self.camFloorRayNode = self.attachNewNode("camFloorRayNode") + self.ccRay2 = CollisionRay(0, 0, 0, 0, 0, -1) + self.ccRay2Node = CollisionNode("ccRay2Node") + self.ccRay2Node.addSolid(self.ccRay2) + self.ccRay2NodePath = self.camFloorRayNode.attachNewNode(self.ccRay2Node) + self.ccRay2Node.setFromCollideMask(OTPGlobals.FloorBitmask) + self.ccRay2Node.setIntoCollideMask(BitMask32.allOff()) + self.ccRay2MoveNodePath = hidden.attachNewNode("ccRay2MoveNode") + self.camFloorCollisionBroadcaster = CollisionHandlerFloor() + self.camFloorCollisionBroadcaster.setInPattern("zone_on-floor") + self.camFloorCollisionBroadcaster.setOutPattern("zone_off-floor") + self.camFloorCollisionBroadcaster.addCollider( + self.ccRay2NodePath, self.ccRay2MoveNodePath) + self.cTravOnFloor.addCollider( + self.ccRay2NodePath, self.camFloorCollisionBroadcaster) + + def destroyCollisions(self): + del self.cTravOnFloor + del self.ccRay2 + del self.ccRay2Node + self.ccRay2NodePath.remove_node() + del self.ccRay2NodePath + self.ccRay2MoveNodePath.remove_node() + del self.ccRay2MoveNodePath + self.camFloorRayNode.remove_node() + del self.camFloorRayNode + + # ------------------------------------------------------------------ # + # FSM states # + # ------------------------------------------------------------------ # + + def enterActive(self): + self._active = True + self.cam_toggled = not self.cam_toggled + + self._loadSettings() + self.enableInput() + # Orbit camera expects strafe-style movement (A/D = strafe) and + # camera-relative facing while moving. Disable turn input while orbit + # camera is active; the toon will face the orbit heading instead. + if getattr(self.subject, 'controlManager', None): + try: + self._oldWASDTurn = getattr(self.subject.controlManager, '_ControlManager__WASDTurn', None) + except Exception: + self._oldWASDTurn = None + try: + self.subject.controlManager.setWASDTurn(False) + except Exception: + pass + try: + self.subject.controlManager.setTurn(0) + except Exception: + pass + base.camNode.setLodCenter(self.subject) + self._startWallCheck() + self.acceptWheel() + self.acceptTab() + + # Reparent rig to toon, pivot at head height + self.reparentTo(self.subject) + self.setPos(0, 0, self.subject.getHeight()) + self.setScale(1) + self.setR(0) + + # Start camera behind toon with zero visible pop + self._desiredH = self.subject.getH(render) + self._actualH = self._desiredH + self._collisionDistance = self._MaxCamDistance + + base.camera.reparentTo(self) + base.camera.setScale(1) + self._applyTransform() + + # Must run *before* GravityWalker.handleAvatarControls (priority 25): movement + # uses avatar heading, and we set camera-relative facing here. When this ran + # at 40, facing updated after the walker — strafe/back only rotated the model. + taskMgr.add(self._cameraUpdateTask, self.UpdateTaskName, priority=22) + + def exitActive(self): + self._active = False + taskMgr.remove(self.UpdateTaskName) + self._stopWallCheck() + base.camNode.setLodCenter(NodePath()) + self.ignoreWheel() + self.ignoreTab() + self.disableInput() + if getattr(self.subject, 'controlManager', None): + try: + self.subject.controlManager.setTurn(1) + except Exception: + pass + # Restore previous WASD turn/strafe mode if we were able to read it. + if self._oldWASDTurn is not None: + try: + self.subject.controlManager.setWASDTurn(bool(self._oldWASDTurn)) + except Exception: + pass + if not base.camera.isEmpty() and base.camera.getParent() == self: + base.camera.wrtReparentTo(self.subject) + if not self.isEmpty() and self.getParent() == self.subject: + self.detachNode() + + def enterOff(self): + pass + + def exitOff(self): + pass + + # ------------------------------------------------------------------ # + # Camera placement # + # ------------------------------------------------------------------ # + + def _applyTransform(self): + """ + Push the smoothed orbit state onto the scene graph. + The rig's heading and pitch drive the orbit orientation; the camera + always sits at (0, -distance, 0) in rig-local space. + """ + self.setH(render, self._actualH) + self.setP(self._actualP) + self.setR(0) + base.camera.setPos(self, Vec3(0, -self._actualDistance, 0)) + base.camera.setHpr(self, Vec3(0, 0, 0)) + base.camera.setScale(1) + + # ------------------------------------------------------------------ # + # Settings # + # ------------------------------------------------------------------ # + + def _loadSettings(self): + """Load persistent camera settings from useropt.json.""" + try: + dist = float(base.settings.getSetting('cam-distance', 14.0)) + dist = max(self._MinCamDistance, min(self._MaxCamDistance, dist)) + self._desiredDistance = dist + self._actualDistance = dist + except Exception: + pass + + def onSettingsChanged(self): + """Call this after writing new camera settings from the options menu.""" + self._loadSettings() + + # ------------------------------------------------------------------ # + # Main per-frame update task (runs every frame while Active) # + # ------------------------------------------------------------------ # + + def _calcAlpha(self, speed, dt): + """Frame-rate-independent lerp coefficient from a per-60fps speed.""" + return 1.0 - pow(max(0.0, 1.0 - speed), dt * 60.0) + + def _cameraUpdateTask(self, task): + if self.oobeEnabled(): + return task.cont + + try: + dt = globalClock.getDt() + dt = max(0.0, min(dt, 0.1)) # guard against spike frames + except Exception: + dt = 1.0 / 60.0 + + # 1. Consume mouse delta when in orbit-look mode ───────────────── + if self.mouseControl and (self.mouseDelta[0] or self.mouseDelta[1]): + self._applyMouseDelta() + self.mouseDelta = (0, 0) + + # 2. Run wall-collision check ───────────────────────────────────── + self._runCollision() + + # 3. Smooth orbit heading (H) and pitch (P) ─────────────────────── + orbitAlpha = self._calcAlpha(self._OrbitSmoothSpeed, dt) + self._actualH = _angleLerpToward(self._actualH, self._desiredH, orbitAlpha) + self._actualP += (self._desiredP - self._actualP) * orbitAlpha + + # 4. Smooth camera distance ─────────────────────────────────────── + targetDist = max(self._MinCamDistance, + min(self._desiredDistance, self._collisionDistance)) + # Fast pull-in when a wall is closer; slow ease-out when it clears + if targetDist < self._actualDistance: + distAlpha = self._calcAlpha(self._DistancePullSpeed, dt) + else: + distAlpha = self._calcAlpha(self._DistanceReleaseSpeed, dt) + self._actualDistance += (targetDist - self._actualDistance) * distAlpha + self._actualDistance = max(self._MinCamDistance, self._actualDistance) + + # 5. Apply to scene graph ───────────────────────────────────────── + self._applyTransform() + + # 6. Match RMB orbit-look: keep the toon facing the camera rig heading while + # moving so W/A/S/D use the same camera-relative walk as mouse-look (strafe + # is slide keys; forward/back along view — no separate "face movement" mode). + if self.isSubjectMoving(): + try: + self.subject.setH(render, self._actualH) + except Exception: + pass + return task.cont + + def _applyMouseDelta(self): + """Convert raw mouse delta into desired-orbit-state changes.""" + dx, dy = self.mouseDelta + try: + dx, dy = float(dx), float(dy) + except Exception: + return + + # Clamp to guard against pointer-warp / missed-frame spikes + dx = max(-self._MaxMouseDelta, min(self._MaxMouseDelta, dx)) + dy = max(-self._MaxMouseDelta, min(self._MaxMouseDelta, dy)) + if dx == 0.0 and dy == 0.0: + return + + try: + mult = float(base.settings.getSetting('mouse-sensitivity', 1.0)) + mult = max(0.3, min(3.0, mult)) + except Exception: + mult = 1.0 + + try: + invertY = bool(base.settings.getSetting('cam-invert-y', False)) + except Exception: + invertY = False + + sens = 0.18 * mult + + # Horizontal orbit: mouse right (dx > 0) → heading decreases → camera orbits right + self._desiredH += -dx * sens + + # Vertical orbit: without invert, mouse down (dy > 0) → pitch decreases → camera elevates + pitchDir = 1.0 if invertY else -1.0 + self._desiredP = max(self.MinP, + min(self.MaxP, self._desiredP + dy * sens * pitchDir)) + + # Optional heading bounds (set by external scene code) + if self.baseH is not None: + self._clampDesiredH() + + # When subject is moving, snap orbit heading so the toon immediately + # faces the camera's look direction (no lag on deliberate turning). + if self.isSubjectMoving(): + self.subject.setH(render, self._desiredH) + self._actualH = self._desiredH + + def _clampDesiredH(self): + currH = fitSrcAngle2Dest(self._desiredH, 180) + if currH < self.minH: + self._desiredH = reduceAngle(self.minH) + elif currH > self.maxH: + self._desiredH = reduceAngle(self.maxH) + + # ------------------------------------------------------------------ # + # Wall-collision check (runs inside _cameraUpdateTask each frame) # + # ------------------------------------------------------------------ # + + def _startWallCheck(self): + """Build the collision segment and traverser for wall detection.""" + self._wallQueue = CollisionHandlerQueue() + self._wallCTrav = CollisionTraverser("OrbitCam.wallTrav") + + # Segment from rig pivot (0,0,0) toward (0,-maxDist,0) in rig-local + # space. As H/P change the rig's orientation, the segment naturally + # sweeps to where the camera would be. + self._wallSolid = CollisionSegment(0, 0, 0, + 0, -(self._MaxCamDistance + 1.0), 0) + wallNode = CollisionNode("OrbitCam.wallNode") + wallNode.addSolid(self._wallSolid) + wallNode.setFromCollideMask( + OTPGlobals.CameraBitmask + | OTPGlobals.CameraTransparentBitmask + | OTPGlobals.FloorBitmask + ) + wallNode.setIntoCollideMask(BitMask32.allOff()) + self._wallNp = self.attachNewNode(wallNode) + self._wallCTrav.addCollider(self._wallNp, self._wallQueue) + + def _runCollision(self): + """Traverse world geometry and update _collisionDistance.""" + if not hasattr(self, '_wallCTrav'): + return + + self._wallCTrav.traverse(self.subject.getGeom()) + + # Toon visibility: hide when too close or disguised + if not self.firstPerson: + visible = (not self.subject.isDisguised) and (self._actualDistance >= 2.0) + if visible: + self.subject.getGeomNode().show() + else: + self.subject.getGeomNode().hide() + + numEntries = self._wallQueue.getNumEntries() + if numEntries == 0: + self._collisionDistance = self._MaxCamDistance + return + + self._wallQueue.sortEntries() + entry = self._wallQueue.getEntry(0) + if not (entry and entry.hasSurfacePoint()): + self._collisionDistance = self._MaxCamDistance + return + + # Hit point in rig-local space. The segment runs along -Y, so the + # length of the hit point equals the distance along the camera ray. + hitLocal = entry.getSurfacePoint(self) + hitDist = Vec3(hitLocal).length() + self._collisionDistance = max(self._MinCamDistance, + hitDist - self._CollisionBuffer) + + def _stopWallCheck(self): + if hasattr(self, '_wallCTrav') and hasattr(self, '_wallNp'): + self._wallCTrav.removeCollider(self._wallNp) + for attr in ('_wallQueue', '_wallCTrav', '_wallSolid'): + if hasattr(self, attr): + delattr(self, attr) + if hasattr(self, '_wallNp'): + self._wallNp.detachNode() + del self._wallNp + if self.subject: + if self.subject.isDisguised: + self.subject.getGeomNode().hide() + else: + self.subject.getGeomNode().show() + + # ------------------------------------------------------------------ # + # Mouse-look enable / disable # + # ------------------------------------------------------------------ # + + def enableMouseControl(self, pressed, toggle=False): + if not toggle and (not pressed or self.ignoreRMB): + return + + if not base.CAM_TOGGLE_LOCK: + self.ignore("InputState-RMB") + self.accept("InputState-RMB", self.disableMouseControl) + else: + self.ignore("InputState-RMB") + self.accept("InputState-RMB", self.toggleMouseControl) + + if self.oobeEnabled(): + return + + self.mouseControl = True + md = base.win.getPointer(0) + self.origMousePos = (md.getX(), md.getY()) + cx, cy = base.win.getXSize() // 2, base.win.getYSize() // 2 + base.win.movePointer(0, cx, cy) + md2 = base.win.getPointer(0) + self.lastMousePos = (float(md2.getX()), float(md2.getY())) + + if self.getCurrentOrNextState() == "Active": + self._startMouseTasks() + + self._setCursor(True) + self.runner.startInput() + self.subject.controlManager.setTurn(0) + try: + self.subject.controlManager.setWASDTurn(False) + except Exception: + pass + + def toggleMouseControl(self, pressed): + if pressed and not self.mouseControl: + self.enableMouseControl(True, False) + elif pressed and self.mouseControl: + self.disableMouseControl(True, True) + + def disableMouseControl(self, pressed, disabledByMouse=True): + if not base.CAM_TOGGLE_LOCK: + self.ignore("InputState-RMB") + self.accept("InputState-RMB", self.enableMouseControl) + else: + self.ignore("InputState-RMB") + self.accept("InputState-RMB", self.toggleMouseControl) + + if self.oobeEnabled(): + return + + if self.mouseControl: + self.mouseControl = False + self._stopMouseTasks() + base.win.movePointer(0, int(self.origMousePos[0]), + int(self.origMousePos[1])) + self._setCursor(False) + self.runner.stopInput() + + # While orbital is active, keep the same control lock as RMB (no keyboard turn). + if getattr(self.subject, 'controlManager', None): + try: + if self.getCurrentOrNextState() == "Active": + self.subject.controlManager.setTurn(0) + self.subject.controlManager.setWASDTurn(False) + else: + self.subject.controlManager.setTurn(1) + except Exception: + pass + + def _setCursor(self, hidden): + wp = WindowProperties() + wp.setCursorHidden(hidden) + base.win.requestProperties(wp) + + def enableInput(self): + self.__inputEnabled = True + self.accept("InputState-RMB", self.enableMouseControl) + if inputState.isSet("RMB"): + self.enableMouseControl(True) + + def disableInput(self): + self.__inputEnabled = False + self.disableMouseControl(False, False) + self.ignore("InputState-RMB") + + def isInputEnabled(self): + return self.__inputEnabled + + # ------------------------------------------------------------------ # + # Mouse-read + avatar-facing tasks (only active during mouse-look) # + # ------------------------------------------------------------------ # + + def _startMouseTasks(self): + if not self.mouseControl: + return + taskMgr.add(self._mouseReadTask, self.ReadMouseTaskName, priority=-29) + taskMgr.add(self._avatarFacingTask, self.AvatarFacingTaskName, priority=23) + + def _stopMouseTasks(self): + taskMgr.remove(self.ReadMouseTaskName) + taskMgr.remove(self.AvatarFacingTaskName) + props = WindowProperties() + props.setMouseMode(props.MAbsolute) + base.win.requestProperties(props) + + def _mouseReadTask(self, task): + """Capture raw mouse delta and re-center the cursor.""" + if self.oobeEnabled() or not base.mouseWatcherNode.hasMouse(): + self.mouseDelta = (0, 0) + return task.cont + winX = base.win.getXSize() + winY = base.win.getYSize() + md = base.win.getPointer(0) + px, py = md.getX(), md.getY() + if px > winX or py > winY: + self.mouseDelta = (0, 0) + else: + self.mouseDelta = (px - self.lastMousePos[0], + py - self.lastMousePos[1]) + base.win.movePointer(0, winX // 2, winY // 2) + md2 = base.win.getPointer(0) + self.lastMousePos = (float(md2.getX()), float(md2.getY())) + return task.cont + + def _avatarFacingTask(self, task): + """Keep the toon facing the camera's heading while moving.""" + if self.oobeEnabled(): + return task.cont + if self.isSubjectMoving(): + self.subject.setH(render, self._actualH) + return task.cont + + # ------------------------------------------------------------------ # + # Scroll-wheel zoom # + # ------------------------------------------------------------------ # + + def acceptWheel(self): + self.accept('wheel_up', self._wheelIn) + self.accept('wheel_down', self._wheelOut) + + def ignoreWheel(self): + self.ignore('wheel_up') + self.ignore('wheel_down') + + def _wheelIn(self): + self._desiredDistance = max(self._MinCamDistance, + self._desiredDistance - 1.5) + + def _wheelOut(self): + self._desiredDistance = min(self._MaxCamDistance, + self._desiredDistance + 1.5) + + # ------------------------------------------------------------------ # + # Tab-key preset cycling # + # ------------------------------------------------------------------ # + + def acceptTab(self): + self.accept("tab", self._cyclePreset) + + def ignoreTab(self): + self.ignore("tab") + + def _cyclePreset(self): + self.presetPos = (self.presetPos + 1) % len(self.presets) + p = self.presets[self.presetPos] + self._desiredDistance = p["dist"] + self._desiredP = p.get("p", -20.0) + + # ------------------------------------------------------------------ # + # Helpers # + # ------------------------------------------------------------------ # + + def isSubjectMoving(self): + return any(inputState.isSet(m) for m in + ("forward", "reverse", "turnRight", "turnLeft", + "slideRight", "slideLeft")) + + def isActive(self): + return self.state == "Active" + + def oobeEnabled(self): + return getattr(base, "oobeMode", False) + + # ------------------------------------------------------------------ # + # Legacy / compatibility API # + # ------------------------------------------------------------------ # + + def setPresetPos(self, idx, transition=True): + self.presetPos = idx % len(self.presets) + p = self.presets[self.presetPos] + self._desiredDistance = p["dist"] + self._desiredP = p.get("p", -20.0) + + def setCameraPos(self, y, h, p, transition=True): + """Legacy method – adjusts desired orbit state directly.""" + self._desiredDistance = max(self._MinCamDistance, abs(float(y))) + self._desiredH = float(h) if h else self._desiredH + self._desiredP = max(self.MinP, min(self.MaxP, float(p))) + + def getCamOffset(self): + return Vec3(0, -self._desiredDistance, 0) + + def setCamOffset(self, offset): + self._desiredDistance = max(self._MinCamDistance, abs(float(offset[1]))) + + def applyCamOffset(self): + if self.isActive(): + self._applyTransform() + + @property + def camOffset(self): + """Property so that camOffset reads behave as expected.""" + return Vec3(0, -self._actualDistance, 0) + + @camOffset.setter + def camOffset(self, v): + """Allow external code / ParamObj to set camOffset by Vec3.""" + try: + self._desiredDistance = max(self._MinCamDistance, abs(float(v[1]))) + except Exception: + pass + + def start(self): + if not self.isActive(): + self.request("Active") + + def stop(self): + if self.isActive(): + self.request('Off') diff --git a/toontown/toon/ParamObj.py b/toontown/toon/ParamObj.py new file mode 100644 index 0000000..dce00df --- /dev/null +++ b/toontown/toon/ParamObj.py @@ -0,0 +1,239 @@ +from direct.showbase.PythonUtil import * + +""" +ParamObj/ParamSet +================= + +These two classes support you in the definition of a formal set of +parameters for an object type. The parameters may be safely queried/set on +an object instance at any time, and the object will react to newly-set +values immediately. + +ParamSet & ParamObj also provide a mechanism for atomically setting +multiple parameter values before allowing the object to react to any of the +new values--useful when two or more parameters are interdependent and there +is risk of setting an illegal combination in the process of applying a new +set of values. + +To make use of these classes, derive your object from ParamObj. Then define +a 'ParamSet' subclass that derives from the parent class' 'ParamSet' class, +and define the object's parameters within its ParamSet class. (see examples +below) + +The ParamObj base class provides 'get' and 'set' functions for each +parameter if they are not defined. These default implementations +respectively set the parameter value directly on the object, and expect the +value to be available in that location for retrieval. + +Classes that derive from ParamObj can optionally declare a 'get' and 'set' +function for each parameter. The setter should simply store the value in a +location where the getter can find it; it should not do any further +processing based on the new parameter value. Further processing should be +implemented in an 'apply' function. The applier function is optional, and +there is no default implementation. + +NOTE: the previous value of a parameter is available inside an apply +function as 'self.getPriorValue()' + +The ParamSet class declaration lists the parameters and defines a default +value for each. ParamSet instances represent a complete set of parameter +values. A ParamSet instance created with no constructor arguments will +contain the default values for each parameter. The defaults may be +overriden by passing keyword arguments to the ParamSet's constructor. If a +ParamObj instance is passed to the constructor, the ParamSet will extract +the object's current parameter values. + +ParamSet.applyTo(obj) sets all of its parameter values on 'obj'. +""" + + +class ParamObj: + class ParamSet: + Params = {} + + def __init__(self, *args, **kwArgs): + self.__class__._compileDefaultParams() + if len(args) == 1 and len(kwArgs) == 0: + obj = args[0] + self.paramVals = {} + for param in self.getParams(): + self.paramVals[param] = getSetter(obj, param, 'get')() + else: + assert len(args) == 0 + if __debug__: + for arg in list(kwArgs.keys()): + assert arg in self.getParams() + self.paramVals = dict(kwArgs) + + def getValue(self, param): + if param in self.paramVals: + return self.paramVals[param] + return self._Params[param] + + def applyTo(self, obj): + obj.lockParams() + for param in self.getParams(): + getSetter(obj, param)(self.getValue(param)) + obj.unlockParams() + + def extractFrom(self, obj): + obj.lockParams() + for param in self.getParams(): + self.paramVals[param] = getSetter(obj, param, 'get')() + obj.unlockParams() + + @classmethod + def getParams(cls): + cls._compileDefaultParams() + return list(cls._Params.keys()) + + @classmethod + def getDefaultValue(cls, param): + cls._compileDefaultParams() + dv = cls._Params[param] + if hasattr(dv, '__call__'): + dv = dv() + return dv + + @classmethod + def _compileDefaultParams(cls): + if '_Params' in cls.__dict__: + return + bases = list(cls.__bases__) + if object in bases: + bases.remove(object) + mostDerivedLast(bases) + cls._Params = {} + for c in (bases + [cls]): + c._compileDefaultParams() + if 'Params' in c.__dict__: + cls._Params.update(c.Params) + del bases + + def __repr__(self): + argStr = '' + for param in self.getParams(): + argStr += '%s=%s,' % (param, repr(self.getValue(param))) + return '%s.%s(%s)' % (self.__class__.__module__, self.__class__.__name__, argStr) + + def __init__(self, *args, **kwArgs): + assert issubclass(self.ParamSet, ParamObj.ParamSet) + params = None + if len(args) == 1 and len(kwArgs) == 0: + params = args[0] + elif len(kwArgs) > 0: + assert len(args) == 0 + params = self.ParamSet(**kwArgs) + + self._paramLockRefCount = 0 + self._curParamStack = [] + self._priorValuesStack = [] + + for param in self.ParamSet.getParams(): + setattr(self, param, self.ParamSet.getDefaultValue(param)) + + setterName = getSetterName(param) + getterName = getSetterName(param, 'get') + + if not hasattr(self, setterName): + def defaultSetter(self, value, param=param): + setattr(self, param, value) + + self.__class__.__dict__[setterName] = defaultSetter + + if not hasattr(self, getterName): + def defaultGetter(self, param=param, default=self.ParamSet.getDefaultValue(param)): + return getattr(self, param, default) + + self.__class__.__dict__[getterName] = defaultGetter + + origSetterName = '%s_ORIG' % (setterName,) + if not hasattr(self, origSetterName): + origSetterFunc = getattr(self.__class__, setterName) + setattr(self.__class__, origSetterName, origSetterFunc) + + def setterStub(self, value, param=param, origSetterName=origSetterName): + if self._paramLockRefCount > 0: + priorValues = self._priorValuesStack[-1] + if param not in priorValues: + try: + priorValue = getSetter(self, param, 'get')() + except: + priorValue = None + priorValues[param] = priorValue + self._paramsSet[param] = None + getattr(self, origSetterName)(value) + else: + try: + priorValue = getSetter(self, param, 'get')() + except: + priorValue = None + self._priorValuesStack.append({param: priorValue}) + getattr(self, origSetterName)(value) + applier = getattr(self, getSetterName(param, 'apply'), None) + if applier is not None: + self._curParamStack.append(param) + applier() + self._curParamStack.pop() + self._priorValuesStack.pop() + if hasattr(self, 'handleParamChange'): + self.handleParamChange((param,)) + + setattr(self.__class__, setterName, setterStub) + + if params is not None: + params.applyTo(self) + + def destroy(self): + pass + + def setDefaultParams(self): + self.ParamSet().applyTo(self) + + def getCurrentParams(self): + params = self.ParamSet() + params.extractFrom(self) + return params + + def lockParams(self): + self._paramLockRefCount += 1 + if self._paramLockRefCount == 1: + self._handleLockParams() + + def unlockParams(self): + if self._paramLockRefCount > 0: + self._paramLockRefCount -= 1 + if self._paramLockRefCount == 0: + self._handleUnlockParams() + + def _handleLockParams(self): + self._paramsSet = {} + self._priorValuesStack.append({}) + + def _handleUnlockParams(self): + for param in self._paramsSet: + applier = getattr(self, getSetterName(param, 'apply'), None) + if applier is not None: + self._curParamStack.append(param) + applier() + self._curParamStack.pop() + self._priorValuesStack.pop() + if hasattr(self, 'handleParamChange'): + self.handleParamChange(tuple(self._paramsSet.keys())) + del self._paramsSet + + def paramsLocked(self): + return self._paramLockRefCount > 0 + + def getPriorValue(self): + return self._priorValuesStack[-1][self._curParamStack[-1]] + + def __repr__(self): + argStr = '' + for param in self.ParamSet.getParams(): + try: + value = getSetter(self, param, 'get')() + except: + value = '' + argStr += '%s=%s,' % (param, repr(value)) + return '%s(%s)' % (self.__class__.__name__, argStr) diff --git a/toontown/toonbase/AssetCache.py b/toontown/toonbase/AssetCache.py index ef0c021..7e3173e 100644 --- a/toontown/toonbase/AssetCache.py +++ b/toontown/toonbase/AssetCache.py @@ -31,7 +31,6 @@ class AssetCache: # Common models that are used everywhere commonModels = [ - 'phase_3/models/gui/toontown-logo', 'phase_3/models/props/arrow', 'phase_3/models/props/panel', 'phase_3/models/props/chatbox', diff --git a/toontown/toonbase/DisplayOptions.py b/toontown/toonbase/DisplayOptions.py index 362185a..15902ea 100644 --- a/toontown/toonbase/DisplayOptions.py +++ b/toontown/toonbase/DisplayOptions.py @@ -26,17 +26,21 @@ class DisplayOptions: music = base.settings.getSetting('music', True) sfx = base.settings.getSetting('sfx', True) toonChatSounds = base.settings.getSetting('toon-chat-sounds', True) - res = base.settings.getSetting('resolution', (800, 600)) + # Default to a smaller 16:9 windowed resolution on first launch. + res = base.settings.getSetting('resolution', (960, 540)) embed = False # base.settings.getSetting('embedded-mode', False) self.notify.debug('before prc settings embedded mode=%s' % str(embed)) self.notify.debug('before prc settings full screen mode=%s' % str(mode)) if mode == None: mode = 1 if res == None: - res = (800, 600) + res = (960, 540) if not base.settings.doSavedSettingsExist(): self.notify.info('loadFromSettings: No settings; isDefaultEmbedded=%s' % self.isDefaultEmbedded()) embed = self.isDefaultEmbedded() + # First launch defaults: windowed + 16:9 resolution. + mode = False + res = (960, 540) if embed and not self.isEmbeddedPossible(): self.notify.warning('Embedded mode is not possible.') embed = False diff --git a/toontown/toonbase/TTLocalizerEnglish.py b/toontown/toonbase/TTLocalizerEnglish.py index 061031a..903bf6d 100644 --- a/toontown/toonbase/TTLocalizerEnglish.py +++ b/toontown/toonbase/TTLocalizerEnglish.py @@ -4179,6 +4179,17 @@ PartyCanStart = "It's Party Time, click Start Party in your Shticker Book Hostin PartyHasStartedAcceptedInvite = '%s party has started! Click the host then "Go To Party" in the Shticker Book Invites page.' PartyHasStartedNotAcceptedInvite = '%s party has started! You can still go to it by teleporting to the host.' EventsPageName = 'Events' +AutoerPageTitle = 'Autoers' +AutoerPageTabTitle = 'Auto' +AutoerPageHelp = 'Load scripts once, then Start task automation. Press \\ (backslash) anytime to stop all autoers.' +AutoerPageLoad = 'Load autoer scripts' +AutoerPageStartTasks = 'Start task automation' +AutoerPageStopAll = 'STOP ALL AUTOERS' +AutoerPageStatusIdle = 'Scripts: not loaded' +AutoerPageStatusLoaded = 'Scripts: loaded' +AutoerPageMsgLoaded = 'Autoer scripts loaded.' +AutoerPageMsgLoadFail = 'Could not find autoer bundle.' +AutoerPageMsgStarted = 'Task automation started.' EventsPageCalendarTabName = 'Calendar' EventsPageCalendarTabParty = 'Party' EventsPageToontownTimeIs = 'TOONTOWN TIME IS' @@ -4691,6 +4702,48 @@ OptionsPageChange = 'Change' OptionsPageDisplaySettings = 'Display: %(screensize)s, %(api)s' OptionsPageDisplaySettingsNoApi = 'Display: %(screensize)s' OptionsPageExitConfirm = 'Exit Toontown?' +OptionsPageSubTabAudio = 'Sound' +OptionsPageSubTabSocial = 'Friends' +OptionsPageSubTabDisplayChat = 'Display' +OptionsPageSubTabAdvanced = 'Extras' +OptionsPageSubTabGameplay = 'Play' +OptionsPageSectionAudioTitle = 'Sound & Music' +OptionsPageSectionAudioHelp = 'Mix music, sound effects, and chat bloops to match your play style.' +OptionsPageSectionSocialTitle = 'Friends & Whispers' +OptionsPageSectionSocialHelp = 'Decide who can friend you and who can whisper out of the blue.' +OptionsPageSectionDisplayTitle = 'Screen & SpeedChat' +OptionsPageSectionDisplayHelp = 'Resolution, effects, shadows, and your SpeedChat colors.' +OptionsPageSectionAdvancedTitle = 'Silly Extras' +OptionsPageSectionAdvancedHelp = 'Camera, nametags, and other tweaks for power-Toons.' +OptionsPageMuteAll = 'Mute All' +OptionsPageRestoreAudio = 'Restore Audio' +OptionsPageDisplaySummaryIntro = 'Right now:' +OptionsPageShowFpsOn = 'FPS Counter: ON' +OptionsPageShowFpsOff = 'FPS Counter: OFF' +OptionsPageMouseSensitivityLabel = 'Mouse Sensitivity' +OptionsPageCameraFovLabel = 'Camera Field of View' +OptionsPageSmoothAnimsOn = 'Smooth Animations: ON' +OptionsPageSmoothAnimsOff = 'Smooth Animations: OFF' +OptionsPageShowNametagsOn = 'Show Nametags: ON' +OptionsPageShowNametagsOff = 'Show Nametags: OFF' +OptionsPageTKeyOnlyOn = 'T-Key Only Chat: ON' +OptionsPageTKeyOnlyOff = 'T-Key Only Chat: OFF' +OptionsPageControlsHint = 'Tip: WASD or arrow keys to move. F9 saves a snapshot to your Toontown folder.' +OptionsPageSectionGameplayTitle = 'Battles & Flow' +OptionsPageSectionGameplayHelp = 'Trim repeated cinematics when you are grinding battles.' +OptionsPageParticlesOn = 'Particle effects: ON' +OptionsPageParticlesOff = 'Particle effects: OFF' +OptionsPageShadowsOn = 'Toon shadows: ON' +OptionsPageShadowsOff = 'Toon shadows: OFF' +OptionsPageSkipBattleMoviesOn = 'Auto-skip battle movies: ON' +OptionsPageSkipBattleMoviesOff = 'Auto-skip battle movies: OFF' +OptionsPageSkipBattleMoviesForced = 'Battle movies always skipped (game config).' +OptionsPageWalkSpeedLabel = 'Walk speed' +OptionsPageWalkSpeedValue = '%d%%' +OptionsPageCameraDistanceLabel = 'Default Camera Distance' +OptionsPageCameraDistanceValue = '%.0f' +OptionsPageCameraYInvertOn = 'Camera Y-Axis: Inverted' +OptionsPageCameraYInvertOff = 'Camera Y-Axis: Normal' DisplaySettingsTitle = 'Display Settings' DisplaySettingsIntro = 'The following settings are used to configure the way Toontown is displayed on your computer. It is probably not necessary to adjust these unless you are experiencing a problem.' DisplaySettingsIntroSimple = 'You may adjust the screen resolution to a higher value to improve the clarity of text and graphics in Toontown, but depending on your graphics card, some higher values may make the game run less smoothly or may not work at all.' @@ -4706,6 +4759,48 @@ DisplaySettingsAccept = 'Press OK to keep the new settings, or Cancel to revert. DisplaySettingsRevertUser = 'Your previous display settings have been restored.' DisplaySettingsRevertFailed = 'The selected display settings do not work on your computer. Your previous display settings have been restored.' OptionsPageCodesTab = 'Enter Code' +OptionsPageSubTabLighting = 'Lighting' +OptionsPageSectionLightingTitle = 'Advanced Lighting' +OptionsPageSectionLightingHelp = 'Zone-specific sun, moon, god rays, atmospheric fog, and colour temperature — all fully customisable.' +OptionsPageAdvancedLightingOn = 'Advanced Lighting: ON' +OptionsPageAdvancedLightingOff = 'Advanced Lighting: OFF' +OptionsPageGodRaysOn = 'Sun / Moon Rays: ON' +OptionsPageGodRaysOff = 'Sun / Moon Rays: OFF' +OptionsPageLightingIntensityLabel = 'Light Intensity' +OptionsPageLightingFogOn = 'Atmosphere & Fog: ON' +OptionsPageLightingFogOff = 'Atmosphere & Fog: OFF' +OptionsPageColorTempLabel = 'Color Temperature' +OptionsPageColorTempWarm = 'Warmer' +OptionsPageColorTempCool = 'Cooler' +# Tonemapping +OptionsPageTonemapOn = 'Tonemapping: ON' +OptionsPageTonemapOff = 'Tonemapping: OFF' +# Day / Night Cycle +OptionsPageDayNightOn = 'Day/Night Cycle: ON' +OptionsPageDayNightOff = 'Day/Night Cycle: OFF' +# Procedural Sky +OptionsPageProceduralSkyOn = 'Procedural Sky: ON' +OptionsPageProceduralSkyOff = 'Procedural Sky: OFF' +# Bloom +OptionsPageBloomOn = 'Bloom: ON' +OptionsPageBloomOff = 'Bloom: OFF' +# Water Reflections +OptionsPageWaterReflOn = 'Water Reflections: ON' +OptionsPageWaterReflOff = 'Water Reflections: OFF' +# Shadow Quality +OptionsPageShadowQualityLabel = 'Shadow Quality' +OptionsPageShadowQualityOff = 'Off' +OptionsPageShadowQualityLow = 'Low' +OptionsPageShadowQualityMedium = 'Medium' +OptionsPageShadowQualityHigh = 'High' +OptionsPageCycleButton = 'Cycle' +# Fog Density +OptionsPageFogDensityLabel = 'Fog Density' +# Vignette +OptionsPageVignetteOn = 'Vignette: ON' +OptionsPageVignetteOff = 'Vignette: OFF' +# Day/Night Speed +OptionsPageDayNightSpeedLabel = 'Day/Night Speed' CdrPageTitle = 'Enter a Code' CdrInstructions = 'Enter your code to receive a special item in your mailbox.' CdrResultSuccess = 'Congratulations! Check your mailbox to claim your item!' diff --git a/toontown/toonbase/TTLocalizerEnglishProperty.py b/toontown/toonbase/TTLocalizerEnglishProperty.py index cd70ec0..1f645e2 100644 --- a/toontown/toonbase/TTLocalizerEnglishProperty.py +++ b/toontown/toonbase/TTLocalizerEnglishProperty.py @@ -246,6 +246,9 @@ TPstartFrame = 0.12 TPendFrame = 0.12 SBpageTab = 0.75 OPoptionsTab = 0.07 +OPsubTab = 0.048 +OPsubTabHelp = 0.038 +OPsectionTitle = 0.058 OPCodesInstructionPanelTextPos = (0, -0.01) OPCodesInstructionPanelTextWordWrap = 6 OPCodesResultPanelTextPos = (0, 0.35) diff --git a/toontown/toonbase/ToonBase.py b/toontown/toonbase/ToonBase.py index e6bbecc..e69236c 100644 --- a/toontown/toonbase/ToonBase.py +++ b/toontown/toonbase/ToonBase.py @@ -21,31 +21,27 @@ from toontown.toonbase.AssetCache import assetCache class ToonBase(OTPBase.OTPBase): notify = DirectNotifyGlobal.directNotify.newCategory('ToonBase') + CAM_TOGGLE_LOCK = False def __init__(self): self.settings = Settings() - # Remap orbital camera to right-click instead of middle-click - loadPrcFileData('toonBase Camera Controls', 'drive-button2 alt-mouse3') - # Make orbital camera more responsive (less smooth, more precise) - loadPrcFileData('toonBase Camera Responsive', 'drive-rotational-speed 100') - loadPrcFileData('toonBase Camera Direct', 'drive-mouse-scale 0.015') - # Fix camera locking bug - disable acceleration and smoothing - loadPrcFileData('toonBase Camera No Lock', 'drive-rotate-accel 0') - loadPrcFileData('toonBase Camera No Smooth', 'drive-angular-smooth 0') - loadPrcFileData('toonBase Camera Instant', 'drive-heading-dampening 0') - # Enable tank-like controls (A/D rotate, W/S move forward/backward) - loadPrcFileData('toonBase Tank Controls', 'drive-mode tank') if not ConfigVariableInt('ignore-user-options', 0).value: self.settings.readSettings() mode = not self.settings.getSetting('windowed-mode', True) music = self.settings.getSetting('music', True) sfx = self.settings.getSetting('sfx', True) toonChatSounds = self.settings.getSetting('toon-chat-sounds', True) - res = self.settings.getSetting('resolution', (800, 600)) + # Default to a smaller 16:9 windowed resolution on first launch. + # (Chosen to fit comfortably on low-res displays.) + res = self.settings.getSetting('resolution', (960, 540)) if mode == None: mode = 1 if res == None: - res = (800, 600) + res = (960, 540) + # If the user has never saved settings, default to windowed 16:9. + # (Avoid surprising fullscreen on first launch.) + if not self.settings.doSavedSettingsExist(): + mode = False loadPrcFileData('toonBase Settings Window Res', 'win-size %s %s' % (res[0], res[1])) loadPrcFileData('toonBase Settings Window FullScreen', 'fullscreen %s' % mode) loadPrcFileData('toonBase Settings Music Active', 'audio-music-active %s' % music) @@ -60,18 +56,36 @@ class ToonBase(OTPBase.OTPBase): sys.exit(1) self.disableShowbaseMouse() + self.applyMouseSensitivity() base.debugRunningMultiplier /= OTPGlobals.ToonSpeedFactor self.toonChatSounds = ConfigVariableBool('toon-chat-sounds', 1).value # Setup WASD controls alongside arrow keys self.setupWASDControls() + # Some camera/control code expects a `base.controls` object that defines + # movement event names. In this codebase, movement is driven via + # InputState watchers ('forward', 'reverse', etc.), so map these here. + # This prevents crashes like `AttributeError: 'ToonBase' object has no attribute 'controls'`. + if not hasattr(self, 'controls'): + self.controls = ScratchPad( + MOVE_UP='forward', + MOVE_DOWN='reverse', + MOVE_LEFT='turnLeft', + MOVE_RIGHT='turnRight', + JUMP='jump', + ) + base.controls = self.controls self.placeBeforeObjects = ConfigVariableBool('place-before-objects', 0).value self.endlessQuietZone = False - self.wantDynamicShadows = 0 + self.wantDynamicShadows = 1 if self.settings.getSetting('dynamic-shadows', False) else 0 self.exitErrorCode = 0 camera.setPosHpr(0, 0, 0, 0, 0, 0) # Set up widescreen support with dynamic FOV based on aspect ratio - self.baseFov = ToontownGlobals.DefaultCameraFov + try: + ufov = float(self.settings.getSetting('camera-fov', ToontownGlobals.DefaultCameraFov)) + except (TypeError, ValueError): + ufov = ToontownGlobals.DefaultCameraFov + self.baseFov = max(40.0, min(90.0, ufov)) self.updateFovForAspectRatio() self.camLens.setNearFar(ToontownGlobals.DefaultCameraNear, ToontownGlobals.DefaultCameraFar) # Apply saved volume settings @@ -90,11 +104,14 @@ class ToonBase(OTPBase.OTPBase): tpm.setProperties('candidate_inactive', candidateInactive) self.transitions.IrisModelName = 'phase_3/models/misc/iris' self.transitions.FadeModelName = 'phase_3/models/misc/fade' + smooth = self.settings.getSetting('smooth-animations', True) + if hasattr(self.transitions, 'setUseBlend'): + self.transitions.setUseBlend(not smooth) self.exitFunc = self.userExit if 'launcher' in __builtins__ and launcher: launcher.setPandaErrorCode(11) globalClock.setMaxDt(0.2) - if ConfigVariableBool('want-particles', 1).value == 1: + if ConfigVariableBool('want-particles', 1).value and self.settings.getSetting('particles-enabled', True): self.notify.debug('Enabling particles') self.enableParticles() self.accept(ToontownGlobals.ScreenshotHotkey, self.takeScreenShot) @@ -134,7 +151,10 @@ class ToonBase(OTPBase.OTPBase): self.cogdoGameDifficulty = cogdoGameDifficulty if cogdoGameSafezoneId != -1: self.cogdoGameSafezoneId = cogdoGameSafezoneId - ToontownBattleGlobals.SkipMovie = ConfigVariableBool('skip-battle-movies', 0).value + if ConfigVariableBool('skip-battle-movies', 0).value: + ToontownBattleGlobals.SkipMovie = 1 + else: + ToontownBattleGlobals.SkipMovie = 1 if self.settings.getSetting('skip-battle-movies', False) else 0 self.creditCardUpFront = ConfigVariableInt('credit-card-up-front', -1).value if self.creditCardUpFront == -1: del self.creditCardUpFront @@ -172,6 +192,15 @@ class ToonBase(OTPBase.OTPBase): self.oldY = max(1, base.win.getYSize()) self.aspectRatio = float(self.oldX) / self.oldY return + + def applyMouseSensitivity(self, multiplier=None): + if multiplier is None: + try: + multiplier = float(self.settings.getSetting('mouse-sensitivity', 1.0)) + except (TypeError, ValueError): + multiplier = 1.0 + multiplier = max(0.5, min(2.0, multiplier)) + self.mouseSensitivity = multiplier def updateFovForAspectRatio(self): """Update FOV dynamically based on aspect ratio for proper widescreen support.""" @@ -241,6 +270,13 @@ class ToonBase(OTPBase.OTPBase): # Update FOV when window is resized for widescreen support self.updateFovForAspectRatio() + + # If anything left the main DisplayRegions cropped (common after some + # RTT/post-process paths), restore full-window rendering on resize. + try: + self.repairMainViewports() + except Exception: + pass if not ConfigVariableInt('keep-aspect-ratio', 0).value: return @@ -294,11 +330,14 @@ class ToonBase(OTPBase.OTPBase): """Add WASD key bindings as alternative to arrow keys""" from direct.showbase.InputStateGlobal import inputState - # Map WASD to same states as arrow keys + # Map WASD for modern third-person orbit camera movement: + # - W/S: forward/back + # - A/D: strafe left/right + # Turning is handled by camera orbit; arrow keys remain for legacy turning. inputState.watchWithModifiers('forward', 'w') inputState.watchWithModifiers('reverse', 's') - inputState.watchWithModifiers('turnLeft', 'a') - inputState.watchWithModifiers('turnRight', 'd') + inputState.watchWithModifiers('slideLeft', 'a') + inputState.watchWithModifiers('slideRight', 'd') inputState.watchWithModifiers('jump', 'space') # Keep arrow keys working too @@ -450,6 +489,14 @@ class ToonBase(OTPBase.OTPBase): def exitShow(self, errorCode = None): self.notify.info('Exiting Toontown: errorCode = %s' % errorCode) + # If we started local servers from the client, stop them now. + try: + mgr = getattr(base, 'localServerManager', None) + if mgr: + mgr.shutdown() + base.localServerManager = None + except Exception: + pass if errorCode: launcher.setPandaErrorCode(errorCode) else: @@ -491,6 +538,14 @@ class ToonBase(OTPBase.OTPBase): def panda3dRenderError(self): launcher.setPandaErrorCode(14) + # Ensure local servers are stopped on render/device loss exit. + try: + mgr = getattr(base, 'localServerManager', None) + if mgr: + mgr.shutdown() + base.localServerManager = None + except Exception: + pass if self.cr.timeManager: self.cr.timeManager.setDisconnectReason(ToontownGlobals.DisconnectGraphicsError) self.cr.sendDisconnect() diff --git a/toontown/toonbase/ToontownAccess.py b/toontown/toonbase/ToontownAccess.py index f36a066..afb6a42 100644 --- a/toontown/toonbase/ToontownAccess.py +++ b/toontown/toonbase/ToontownAccess.py @@ -1,9 +1,15 @@ from toontown.hood import ZoneUtil from toontown.toonbase import ToontownGlobals +import builtins class ToontownAccess: def canAccess(self, zoneId = None): + # Don't restrict travel for avatars that haven't acknowledged/finished the tutorial yet. + # (Works for existing toons that got stuck with tutorialAck == 0.) + base = getattr(builtins, 'base', None) + if base and getattr(base, 'localAvatar', None) and getattr(base.localAvatar, 'tutorialAck', 1) == 0: + return True if base.cr.isPaid(): return True allowed = False diff --git a/toontown/toonbase/ToontownAccessAI.py b/toontown/toonbase/ToontownAccessAI.py index 1153661..eafc5c6 100644 --- a/toontown/toonbase/ToontownAccessAI.py +++ b/toontown/toonbase/ToontownAccessAI.py @@ -33,7 +33,13 @@ def openToAll(zoneId, avatar): for zone in simbase.air.estateMgr.getEstateZones(ownerId): specialZones.append(zone) - if canonicalZoneId in allowedZones or avatar.isInEstate(): + # If the avatar is actively in the tutorial flow (or hasn't acknowledged it yet), + # don't restrict access. This makes travel unrestricted for "stuck" existing toons too. + if not avatar.getTutorialAck(): + allowed = True + elif hasattr(simbase.air, 'tutorialManager') and avatar.doId in simbase.air.tutorialManager.playerDict: + allowed = True + elif canonicalZoneId in allowedZones or avatar.isInEstate(): allowed = True elif zoneId in specialZones: allowed = True diff --git a/toontown/toonbase/ToontownGlobals.py b/toontown/toonbase/ToontownGlobals.py index da6e354..ca558e6 100644 --- a/toontown/toonbase/ToontownGlobals.py +++ b/toontown/toonbase/ToontownGlobals.py @@ -99,6 +99,7 @@ SPMinniesPiano = 4 CEVirtual = 14 MaxHpLimit = 137 MaxCarryLimit = 80 +WantUnlimitedGags = True MaxQuestCarryLimit = 4 MaxCogSuitLevel = 50 - 1 CogSuitHPLevels = (15 - 1, @@ -832,6 +833,84 @@ CashbotBossCranePosHprs = [(97.4, 45, 0, 0)] +TTCCraneSandboxCranePosHprs = [(-90.0, + 50.0, + 0.0, + 135.0, + 0.0, + 0.0), + (-90.0, + -50.0, + 0.0, + 45.0, + 0.0, + 0.0), + (90.0, + -50.0, + 0.0, + -45.0, + 0.0, + 0.0), + (90.0, + 50.0, + 0.0, + -135.0, + 0.0, + 0.0)] +TTCCraneSandboxSafePosHprs = [(0.0, + 0.0, + 30.0, + 0.0, + 0.0, + 0.0), + (-32.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0), + (32.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0), + (-32.0, + -8.0, + 0.0, + 0.0, + 0.0, + 0.0), + (32.0, + -8.0, + 0.0, + 0.0, + 0.0, + 0.0), + (0.0, + 24.0, + 0.0, + 0.0, + 0.0, + 0.0), + (0.0, + -20.0, + 0.0, + 0.0, + 0.0, + 0.0), + (-20.0, + 20.0, + 0.0, + 0.0, + 0.0, + 0.0), + (20.0, + 20.0, + 0.0, + 0.0, + 0.0, + 0.0)] CashbotBossToMagnetTime = 0.2 CashbotBossFromMagnetTime = 1 CashbotBossSafeKnockImpact = 0.5 diff --git a/toontown/toonbase/ToontownStart.py b/toontown/toonbase/ToontownStart.py index 58cdf31..f198be0 100644 --- a/toontown/toonbase/ToontownStart.py +++ b/toontown/toonbase/ToontownStart.py @@ -22,12 +22,15 @@ from panda3d.core import ( import time import sys + +# Always load config so PRC toggles work regardless of launcher path / -O. +try: + loadPrcFile('etc/Configrc.prc') +except Exception: + pass try: launcher except: - if __debug__: - loadPrcFile('etc/Configrc.prc') - from toontown.launcher.ToontownDummyLauncher import ToontownDummyLauncher launcher = ToontownDummyLauncher() builtins.launcher = launcher @@ -46,7 +49,6 @@ else: http = launcher.http tempLoader = Loader() -backgroundNode = tempLoader.loadSync(Filename('phase_3/models/gui/loading-background')) from direct.gui import DirectGuiGlobals print('ToontownStart: setting default font') from . import ToontownGlobals @@ -68,12 +70,101 @@ ConfigVariableBool('compressed-textures').setValue(1) ConfigVariableBool('garbage-collect-states').setValue(0) ConfigVariableBool('support-threads').setValue(1) # Texture and Model pools are managed automatically by Panda3D -backgroundNodePath = aspect2d.attachNewNode(backgroundNode, 0) -backgroundNodePath.setPos(0.0, 0.0, 0.0) -backgroundNodePath.setScale(render2d, VBase3(1)) -backgroundNodePath.find('**/fg').setBin('fixed', 20) -backgroundNodePath.find('**/bg').setBin('fixed', 10) + +# Modern launcher/loading overlay (best-effort). +try: + from toontown.toontowngui.ModernLoadingScreen import ModernLoadingScreen + base.modernLoading = ModernLoadingScreen() + _ml = getattr(base, 'modernLoading', None) + if _ml and _ml.enabled(): + base.modernLoading.set_title('Toontown', 'Starting up…') + base.modernLoading.set_status('Initializing engine…') + base.modernLoading.set_progress(3) +except Exception: + try: + import traceback + print('ToontownStart: ModernLoadingScreen failed:') + print(traceback.format_exc()) + except Exception: + pass + base.modernLoading = None + +# Legacy loading background when modern UI is unavailable (init failure or PRC disabled). +# Important: ModernLoadingScreen() is truthy even when want-modern-launcher-ui is off (root is None); +# skipping legacy in that case leaves a blank (grey) window until login draws. +backgroundNodePath = None +_modern = getattr(base, 'modernLoading', None) +if not (_modern and _modern.enabled()): + backgroundNode = tempLoader.loadSync(Filename('phase_3/models/gui/loading-background')) + backgroundNodePath = aspect2d.attachNewNode(backgroundNode, 0) + backgroundNodePath.setPos(0.0, 0.0, 0.0) + backgroundNodePath.setScale(render2d, VBase3(1)) + backgroundNodePath.find('**/fg').setBin('fixed', 20) + backgroundNodePath.find('**/bg').setBin('fixed', 10) base.graphicsEngine.renderFrame() + +# Optional: auto-start local servers (Astron/UberDOG/AI) before connecting. +try: + from panda3d.core import ConfigVariableBool + wantAutoServers = ConfigVariableBool('auto-start-local-servers', False).value +except Exception: + wantAutoServers = False + +try: + print('ToontownStart: auto-start-local-servers = %s' % wantAutoServers) + print('ToontownStart: local-servers-forward-logs = %s' % ConfigVariableBool('local-servers-forward-logs', True).value) + print('ToontownStart: local-servers-always-spawn-python = %s' % ConfigVariableBool('local-servers-always-spawn-python', True).value) +except Exception: + pass + +if wantAutoServers: + try: + from toontown.launcher.LocalServerManager import LocalServerManager + mgr = LocalServerManager() + # Keep a reference for shutdown cleanup. + try: + base.localServerManager = mgr + except Exception: + pass + + def _status_cb(msg: str): + try: + try: + print('ToontownStart: LocalServers: %s' % msg) + except Exception: + pass + if getattr(base, 'modernLoading', None): + base.modernLoading.set_status(msg) + base.modernLoading.set_progress(18) + base.graphicsEngine.renderFrame() + except Exception: + pass + + if getattr(base, 'modernLoading', None): + base.modernLoading.set_status('Booting local servers…') + base.modernLoading.set_progress(12) + base.graphicsEngine.renderFrame() + + ok = mgr.start_if_needed(status_cb=_status_cb) + if not ok: + # Give the user a readable status before connection attempts. + if getattr(base, 'modernLoading', None): + base.modernLoading.set_status('Server not ready yet… retrying shortly') + base.modernLoading.set_progress(18) + base.graphicsEngine.renderFrame() + time.sleep(1.0) + + if getattr(base, 'modernLoading', None): + base.modernLoading.set_status('Connecting…') + base.modernLoading.set_progress(26) + base.graphicsEngine.renderFrame() + except Exception: + try: + import traceback + print('ToontownStart: ERROR starting local servers:') + print(traceback.format_exc()) + except Exception: + pass DirectGuiGlobals.setDefaultRolloverSound(base.loader.loadSfx('phase_3/audio/sfx/GUI_rollover.ogg')) DirectGuiGlobals.setDefaultClickSound(base.loader.loadSfx('phase_3/audio/sfx/GUI_create_toon_fwd.ogg')) DirectGuiGlobals.setDefaultDialogGeom(loader.loadModel('phase_3/models/gui/dialog_box_gui')) @@ -97,7 +188,15 @@ from direct.gui.DirectGui import OnscreenText serverVersion = ConfigVariableString('server-version', 'no_version_set').value print('ToontownStart: serverVersion: ', serverVersion) version = OnscreenText(serverVersion, pos=(-1.3, -0.975), scale=0.06, fg=Vec4(0, 0, 1, 0.6), align=TextNode.ALeft) -loader.beginBulkLoad('init', TTLocalizer.LoaderLabel, 138, 0, TTLocalizer.TIP_NONE) +try: + if getattr(base, 'modernLoading', None): + base.modernLoading.set_status('Loading client repository…') + base.modernLoading.set_progress(38) + base.graphicsEngine.renderFrame() +except Exception: + pass +# Progress range: six `loader.loadModel` calls in initNametagGlobals (ToonBase). +loader.beginBulkLoad('init', TTLocalizer.LoaderLabel, 6, 0, TTLocalizer.TIP_NONE) from toontown.distributed.ToontownClientRepository import ToontownClientRepository cr = ToontownClientRepository(serverVersion, launcher) cr.music = music @@ -112,10 +211,14 @@ if not launcher.isDummy(): else: base.startShow(cr) -backgroundNodePath.reparentTo(hidden) -backgroundNodePath.removeNode() -del backgroundNodePath -del backgroundNode +if backgroundNodePath is not None: + backgroundNodePath.reparentTo(hidden) + backgroundNodePath.removeNode() + del backgroundNodePath +try: + del backgroundNode +except Exception: + pass del tempLoader version.cleanup() del version diff --git a/toontown/town/Street.py b/toontown/town/Street.py index 5f0238a..178021d 100644 --- a/toontown/town/Street.py +++ b/toontown/town/Street.py @@ -8,13 +8,63 @@ from direct.directnotify import DirectNotifyGlobal from toontown.battle import BattlePlace from direct.fsm import ClassicFSM, State from direct.task import Task +import time +import traceback from otp.distributed.TelemetryLimiter import RotationLimitToH, TLGatherAllAvs from toontown.building import Elevator +from toontown.hood import OutdoorLighting from toontown.hood import ZoneUtil from toontown.toonbase import ToontownGlobals from toontown.toon.Toon import teleportDebug from direct.interval.IntervalGlobal import * visualizeZones = ConfigVariableBool('visualize-zones', 0).value +wholeStreetLoading = ConfigVariableBool('street-load-whole', 1).value +streetWholeStaggerProps = ConfigVariableBool('street-load-whole-stagger-props', 1) +streetWholeVisgroupsPerFrame = ConfigVariableInt('street-load-whole-visgroups-per-frame', 2) +streetPerfDebug = ConfigVariableBool('street-debug-perf', 0) +streetPerfDebugIntervalFrames = ConfigVariableInt('street-debug-perf-interval-frames', 60) +streetPerfDebugThresholdMs = ConfigVariableInt('street-debug-perf-threshold-ms', 8) +streetPerfDebugTrace = ConfigVariableBool('street-debug-perf-trace', 0) +streetPerfDebugTopN = ConfigVariableInt('street-debug-perf-topn', 8) + + +class _PerfAgg(object): + + def __init__(self, notify, prefix): + self.notify = notify + self.prefix = prefix + self.frame = 0 + self._lastFlushFrame = 0 + self._stats = {} + + def add(self, name, dt): + s = self._stats.get(name) + if s is None: + self._stats[name] = [dt, dt, dt, 1] + else: + s[0] += dt + if dt < s[1]: + s[1] = dt + if dt > s[2]: + s[2] = dt + s[3] += 1 + + def maybeFlush(self, intervalFrames, topN): + if intervalFrames <= 0: + return + if (self.frame - self._lastFlushFrame) < intervalFrames: + return + self._lastFlushFrame = self.frame + if not self._stats: + return + items = sorted(self._stats.items(), key=lambda kv: kv[1][0], reverse=True) + lines = [] + for name, s in items[:max(1, topN)]: + total, mn, mx, cnt = s + avg = total / float(max(1, cnt)) + lines.append('%s: total=%.2fms avg=%.2fms min=%.2fms max=%.2fms n=%d' % (name, total * 1000.0, avg * 1000.0, mn * 1000.0, mx * 1000.0, cnt)) + self.notify.info('%s perf (last %d frames):\n %s' % (self.prefix, intervalFrames, '\n '.join(lines))) + self._stats.clear() class Street(BattlePlace.BattlePlace): notify = DirectNotifyGlobal.directNotify.newCategory('Street') @@ -91,13 +141,18 @@ class Street(BattlePlace.BattlePlace): self.tunnelOriginList = [] self.elevatorDoneEvent = 'elevatorDone' self.halloweenLights = [] + self._wholeStreetPropTaskName = None + self._perfAgg = None def enter(self, requestStatus, visibilityFlag = 1, arrowsOn = 1): teleportDebug(requestStatus, 'Street.enter(%s)' % (requestStatus,)) self._ttfToken = None + self._spawnStreetZoneId = requestStatus.get('zoneId') if wholeStreetLoading else None self.fsm.enterInitialState() base.playMusic(self.loader.music, looping=1, volume=0.8) self.loader.geom.reparentTo(render) + _hoodId = getattr(getattr(self.loader, 'hood', None), 'id', None) + OutdoorLighting.begin(self.loader.geom, 'playground', hoodId=_hoodId) if visibilityFlag: self.visibilityOn() base.localAvatar.setGeom(self.loader.geom) @@ -134,12 +189,15 @@ class Street(BattlePlace.BattlePlace): self.fsm.request(requestStatus['how'], [requestStatus]) if base.cr.wantStreetSign: self.replaceStreetSignTextures() + if hasattr(self, '_spawnStreetZoneId'): + del self._spawnStreetZoneId return def exit(self, visibilityFlag = 1): if visibilityFlag: self.visibilityOff() self.loader.geom.reparentTo(hidden) + OutdoorLighting.end(self.loader.geom) self._telemLimiter.destroy() del self._telemLimiter @@ -159,6 +217,8 @@ class Street(BattlePlace.BattlePlace): self.parentFSM.getStateNamed('street').addChild(self.fsm) def unload(self): + self._cancelWholeStreetPropTask() + self._perfAgg = None self.parentFSM.getStateNamed('street').removeChild(self.fsm) del self.parentFSM del self.fsm @@ -310,15 +370,178 @@ class Street(BattlePlace.BattlePlace): for i in self.loader.nodeList: i.unstash() + def _refreshStreetHolidayLights(self): + geom = base.cr.playGame.getPlace().loader.geom + self.halloweenLights = geom.findAllMatches('**/*light*') + self.halloweenLights += geom.findAllMatches('**/*lamp*') + self.halloweenLights += geom.findAllMatches('**/prop_snow_tree*') + for light in self.halloweenLights: + light.setColorScaleOff(1) + + def _cancelWholeStreetPropTask(self): + if self._wholeStreetPropTaskName: + taskMgr.remove(self._wholeStreetPropTaskName) + self._wholeStreetPropTaskName = None + if hasattr(self, '_wholeStreetPropNodes'): + del self._wholeStreetPropNodes + if hasattr(self, '_wholeStreetPropIndex'): + del self._wholeStreetPropIndex + + def _orderedWholeStreetVisgroups(self): + nodes = list(self.loader.nodeList) + zid = getattr(self, '_spawnStreetZoneId', None) + if zid is not None: + zn = self.loader.zoneDict.get(zid) + if zn is not None: + try: + nodes.remove(zn) + except ValueError: + pass + else: + nodes.insert(0, zn) + return nodes + + def _wholeStreetPropStep(self, task): + if not getattr(self, 'loader', None) or not hasattr(self, '_wholeStreetPropNodes'): + self._wholeStreetPropTaskName = None + return task.done + perfOn = streetPerfDebug.getValue() + threshold = max(0, streetPerfDebugThresholdMs.getValue()) / 1000.0 + topN = streetPerfDebugTopN.getValue() + if perfOn and self._perfAgg is None: + self._perfAgg = _PerfAgg(self.notify, 'Street(%s)' % (getattr(self, 'zoneId', '?'),)) + t0 = time.perf_counter() if perfOn else None + nodes = self._wholeStreetPropNodes + per = max(1, streetWholeVisgroupsPerFrame.getValue()) + i = self._wholeStreetPropIndex + end = min(i + per, len(nodes)) + for j in range(i, end): + if perfOn: + t1 = time.perf_counter() + self.loader.enterAnimatedProps(nodes[j]) + dt = time.perf_counter() - t1 + self._perfAgg.add('enterAnimatedProps', dt) + if dt >= threshold: + self.notify.warning('street perf hitch: enterAnimatedProps visgroup=%s dt=%.2fms' % (nodes[j].getName(), dt * 1000.0)) + if streetPerfDebugTrace.getValue(): + self.notify.warning('street perf trace (enterAnimatedProps):\n%s' % ''.join(traceback.format_stack(limit=20))) + else: + self.loader.enterAnimatedProps(nodes[j]) + if end >= len(nodes): + self._wholeStreetPropTaskName = None + del self._wholeStreetPropNodes + del self._wholeStreetPropIndex + return task.done + self._wholeStreetPropIndex = end + if perfOn: + self._perfAgg.frame += 1 + dt = time.perf_counter() - t0 + self._perfAgg.add('_wholeStreetPropStep', dt) + if dt >= threshold: + self.notify.warning('street perf hitch: _wholeStreetPropStep dt=%.2fms per=%d (%d->%d of %d)' % (dt * 1000.0, per, i, end, len(nodes))) + if streetPerfDebugTrace.getValue(): + self.notify.warning('street perf trace (_wholeStreetPropStep):\n%s' % ''.join(traceback.format_stack(limit=20))) + self._perfAgg.maybeFlush(streetPerfDebugIntervalFrames.getValue(), topN) + return task.cont + def visibilityOn(self): - self.hideAllVisibles() - self.accept('on-floor', self.enterZone) + if wholeStreetLoading: + self._cancelWholeStreetPropTask() + perfOn = streetPerfDebug.getValue() + threshold = max(0, streetPerfDebugThresholdMs.getValue()) / 1000.0 + topN = streetPerfDebugTopN.getValue() + if perfOn and self._perfAgg is None: + self._perfAgg = _PerfAgg(self.notify, 'Street(%s)' % (getattr(self, 'zoneId', '?'),)) + if perfOn: + t0 = time.perf_counter() + self.showAllVisibles() + dt = time.perf_counter() - t0 + self._perfAgg.add('showAllVisibles', dt) + if dt >= threshold: + self.notify.warning('street perf hitch: showAllVisibles dt=%.2fms nodeList=%d' % (dt * 1000.0, len(getattr(self.loader, 'nodeList', ()) or ()))) # noqa: E501 + if streetPerfDebugTrace.getValue(): + self.notify.warning('street perf trace (showAllVisibles):\n%s' % ''.join(traceback.format_stack(limit=20))) + self._perfAgg.maybeFlush(streetPerfDebugIntervalFrames.getValue(), topN) + else: + self.showAllVisibles() + if streetWholeStaggerProps.getValue() and self.loader.nodeList: + self._wholeStreetPropTaskName = uniqueName('wholeStreetProps') + self._wholeStreetPropNodes = self._orderedWholeStreetVisgroups() + self._wholeStreetPropIndex = 0 + taskMgr.add(self._wholeStreetPropStep, self._wholeStreetPropTaskName) + else: + for node in self._orderedWholeStreetVisgroups(): + if perfOn: + t1 = time.perf_counter() + self.loader.enterAnimatedProps(node) + dt = time.perf_counter() - t1 + self._perfAgg.add('enterAnimatedProps', dt) + if dt >= threshold: + self.notify.warning('street perf hitch: enterAnimatedProps visgroup=%s dt=%.2fms' % (node.getName(), dt * 1000.0)) + if streetPerfDebugTrace.getValue(): + self.notify.warning('street perf trace (enterAnimatedProps):\n%s' % ''.join(traceback.format_stack(limit=20))) + else: + self.loader.enterAnimatedProps(node) + if perfOn: + self._perfAgg.maybeFlush(streetPerfDebugIntervalFrames.getValue(), topN) + # Still track the local avatar's zone transitions for gameplay logic, + # but keep visibility/network interest for the whole street. + self.accept('on-floor', self.enterZone) + else: + self.hideAllVisibles() + self.accept('on-floor', self.enterZone) def visibilityOff(self): + self._cancelWholeStreetPropTask() self.ignore('on-floor') self.showAllVisibles() def doEnterZone(self, newZoneId): + if wholeStreetLoading: + perfOn = streetPerfDebug.getValue() + threshold = max(0, streetPerfDebugThresholdMs.getValue()) / 1000.0 + topN = streetPerfDebugTopN.getValue() + if perfOn and self._perfAgg is None: + self._perfAgg = _PerfAgg(self.notify, 'Street(%s)' % (getattr(self, 'zoneId', '?'),)) + if newZoneId != self.zoneId: + if newZoneId is not None: + if perfOn: + t0 = time.perf_counter() + if not __astron__: + base.cr.sendSetZoneMsg(newZoneId) + else: + # Request interest in all visgroups for this street, not just the adjacency list. + tSet0 = time.perf_counter() if perfOn else None + allZones = set(self.loader.zoneDict.keys()) + allZones.add(ZoneUtil.getBranchZone(newZoneId)) + allZones.add(newZoneId) + if perfOn: + dtSet = time.perf_counter() - tSet0 + self._perfAgg.add('buildAllZonesInterestSet', dtSet) + if dtSet >= threshold: + self.notify.warning('street perf hitch: buildAllZonesInterestSet dt=%.2fms size=%d' % (dtSet * 1000.0, len(allZones))) + base.cr.sendSetZoneMsg(newZoneId, sorted(allZones)) + if perfOn: + dt = time.perf_counter() - t0 + self._perfAgg.add('sendSetZoneMsg', dt) + if dt >= threshold: + self.notify.warning('street perf hitch: sendSetZoneMsg newZoneId=%s dt=%.2fms (whole street interests=%d)' % (newZoneId, dt * 1000.0, len(getattr(self.loader, "zoneDict", {}) or {}))) # noqa: E501 + if streetPerfDebugTrace.getValue(): + self.notify.warning('street perf trace (sendSetZoneMsg):\n%s' % ''.join(traceback.format_stack(limit=20))) + self.notify.debug('Entering Zone %d' % newZoneId) + self.zoneId = newZoneId + if perfOn: + t1 = time.perf_counter() + self._refreshStreetHolidayLights() + dt = time.perf_counter() - t1 + self._perfAgg.add('_refreshStreetHolidayLights', dt) + if dt >= threshold: + self.notify.warning('street perf hitch: _refreshStreetHolidayLights dt=%.2fms' % (dt * 1000.0)) + self._perfAgg.maybeFlush(streetPerfDebugIntervalFrames.getValue(), topN) + else: + self._refreshStreetHolidayLights() + return + if self.zoneId != None: for i in self.loader.nodeDict[self.zoneId]: if newZoneId: @@ -360,12 +583,7 @@ class Street(BattlePlace.BattlePlace): base.cr.sendSetZoneMsg(newZoneId, visZones) self.notify.debug('Entering Zone %d' % newZoneId) self.zoneId = newZoneId - geom = base.cr.playGame.getPlace().loader.geom - self.halloweenLights = geom.findAllMatches('**/*light*') - self.halloweenLights += geom.findAllMatches('**/*lamp*') - self.halloweenLights += geom.findAllMatches('**/prop_snow_tree*') - for light in self.halloweenLights: - light.setColorScaleOff(1) + self._refreshStreetHolidayLights() return diff --git a/toontown/town/TownBattle.py b/toontown/town/TownBattle.py index f5d7f25..43b9a22 100644 --- a/toontown/town/TownBattle.py +++ b/toontown/town/TownBattle.py @@ -363,23 +363,16 @@ class TownBattle(StateData.StateData): response['target'] = self.target messenger.send(self.battleEvent, [response]) self.fsm.request('AttackWait') - elif self.numToons == 3 or self.numToons == 4: - self.fsm.request('ChooseToon') - elif self.numToons == 2: + elif self.numToons == 1: response = {} response['mode'] = 'Attack' response['track'] = self.track response['level'] = self.level - if self.localNum == 0: - response['target'] = 1 - elif self.localNum == 1: - response['target'] = 0 - else: - self.notify.error('Bad localNum value: %s' % self.localNum) + response['target'] = 0 messenger.send(self.battleEvent, [response]) self.fsm.request('AttackWait') else: - self.notify.error('Heal was chosen when number of toons is %s' % self.numToons) + self.fsm.request('ChooseToon') elif self.__isCogChoiceNecessary(): self.notify.debug('choice needed') self.fsm.request('ChooseCog') @@ -423,14 +416,11 @@ class TownBattle(StateData.StateData): else: canTrap = 1 if len(self.luredIndices) == self.numCogs: - canLure = 0 + canLure = 1 canTrap = 0 else: canLure = 1 - if self.numToons == 1: - canHeal = 0 - else: - canHeal = 1 + canHeal = 1 return (canHeal, canTrap, canLure) def adjustCogsAndToons(self, cogs, luredIndices, trappedIndices, toons): diff --git a/toontown/town/TownBattleChooseAvatarPanel.py b/toontown/town/TownBattleChooseAvatarPanel.py index 9021050..ac5eec6 100644 --- a/toontown/town/TownBattleChooseAvatarPanel.py +++ b/toontown/town/TownBattleChooseAvatarPanel.py @@ -55,7 +55,7 @@ class TownBattleChooseAvatarPanel(StateData.StateData): invalidTargets = [] if not self.toon: if len(luredIndices) > 0: - if track == BattleBase.TRAP or track == BattleBase.LURE: + if track == BattleBase.TRAP: invalidTargets += luredIndices if len(trappedIndices) > 0: if track == BattleBase.TRAP: @@ -77,7 +77,7 @@ class TownBattleChooseAvatarPanel(StateData.StateData): def adjustCogs(self, numAvatars, luredIndices, trappedIndices, track): invalidTargets = [] if len(luredIndices) > 0: - if track == BattleBase.TRAP or track == BattleBase.LURE: + if track == BattleBase.TRAP: invalidTargets += luredIndices if len(trappedIndices) > 0: if track == BattleBase.TRAP: @@ -90,7 +90,7 @@ class TownBattleChooseAvatarPanel(StateData.StateData): def __placeButtons(self, numAvatars, invalidTargets, localNum): for i in range(4): - if numAvatars > i and i not in invalidTargets and i != localNum: + if numAvatars > i and i not in invalidTargets: self.avatarButtons[i].show() else: self.avatarButtons[i].hide() diff --git a/toontown/town/TownLoader.py b/toontown/town/TownLoader.py index 3abb4f2..bba8d3c 100644 --- a/toontown/town/TownLoader.py +++ b/toontown/town/TownLoader.py @@ -20,6 +20,12 @@ from toontown.building import ToonInterior from toontown.hood import QuietZoneState from toontown.hood import ZoneUtil from direct.interval.IntervalGlobal import * +import time +import traceback + +townPerfDebug = ConfigVariableBool('street-debug-perf', 0) +townPerfDebugThresholdMs = ConfigVariableInt('street-debug-perf-threshold-ms', 8) +townPerfDebugTrace = ConfigVariableBool('street-debug-perf-trace', 0) class TownLoader(StateData.StateData): notify = DirectNotifyGlobal.directNotify.newCategory('TownLoader') @@ -192,22 +198,42 @@ class TownLoader(StateData.StateData): pass def createHood(self, dnaFile, loadStorage = 1): + def _cooperative_yield(): + # Break up long synchronous hood loads into smaller slices so the + # game doesn't appear frozen (and so heartbeats can be sent). + try: + if getattr(base, 'cr', None): + base.cr.considerHeartbeat() + except Exception: + pass + try: + time.sleep(0) + except Exception: + pass + if loadStorage: loader.loadDNAFile(self.hood.dnaStore, 'phase_5/dna/storage_town.dna') self.notify.debug('done loading %s' % 'phase_5/dna/storage_town.dna') loader.loadDNAFile(self.hood.dnaStore, self.townStorageDNAFile) self.notify.debug('done loading %s' % self.townStorageDNAFile) + _cooperative_yield() node = loader.loadDNAFile(self.hood.dnaStore, dnaFile) self.notify.debug('done loading %s' % dnaFile) + _cooperative_yield() if node.getNumParents() == 1: self.geom = NodePath(node.getParent(0)) self.geom.reparentTo(hidden) else: self.geom = hidden.attachNewNode(node) - self.makeDictionaries(self.hood.dnaStore) + _cooperative_yield() + self.makeDictionaries(self.hood.dnaStore, _yield=_cooperative_yield) + _cooperative_yield() self.reparentLandmarkBlockNodes() - self.renameFloorPolys(self.nodeList) - self.createAnimatedProps(self.nodeList) + _cooperative_yield() + self.renameFloorPolys(self.nodeList, _yield=_cooperative_yield) + _cooperative_yield() + self.createAnimatedProps(self.nodeList, _yield=_cooperative_yield) + _cooperative_yield() self.holidayPropTransforms = {} npl = self.geom.findAllMatches('**/=DNARoot=holiday_prop') for i in range(npl.getNumPaths()): @@ -217,7 +243,7 @@ class TownLoader(StateData.StateData): self.notify.info('skipping self.geom.flattenMedium') gsg = base.win.getGsg() - if gsg: + if gsg and base.config.GetBool('dna-want-prepare-scene', True): def prepareSceneTask(task, geom=self.geom, gsg=gsg): geom.prepareScene(gsg) return task.done @@ -236,7 +262,7 @@ class TownLoader(StateData.StateData): nodePath = npc.getPath(i) nodePath.wrtReparentTo(bucket) - def makeDictionaries(self, dnaStore): + def makeDictionaries(self, dnaStore, _yield=None): self.nodeDict = {} self.zoneDict = {} if __astron__: @@ -248,6 +274,8 @@ class TownLoader(StateData.StateData): a0 = Vec4(1, 1, 1, 0) numVisGroups = dnaStore.getNumDNAVisGroups() for i in range(numVisGroups): + if _yield and i and (i % 10) == 0: + _yield() groupFullName = dnaStore.getDNAVisGroupName(i) groupName = base.cr.hoodMgr.extractGroupName(groupFullName) zoneId = int(groupName) @@ -271,6 +299,8 @@ class TownLoader(StateData.StateData): self.fadeInDict[groupNode] = Sequence(Func(groupNode.unstash), Func(groupNode.setTransparency, 1), LerpColorScaleInterval(groupNode, fadeDuration, a1, startColorScale=a0), Func(groupNode.clearColorScale), Func(groupNode.clearTransparency), name='fadeZone-' + str(zoneId), autoPause=1) for i in range(numVisGroups): + if _yield and i and (i % 10) == 0: + _yield() groupFullName = dnaStore.getDNAVisGroupName(i) zoneId = int(base.cr.hoodMgr.extractGroupName(groupFullName)) zoneId = ZoneUtil.getTrueZoneId(zoneId, self.zoneId) @@ -287,9 +317,11 @@ class TownLoader(StateData.StateData): self.hood.dnaStore.resetDNAVisGroups() self.hood.dnaStore.resetDNAVisGroupsAI() - def renameFloorPolys(self, nodeList): + def renameFloorPolys(self, nodeList, _yield=None): # Optimized collision poly renaming - process in batches - for i in nodeList: + for idx, i in enumerate(nodeList): + if _yield and idx and (idx % 8) == 0: + _yield() collNodePaths = i.findAllMatches('**/+CollisionNode') numCollNodePaths = collNodePaths.getNumPaths() if numCollNodePaths == 0: @@ -301,10 +333,12 @@ class TownLoader(StateData.StateData): if bitMask.getBit(1): collNodePath.node().setName(visGroupName) - def createAnimatedProps(self, nodeList): + def createAnimatedProps(self, nodeList, _yield=None): self.animPropDict = {} self.zoneIdToInteractivePropDict = {} - for i in nodeList: + for idx, i in enumerate(nodeList): + if _yield and idx and (idx % 4) == 0: + _yield() animPropNodes = i.findAllMatches('**/animated_prop_*') numAnimPropNodes = animPropNodes.getNumPaths() for j in range(numAnimPropNodes): @@ -378,8 +412,25 @@ class TownLoader(StateData.StateData): del self.animPropDict def enterAnimatedProps(self, zoneNode): + perfOn = townPerfDebug.getValue() + threshold = max(0, townPerfDebugThresholdMs.getValue()) / 1000.0 + if not perfOn: + for animProp in self.animPropDict.get(zoneNode, ()): + animProp.enter() + return + vis = None + try: + vis = zoneNode.getName() + except Exception: + vis = repr(zoneNode) for animProp in self.animPropDict.get(zoneNode, ()): + t0 = time.perf_counter() animProp.enter() + dt = time.perf_counter() - t0 + if dt >= threshold: + self.notify.warning('street perf hitch: animProp.enter visgroup=%s prop=%s dt=%.2fms' % (vis, animProp.__class__.__name__, dt * 1000.0)) + if townPerfDebugTrace.getValue(): + self.notify.warning('street perf trace (animProp.enter):\n%s' % ''.join(traceback.format_stack(limit=20))) def exitAnimatedProps(self, zoneNode): for animProp in self.animPropDict.get(zoneNode, ()): diff --git a/toontown/tutorial/TutorialManager.py b/toontown/tutorial/TutorialManager.py index e8a641e..a323028 100644 --- a/toontown/tutorial/TutorialManager.py +++ b/toontown/tutorial/TutorialManager.py @@ -36,7 +36,7 @@ class TutorialManager(DistributedObject.DistributedObject): def enterTutorial(self, branchZone, streetZone, shopZone, hqZone): base.localAvatar.cantLeaveGame = 1 - ZoneUtil.overrideOn(branch=branchZone, exteriorList=[streetZone], interiorList=[shopZone, hqZone]) + # Let tutorial toons travel anywhere; don't restrict ZoneUtil. messenger.send('startTutorial', [shopZone]) self.acceptOnce('stopTutorial', self.__handleStopTutorial) self.acceptOnce('toonArrivedTutorial', self.d_toonArrived) diff --git a/toontown/tutorial/TutorialManagerAI.py b/toontown/tutorial/TutorialManagerAI.py index 6fa6b45..addf77f 100644 --- a/toontown/tutorial/TutorialManagerAI.py +++ b/toontown/tutorial/TutorialManagerAI.py @@ -10,6 +10,7 @@ from toontown.toonbase import ToontownBattleGlobals from toontown.toon import NPCToons from toontown.ai import BlackCatHolidayMgrAI from toontown.ai import DistributedBlackCatMgrAI +from toontown.toonbase import ToontownGlobals class TutorialManagerAI(DistributedObjectAI.DistributedObjectAI): notify = DirectNotifyGlobal.directNotify.newCategory("TutorialManagerAI") @@ -81,11 +82,11 @@ class TutorialManagerAI(DistributedObjectAI.DistributedObjectAI): # Clear out the avatar's quests, hp, inventory, and everything else in case # he made it half way through the tutorial last time. if av: - # No quests + # No quests; full carry limit so after the tutorial they only need HQ fills av.b_setQuests([]) av.b_setQuestHistory([]) av.b_setRewardHistory(0, []) - av.b_setQuestCarryLimit(1) + av.b_setQuestCarryLimit(ToontownGlobals.MaxQuestCarryLimit) # Starting HP av.b_setMaxHp(69) av.b_setHp(69) @@ -111,6 +112,9 @@ class TutorialManagerAI(DistributedObjectAI.DistributedObjectAI): if av: self.air.writeServerEvent('finishedTutorial', avId, '') av.b_setTutorialAck(1) + av.b_setQuests([]) + av.b_setQuestHistory([]) + av.b_setQuestCarryLimit(ToontownGlobals.MaxQuestCarryLimit) self.__destroyTutorial(avId) else: self.notify.warning( @@ -205,6 +209,10 @@ class TutorialManagerAI(DistributedObjectAI.DistributedObjectAI): # Acknowlege that the player has seen a tutorial self.air.writeServerEvent('finishedTutorial', avId, '') av.b_setTutorialAck(1) + av.b_setQuests([]) + av.b_setQuestHistory([]) + av.b_setRewardHistory(0, []) + av.b_setQuestCarryLimit(ToontownGlobals.MaxQuestCarryLimit) self.sendUpdateToAvatarId(avId, "skipTutorialResponse", [1]) else: @@ -231,27 +239,10 @@ class TutorialManagerAI(DistributedObjectAI.DistributedObjectAI): # Acknowlege that the player has seen a tutorial self.air.writeServerEvent('skippedTutorial', avId, '') av.b_setTutorialAck(1) - # these values were taken by running a real tutorial - self.air.questManager.assignQuest(avId, - 20000, - 101, - 100, - 1000, - 1 - ) - - self.air.questManager.completeAllQuestsMagically(av) - av.removeQuest(101) - self.air.questManager.assignQuest(avId, - 1000, - 110, - 2, - 1000, - 0 - ) - self.air.questManager.completeAllQuestsMagically(av) - - # do whatever needs to be done to make his quest state good + av.b_setQuests([]) + av.b_setQuestHistory([]) + av.b_setRewardHistory(0, []) + av.b_setQuestCarryLimit(ToontownGlobals.MaxQuestCarryLimit) elif av: self.notify.debug("%s requestedSkipTutorial, but tutorialAck is 1") else: