function App.init(V)V.state={canvases={},spots={},activeSpotId=nil,activeCanvasId=nil;errorMessage=nil,isLoaded={}}V.spotRefs={}V.canvasRefs={}V.selectorRef=Roact.createRef()V.errorMessageRef=Roact.createRef()V.cameraModule=CameraModule.new(workspace.CurrentCamera)V.userInputController=UserInputController.new()V.guiController=GuiController.new({V.selectorRef,V.errorMessageRef})V.humanoidControllers={}for E,s in pairs(Players:GetPlayers())do V.humanoidControllers[s.UserId]=HumanoidController.new(s)end V.isAnimating=false V.getTagged=modules.getTagged(V)V.fetchArt=modules.fetchArt(V)V.fetchAllArt=modules.fetchAllArt(V)V.applyArt=modules.applyArt(V)V.getRandomSpot=modules.getRandomSpot(V)V.clearErrorMessage=modules.clearErrorMessage(V)V.hideHumanoids=modules.hideHumanoids(V)V.showHumanoids=modules.showHumanoids(V)V.waitForHumanoidAnimations=modules.waitForHumanoidAnimations(V)V.getModelFace=modules.getModelFace()V.cleanup=modules.cleanup(V)V.onSpotChanged=modules.onSpotChanged(V,events)V.onPromptShown=modules.onPromptShown(V)V.onPromptHidden=modules.onPromptHidden(V)V.onCancel=modules.onCancel(V)V.onPlayerAdded=function(E)V.humanoidControllers[E.UserId]=HumanoidController.new(E)end V.onPlayerRemoving=function(E)V.humanoidControllers[E.UserId]=nil end end function App.render(V)if not V.props.configuration.enabled then return nil end local E=V:_renderSurfaceGuis()local s=V:_renderProximityPrompts()local t=V:_renderSelector()local H=V:_canvasAttributeListeners()return Roact.createFragment(Cryo.Dictionary.join(t,E,s,H,{ErrorMessage=V.state.errorMessage and Roact.createElement("\083\099\114\101\101\110\071\117\105",{IgnoreGuiInset=true;ResetOnSpawn=false,[Roact.Ref]=V.errorMessageRef},{Roact.createElement(ErrorMessage,{errorCode=V.state.errorMessage;onActivated=V.clearErrorMessage})});FetchAllEvent=Roact.createElement(ExternalEventConnection,{event=V.props.SpotChanged.OnClientEvent;callback=V.onSpotChanged}),InitializeHumanoidController=Roact.createElement(ExternalEventConnection,{event=Players.PlayerAdded,callback=V.onPlayerAdded});CleanupHumanoidController=Roact.createElement(ExternalEventConnection,{event=Players.PlayerRemoving,callback=V.onPlayerRemoving})}))end function App.didMount(V)V.fetchAllArt()end function App.didUpdate(s,V,E)if E.activeSpotId~=s.state.activeSpotId then if s.state.activeSpotId~=nil then events.selectorShown:Fire()else events.selectorHidden:Fire()end end end function App._renderSurfaceGuis(V)local E={}for s,t in ipairs(V.getTagged())do local H=t:GetAttribute(constants.Attributes.SurfaceCanvasId)if not H then warn("\091\083\117\114\102\097\099\101\065\114\116\093 \067\097\110\118\097\115\073\068 \100\111\101\115 \110\111\116 \101\120\105\115\116 \102\111\114 \109\111\100\101\108")continue end if not V.state.isLoaded[H]then continue end if not V.canvasRefs[H]then V.canvasRefs[H]=Roact.createRef()end local Y=V:_renderSpots(t)local h=Roact.createElement("\083\117\114\102\097\099\101\071\117\105",{Adornee=t;SizingMode=Enum.SurfaceGuiSizingMode.PixelsPerStud,PixelsPerStud=50;Face=V.getModelFace(t),ResetOnSpawn=false,[Roact.Ref]=V.canvasRefs[H]},{SurfaceCanvas=Roact.createElement(SurfaceCanvas,{numRows=V.props.configuration.rowsPerCanvas,numCols=V.props.configuration.colsPerCanvas;paddingLeft=V.props.configuration.canvasPaddingLeft,paddingRight=V.props.configuration.canvasPaddingRight,paddingTop=V.props.configuration.canvasPaddingTop,paddingBottom=V.props.configuration.canvasPaddingBottom},Y)})E["\099\097\110\118\097\115\095"..H]=h end return E end function App._renderSpots(E,V)local s={}local t=E.props.configuration.rowsPerCanvas*E.props.configuration.colsPerCanvas for t=1,t,1 do local H=V:GetAttribute(constants.Attributes.SpotIdPrefix..t)local Y=E.state.spots[H]if not Y then continue end local h=Y.artId local C=h and E.props.configuration.assets[h]if not E.spotRefs[H]then E.spotRefs[H]=Roact.createRef()end local Q=Roact.createElement(SurfaceSpot,{image=C and C.assetId;[Roact.Ref]=E.spotRefs[H]})table.insert(s,Q)end return s end function App._renderProximityPrompts(V)local E={}for s,t in ipairs(V.getTagged())do local H=t:GetAttribute(constants.Attributes.SurfaceCanvasId)if not H then warn("\091\083\117\114\102\097\099\101\065\114\116\093 \067\097\110\118\097\115\073\068 \100\111\101\115 \110\111\116 \101\120\105\115\116 \102\111\114 \109\111\100\101\108")continue end local Y=Roact.createFragment({Roact.createElement(Roact.Portal,{target=t},{ProximityPrompt=Roact.createElement("\080\114\111\120\105\109\105\116\121\080\114\111\109\112\116",{Enabled=true,KeyboardKeyCode=V.props.configuration.promptKeyCode,RequiresLineOfSight=V.props.configuration.promptRequiresLineOfSight,MaxActivationDistance=V.props.configuration.promptMaxActivationDistance,Exclusivity=V.props.configuration.promptExclusivity;Style=Enum.ProximityPromptStyle.Custom,[Roact.Event.TriggerEnded]=function()V.getRandomSpot(H)end,[Roact.Event.PromptShown]=function()V.onPromptShown(H)events.promptShown:Fire(t)end;[Roact.Event.PromptHidden]=function()V.onPromptHidden()events.promptHidden:Fire(t)end})}),SurfacePrompt=Roact.createElement("\066\105\108\108\098\111\097\114\100\071\117\105",{Active=true;Adornee=t;Size=UDim2.fromOffset(60,60);AlwaysOnTop=true,ZIndexBehavior=Enum.ZIndexBehavior.Sibling,ResetOnSpawn=false},{Roact.createElement(SurfacePrompt,{isShown=H==V.state.activeCanvasId and not V.state.activeSpotId,onClick=function()V.getRandomSpot(H)end})})})E["\112\114\111\109\112\116\115\095"..H]=Y end return E end function App._renderSelector(V)local E=Cryo.Dictionary.keys(V.props.configuration.assets)return{Selector=V.state.activeSpotId~=nil and Roact.createElement("\083\099\114\101\101\110\071\117\105",{IgnoreGuiInset=true;ResetOnSpawn=false;ZIndexBehavior=Enum.ZIndexBehavior.Sibling,[Roact.Ref]=V.selectorRef},{Roact.createElement(SurfaceArtSelector,{images=Cryo.List.map(E,function(E)return V.props.configuration.assets[E].assetId end),onApply=function(s)local t=E[s]V.applyArt(t)end,onCancel=V.onCancel})})}end function App._canvasAttributeListeners(V)local E={}for s,t in ipairs(V.getTagged())do E["\108\105\115\116\101\110\101\114\095"..s]=Roact.createElement(ExternalEventConnection,{event=t.AttributeChanged;callback=function(E)if E==constants.Attributes.SurfaceCanvasId then local E=t:GetAttribute(constants.Attributes.SurfaceCanvasId)V.fetchArt(E)end end})end return E end return ConfigurationContext.withConfiguration(App)

