core: codebase fixes, Astron compatibility, and widescreen offset updates

This commit is contained in:
Toontown Super 2026-07-13 14:08:53 -04:00
parent 04599db6d6
commit 56d77fe845
94 changed files with 4698 additions and 695 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = ['']

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

239
toontown/toon/ParamObj.py Normal file
View File

@ -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 = '<unknown>'
argStr += '%s=%s,' % (param, repr(value))
return '%s(%s)' % (self.__class__.__name__, argStr)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, ()):

View File

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

View File

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