feat(art): integrate generated pixel-art assets for sprites and scenes
Replace procedural pixel art with pre-generated PNGs for player, princess, guards (8 types + suspicion tiers), tiles, keys, stones, and scenes. Add src/lib/generated-assets.js as the central path registry and update all sprite/scene components to consume it.
No files matched your search
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -1,17 +1,8 @@
|
||||
<script>
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import {
|
||||
TILE_EMPTY, TILE_EMPTY_PAL,
|
||||
TILE_WALL, TILE_WALL_PAL,
|
||||
TILE_GOAL, TILE_GOAL_PAL,
|
||||
TILE_LIT, TILE_LIT_PAL,
|
||||
TILE_PREVIEW, TILE_PREVIEW_PAL,
|
||||
} from '../lib/pixel/art-tiles.js';
|
||||
import { GENERATED_TILES } from '../lib/generated-assets.js';
|
||||
|
||||
// Key color palette by keyId: 1=gold, 2=silver, 3=copper
|
||||
const KEY_COLORS = { 1: '#d4af37', 2: '#c0c0c0', 3: '#b87333' };
|
||||
// One-way arrow glyphs by numeric direction encoding (0=up,1=right,2=down,3=left)
|
||||
const ONEWAY_ARROWS = { 0: '↑', 1: '→', 2: '↓', 3: '←' };
|
||||
// Human-readable direction names for ARIA
|
||||
const DIR_NAMES = { 0: 'up', 1: 'right', 2: 'down', 3: 'left' };
|
||||
|
||||
@@ -49,16 +40,15 @@
|
||||
|
||||
// Resolve the base tile art/palette. Priority: lit > door > goal > wall > empty.
|
||||
// Key, one-way, warm overlays are rendered on top of the base tile separately.
|
||||
function tileFor(cell) {
|
||||
if (cell.isLight) return { art: TILE_LIT, pal: TILE_LIT_PAL };
|
||||
if (cell.isGoal) return { art: TILE_GOAL, pal: TILE_GOAL_PAL };
|
||||
if (cell.isWall) return { art: TILE_WALL, pal: TILE_WALL_PAL };
|
||||
return { art: TILE_EMPTY, pal: TILE_EMPTY_PAL };
|
||||
function tileSrcFor(cell) {
|
||||
if (cell.isLight) return GENERATED_TILES.lit;
|
||||
if (cell.isGoal) return GENERATED_TILES.goal;
|
||||
if (cell.isWall) return GENERATED_TILES.wall;
|
||||
return GENERATED_TILES.empty;
|
||||
}
|
||||
|
||||
// Border color for door overlay (keyed by keyId)
|
||||
function doorBorderColor(keyId) {
|
||||
return KEY_COLORS[keyId] ?? '#888888';
|
||||
function oneWayRotation(dir) {
|
||||
return `${((dir ?? 1) - 1) * 90}deg`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -69,7 +59,7 @@
|
||||
style="grid-template-columns: repeat({cols}, {cellSize}px); grid-template-rows: repeat({rows}, {cellSize}px);"
|
||||
>
|
||||
{#each cells as cell (cell.row * cols + cell.col)}
|
||||
{@const tile = tileFor(cell)}
|
||||
{@const tileSrc = tileSrcFor(cell)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_interactive_supports_focus -->
|
||||
<div
|
||||
tabindex="-1"
|
||||
@@ -79,51 +69,54 @@
|
||||
class:detected-flash={isDetected(cell)}
|
||||
class:throw-target={isThrowTarget(cell)}
|
||||
class:throw-cursor={isThrowCursor(cell)}
|
||||
class:lit={cell.isLight}
|
||||
class:warm={cell.isWarm && !cell.isLight}
|
||||
class:key-cell={cell.isKey}
|
||||
class:door-cell={cell.isDoor}
|
||||
class:oneway-cell={cell.isOneWay}
|
||||
role="gridcell"
|
||||
aria-label={cellLabel(cell)}
|
||||
onclick={() => oncellclick?.(cell.row, cell.col)}
|
||||
>
|
||||
<Pixel art={tile.art} palette={tile.pal} width={cellSize} height={cellSize} />
|
||||
<img class="tile-image" src={tileSrc} alt="" draggable="false" />
|
||||
|
||||
<!-- Preview overlay (turn-preview ghost) -->
|
||||
{#if previewCells.has(`${cell.row},${cell.col}`) && !cell.isLight}
|
||||
<div class="preview-overlay">
|
||||
<Pixel art={TILE_PREVIEW} palette={TILE_PREVIEW_PAL} width={cellSize} height={cellSize} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Door overlay: colored border + padlock glyph -->
|
||||
{#if cell.isDoor}
|
||||
<div
|
||||
class="door-overlay"
|
||||
style="border-color: {doorBorderColor(cell.doorKeyId)}; box-shadow: inset 0 0 0 2px {doorBorderColor(cell.doorKeyId)};"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="door-glyph">🔒</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Key overlay: colored circle + key glyph -->
|
||||
{#if cell.isKey}
|
||||
<div
|
||||
class="key-overlay"
|
||||
style="background: radial-gradient(circle, {KEY_COLORS[cell.keyId] ?? '#888'}44 60%, transparent 100%);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="key-glyph" style="color: {KEY_COLORS[cell.keyId] ?? '#888'};">🗝</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- One-way overlay: arrow glyph in allowed-entry direction -->
|
||||
{#if cell.isOneWay}
|
||||
<div class="oneway-overlay" aria-hidden="true">
|
||||
<span class="oneway-arrow">{ONEWAY_ARROWS[cell.oneWayDir] ?? '?'}</span>
|
||||
<div class="preview-corners"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Warm cell overlay: dim orange glow (distinct from yellow lit cells) -->
|
||||
{#if cell.isWarm && !cell.isLight}
|
||||
<div class="warm-overlay" aria-hidden="true"></div>
|
||||
<div class="tile-overlay warm-overlay" aria-hidden="true">
|
||||
<img class="tile-image" src={GENERATED_TILES.warm} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- One-way overlay: pixel arrow in allowed-entry direction -->
|
||||
{#if cell.isOneWay}
|
||||
<div
|
||||
class="tile-overlay oneway-overlay"
|
||||
style="transform: rotate({oneWayRotation(cell.oneWayDir)});"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img class="tile-image" src={GENERATED_TILES.oneway} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Door overlay: color keyed by matching keyId -->
|
||||
{#if cell.isDoor}
|
||||
<div class="tile-overlay door-overlay" aria-hidden="true">
|
||||
<img class="tile-image" src={GENERATED_TILES.doors[cell.doorKeyId] ?? GENERATED_TILES.doors[1]} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Key overlay: color keyed to matching door -->
|
||||
{#if cell.isKey}
|
||||
<div class="tile-overlay key-overlay" aria-hidden="true">
|
||||
<img class="key-image" src={GENERATED_TILES.keys[cell.keyId] ?? GENERATED_TILES.keys[1]} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Throw targeting overlays -->
|
||||
@@ -149,6 +142,28 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.cell.wall { cursor: default; }
|
||||
.cell.lit .tile-image {
|
||||
animation: lit-tile-pulse 900ms steps(3, end) infinite;
|
||||
}
|
||||
.cell.warm .tile-image {
|
||||
animation: warm-tile-breathe 1.2s steps(3, end) infinite;
|
||||
}
|
||||
.cell.door-cell .door-overlay {
|
||||
animation: door-lock-glint 1.7s steps(4, end) infinite;
|
||||
}
|
||||
.cell.key-cell .key-overlay {
|
||||
animation: key-bob 1s steps(3, end) infinite;
|
||||
}
|
||||
.cell.oneway-cell .oneway-overlay {
|
||||
animation: oneway-nudge 1.1s steps(3, end) infinite;
|
||||
}
|
||||
.tile-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Preview ghost overlay */
|
||||
.preview-overlay {
|
||||
@@ -157,6 +172,18 @@
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.preview-corners {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 3px solid rgba(255, 234, 0, 0.65);
|
||||
clip-path: polygon(
|
||||
0 0, 28% 0, 28% 8%, 8% 8%, 8% 28%, 0 28%,
|
||||
0 72%, 8% 72%, 8% 92%, 28% 92%, 28% 100%, 0 100%,
|
||||
72% 100%, 72% 92%, 92% 92%, 92% 72%, 100% 72%,
|
||||
100% 28%, 92% 28%, 92% 8%, 72% 8%, 72% 0, 100% 0,
|
||||
100% 100%, 0 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Detection flash animation */
|
||||
.cell.detected-flash {
|
||||
@@ -166,64 +193,58 @@
|
||||
0% { filter: brightness(2) hue-rotate(-60deg); }
|
||||
100% { filter: none; }
|
||||
}
|
||||
|
||||
/* ── Door overlay ── */
|
||||
.door-overlay {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 3px solid; /* color set inline */
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
@keyframes lit-tile-pulse {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
50% { filter: brightness(1.22) saturate(1.1); }
|
||||
}
|
||||
.door-glyph {
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.8));
|
||||
@keyframes warm-tile-breathe {
|
||||
0%, 100% { opacity: 0.82; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes door-lock-glint {
|
||||
0%, 100% { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.75)) brightness(1); }
|
||||
50% { filter: drop-shadow(0 0 5px rgba(255, 244, 214, 0.35)) brightness(1.08); }
|
||||
}
|
||||
@keyframes key-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
@keyframes oneway-nudge {
|
||||
0%, 100% { translate: 0 0; opacity: 0.82; }
|
||||
50% { translate: 2px 0; opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Key overlay ── */
|
||||
.key-overlay {
|
||||
.tile-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.key-glyph {
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 1px 3px rgba(0,0,0,0.9));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
/* ── One-way overlay ── */
|
||||
.oneway-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(100, 180, 255, 0.08);
|
||||
}
|
||||
.oneway-arrow {
|
||||
font-size: 1.3em;
|
||||
color: rgba(140, 210, 255, 0.75);
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
/* ── Warm cell overlay (distinct orange from yellow isLight) ── */
|
||||
.warm-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(255, 140, 0, 0.25);
|
||||
border-radius: 1px;
|
||||
opacity: 0.9;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.oneway-overlay {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.door-overlay {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.75));
|
||||
}
|
||||
|
||||
.key-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
.key-image {
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* ── Throw targeting rings ── */
|
||||
@@ -248,4 +269,12 @@
|
||||
from { opacity: 0.7; }
|
||||
to { opacity: 1.0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cell .tile-image,
|
||||
.tile-overlay,
|
||||
.key-overlay,
|
||||
.throw-cursor-ring {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script>
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import SuspicionRing from './SuspicionRing.svelte';
|
||||
import { GUARD_SPRITES } from '../lib/pixel/art-characters.js';
|
||||
import { GENERATED_CHARACTERS } from '../lib/generated-assets.js';
|
||||
|
||||
let { guard, cellSize = 50, allCells = [] } = $props();
|
||||
|
||||
@@ -10,12 +9,16 @@
|
||||
let size = $derived(Math.floor(cellSize * 0.95));
|
||||
let rotation = $derived(guard.direction * 90);
|
||||
let hasDirection = $derived(
|
||||
guard.type === 'rotating' || guard.type === 'patrolling' || guard.type === 'chaser'
|
||||
);
|
||||
let sprite = $derived(GUARD_SPRITES[guard.type]);
|
||||
let activePal = $derived(
|
||||
guard.type === 'blinking' && !guard.isOn ? sprite?.palOff : sprite?.pal
|
||||
guard.type === 'rotating' || guard.type === 'patrolling' || guard.type === 'chaser' || guard.type === 'sniper'
|
||||
);
|
||||
let spriteSrc = $derived.by(() => {
|
||||
if (guard.type === 'suspicion') {
|
||||
if ((guard.tier ?? 0) >= 2) return GENERATED_CHARACTERS.guards.suspicion.fire;
|
||||
if ((guard.tier ?? 0) === 1) return GENERATED_CHARACTERS.guards.suspicion.alert;
|
||||
return GENERATED_CHARACTERS.guards.suspicion.calm;
|
||||
}
|
||||
return GENERATED_CHARACTERS.guards[guard.type];
|
||||
});
|
||||
|
||||
// Sniper: compute beam cells visually by re-walking beam logic from guard position.
|
||||
// NOTE: this duplicates SniperGuard._castBeam logic. If engine beam logic changes,
|
||||
@@ -58,29 +61,6 @@
|
||||
guard.type === 'sniper' ? computeBeamCells(guard, allCells) : []
|
||||
);
|
||||
|
||||
// Triangle polygon points for sniper sprite, pointing in `facing` direction
|
||||
// Triangle is drawn within a [0,size] × [0,size] box
|
||||
function sniperTrianglePoints(sz, dir) {
|
||||
const h = sz;
|
||||
const w = sz;
|
||||
const m = sz / 2;
|
||||
// Points: (tip, base-left, base-right) depending on direction
|
||||
switch (dir) {
|
||||
case 0: return `${m},2 2,${h - 2} ${w - 2},${h - 2}`; // up
|
||||
case 1: return `${w - 2},${m} 2,2 2,${h - 2}`; // right
|
||||
case 2: return `${m},${h - 2} 2,2 ${w - 2},2`; // down
|
||||
case 3: return `2,${m} ${w - 2},2 ${w - 2},${h - 2}`; // left
|
||||
default: return `${m},2 2,${h - 2} ${w - 2},${h - 2}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Suspicion tier color for the guard body tint
|
||||
function suspicionColor(tier) {
|
||||
if (tier >= 2) return '#ff3300';
|
||||
if (tier === 1) return '#ffcc00';
|
||||
return '#8844aa'; // idle purple
|
||||
}
|
||||
|
||||
// Human-readable ARIA label for the guard sprite
|
||||
let ariaLabel = $derived((() => {
|
||||
switch (guard.type) {
|
||||
@@ -101,6 +81,8 @@
|
||||
class="guard {guard.type}"
|
||||
class:off={guard.type === 'blinking' && !guard.isOn}
|
||||
class:alert={guard.type === 'chaser' && guard.isChasing}
|
||||
class:tier-alert={guard.type === 'suspicion' && (guard.tier ?? 0) === 1}
|
||||
class:tier-fire={guard.type === 'suspicion' && (guard.tier ?? 0) >= 2}
|
||||
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
@@ -129,47 +111,30 @@
|
||||
stroke-dasharray="5 4"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Triangle body pointing in facing direction -->
|
||||
<polygon
|
||||
points={sniperTrianglePoints(size, facing)}
|
||||
fill="#cc3300"
|
||||
stroke="#ff6600"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<!-- Eye dot at center -->
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2}
|
||||
r={size * 0.08}
|
||||
fill="#ffaa00"
|
||||
/>
|
||||
</svg>
|
||||
{#if spriteSrc}
|
||||
<div class="sprite-motion">
|
||||
<img class="sprite-image" src={spriteSrc} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="direction-indicator" style="transform: rotate({rotation}deg);"></div>
|
||||
|
||||
<!-- ── Suspicion guard: circle with tier ring overlay ── -->
|
||||
{:else if guard.type === 'suspicion'}
|
||||
<svg width={size} height={size} viewBox="0 0 {size} {size}" aria-hidden="true">
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2}
|
||||
r={size * 0.38}
|
||||
fill={suspicionColor(guard.tier ?? 0)}
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Suspicion level dots -->
|
||||
{#if (guard.tier ?? 0) >= 1}
|
||||
<circle cx={size / 2} cy={size * 0.22} r={size * 0.07} fill="#fff" opacity="0.9" />
|
||||
{/if}
|
||||
{#if (guard.tier ?? 0) >= 2}
|
||||
<circle cx={size * 0.3} cy={size * 0.72} r={size * 0.07} fill="#fff" opacity="0.9" />
|
||||
<circle cx={size * 0.7} cy={size * 0.72} r={size * 0.07} fill="#fff" opacity="0.9" />
|
||||
{/if}
|
||||
</svg>
|
||||
{#if spriteSrc}
|
||||
<div class="sprite-motion">
|
||||
<img class="sprite-image" src={spriteSrc} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- SuspicionRing overlay positioned over this sprite -->
|
||||
<SuspicionRing tier={guard.tier ?? 0} {size} />
|
||||
|
||||
<!-- ── Standard guard types (existing behavior unchanged) ── -->
|
||||
{:else}
|
||||
{#if sprite}
|
||||
<Pixel art={sprite.art} palette={activePal} width={size} height={size} />
|
||||
{#if spriteSrc}
|
||||
<div class="sprite-motion">
|
||||
<img class="sprite-image" src={spriteSrc} alt="" draggable="false" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if hasDirection}
|
||||
<div class="direction-indicator" style="transform: rotate({rotation}deg);"></div>
|
||||
@@ -191,6 +156,56 @@
|
||||
.guard.off {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.sprite-motion {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: 50% 84%;
|
||||
animation: guard-idle 1.7s steps(2, end) infinite;
|
||||
}
|
||||
.sprite-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
.guard.static .sprite-motion {
|
||||
animation: tomato-wilt 2.2s steps(3, end) infinite;
|
||||
}
|
||||
.guard.rotating .sprite-motion {
|
||||
animation: blueberry-scan 1.1s steps(4, end) infinite;
|
||||
}
|
||||
.guard.blinking .sprite-motion {
|
||||
animation: corn-blink 1s steps(2, end) infinite;
|
||||
}
|
||||
.guard.blinking.off .sprite-motion {
|
||||
animation: corn-off 1.2s steps(2, end) infinite;
|
||||
filter: saturate(0.75) brightness(0.72);
|
||||
}
|
||||
.guard.patrolling .sprite-motion {
|
||||
animation: patrol-step 600ms steps(2, end) infinite;
|
||||
}
|
||||
.guard.mirror .sprite-motion {
|
||||
animation: mirror-shimmer 1.6s steps(3, end) infinite;
|
||||
}
|
||||
.guard.chaser .sprite-motion {
|
||||
animation: pumpkin-stomp 850ms steps(3, end) infinite;
|
||||
}
|
||||
.guard.chaser.alert .sprite-motion {
|
||||
animation: pumpkin-chase 420ms steps(3, end) infinite;
|
||||
}
|
||||
.guard.sniper .sprite-motion {
|
||||
animation: sniper-breathe 1.8s steps(2, end) infinite;
|
||||
}
|
||||
.guard.suspicion .sprite-motion {
|
||||
animation: onion-watch 1.5s steps(2, end) infinite;
|
||||
}
|
||||
.guard.suspicion.tier-alert .sprite-motion {
|
||||
animation: onion-alert 700ms steps(2, end) infinite;
|
||||
}
|
||||
.guard.suspicion.tier-fire .sprite-motion {
|
||||
animation: onion-fire 360ms steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.direction-indicator {
|
||||
position: absolute;
|
||||
@@ -203,5 +218,84 @@
|
||||
transform-origin: bottom center;
|
||||
translate: -50% 0;
|
||||
}
|
||||
.guard.rotating .direction-indicator {
|
||||
animation: beam-tick 1s steps(4, end) infinite;
|
||||
}
|
||||
.guard.sniper .direction-indicator {
|
||||
height: 46%;
|
||||
background: #ffb24a;
|
||||
animation: sniper-aim-pulse 900ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes guard-idle {
|
||||
0%, 100% { transform: translateY(0) scaleY(1); }
|
||||
50% { transform: translateY(-1px) scaleY(1.02); }
|
||||
}
|
||||
@keyframes tomato-wilt {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); filter: brightness(1); }
|
||||
50% { transform: translateY(1px) rotate(-1deg); filter: brightness(0.9); }
|
||||
}
|
||||
@keyframes blueberry-scan {
|
||||
0%, 100% { transform: translateX(0); filter: drop-shadow(0 0 0 rgba(68, 136, 255, 0)); }
|
||||
25% { transform: translateX(1px); filter: drop-shadow(0 0 5px rgba(68, 136, 255, 0.35)); }
|
||||
75% { transform: translateX(-1px); filter: drop-shadow(0 0 5px rgba(68, 136, 255, 0.35)); }
|
||||
}
|
||||
@keyframes corn-blink {
|
||||
0%, 100% { filter: brightness(1.05); }
|
||||
50% { filter: brightness(1.28) drop-shadow(0 0 5px rgba(255, 221, 68, 0.45)); }
|
||||
}
|
||||
@keyframes corn-off {
|
||||
0%, 100% { opacity: 0.72; }
|
||||
50% { opacity: 0.56; }
|
||||
}
|
||||
@keyframes patrol-step {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
25% { transform: translateY(-2px) rotate(-1deg); }
|
||||
75% { transform: translateY(-2px) rotate(1deg); }
|
||||
}
|
||||
@keyframes mirror-shimmer {
|
||||
0%, 100% { filter: brightness(1) drop-shadow(0 2px 2px rgba(0, 0, 0, 0.8)); }
|
||||
50% { filter: brightness(1.18) drop-shadow(0 0 7px rgba(170, 235, 255, 0.55)); }
|
||||
}
|
||||
@keyframes pumpkin-stomp {
|
||||
0%, 100% { transform: translateY(0) scaleX(1); }
|
||||
50% { transform: translateY(-2px) scaleX(1.03); }
|
||||
}
|
||||
@keyframes pumpkin-chase {
|
||||
0%, 100% { transform: translateY(0) scaleX(1.04); }
|
||||
50% { transform: translateY(-4px) scaleX(1.08); }
|
||||
}
|
||||
@keyframes sniper-breathe {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(1px); }
|
||||
}
|
||||
@keyframes onion-watch {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1px); }
|
||||
}
|
||||
@keyframes onion-alert {
|
||||
0%, 100% { transform: translateY(0) scale(1); filter: brightness(1.05); }
|
||||
50% { transform: translateY(-2px) scale(1.04); filter: brightness(1.25); }
|
||||
}
|
||||
@keyframes onion-fire {
|
||||
0%, 100% { transform: translateX(0) scale(1.02); filter: brightness(1.2); }
|
||||
33% { transform: translateX(-2px) scale(1.06); filter: brightness(1.45); }
|
||||
66% { transform: translateX(2px) scale(1.06); filter: brightness(1.45); }
|
||||
}
|
||||
@keyframes beam-tick {
|
||||
0%, 100% { opacity: 0.75; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes sniper-aim-pulse {
|
||||
from { opacity: 0.55; box-shadow: 0 0 2px rgba(255, 178, 74, 0.4); }
|
||||
to { opacity: 1; box-shadow: 0 0 7px rgba(255, 178, 74, 0.75); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.guard,
|
||||
.sprite-motion,
|
||||
.direction-indicator {
|
||||
transition: none;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,13 +3,12 @@
|
||||
// keysHeld is a bitmask: bit 0 = key 1 (gold), bit 1 = key 2 (silver), bit 2 = key 3 (copper).
|
||||
// Only mounted when level.keys?.length > 0.
|
||||
import { getText } from '../lib/localization.js';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { TILE_KEY, TILE_KEY_GOLD_PAL, TILE_KEY_SILVER_PAL, TILE_KEY_COPPER_PAL } from '../lib/pixel/art-tiles.js';
|
||||
import { GENERATED_TILES } from '../lib/generated-assets.js';
|
||||
|
||||
const KEY_DEFS = [
|
||||
{ id: 1, color: '#d4af37', pal: TILE_KEY_GOLD_PAL },
|
||||
{ id: 2, color: '#c0c0c0', pal: TILE_KEY_SILVER_PAL },
|
||||
{ id: 3, color: '#b87333', pal: TILE_KEY_COPPER_PAL },
|
||||
{ id: 1, color: '#d4af37', src: GENERATED_TILES.keys[1] },
|
||||
{ id: 2, color: '#c0c0c0', src: GENERATED_TILES.keys[2] },
|
||||
{ id: 3, color: '#b87333', src: GENERATED_TILES.keys[3] },
|
||||
];
|
||||
|
||||
let { keysHeld = 0 } = $props();
|
||||
@@ -33,7 +32,7 @@
|
||||
aria-label={k.label}
|
||||
>
|
||||
<span class="key-icon" aria-hidden="true">
|
||||
<Pixel art={TILE_KEY} palette={k.pal} scale={1.5} />
|
||||
<img src={k.src} alt="" draggable="false" />
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -61,4 +60,10 @@
|
||||
align-items: center;
|
||||
line-height: 0;
|
||||
}
|
||||
.key-icon img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,46 @@
|
||||
<script>
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { RABBIT_ART, RABBIT_PAL } from '../lib/pixel/art-characters.js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { GENERATED_CHARACTERS } from '../lib/generated-assets.js';
|
||||
let { row = 0, col = 0, cellSize = 50, shake = false } = $props();
|
||||
let top = $derived(row * cellSize + cellSize / 2);
|
||||
let left = $derived(col * cellSize + cellSize / 2);
|
||||
let size = $derived(Math.floor(cellSize * 0.9));
|
||||
|
||||
let moving = $state(false);
|
||||
let facing = $state('right');
|
||||
let lastRow = $state(null);
|
||||
let lastCol = $state(null);
|
||||
let moveTimer;
|
||||
|
||||
$effect(() => {
|
||||
if (lastRow === null || lastCol === null) {
|
||||
lastRow = row;
|
||||
lastCol = col;
|
||||
return;
|
||||
}
|
||||
if (row === lastRow && col === lastCol) return;
|
||||
if (col < lastCol) facing = 'left';
|
||||
else if (col > lastCol) facing = 'right';
|
||||
moving = true;
|
||||
clearTimeout(moveTimer);
|
||||
moveTimer = setTimeout(() => moving = false, 180);
|
||||
lastRow = row;
|
||||
lastCol = col;
|
||||
});
|
||||
|
||||
onDestroy(() => clearTimeout(moveTimer));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="player"
|
||||
class:shake
|
||||
class:moving
|
||||
class:face-left={facing === 'left'}
|
||||
style="top: {top}px; left: {left}px; width: {size}px; height: {size}px;"
|
||||
>
|
||||
<Pixel art={RABBIT_ART} palette={RABBIT_PAL} width={size} height={size} />
|
||||
<div class="sprite-motion">
|
||||
<img class="sprite-image" src={GENERATED_CHARACTERS.player} alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -23,9 +51,37 @@
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sprite-motion {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: 50% 82%;
|
||||
animation: player-idle 1.5s steps(2, end) infinite;
|
||||
}
|
||||
.sprite-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
.player.face-left .sprite-image {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.player.moving .sprite-motion {
|
||||
animation: player-hop 180ms steps(3, end);
|
||||
}
|
||||
.player.shake {
|
||||
animation: player-shake 0.3s ease-out;
|
||||
}
|
||||
@keyframes player-idle {
|
||||
0%, 100% { transform: translateY(0) scaleY(1); }
|
||||
50% { transform: translateY(-1px) scaleY(1.03); }
|
||||
}
|
||||
@keyframes player-hop {
|
||||
0%, 100% { transform: translateY(0) scaleY(1); }
|
||||
40% { transform: translateY(-7px) scaleY(1.04); }
|
||||
70% { transform: translateY(1px) scaleY(0.96); }
|
||||
}
|
||||
@keyframes player-shake {
|
||||
0%, 100% { translate: 0 0; }
|
||||
20% { translate: -4px 0; }
|
||||
@@ -33,4 +89,11 @@
|
||||
60% { translate: -3px 0; }
|
||||
80% { translate: 2px 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.player,
|
||||
.sprite-motion {
|
||||
transition: none;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,8 @@
|
||||
<script>
|
||||
// StonesCounter — HUD pill showing remaining throwable stones.
|
||||
// Only mounted when level.stones > 0.
|
||||
// Uses SVG pixel-art stone icon instead of emoji for consistent rendering.
|
||||
import { getText } from '../lib/localization.js';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { ICON_STONE, ICON_STONE_PAL } from '../lib/pixel/art-tiles.js';
|
||||
import { GENERATED_TILES } from '../lib/generated-assets.js';
|
||||
|
||||
let { stonesLeft = 0 } = $props();
|
||||
|
||||
@@ -13,7 +11,7 @@
|
||||
|
||||
<div class="stones-counter" aria-label={label}>
|
||||
<span class="stone-icon" aria-hidden="true">
|
||||
<Pixel art={ICON_STONE} palette={ICON_STONE_PAL} scale={1.5} />
|
||||
<img src={GENERATED_TILES.stone} alt="" draggable="false" />
|
||||
</span>
|
||||
<span class="stone-times" aria-hidden="true">×</span>
|
||||
<span class="stone-count">{stonesLeft}</span>
|
||||
@@ -37,6 +35,12 @@
|
||||
align-items: center;
|
||||
line-height: 0;
|
||||
}
|
||||
.stone-icon img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.stone-times { color: var(--text-secondary); font-size: 0.75em; }
|
||||
.stone-count { font-weight: bold; min-width: 1ch; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
const BASE = 'assets/generated/';
|
||||
|
||||
export const GENERATED_TILES = {
|
||||
empty: `${BASE}tile-empty.png`,
|
||||
wall: `${BASE}tile-wall.png`,
|
||||
goal: `${BASE}tile-goal.png`,
|
||||
lit: `${BASE}tile-lit.png`,
|
||||
warm: `${BASE}tile-warm.png`,
|
||||
mirror: `${BASE}tile-mirror.png`,
|
||||
oneway: `${BASE}tile-oneway.png`,
|
||||
doors: {
|
||||
1: `${BASE}tile-door-gold.png`,
|
||||
2: `${BASE}tile-door-silver.png`,
|
||||
3: `${BASE}tile-door-copper.png`,
|
||||
},
|
||||
keys: {
|
||||
1: `${BASE}icon-key-gold.png`,
|
||||
2: `${BASE}icon-key-silver.png`,
|
||||
3: `${BASE}icon-key-copper.png`,
|
||||
},
|
||||
stone: `${BASE}icon-stone.png`,
|
||||
};
|
||||
|
||||
export const GENERATED_CHARACTERS = {
|
||||
player: `${BASE}player-rabbit.png`,
|
||||
princess: `${BASE}princess-carrot.png`,
|
||||
guards: {
|
||||
static: `${BASE}guard-tomato.png`,
|
||||
rotating: `${BASE}guard-blueberry.png`,
|
||||
blinking: `${BASE}guard-corn.png`,
|
||||
patrolling: `${BASE}guard-eggplant.png`,
|
||||
mirror: `${BASE}guard-lettuce.png`,
|
||||
chaser: `${BASE}guard-pumpkin.png`,
|
||||
sniper: `${BASE}guard-sniper.png`,
|
||||
suspicion: {
|
||||
calm: `${BASE}guard-suspicion-calm.png`,
|
||||
alert: `${BASE}guard-suspicion-alert.png`,
|
||||
fire: `${BASE}guard-suspicion-fire.png`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GENERATED_SCENES = [
|
||||
`${BASE}scene-garden.png`,
|
||||
`${BASE}scene-garden.png`,
|
||||
`${BASE}scene-walls.png`,
|
||||
`${BASE}scene-walls.png`,
|
||||
`${BASE}scene-fortress.png`,
|
||||
`${BASE}scene-fortress.png`,
|
||||
`${BASE}scene-underground.png`,
|
||||
`${BASE}scene-underground.png`,
|
||||
`${BASE}scene-palace.png`,
|
||||
`${BASE}scene-palace.png`,
|
||||
`${BASE}scene-palace.png`,
|
||||
`${BASE}scene-chamber.png`,
|
||||
];
|
||||
|
||||
export function generatedSceneForLevel(level) {
|
||||
const idx = Math.max(0, Math.min(GENERATED_SCENES.length - 1, level - 1));
|
||||
return GENERATED_SCENES[idx];
|
||||
}
|
||||
@@ -128,20 +128,20 @@ export const TILE_PREVIEW_PAL = { y: NNTV.gridLit };
|
||||
// Base art shape; palette swapped per keyId (gold=1, silver=2, copper=3)
|
||||
export const TILE_DOOR_LOCKED = [
|
||||
'kkkkkkkkkkkkkkkk',
|
||||
'kcccccccccccccc k',
|
||||
'kcdddddddddddcck',
|
||||
'kcdkkkkkkkkkdcck',
|
||||
'kcdkbbbbbbbbkcck',
|
||||
'kcdkbhhbbhhbkck.',
|
||||
'kcdkbhwbbhwbkck.',
|
||||
'kcdkbbbbbbbbkck.',
|
||||
'kcdkbbbbbbbbkck.',
|
||||
'kcdkbbXXbbbbkck.',
|
||||
'kcdkbbXXbbbbkck.',
|
||||
'kcdkbbbbbbbbkck.',
|
||||
'kcdkkkkkkkkkdcck',
|
||||
'kcdddddddddddcck',
|
||||
'kcccccccccccccc k',
|
||||
'kcccccccccccccck',
|
||||
'kcddddddddddddck',
|
||||
'kcddkkkkkkkkddck',
|
||||
'kcddkbbbbbbkddck',
|
||||
'kcddkbwwwwbkddck',
|
||||
'kcddkbwhhwbkddck',
|
||||
'kcddkbbbbbbkddck',
|
||||
'kcddkbbXXbbkddck',
|
||||
'kcddkbbXXbbkddck',
|
||||
'kcddkbbbbbbkddck',
|
||||
'kcddkbbbbbbkddck',
|
||||
'kcddkkkkkkkkddck',
|
||||
'kcddddddddddddck',
|
||||
'kcccccccccccccck',
|
||||
'kkkkkkkkkkkkkkkk',
|
||||
];
|
||||
// Gold door (key 1)
|
||||
@@ -166,20 +166,20 @@ export const TILE_DOOR_COPPER_PAL = {
|
||||
// ── DOOR OPEN — same silhouette, brighter frame, open gap ────────────────────
|
||||
export const TILE_DOOR_OPEN = [
|
||||
'kkkkkkkkkkkkkkkk',
|
||||
'k..............k',
|
||||
'kd..............dk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kdk..........kdk',
|
||||
'kd..............dk',
|
||||
'k..............k',
|
||||
'kddddddddddddddk',
|
||||
'kd............dk',
|
||||
'kd.kkkkkkkk...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.k......k...dk',
|
||||
'kd.kkkkkkkk...dk',
|
||||
'kd............dk',
|
||||
'kddddddddddddddk',
|
||||
'kkkkkkkkkkkkkkkk',
|
||||
];
|
||||
export const TILE_DOOR_OPEN_PAL = { k: NNTV.ink, d: NNTV.stoneLight };
|
||||
@@ -210,49 +210,46 @@ export const TILE_KEY_COPPER_PAL = { k: NNTV.ink, b: '#b87333', h: '#7a4a1a', w:
|
||||
// ── ONE-WAY ARROW — 16×16, 4 rotations ────────────────────────────────────────
|
||||
// Direction: 0=up, 1=right, 2=down, 3=left (matches level data encoding)
|
||||
export const TILE_ONEWAY_RIGHT = [
|
||||
'aaaaaaaaaaaaaaa.',
|
||||
'aaaaaaaaaaaaaaaa',
|
||||
'abbbbbbbbbbbbbba',
|
||||
'abccccccccccccba',
|
||||
'abccccccc...ccba',
|
||||
'abcccccca...cba.',
|
||||
'abccccca.....ba.',
|
||||
'abcaaaaaaaaaa.a.',
|
||||
'abcaaaaaaaaaaaaa',
|
||||
'abcaaaaaaaaaaaaa',
|
||||
'abcaaaaaaaaaa.a.',
|
||||
'abccccca.....ba.',
|
||||
'abcccccca...cba.',
|
||||
'abccccccc...ccba',
|
||||
'abccccccccxcccba',
|
||||
'abccccccccxxccba',
|
||||
'abccxxxxxxxxxcba',
|
||||
'abcxxxxxxxxxxcba',
|
||||
'abxxxxxxxxxxxxba',
|
||||
'abxxxxxxxxxxxxba',
|
||||
'abcxxxxxxxxxxcba',
|
||||
'abccxxxxxxxxxcba',
|
||||
'abccccccccxxccba',
|
||||
'abccccccccxcccba',
|
||||
'abccccccccccccba',
|
||||
'abbbbbbbbbbbbbba',
|
||||
'aaaaaaaaaaaaaaa.',
|
||||
'aaaaaaaaaaaaaaaa',
|
||||
];
|
||||
export const TILE_ONEWAY_PAL = { a: NNTV.gridBorder, b: NNTV.twilight, c: NNTV.gridEmpty, a: '#3a3a5a' };
|
||||
// Separate palette with arrow color distinct from background
|
||||
export const TILE_ONEWAY_BASE_PAL = {
|
||||
a: NNTV.gridBorder, b: NNTV.twilight, c: NNTV.gridEmpty,
|
||||
};
|
||||
export const TILE_ONEWAY_ARROW_PAL = {
|
||||
...TILE_ONEWAY_BASE_PAL,
|
||||
a: '#5a5a8a', // subtle blue-grey arrow
|
||||
export const TILE_ONEWAY_PAL = {
|
||||
a: NNTV.gridBorder,
|
||||
b: NNTV.twilight,
|
||||
c: NNTV.gridEmpty,
|
||||
x: '#74b7ff',
|
||||
};
|
||||
|
||||
// ── WARM CELL OVERLAY — 16×16, dim orange glow, distinct from bright TILE_LIT ─
|
||||
export const TILE_WARM = [
|
||||
'oooooooooooooooo',
|
||||
'owwwwwwwwwwwwwwo',
|
||||
'owooooooooooooowo',
|
||||
'owoooooooooooowo',
|
||||
'owooofffoooooowo',
|
||||
'owooofffooooowo.',
|
||||
'owoooofoooooowo.',
|
||||
'owo..........owo',
|
||||
'owo..........owo',
|
||||
'owo..........owo',
|
||||
'owo..........owo',
|
||||
'owooooooooooowo.',
|
||||
'owooofffooooowo.',
|
||||
'owooofffoooooowo',
|
||||
'owooooooooooooowo',
|
||||
'owoooofooooooowo',
|
||||
'owo..........owo',
|
||||
'owo..........owo',
|
||||
'owo..........owo',
|
||||
'owo..........owo',
|
||||
'owoooooooooooowo',
|
||||
'owooofffoooooowo',
|
||||
'owooofffoooooowo',
|
||||
'owoooooooooooowo',
|
||||
'owwwwwwwwwwwwwwo',
|
||||
'oooooooooooooooo',
|
||||
];
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
import LevelCompletePopup from '../components/LevelCompletePopup.svelte';
|
||||
import PauseMenu from '../components/PauseMenu.svelte';
|
||||
import ControlsOverlay from '../components/ControlsOverlay.svelte';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { sceneForLevel } from '../lib/pixel/art-scenes.js';
|
||||
import { generatedSceneForLevel } from '../lib/generated-assets.js';
|
||||
|
||||
let { navigate, level = 1 } = $props();
|
||||
|
||||
@@ -86,7 +85,7 @@
|
||||
? turnManager.previewNextTurn(grid, player, guards, throwSystem)
|
||||
: new Set()));
|
||||
let canUndo = $derived((renderVersion, history.canUndo()));
|
||||
let scene = $derived(sceneForLevel(currentLevel));
|
||||
let scene = $derived(generatedSceneForLevel(currentLevel));
|
||||
|
||||
// HUD reactive values
|
||||
let stonesLeft = $derived((renderVersion, throwSystem ? throwSystem.stonesLeft : 0));
|
||||
@@ -534,7 +533,7 @@
|
||||
<div class="game-scene" class:flash={showFlash}
|
||||
ontouchstart={onTouchStart} ontouchend={onTouchEnd}>
|
||||
<div class="scene-backdrop">
|
||||
<Pixel art={scene.art} palette={scene.pal} width={1024} height={384} />
|
||||
<img src={scene} alt="" draggable="false" />
|
||||
</div>
|
||||
<GameHud
|
||||
level={currentLevel}
|
||||
@@ -642,6 +641,12 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.scene-backdrop img {
|
||||
width: 100%;
|
||||
height: 384px;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.game-scene.flash {
|
||||
animation: level-flash 0.4s ease-out;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { getText } from '../lib/localization.js';
|
||||
import Button from '../components/Button.svelte';
|
||||
import { GENERATED_CHARACTERS, GENERATED_SCENES } from '../lib/generated-assets.js';
|
||||
|
||||
// flow: 'runComplete' (L11 cleared) | 'bittersweet' (L12 reached goal)
|
||||
let { navigate, level = 1, flow = 'runComplete' } = $props();
|
||||
@@ -12,6 +13,10 @@
|
||||
</script>
|
||||
|
||||
<div class="gameover">
|
||||
<img class="gameover-bg" src={isRunComplete ? GENERATED_SCENES[10] : GENERATED_SCENES[11]} alt="" draggable="false" />
|
||||
{#if !isRunComplete}
|
||||
<img class="princess" src={GENERATED_CHARACTERS.princess} alt="" draggable="false" />
|
||||
{/if}
|
||||
<h1 class:accent={isRunComplete} class:bittersweet={!isRunComplete}>{title}</h1>
|
||||
<p>{desc}</p>
|
||||
<div class="buttons">
|
||||
@@ -29,6 +34,25 @@
|
||||
justify-content: center; align-items: center;
|
||||
gap: 24px;
|
||||
background: var(--bg-dark);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gameover-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.28;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.princess {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
filter: drop-shadow(0 0 18px rgba(255, 234, 0, 0.55));
|
||||
z-index: 1;
|
||||
}
|
||||
h1 { font: bold 48px Arial, sans-serif; }
|
||||
h1.accent { color: var(--text-accent); }
|
||||
@@ -39,6 +63,8 @@
|
||||
text-align: center;
|
||||
max-width: 70%;
|
||||
white-space: pre-line;
|
||||
z-index: 1;
|
||||
}
|
||||
.buttons { display: flex; flex-direction: column; gap: 12px; }
|
||||
h1 { z-index: 1; }
|
||||
.buttons { display: flex; flex-direction: column; gap: 12px; z-index: 1; }
|
||||
</style>
|
||||
@@ -3,14 +3,13 @@
|
||||
import { LEVELS } from '../lib/levels/levels.js';
|
||||
import Button from '../components/Button.svelte';
|
||||
import AffordanceBanner from '../components/AffordanceBanner.svelte';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { sceneForLevel } from '../lib/pixel/art-scenes.js';
|
||||
import { generatedSceneForLevel } from '../lib/generated-assets.js';
|
||||
let { navigate, level = 1 } = $props();
|
||||
|
||||
let levelData = $derived(LEVELS[level - 1]);
|
||||
let levelName = $derived(levelData?.name || `Level ${level}`);
|
||||
let storyText = $derived(levelData?.storyKey ? getText(levelData.storyKey) : '');
|
||||
let scene = $derived(sceneForLevel(level));
|
||||
let scene = $derived(generatedSceneForLevel(level));
|
||||
|
||||
// Affordance gates — default both enabled until phase 04 populates levels.js
|
||||
let affordances = $derived(levelData?.affordances ?? { undo: true, preview: true });
|
||||
@@ -18,7 +17,7 @@
|
||||
|
||||
<div class="intro">
|
||||
<div class="backdrop">
|
||||
<Pixel art={scene.art} palette={scene.pal} width={1024} height={480} />
|
||||
<img src={scene} alt="" draggable="false" />
|
||||
</div>
|
||||
<div class="foreground">
|
||||
<span class="level-num">{getText('level')}{level}</span>
|
||||
@@ -47,6 +46,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.backdrop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.foreground {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Button from '../components/Button.svelte';
|
||||
import Pixel from '../lib/pixel/Pixel.svelte';
|
||||
import { MOON_ART, MOON_PAL } from '../lib/pixel/art-ui.js';
|
||||
import { RABBIT_ART, RABBIT_PAL } from '../lib/pixel/art-characters.js';
|
||||
import { GENERATED_CHARACTERS } from '../lib/generated-assets.js';
|
||||
let { navigate } = $props();
|
||||
|
||||
const stars = Array.from({ length: 40 }, () => ({
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<h1>{getText('gameTitle')}</h1>
|
||||
<div class="hero">
|
||||
<Pixel art={RABBIT_ART} palette={RABBIT_PAL} width={96} height={96} />
|
||||
<img src={GENERATED_CHARACTERS.player} alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
@@ -71,6 +71,12 @@
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 4px 12px rgba(83, 52, 131, 0.6));
|
||||
}
|
||||
.hero img {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.buttons {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 12px; z-index: 1;
|
||||
|
||||