function App:init()
	self.state = {
		-- Maps a canvas ID to its spots UUID
		--
		-- Schema:
		-- {
		--		[canvasId] = {
		--			spots = { <Spot UUID>, ... },
		--		},
		--		...
		-- }
		canvases = {},

		-- Maps spot UUID to its art metadata
		--
		-- Schema:
		-- {
		--		[spotId] = {
		--			artId =  "<art ID (NOT ASSET ID)>",
		--			ownerUserId = "<UserId of player who placed this art>",
		--		},
		--		...
		-- }
		spots = {},

		-- Active spot index that the player is assigned to place the art in
		activeSpotId = nil,

		-- Canvas ID that we are showing proximity prompt for
		activeCanvasId = nil,

		-- Error message shown to user as a modal when something goes wrong
		errorMessage = nil,

		-- Flag to mark if a canvas has the initial spots information loaded
		isLoaded = {},
	}

	-- Map between spot UUID and its corresponding ref for usage by CameraModule to animate
	self.spotRefs = {}

	-- Keep track of the SurfaceGui for each canvas for CameraModule to animate
	self.canvasRefs = {}

	-- Keeps track of selector GUI
	self.selectorRef = Roact.createRef()

	-- Keeps track of error message GUI
	self.errorMessageRef = Roact.createRef()

	-- Controls camera movement
	self.cameraModule = CameraModule.new(workspace.CurrentCamera)

	-- Controls user's input
	self.userInputController = UserInputController.new()

	-- Controls user's active GUI
	self.guiController = GuiController.new({ self.selectorRef, self.errorMessageRef })

	-- Controls user's (and potentially other Player's) Humanoid
	-- Maps between UserId and their HumanoidController.
	self.humanoidControllers = {}
	for _, player in pairs(Players:GetPlayers()) do
		self.humanoidControllers[player.UserId] = HumanoidController.new(player)
	end

	-- Set to true when we are transitioning between camera scenes
	self.isAnimating = false

	self.getTagged = modules.getTagged(self)
	self.fetchArt = modules.fetchArt(self)
	self.fetchAllArt = modules.fetchAllArt(self)
	self.applyArt = modules.applyArt(self)
	self.getRandomSpot = modules.getRandomSpot(self)
	self.clearErrorMessage = modules.clearErrorMessage(self)
	self.hideHumanoids = modules.hideHumanoids(self)
	self.showHumanoids = modules.showHumanoids(self)
	self.waitForHumanoidAnimations = modules.waitForHumanoidAnimations(self)
	self.getModelFace = modules.getModelFace()
	self.cleanup = modules.cleanup(self)
	self.onSpotChanged = modules.onSpotChanged(self, events)
	self.onPromptShown = modules.onPromptShown(self)
	self.onPromptHidden = modules.onPromptHidden(self)
	self.onCancel = modules.onCancel(self)
	self.onPlayerAdded = function(player)
		self.humanoidControllers[player.UserId] = HumanoidController.new(player)
	end
	self.onPlayerRemoving = function(player)
		self.humanoidControllers[player.UserId] = nil
	end
