Topic 7 · UI
CSS mock to uGUI
Rebuild a CSS layout in Canvas/uGUI with correct anchors, layout groups and sorting.
CSS mock → Unity uGUI (old UI)
Procedure for remaking an HTML/CSS screen in uGUI (RectTransform,
Image, VerticalLayoutGroup / HorizontalLayoutGroup, engine Shadow).
Not UITK. Not a per-panel C# builder.
Proven on a mobile card game daily-login 6a. Load this when the ask is “remake this CSS/HTML mock in Unity UI.”
1. Decide the scale — once
| Mock kind | Scale |
|---|---|
| The mock is the phone / viewport frame | canvasW / mockW |
| The mock is a component already in canvas units | 1 |
Never invent a third scale. Never mix 1:1 component scale with viewport scale on the same overlay.
canvas = cssPx * (canvasW / mockW)
A tiny static helper that prints numbers is fine. A MenuItem / baker /
ApplyThisPanel that only one overlay can run is not.
2. Copy the CSS tree — do not generate a panel
Same parent/child order as the mock. Flex column → VerticalLayoutGroup.
Flex row → HorizontalLayoutGroup.
- Column VLG stays in control of width:
childControlWidth = true,childForceExpandWidth = true. Title, content row, button row stretch. - Shrink-wrap chips (
paddingaround text, “DAY 3 STREAK”) live in their own full-width row. That row’s HLG is MiddleCenter and expand-off. The chip usesContentSizeFitter(Preferred Size) or a fixedsizeDeltafrom the calculator. - Do not turn the column’s
childControlWidthoff to unstretch one child. Sibling rows go to width 0; with a centre pivot the layout group parks them on a vertical line at the column centre and they overflow to the right. - Do not put the chip itself on stretch anchors
(0,1)-(1,1).sizeDelta.xon a stretch rect is an inset, so420becomes parent+420 (full-card bar, oval coins).
Block rows stretch. Chips, coins, and fixed tiles do not.
3. Chrome
| CSS | uGUI |
|---|---|
| Solid / gradient + radius | Existing 9-slice, or bake once into a generic sprite. spriteMeshType FullRect (4 verts). Capsule: L/R = cap, T/B = 0. |
box-shadow: 0 4px 0 (hard offset) |
UnityEngine.UI.Shadow. CSS +Y is down; Unity Shadow +Y is up. effectDistance = (cssX, -cssY) * scale. |
box-shadow: 0 18px 50px (wide blur) |
Bake. Engine Shadow has no blur. |
inset 0 -3px 0 (pressed lip) |
Dark band on the bottom of the sprite, or a child Image. Engine Shadow is outset only. |
@keyframes / CSS motion |
Later pass: the project’s clip player. Not CSS-in-C#. |
HTML mock folders are often no PNG kit — say so. Bake a generic sprite once if needed, then delete the baker. Do not leave a per-panel MenuItem.
If you flip a PNG on save (Unity vs CSS Y), flip the insets too or the pressed recess lands on top.
4. Overlay canvas
Nested overlay under a Screen Space Overlay root:
AddComponent<Canvas>+GraphicRaycaster- Set
overrideSorting+sortingOrderonly - Never write
renderModeon the root (or flip a child Overlay ↔ World Space). That can rewrite the parent to World Space. Game view becomes camera-clear + a slab; Scene view still looks fine. Full rule: CANVAS-RENDERMODE-PARENT-REWRITE in the CSS mock to uGUI page.
After any canvas MCP write, re-read the root m_RenderMode. Overlay = 0.
5. Show / Hide
A Hide that SizeDelta → (0,0) the frame does not hide children with
their own sizeDelta. Put a CanvasGroup on that same transform and
FadeAlpha it (Show → 1, Hide → 0). Then deactivate the overlay root on
clip complete if that is the existing pattern.
GetComponent<T> ?? AddComponent<T> never adds (Unity fake-null).
var c = GetComponent<T>; if (c == null) c = AddComponent<T>;
Do not guess a serialized tween enum from a YAML int. Read the enum declaration.
6. Motion is a later pass
Freeze the tree and the calculator sizes first. Animation adds clips on that tree. Do not rebuild the overlay, retarget Show/Hide end-values, or change VLG/HLG flags to “make room” for motion.
7. Don't
- A per-panel sprite baker /
Apply This OverlayMenuItem. - A third scale.
- T/B 9-slice on a short capsule (dirt in the middle).
- Bake an inset, flip the PNG, forget to flip Y.
- Disable a column VLG’s width control to shrink one chip.
- Stretch-anchor a chip or a circular coin.
- Write
renderModeon the overlay root. - Rebuild a frozen layout to start the animation pass.
- Skip the end-of-task overlap pass. Dump world corners of every new Graphic; leftover stretch texts and 8px LayoutElements will overlap.
- Draw a check/star/icon with TMP if the font atlas does not have that
glyph. A
*is not a star. Use an Image sprite.
8. Things a mock will lie to you about
A 9-slice bakes ring:radius as a FIXED RATIO
pixelsPerUnitMultiplier scales the whole border region — the corner radius AND
any ring, lip or bevel baked beside it. So an existing card sprite can be
retargeted to a different SIZE but never to a different ring-to-radius ratio.
Land the radius and the ring shrinks to nothing; land the ring and the corners
go round. Check the ratio before reaching for a multiplier; if the mock's
differs, bake a new sprite rather than tuning ppuM until it "looks close".
Bake the new one so ppuM 1 == one sprite pixel per canvas unit (author every band at its canvas-unit size, and set the 9-slice border to cover radius + ring). Then the multiplier stays out of the maths entirely.
Dim alphas do not survive sRGB -> Linear
CSS composites in sRGB. A Linear-space project (URP, and most modern setups)
blends on linearised values, so copying the mock's alpha does not copy the
mock's appearance — it lands far lighter. Measured on a mobile card game: a rgba(12,24,44,.62)
scrim authored at 0.62 left 66-72% of the original screen brightness, where the
CSS look is ~47%; 0.85 was needed to land 48%.
Set a scrim by capturing before/after in the same session and comparing pixel luminance. Never by copying the number, and never by eye — the difference between 66% and 48% is not reliably visible in a screenshot, but it is obvious on device.
The font may not have the mock's punctuation
Designers reach for · × – — freely. A subsetted game font usually has
none of them, and TMP renders a missing glyph as tofu or silently falls back to a
different typeface mid-line. This is the same failure as * for a star, but it
hides better because the surrounding text is fine.
Probe the atlas before transcribing, not after:
fontAsset.characterLookupTable.ContainsKey(0x00B7) // middot
Substitute ASCII (-, x) or use an Image. Check every font the panel uses —
the body and title materials are often different assets.
A sprite's RECT is not its visible extent
Reusing an existing sprite is right (REUSE-BEFORE-BUILDING), but a sprite
authored with transparent padding — decorative discs, anything with a baked drop
shadow — draws far smaller than the rect you size it to. a mobile card game's
Profile_CircleGold is a gold disc occupying 62.5% of its sprite width with
an asymmetric opaque bbox (the shadow). Sized as a ring behind a 56px avatar it
landed inside the avatar and vanished: active, correct sprite, correct rect,
alpha 1, invisible.
Measure before sizing:
Image.open(path).convert("RGBA").getchannel("A").getbbox
If the padding is asymmetric the sprite has a baked shadow and is not a symmetric primitive at all — do not centre anything on it.
A 9-slice whose corner radius equals HALF its rect IS a circle
Before baking a circle, check whether a rounded-rect 9-slice already in the kit
can be one: set ppuM = (2 * spriteBorder) / diameter so the rendered radius is
diameter / 2. The stretchable middle collapses to zero width and only the four
corner quarter-circles draw. Stack two at different diameters and you have a
ring, in the exact colours the mock asks for, with no new asset and no tint
multiplying a baked hue.
9. Scroll viewports, column headers, and tint (uGUI)
A ScrollRect viewport clips with RectMask2D, never a stencil Mask
A stencil Mask on the viewport eats the rows: the symptom is a correct, populated, INVISIBLE
content tree. Two a mobile card game panels shipped empty scroll lists from this.
(UITK is unaffected — it clips with overflow: hidden; its analogous trap is
UITK-IMAGE-NATIVE-OVERFLOW.)
ppuM = spriteBorder / wantedRadiusInCanvasUnits
type = Sliced alone draws the border at 1:1 texture pixels on every rect, so one sprite gives the
same radius everywhere — a 36-unit radius on a 65-unit chip. Set
pixelsPerUnitMultiplier = spriteBorder / wantedRadiusInCanvasUnits per rect.
- A rect shorter than 2× the border loses the shape. The two borders do not fit, Unity compresses
them, and the authored corner is gone. Clamp the wanted radius to
height / 2and recompute. - A button quoting another button at a smaller size must scale the border by the height ratio, or its baked lip and end caps come out heavier than the button it quotes.
- The ratio law transfers to UITK; the property does not (USS uses
-unity-slice-*+-unity-slice-scale).
One white luminance-ramp sprite + tint replaces N authored gradients
When a mock authors the same widget in N gradients, bake ONE white sprite as a luminance ramp (e.g. 1.0 → 0.80, with any lip at its own stop) and tint it — the tint value is the gradient's TOP colour. Nine authored gradients become one asset and one authored colour per row.
Anything that must keep its own colour (a white rim, an already-alpha'd glow) is a SEPARATE untinted
Image: a rim baked into the ramp gets multiplied by the tint, and that is the failure that makes the
ninth one look wrong. (Applies to UITK too — -unity-background-image-tint-color.)
A column-header row is a SECOND layout group — mirror it, one flexible sink
Any width change to a body row must be mirrored in the header, and each screen needs exactly one
flexible column, in the same slot on both. Without a sink the row is exactly full, the slack becomes
dead space on the right, and every column slides off its header a few units at a time. Verify with
world corners (dx = 0 per fixed column), never by eye. (Law transfers to UITK flexbox; flex-grow
replaces LayoutElement.flexibleWidth.)
10. "Make X match Y" is a DIFF task, not a styling task
UI-MATCH-BY-DIFF. When asked to make one element identical to another, enumerate and diff
both, component by component and property by property, BEFORE changing anything. Cherry-picking
the fields you think matter fails every time, because the differences that read as "lumpy" are
never the ones you would guess.
Do it mechanically, in this order:
- Composition first — the component LISTS differ. Walk both subtrees (root + every child) and
list components with their
enabledstate. One will carry an effect the other lacks. Real example: the copy had aShadowthe target did not, and the target carried a disabledOutlinethe copy lacked. Neither is visible in a casual inspector glance, and a disabled component still counts — copy it with its enabled flag and its values. - Then every serialized property, via
SerializedObject+GetIterator/NextVisible(true), not by reading the inspector. Diff as strings and print only the mismatches. - Fix, then re-run the same diff until the only remaining differences are the ones that MUST
differ — position/slot, label text,
onClicktarget,targetGraphic. Anything else surviving is a bug you have not found yet. - Confirm with a screenshot. Matching numbers is not matching pixels.
The silent one is Image.pixelsPerUnitMultiplier. It scales a sliced sprite's border, so two
Images sharing a sprite, a rect and a colour still render different corner radii. Diff type,
fillCenter and ppuM together — same sprite is not same look. (See also the 9-slice ratio rules
in §8.)
Sizing rules do not travel with a moved element. An element authored for a small box carries
padding and font settings sized for it: a fixed fontSize with autosizing OFF stays tiny in a
bigger rect, and the label's rect insets stay at the old box's padding. Copy the target's
enableAutoSizing, fontSizeMin/Max, and the text child's sizeDelta insets — not just the
button's own rect. And when a LayoutGroup is enabled == false, LayoutElement values are
inert and the RECT is what positions things; copy both anyway so the pair stays identical if the
group is ever switched on.
Do not ask which structural or scaling components to bring across — bring them. "Should I also copy the Outline?" is the wrong question when the instruction was make it identical.
11. Aspect ratios - the canvas changes SIZE, not just scale
UGUI-ASPECT-ANCHOR-GAP. With ScaleWithScreenSize + MatchWidthOrHeight, an aspect other than
the reference gives you a canvas that is a different number of units wide and tall - not the
same layout at a different zoom. A layout authored at 2622x1206 (2.17) resolves on a 4:3 tablet,
at match 0.263, to about 2306x1729: 316 units narrower and 523 taller.
Two consequences, and both look like unrelated bugs:
- Elements on different anchors lose exactly that delta from the gap between them. A right-anchored panel and a centre-anchored one give up half the width change each - so a 43-unit clearance on the reference aspect becomes a 48-unit overlap on 4:3 without a single value changing. The fix is to move the centre-anchored side, not to shrink the panel.
- Extra height lands as dead space between rows, because top-anchored, centre-anchored and bottom-anchored content all move apart. The board reads as "too small" when nothing shrank.
Correct it with one component holding an offset + scale multiplier per element, blended by
aspect (InverseLerp(referenceAspect, targetAspect, current)) rather than switched at a threshold
- a 16:9 device loses about half the width and should get about half the correction, and nothing
snaps on rotation. Capture the authored values once, in Awake, and always write
authored + delta * t: reading the live value back compounds the correction on every resize.
Keep the component off [ExecuteAlways] - it writes to other objects' RectTransforms, so a
scene saved while it had run bakes the correction in as the authored layout. Give it an explicit
edit-mode Preview/Restore pair instead, and serialize the captured state so a scene saved
mid-preview is still recoverable.
Solve it against VISIBLE ART bounds, never RectTransforms. Glow rings, dotted borders and drop
shadows routinely run tens of units past their rect (47 in the case that produced this rule), so a
scale-up sized against rects clips every one of them at the screen edge. Union the world bounds of
the active Graphic components with alpha > 0, and search for the largest scale that keeps every
clearance positive - the ceiling is a measurement, not a taste call.
UGUI-AABB-IS-NOT-AN-AXIS. GetWorldCorners returns four corners; the axis-aligned box you build
from them says nothing about the rect's own axes. A rotated panel's sizeDelta.x is on-screen
HEIGHT. Read localEulerAngles before concluding which way anything grows, and compute a
hypothetical size through localToWorldMatrix rather than by adding to the AABB. The tell is
cheap: if the measured box is sizeDelta.y * scale wide and sizeDelta.x * scale tall, it is
turned 90 degrees. Getting this wrong inverts the whole diagnosis - a menu that grows straight up
the right edge reads as one sweeping left across half the screen.
Project pin
A repo may keep one local file for canvas size, mock size, and a frozen panel (seen on a mobile card game). That file does not replace this procedure.
uGUI — canvas, layout & sorting
All shell work also follows TEXT-CHANGES-DO-NOT-RESIZE-THE-SHELL (UI orientation page).
Old-UI Canvas architecture: sorting bands, layout-group knobs that are not the ones you think, and measurements that are lies on an inactive hierarchy.
-
CSS mock → uGUI is a calculator, not a per-panel builder (HARD). Full pipeline: the css to ugui page. Viewport mock scales by
canvasW/mockW; a component already in canvas units is 1:1. Copy the CSS tree into VLG/HLG. Shrink-wrap chips get their own non-expanding row — never disable the column'schildControlWidth.sizeDelta.xon a stretch rect is an inset. Hardbox-shadow→ engineShadow(Y flipped, no blur). Do not ship a MenuItem that only one overlay can run. -
A shrink-wrap chip in a stretching VLG gets its own non-expanding row (HARD). Full write-up: the css to ugui page. Do not set the column's
childControlWidth/childForceExpandWidthfalse to unstretch one child. Sibling rows go to width 0 and overflow from the column centre. Wrap the chip in a full-width row whose HLG is MiddleCenter and expand-off. -
UI Show/Hide that SizeDelta→0 a frame must FadeAlpha a CanvasGroup on that frame. Children with their own
sizeDeltastay visible when the parent rect is 0×0.FadeAlphaprefers CanvasGroup over Image/TMP. Then deactivate the overlay root on clip complete if that is the existing pattern. -
Before "moving" a UI element, find out what its animator actually drives (HARD). An authored
anchoredPositionis only the element's resting place if nothing animates it. Show/Hide clips routinely tween size (sizeDelta → 0is a common authored-collapsed state, not an off-screen position) or alpha, and they usually write absolute end values, so a clip re-asserts its target a beat after the scene loads and silently undoes an inspector edit. Two consequences: the editor's authored values can differ from what players ever see (read the clip's end value, not the RectTransform, to learn the runtime geometry), and an element whose width animates must be anchored/pivoted from the edge you want fixed — a centre pivot slides its contents sideways every time the clip runs. (a mobile card game: the main-menu name row is authored881.94wide but settles at500; a layout judged from the editor was wrong about where its left edge lands in game, and about the name wrapping to two lines at the real width.) -
Never write
renderModeon a Screen Space Overlay root canvas (HARD). Nested overlays (overrideSorting+sortingOrder+GraphicRaycaster) inherit the parent's overlay space and CanvasScaler. AssigningScreenSpaceOverlayon the child detaches it from that scaler — Game view draws 1:1 pixels (blown up), Scene view still shows the authored layout. AssigningWorldSpaceon the child, or flipping the child Overlay ↔ World Space, can rewrite the parent root to World Space (m_RenderMode: 0 → 2) and bake its overlay-zero rect into screen-sized world numbers (pos ~1311,603,sizeDelta ~2622x1206,scale 1). Symptom: Game view is the camera clear color plus a giant slab of one graphic; Scene view still looks like the real menu. Recipe:AddComponent<Canvas>, setoverrideSortingandsortingOrderonly, neverrenderMode. After any canvas MCP write, re-read the root canvasm_RenderModeand confirm it is still0. Game view is the authority, not Scene view. (a mobile card game: daily-login overlay;Canvas - Mainlanded World Space.)
childForceExpand beats flexibleWidth — they are not the same knob
HorizontalLayoutGroup.childForceExpandWidth (and the vertical twin) hands
leftover space to every child in equal shares, regardless of each child's
LayoutElement.flexibleWidth. So a child set to preferredWidth = N,
flexibleWidth = 0 — the normal way to say "this one is a fixed size" — still
stretches, and the fix looks like it did nothing.
flexibleWidth only governs once force-expand is off. Turn it off on any row
that mixes fixed-width and filling children; the filling ones keep flexibleWidth
= 1 and still take the slack.
Verify by reading back rect.width, not by looking — a card 20% too wide in a
row of four reads as "the gap is a bit big".
…and it propagates UPWARD too: the group reports flexible to ITS parent
The same switch changes what the group advertises to the layout above it. A
HorizontalLayoutGroup with childForceExpandHeight = true reports
flexibleHeight >= 1 for itself, because CalcAlongAxis takes the max child
flexible and force-expand floors every child at 1.
So a row you think is inert — its own LayoutElement.flexibleHeight = -1, i.e.
unset — will still fight its parent's other children for surplus space, and win a
share. Symptom: you add a spacer to absorb slack, the spacer measures 0, and the
row silently grew instead.
-1 means "not set", not "zero". Set an explicit flexibleHeight = 0 on the
group's own LayoutElement to say "never absorb". That works because
LayoutElement.layoutPriority (1) outranks LayoutGroup's (0), so the explicit 0
wins the LayoutUtility lookup — without touching childForceExpand, which
you usually still need, since it is what gives the children their size.
A stable panel height: floor it with minHeight, do not pin sizeDelta
For a panel whose content changes (tab switch, list length) and must not resize:
LayoutUtility.GetPreferredHeightreturnsmax(minHeight, preferredHeight), so aLayoutElement.minHeight = Non the same object as theContentSizeFittermakes the fitter resolve toNon every state — while still growing if content ever exceedsN. WritingsizeDelta.y = Nand setting the fitter to Unconstrained clips instead.- The slack still has to land somewhere. With
childAlignment = Upper*an unabsorbed surplus parks below the last child, so anything meant to sit at the bottom (a footer) rides up and down per state. A zero-size child withflexibleHeight = 1placed just before it pins it down.
Layout numbers read off an INACTIVE hierarchy are all lies (HARD)
LayoutUtility.GetPreferredHeight/GetFlexibleHeight/GetMinHeight skip any
ILayoutElement whose component is not isActiveAndEnabled. On a deactivated
panel that is every component, so every query returns 0 — and
LayoutRebuilder.ForceRebuildLayoutImmediate does nothing, leaving rect at
whatever was last serialised.
This does not throw and does not warn. You get a full table of plausible numbers that are pure fiction, and a "verified" conclusion.
UI panels are the common case, because the well-behaved ones ship deactivated and are shown by an animator or a controller. So the default state of the thing you want to measure is exactly the state in which measuring silently fails.
bool was = root.activeSelf;
root.SetActive(true); // REQUIRED before any measurement
LayoutRebuilder.ForceRebuildLayoutImmediate(rt);
Canvas.ForceUpdateCanvases;
LayoutRebuilder.ForceRebuildLayoutImmediate(rt); // twice: nested groups settle on pass 2
/* ...measure... */
root.SetActive(was); // and RESTORE, or you commit a panel that ships open
The tell: every element reports preferred = 0.0 and flexible = 0.00,
including ones you know have an authored LayoutElement. If a whole column reads
zero, you are measuring a corpse — re-run with the root active before believing
anything, including a fix you just "confirmed".
⚠ Edit-mode writes made during Play Mode are discarded on exit, and
EditorSceneManager.MarkSceneDirty throws "This cannot be used during play
mode." — which, if it is the last line of your script, means the mutations above
it already applied to the runtime scene and looked like they worked. Check
Application.isPlaying (or the editor state) BEFORE authoring, not after.
⚠ Driving a view to measure it MUTATES it. Reflection-calling a panel's
Build/Populate/Refresh to set up a measurement is not a read. Those
methods Instantiate from a template, and the ones that do not pool append
without clearing — so N measurement passes leave N copies of every row, and if
you save the scene between passes you have persisted them.
The damage is invisible in numbers: the duplicates stack on top of each other at the same layout slots, so heights still measure correctly and only a screenshot shows twenty overlapping cards.
- Snapshot
childCountper container BEFORE the first drive, and assert it back afterwards. A pre-edit hierarchy dump is the cheapest insurance. - Prefer driving the state over the builder: toggling which child is
active and calling
ForceRebuildLayoutImmediatetests the layout without instantiating anything. - Deduplicate by keeping the FIRST occurrence of each child name — appended copies come after the originals, so this preserves authored instances.
uGUI canvas architecture (a mobile card game / Cross-The-Line)
-
No Canvas means order 0, which is a BAND — not "the bottom". Children without their own
Canvasall share the root's sorting order and are sorted purely by SIBLING INDEX — including full-screen modals. Anything appended at the end of the list silently draws over every modal that has no Canvas of its own. Place a new object on a shared-order canvas by sibling index relative to those modals; a newsortingOrderis usually the wrong tool. -
Keep controller components OFF the UI element chain. Never put custom logic/controller MonoBehaviours on a RectTransform under a Canvas — a controller there entangles its lifecycle and every state/layout mutation with that canvas's dirty→rebuild cycle, so enabling its object (or calling in to mutate children) re-dirties and rebuilds the whole subtree. Host them on a dedicated non-UI
Scripts - Xobject and drive the UI through serialized refs; leave only passive presentation (Image/TMP/CanvasRenderer) on UI nodes. Corollary — the naive relocation trades one bug for another: aScripts - Xobject is always-active, so the relocated controller'sOnEnablefires ONCE at boot, not when the canvas shows. It must (un)subscribe and render on explicit show/hide events from the canvas owner. The fix is event-driven init, not moving the controller back. -
Solve panel geometry in WORLD units when an ancestor is scaled. Authored insets are in LOCAL units, and scaling about the centre walks the edges inward — solve with
worldWidth = local × scaleand treatanchoredPosition.xas the rect centre's offset from the canvas centre. The symptom is a panel that "floats" a fixed gap off its neighbour and no inset value fixes it, because you are editing the wrong space. -
Loop a rotation as a signed DELTA, never an absolute 360. Tween
-360(clockwise) with incremental looping; a tween to an absolute+360endpoint reports as playing and nothing turns, because 0 and 360 are the same quaternion — and the flag people reach for (a FastBeyond360-style option) is fighting the wrong layer. Applies to any quaternion-backed tween: uGUI, UITK transform, DOTween, or a hand-rolled animator. -
TWEEN-KILL-KEY-IS-THE-TWEEN-TARGET (HARD) — kill by the key the tween was created with. DOTween's shortcuts target the COMPONENT they animate (
image.DOFade→ theImage,canvasGroup.DOFade→ theCanvasGroup) andDOTween.Totargets nothing, so an animator that kills by the object its config names (DOTween.Kill(rectTransform)) misses them. The survivor is an infinite loop that outlivesStop/OnDisableand logs "has been destroyed" on the next scene switch. StampSetTarget(key)once in the shared tween-creation path, never per call site, and never silence it with null checks or safe-mode logging. Measured:Kill(rectTransform)killed 0 of a fade loop beforeSetTargetand 1 after.Kill(target)also never reaches a tween nested in aSequence(0 killed), so a loop that must be stoppable plays standalone. -
KILLED-LOOP-RESTARTS-FROM-THE-FROZEN-VALUE — rewind a loop before you kill it.
Killleaves the property wherever the cycle was, and a relative tween (DOFade,DOScale,DOLocalRotate) captures its start value when it starts, so the next play of the same loop yoyos from that frozen value and the authored range shrinks on every stop/replay. Measured: a 0.35..0.75 fade ran 0.452..0.75 after one close/reopen. CallRewindthenKillin the ONE shared kill path (stop, disable, replay), not per panel: the tween restores its own captured start, so a second snapshot (an Awake capture, which also rarely holds scale or rotation) is not needed. Verified: four cycles each 0.35..0.75, closed at exactly 0.35, a spin rewound to 0°, and no destroyed-target warnings when the rewind runs inOnDisableduring a scene unload.
Vendor overlays and dead listeners (a tower-defense game)
- A debug overlay's trigger is a raycast target like any other. SRDebugger's on-screen
trigger (settings default BottomRight, ~110 px) sat on the PLAY button: every real click died
while automation that called
onClick.Invokekept passing. Keep debug triggers off player buttons (TopCenter here) and prove a button withEventSystem.current.RaycastAllat its CENTRE — the top hit must be the button or one of its own children. - Listeners left over from a removed UI framework fire nothing, silently. A persistent
onClickwhose target component was deleted keeps a NULL target — the button looks wired in the Inspector and does nothing at runtime. After removing a framework, grep the scenes for its method names (m_MethodName: Show|Hide) and rewire every hit. - A flow claim needs the player's input path, not the handler. RaycastAll for the hit, plus a
click through the input module — real, or
InputSystem.QueueStateEvent. In an unfocused editor the Mouse device is DISABLED andeditorInputBehaviorInPlayModedrops pointer input: enable the device and setAllDeviceInputAlwaysGoesToGameViewfor the test, restore afterwards, and treat a lost synthetic click as inconclusive (the user clicking other windows resets device state).