Join the RoClicks Discord
RoClicks

Growth.luau

Luau, 163 lines from Building overview

Download Growth.luau
--!strict
-- Growth: one readable prop per upgrade level, placed automatically on any building model, with a pop-in.
--
-- Why: when a model only changes at tier steps, most upgrades look like nothing happened. Here level L
-- shows items 1..L-1 of the role's list, so L+1 = L plus exactly one piece and nothing jumps around.
--
-- Placement: the model is sampled once on a GRID x GRID raster with downward raycasts (only against the
-- model). A cell is a seat when its hit is flat (normal.Y > 0.98). Items take the first free rectangle of
-- seats in level order, ground items low, roof items high; front corners first. Layouts are cached per
-- template name, so the second building of a kind costs almost nothing.
--
--   Growth.ROLES.Farm = { "crate", "silo", "crate" }
--   Growth.ITEMS.silo = { size = Vector3.new(1.2, 3, 1.2), place = "ground", build = function(size) ... end }
--   local props = Growth.dress(model, "Farm", level)      -- returns a Model parented to `model`
--   Growth.pop(props:FindFirstChild("L3"))                -- the upgrade moment for the new piece

local TweenService = game:GetService("TweenService")

local Growth = {}
Growth.GRID = 32
Growth.SCALES = { 1, 0.8, 0.65 } -- try smaller when nothing fits
Growth.SINK = 0.012 -- sink into the seat so nothing hovers (off the 0.01 grid on purpose)

export type Item = { size: Vector3, place: "ground" | "roof" | "any", build: (size: Vector3) -> Model }
Growth.ROLES = {} :: { [string]: { string } }
Growth.ITEMS = {} :: { [string]: Item }

-- a simple default recipe: a stud-style box (replace with real props)
local function box(size: Vector3): Model
	local m = Instance.new("Model")
	local p = Instance.new("Part")
	p.Size, p.Anchored, p.Material = size, true, Enum.Material.Plastic
	p.PivotOffset = CFrame.new(0, -size.Y / 2, 0) -- pivot on the ground: placement and pop use it
	p.Parent = m
	m.PrimaryPart = p
	return m
end
Growth.ITEMS.crate = { size = Vector3.new(1, 1, 1), place = "ground", build = box }

type Seat = { y: number, flat: boolean, used: boolean }
type Layout = { [number]: { cf: CFrame, sc: number }? } -- index = item number in the role list; CFrames relative to the model pivot
type Raster = { cell: number, origin: CFrame, seats: { { Seat } }, ground: number }

local layouts: { [string]: Layout } = {}

-- Builds models must have their pivot on the ground at the footprint centre, and recipes must put their
-- own pivot at the bottom centre, for the cached relative layouts to fit every copy.
local function raster(model: Model): Raster
	local cf, size = model:GetBoundingBox()
	local cell = math.max(size.X, size.Z) / Growth.GRID
	local origin = cf * CFrame.new(-size.X / 2, size.Y / 2 + 1, -size.Z / 2)
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Include
	params.FilterDescendantsInstances = { model }
	local seats, ground = {}, math.huge
	for i = 1, Growth.GRID do
		seats[i] = {}
		for j = 1, Growth.GRID do
			local p = origin * Vector3.new((i - 0.5) * cell, 0, (j - 0.5) * cell)
			local hit = workspace:Raycast(p, Vector3.new(0, -(size.Y + 2), 0), params)
			local y = if hit then hit.Position.Y else -math.huge
			local flat = hit ~= nil and hit.Normal.Y > 0.98 and hit.Instance.Transparency < 0.98
			seats[i][j] = { y = y, flat = flat, used = false }
			if flat then ground = math.min(ground, y) end
		end
	end
	return { cell = cell, origin = origin, seats = seats, ground = ground }
end