end

function App:render()
	-- Render nothing if we turn off the dev module
	if not self.props.configuration.enabled then
		return nil
	end

	local surfaceGuis = self:_renderSurfaceGuis()
	local proximityPrompts = self:_renderProximityPrompts()
	local selector = self:_renderSelector()
	local canvasAttributeListeners = self:_canvasAttributeListeners()

	return Roact.createFragment(
		Cryo.Dictionary.join(selector, surfaceGuis, proximityPrompts, canvasAttributeListeners, {
			ErrorMessage = self.state.errorMessage and Roact.createElement("ScreenGui", {
				IgnoreGuiInset = true,
				ResetOnSpawn = false,
				[Roact.Ref] = self.errorMessageRef,
			}, {
				Roact.createElement(ErrorMessage, {
					errorCode = self.state.errorMessage,
					onActivated = self.clearErrorMessage,
				}),
			}),

			FetchAllEvent = Roact.createElement(ExternalEventConnection, {
				event = self.props.SpotChanged.OnClientEvent,
				callback = self.onSpotChanged,
			}),

			InitializeHumanoidController = Roact.createElement(ExternalEventConnection, {
				event = Players.PlayerAdded,
				callback = self.onPlayerAdded,
			}),

			CleanupHumanoidController = Roact.createElement(ExternalEventConnection, {
				event = Players.PlayerRemoving,
				callback = self.onPlayerRemoving,
			}),
		})
	)
end

function App:didMount()
	self.fetchAllArt()
end

function App:didUpdate(_, prevState)
	if prevState.activeSpotId ~= self.state.activeSpotId then
		if self.state.activeSpotId ~= nil then
			events.selectorShown:Fire()
		else
			events.selectorHidden:Fire()
		end
	end
end

function App:_renderSurfaceGuis()
	-- Surface GUIs for all canvases
	local surfaceGuis = {}

	for _, model in ipairs(self.getTagged()) do
		local canvasId = model:GetAttribute(constants.Attributes.SurfaceCanvasId)
		if not canvasId then
			warn("[SurfaceArt] CanvasID does not exist for model")
			continue
		end

		-- Future improvement support for dynamic loading of canvases possible by using a table instead of a single flag
		-- e.g. only load artwork on canvas when users are within a threshold distance
		if not self.state.isLoaded[canvasId] then
			continue
		end

		if not self.canvasRefs[canvasId] then
			self.canvasRefs[canvasId] = Roact.createRef()
		end

		local spots = self:_renderSpots(model)
		local surfaceGui = Roact.createElement("SurfaceGui", {
			Adornee = model,
			SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud,
			PixelsPerStud = 50,
			Face = self.getModelFace(model),
			ResetOnSpawn = false,
			[Roact.Ref] = self.canvasRefs[canvasId],
		}, {
			SurfaceCanvas = Roact.createElement(SurfaceCanvas, {
				numRows = self.props.configuration.rowsPerCanvas,
				numCols = self.props.configuration.colsPerCanvas,
				paddingLeft = self.props.configuration.canvasPaddingLeft,
				paddingRight = self.props.configuration.canvasPaddingRight,
				paddingTop = self.props.configuration.canvasPaddingTop,
				paddingBottom = self.props.configuration.canvasPaddingBottom,
			}, spots),
		})

		surfaceGuis["canvas_" .. canvasId] = surfaceGui
	end

	return surfaceGuis
