ui/levelselectscreen.razor.scss
// Designed in 1080-reference space (ScreenPanel AutoScreenScale = ConsistentHeight).
@import "Theme.scss";
// Node brightness tiers. Beaten = brightest, selectable-unbeaten = dull, locked = dullest.
// Lines invert that contrast: traversable paths are substantial and dark; blocked paths are lighter.
$node-beaten: #dddd99;
$node-beaten-bright: #f8f5a8;
$node-open: #3f7c84;
$node-locked: #2c323d;
$node-border: #1f242c;
$line-on: #2c323d;
$line-off: #4f9aa0;
// Resting glow pulse for the selected node's white frame (see .node.selected). Eases the soft outer
// glow between faint and bright and back, so the highlighted node gently breathes without shifting
// colour. Overridden by :hover, which swaps it for a static glow.
@keyframes node-selected-pulse {
0% {
box-shadow: 0px 0px 8px 1px rgba( $white, 0.35 );
}
50% {
box-shadow: 0px 0px 26px 4px rgba( $white, 0.85 );
}
100% {
box-shadow: 0px 0px 8px 1px rgba( $white, 0.35 );
}
}
@keyframes node-beaten-selected-pulse {
0% {
box-shadow: 0px 0px 8px 1px rgba( $node-beaten, 0.4 );
}
50% {
box-shadow: 0px 0px 26px 4px rgba( $node-beaten-bright, 0.95 );
}
100% {
box-shadow: 0px 0px 8px 1px rgba( $node-beaten, 0.4 );
}
}
@keyframes node-selection-bounce {
0% { transform: scale( 1 ); }
50% { transform: scale( 1 ); }
60% { transform: scale( 1.14 ); }
100% { transform: scale( 1 ); }
}
@keyframes tutorial-node-unlock {
0% { transform: scale( 0.88 ); box-shadow: 0 0 0 0 rgba( $white, 0 ); }
45% { transform: scale( 1.12 ); box-shadow: 0 0 34px 7px rgba( $white, 0.95 ); }
100% { transform: scale( 1 ); box-shadow: 0 0 16px 2px rgba( $node-open, 0.55 ); }
}
@keyframes tutorial-line-unlock {
0% { opacity: 0.25; }
50% { opacity: 1; background-color: $white; }
100% { opacity: 1; background-color: $line-on; }
}
LevelSelectScreen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
// Anchor the square to the TOP of the screen (not centred) so it hugs the screen top with no gap
// above it — the camera's teal play-square fills the full screen height, so the map square must
// start at the very top to line up with it.
justify-content: flex-start;
font-family: $font;
font-smooth: never;
// The fixed play-square region: the map is laid out and clipped inside this.
.square {
position: relative;
width: 1080px;
height: 1080px;
overflow: hidden;
// Catch the wheel over the WHOLE map region, not just on top of a node — otherwise empty map
// space (between/around nodes) has no pointer-events target and the wheel never reaches
// OnMouseWheel, so map scrolling would only work while hovering a node. The footer is a later
// root sibling drawn on top, so it still intercepts the wheel first when hovered.
pointer-events: all;
// Clip the map above the footer. Offset the clip 1px right to align both letterbox edges
// with the rendered playfield: no preview overhang on the left or empty seam on the right.
// .map-content is shifted back by the same amount so node coordinates stay in full 1080-square
// space; only the visible clip window changes.
.clip {
position: absolute;
left: 9px;
top: 9px;
right: 8px;
bottom: 340px; // match the footer height so map content cannot leak beside it
overflow: hidden;
.map-content {
position: absolute;
left: -9px;
top: -9px;
width: 1080px;
height: 1080px;
}
}
.line {
position: absolute;
background-color: $line-off;
&.on {
background-color: $line-on;
}
&.unlocking {
animation: tutorial-line-unlock 0.7s ease-out;
}
}
.node-anchor {
position: absolute;
transform-origin: 50% 50%;
pointer-events: none;
&.selected {
z-index: 2;
animation: node-selection-bounce 0.25s ease-out;
}
&.unlocking {
z-index: 3;
}
}
.node {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
// The face is a LevelNodePreview miniature of the level; clip it to the node bounds.
overflow: hidden;
border: 4px solid $node-border;
transform-origin: 50% 50%;
pointer-events: all;
cursor: blank-hand;
// State now reads from the FRAME (border) colour, since the fill shows the level preview.
&.beaten {
border-color: $node-beaten;
}
// Black frame so open-but-unbeaten nodes stand out against the yellow beaten ones.
&.open {
border-color: #000000;
}
&.locked {
border-color: $node-locked;
}
&.unlocking {
z-index: 3;
animation: tutorial-node-unlock 0.7s ease-out;
}
// The currently-selected node: a thicker WHITE frame with a gently PULSING glow so it's
// obvious which node is chosen, without changing colour away from white. Selection is driven
// by keyboard/click, not hover. Listed BEFORE :hover so hover can still visibly override it.
&.selected {
border-width: 6px;
border-color: $white;
animation: node-selected-pulse 1.3s ease-in-out infinite;
}
// Mouse-over highlights the outline; it does NOT change the selection (only a click or
// keyboard move does). Placed LAST so it wins even over .selected — hovering a selected node
// swaps the resting pulse for a solid, static bright glow, so the border still visibly
// changes on hover/un-hover even while selected.
&:hover {
border-color: $white;
animation: none;
box-shadow: 0px 0px 24px 3px rgba( $white, 0.9 );
}
// A cleared level stays yellow in every interaction state; selection makes it brighter.
&.beaten.selected {
border-color: $node-beaten-bright;
animation: node-beaten-selected-pulse 1.3s ease-in-out infinite;
}
&.beaten:hover {
border-color: $node-beaten-bright;
animation: none;
box-shadow: 0px 0px 24px 3px rgba( $node-beaten-bright, 0.95 );
}
// White wash over an OPEN (selectable, unbeaten) level's preview. Beaten levels become the
// only true-colour thumbnails, and the three states read distinctly at a glance:
// beaten (vivid) -> open (washed out / pale) -> locked (near-black). Washing out instead of
// darkening keeps open nodes clearly separated from the locked tier.
.dim-veil {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
background-color: rgba( $white, 0.3 );
transition: background-color 0.15s ease-out;
}
// Highlighting an open node lifts its veil, so selection/hover previews the level in its
// true colours (and the saturation change itself is extra selection feedback).
&.selected .dim-veil,
&:hover .dim-veil {
background-color: rgba( $white, 0 );
}
// Dark veil over a locked level's preview so it still reads as locked.
.lock-veil {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
background-color: rgba( $black, 0.9 );
}
// Shared black semi-transparent treatment for the padlock AND the forced-character
// silhouette that replaces it on locked forced-character levels (.lock-char geometry +
// image come inline from the razor; the tint turns the coloured sprite into a shape).
.lock-icon,
.lock-char {
position: absolute;
pointer-events: none;
background-tint: #000000;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
image-rendering: pixelated;
opacity: 0.8;
}
.lock-icon {
left: 20%;
top: 20%;
width: 60%;
height: 60%;
background-image: url( "sprites/ui/level_lock.png" );
}
}
// Soft baked oval behind the character marker (same asset as the picker / leaderboard
// .icon-glow). Anchored per NODE, not to the moving selector — geometry and the faded
// opacity come inline from the razor (see NodeGlowStyle / TickNodeGlows).
.node-glow {
position: absolute;
pointer-events: none;
background-image: url( "sprites/ui/icon_glow.png" );
background-repeat: no-repeat;
background-size: 100% 100%;
}
// Character-sprite marker for the highlighted node (was the smiling cube). The background image,
// position, and scale are set inline by the razor. Drawn after the nodes so it sits above them;
// non-interactive so it never swallows clicks or hover on the node beneath it.
.forced-character,
.selector {
position: absolute;
pointer-events: none;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
image-rendering: pixelated;
}
.forced-character {
opacity: 0.45;
z-index: 1;
}
.selector {
z-index: 2;
background-position: center;
image-rendering: pixelated;
}
// Top bar: the highlighted level's tag + name. Opaque so nodes scrolling under it stay
// masked. The visible square is inset 9px; overlap both letterbox edges by 3 reference
// pixels so fractional scaling cannot expose a thin strip of the playfield beside the bar.
.header {
position: absolute;
top: 0;
left: 7px;
right: 7px;
height: 134px;
pointer-events: none;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
background-color: $black;
// Above the map content so nodes (and their corner tags) scrolling under the bar stay masked.
z-index: 30;
.current {
flex-direction: row;
align-items: center;
gap: 24px;
// The level's map tag (e.g. "2B"): darker + slightly transparent so it reads as a
// secondary label next to the white level name.
.tag {
font-size: 40px;
color: rgba( #8a97a5, 0.65 );
}
.name {
font-size: 44px;
color: $white;
}
}
}
// Bottom bar moved OUT of .square (see the .footer rule at the LevelSelectScreen level) so its
// interactive dropdowns/replay sit in the same simple context as the home button.
}
// Footer: a root-level child (NOT nested in the square / its overflow clip) anchored to span the
// centred square's bottom edge (left:50% + negative half-width margin — s&box's Yoga layout does NOT
// support auto-margin centering on absolute panels, it collapses to the left). Width matches the
// VISIBLE teal square, which is inset 9px per side — the stage's edge blockers tuck WALL_SIZE
// (2 world px = 9 ref px) under where the walls would be, and menu stages spawn no walls. Sized
// The left edge overdraws 1px into the blocker strip, matching the map clip and header; its right
// edge remains at the exact 9px boundary. This prevents fractional scaling from exposing a teal
// seam on the left without widening the footer into the right blocker.
// Structured like the High Scores page: controls in normal flow at the top, then a flex-grow list
// of rows below.
.footer {
position: absolute;
left: 50%;
margin-left: -532px;
bottom: 0;
width: 1063px;
height: 340px;
pointer-events: all;
flex-direction: column;
align-items: stretch;
padding: 16px 40px;
background-color: $black;
// Above the map content so nodes (and their corner tags) scrolling under the bar stay masked.
z-index: 30;
.foot-controls {
flex-direction: row;
justify-content: center;
gap: 48px;
flex-shrink: 0;
z-index: 20; // keep the open dropdown menu above the leaderboard rows below it
// Fixed width so the dropdown stretches to it and its value stays on one line — a
// width-less dropdown collapses to vertical text.
.foot-control {
flex-direction: column;
width: 240px;
}
// Trophy shortcut to the full High Scores screen. Shared .square-btn look, shrunk to the
// dropdown header's 48px height so the row lines up; the 4px borders leave exactly the
// 40px icon inside. Same neutral teal accent as the home button (plain navigation).
.board-button {
width: 48px;
height: 48px;
border-color: $node-open;
&:hover {
border-color: $white;
}
}
}
.foot-list {
position: relative; // anchor for the absolutely-positioned virtual rows + scrollbar
flex-grow: 1;
flex-direction: column;
margin-top: 14px;
overflow: hidden; // clip rows outside the viewport (we render a slice ourselves)
pointer-events: all; // needed so the wheel reaches OnMouseWheel
.foot-info {
font-size: 30px;
color: $white;
opacity: 0.7;
align-self: center;
margin-top: 20px;
}
// Grabbable scroll thumb; top + height are set inline by the virtualizer. Square, pixel
// style, tucked to the right edge (overflow:hidden on .foot-list clips it in bounds).
.foot-scrollbar {
position: absolute;
right: 2px;
width: 12px;
background-color: rgba( 248, 245, 230, 0.35 );
border-radius: 0;
pointer-events: all;
cursor: blank-hand;
&:hover {
background-color: rgba( 248, 245, 230, 0.55 );
}
}
// Each row is a fixed-height line placed by an inline top (the virtualizer); the shared
// LeaderboardRow fills + centres within it.
.foot-row {
position: absolute;
left: 0;
right: 0;
flex-direction: row;
align-items: center;
height: 60px;
background-color: rgba(0, 0, 0, 0.12);
&.even { background-color: rgba(0, 0, 0, 0.22); }
}
}
}
// Editor-only square shortcut in the bottom-left letterbox. Look comes from the shared
// .square-btn (Theme.scss); only position (and accents) live here.
.editor-button {
position: absolute;
bottom: 40px;
left: 40px;
}
// Home button in the bottom-right letterbox (outside the centred square) — returns to the title.
// Mirrors the in-game HUD's quit button geometry, with a neutral teal accent (plain navigation,
// not a hard exit).
.home-button {
position: absolute;
bottom: 40px;
right: 40px;
border-color: $node-open;
&:hover {
border-color: $white;
}
}
// Character picker inside the centred 1080px play square's upper-right corner.
.character-slot {
position: absolute;
z-index: 1000;
right: 50%;
margin-right: -512px;
top: 8px;
align-items: flex-end;
}
}