-- first free flat rectangle of w x d cells at one height, scanning front rows first
local function findSpot(r: Raster, w: number, d: number, place: string): (number?, number?, number?)
	local G = Growth.GRID
	for j = G - d + 1, 1, -1 do -- +Z rows first: treat +Z as the model's front (adjust to your models)
		for _, i in { 1, G - w + 1, (G - w) // 2 + 1 } do -- corners, then centre
			local s0 = r.seats[i][j]
			local ok = s0.flat and not s0.used
			local low = s0.y - r.ground < 0.3
			if place == "ground" and not low then ok = false end
			if place == "roof" and low then ok = false end
			for a = i, i + w - 1 do
				for b = j, j + d - 1 do
					local s = r.seats[a] and r.seats[a][b]
					if not ok or not s or not s.flat or s.used or math.abs(s.y - s0.y) > 0.02 then
						ok = false
						break
					end
				end
				if not ok then break end
			end
			if ok then return i, j, s0.y end
		end
	end
	return nil, nil, nil
end

local function layoutFor(model: Model, role: string): Layout
	local k = model.Name .. "|" .. role
	if layouts[k] then return layouts[k] end
	local r = raster(model)
	local toLocal = model:GetPivot():Inverse()
	local out: Layout = {}
	for n, kind in Growth.ROLES[role] or {} do
		local item = Growth.ITEMS[kind]
		if item then
			for _, sc in Growth.SCALES do
				local w = math.max(1, math.ceil(item.size.X * sc / r.cell))
				local d = math.max(1, math.ceil(item.size.Z * sc / r.cell))
				local i, j, y = findSpot(r, w, d, item.place)
				if i and j and y then
					for a = i, i + w - 1 do for b = j, j + d - 1 do r.seats[a][b].used = true end end
					local local_ = r.origin * Vector3.new((i - 1 + w / 2) * r.cell, 0, (j - 1 + d / 2) * r.cell)
					local at = Vector3.new(local_.X, y - Growth.SINK, local_.Z)
					out[n] = { cf = toLocal * CFrame.new(at), sc = sc }
					break
				end
			end
		end
		-- no spot: leave nil. In a real game place a slim FALLBACK piece so every level adds something.
	end
	layouts[k] = out
	return out
end

-- Build props for levels 2..level. The layout is computed on the first model of a name (it must be in
-- Workspace for the raycasts) and reused for every copy.
function Growth.dress(model: Model, role: string, level: number): Model
	local old = model:FindFirstChild("GrowthProps")
	if old then old:Destroy() end
	local props = Instance.new("Model")
	props.Name = "GrowthProps"
	local layout = layoutFor(model, role)
	for n = 1, math.min(level - 1, #(Growth.ROLES[role] or {})) do
		local spot = layout[n]
		local item = Growth.ITEMS[Growth.ROLES[role][n]]
		if spot and item then
			local m = item.build(item.size * spot.sc)
			m.Name = "L" .. (n + 1)
			m:PivotTo(model:GetPivot() * spot.cf)
			m.Parent = props
		end
	end
	props.Parent = model
	return props
end

-- The upgrade moment: grow from 5% with a Back bounce about the piece's ground point.
function Growth.pop(piece: Instance?, delay: number?)
	if not piece or not piece:IsA("Model") then return end
	local m = piece :: Model
	local full = m:GetScale()
	local v = Instance.new("NumberValue")
	v.Value = 0.05
	m:ScaleTo(full * 0.05)
	v.Changed:Connect(function(k) m:ScaleTo(full * math.max(k, 0.01)) end)
	local tw = TweenService:Create(v, TweenInfo.new(0.35, Enum.EasingStyle.Back, Enum.EasingDirection.Out,
		0, false, delay or 0), { Value = 1 })
	tw.Completed:Once(function() m:ScaleTo(full); v:Destroy() end)
	tw:Play()
	-- add: a sparkle ParticleEmitter:Emit(12) at the piece, a "Level N!" float, a sound, a small shake
end

return Growth