end

function App:_renderSpots(model)
	local spots = {}
	local spotsPerCanvas = self.props.configuration.rowsPerCanvas * self.props.configuration.colsPerCanvas
	for i = 1, spotsPerCanvas do
		local spotId = model:GetAttribute(constants.Attributes.SpotIdPrefix .. i)
		local spot = self.state.spots[spotId]
		if not spot then
			continue
		end
		local artId = spot.artId

		-- We may have not loaded the asset ID yet
		local asset = artId and self.props.configuration.assets[artId]

		if not self.spotRefs[spotId] then
			self.spotRefs[spotId] = Roact.createRef()
		end

		local surfaceSpot = Roact.createElement(SurfaceSpot, {
			image = asset and asset.assetId,
			[Roact.Ref] = self.spotRefs[spotId],
		})

		table.insert(spots, surfaceSpot)
	end

	return spots
end

function App:_renderProximityPrompts()
	local proximityPrompts = {}

	for _, model in ipairs(self.getTagged()) do
		local canvasId = model:GetAttribute(constants.Attributes.SurfaceCanvasId)
		if not canvasId then
			warn("[SurfaceArt] CanvasID does not exist for model")
			continue
		end

		local prompt = Roact.createFragment({
			Roact.createElement(Roact.Portal, {
				target = model,
			}, {
				ProximityPrompt = Roact.createElement("ProximityPrompt", {
					Enabled = true,
					KeyboardKeyCode = self.props.configuration.promptKeyCode,
					RequiresLineOfSight = self.props.configuration.promptRequiresLineOfSight,
					MaxActivationDistance = self.props.configuration.promptMaxActivationDistance,
					Exclusivity = self.props.configuration.promptExclusivity,
					Style = Enum.ProximityPromptStyle.Custom,
					[Roact.Event.TriggerEnded] = function()
						self.getRandomSpot(canvasId)
					end,
					[Roact.Event.PromptShown] = function()
						self.onPromptShown(canvasId)
						events.promptShown:Fire(model)
					end,
					[Roact.Event.PromptHidden] = function()
						self.onPromptHidden()
						events.promptHidden:Fire(model)
					end,
				}),
			}),
			SurfacePrompt = Roact.createElement("BillboardGui", {
				Active = true,
				Adornee = model,
				Size = UDim2.fromOffset(60, 60),
				AlwaysOnTop = true,
				ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
				ResetOnSpawn = false,
			}, {
				Roact.createElement(SurfacePrompt, {
					isShown = canvasId == self.state.activeCanvasId and not self.state.activeSpotId,
					onClick = function()
						self.getRandomSpot(canvasId)
					end,
				}),
			}),
		})

		proximityPrompts["prompts_" .. canvasId] = prompt
	end

	return proximityPrompts
end

function App:_renderSelector()
	local artIds = Cryo.Dictionary.keys(self.props.configuration.assets)

	return {
		Selector = self.state.activeSpotId ~= nil and Roact.createElement("ScreenGui", {
			IgnoreGuiInset = true,
			ResetOnSpawn = false,
			ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
			[Roact.Ref] = self.selectorRef,
		}, {
			Roact.createElement(SurfaceArtSelector, {
				images = Cryo.List.map(artIds, function(artId)
					return self.props.configuration.assets[artId].assetId
				end),
				onApply = function(index)
					local artId = artIds[index]
					self.applyArt(artId)
				end,
				onCancel = self.onCancel,
			}),
		}),
	}
end

function App:_canvasAttributeListeners()
	local listeners = {}
	for i, model in ipairs(self.getTagged()) do
		listeners["listener_" .. i] = Roact.createElement(ExternalEventConnection, {
			event = model.AttributeChanged,
			callback = function(attributeName)
				if attributeName == constants.Attributes.SurfaceCanvasId then
					local canvasId = model:GetAttribute(constants.Attributes.SurfaceCanvasId)
					self.fetchArt(canvasId)
				end
			end,
		})
	end

	return listeners
end

return ConfigurationContext.withConfiguration(App)
