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.
This commit is contained in:
tiennm99 committed 2026-05-16 14:36:36 +07:00
1 parent 1d883d4dde
commit 968e3179a1
44 files changed
+539 -244

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+129 -100
View File
@@ -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>
+156 -62
View File
@@ -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>
+11 -6
View File
@@ -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>
+66 -3
View File
@@ -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>
+8 -4
View File
@@ -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>
+61
View File
@@ -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];
}
+55 -58
View File
@@ -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',
];
+9 -4
View File
@@ -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;
}
+27 -1
View File
@@ -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>
+9 -4
View File
@@ -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%;
+8 -2
View File
@@ -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;