bimi / reference_block_sprite.html
EricaLuvGemma's picture
Update reference_block_sprite.html
e49970f verified
Raw History Blame Contribute Delete
20.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
>
<title>SNES Side Scroller</title>
<style>
:root {
--bg: #080713;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--bg);
}
body {
display: grid;
place-items: center;
}
/* =========================================================
GAME FRAME
========================================================= */
.game-frame {
position: relative;
width: min(96vw, 1000px);
aspect-ratio: 16 / 10;
overflow: hidden;
isolation: isolate;
background: #17102d;
border: 3px solid rgba(63, 239, 255, .35);
border-radius:
43% 57% 54% 46% /
48% 40% 60% 52%;
box-shadow:
0 0 20px rgba(46, 235, 255, .18),
0 0 70px rgba(105, 46, 180, .28),
0 30px 100px rgba(0,0,0,.7);
animation: morph-frame 16s ease-in-out infinite;
}
/*
The canvas itself remains pixel-perfect.
*/
canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/*
Environmental CSS animation.
*/
.game-frame::before {
content: "";
position: absolute;
inset: -30%;
z-index: 3;
pointer-events: none;
background:
linear-gradient(
115deg,
transparent 0 42%,
rgba(255,255,255,.025) 44%,
transparent 46%
);
animation: atmosphere 12s linear infinite;
}
.game-frame::after {
content: "";
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
background:
radial-gradient(
ellipse at center,
transparent 45%,
rgba(0,0,0,.4)
);
}
@keyframes morph-frame {
0% {
border-radius:
43% 57% 54% 46% /
48% 40% 60% 52%;
}
50% {
border-radius:
58% 42% 46% 54% /
39% 56% 44% 61%;
}
100% {
border-radius:
43% 57% 54% 46% /
48% 40% 60% 52%;
}
}
@keyframes atmosphere {
from {
transform: translateX(-12%);
}
to {
transform: translateX(12%);
}
}
@media (prefers-reduced-motion: reduce) {
.game-frame,
.game-frame::before {
animation: none;
}
}
</style>
</head>
<body>
<div class="game-frame">
<canvas
id="game"
width="256"
height="160"
></canvas>
</div>
<script>
/* =========================================================
SNES SIDE-SCROLLER
=========================================================
IMPORTANT:
This is NOT a vector puppet.
The character is a collection of discrete sprite frames.
Each frame is intentionally redrawn.
Nothing rotates around anatomical joints.
This is the fundamental difference between this version
and the previous implementation.
Internal resolution:
256 × 160
Character sprite:
approximately 32 × 56
Display:
nearest-neighbor
========================================================= */
const canvas =
document.getElementById("game");
const ctx =
canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
/* =========================================================
PALETTE
========================================================= */
const PAL = {
/* outline */
O: "#171126",
O2: "#292044",
/* cyan rim */
C: "#46efff",
C2: "#168fae",
/* skin */
S0: "#9c4055",
S1: "#c95d62",
S2: "#e77a6d",
S3: "#ff9b7c",
S4: "#ffc09a",
/* hair */
H0: "#24123e",
H1: "#3b195c",
H2: "#61267c",
H3: "#8b3f9c",
/* clothing */
T0: "#07516f",
T1: "#087e9e",
T2: "#24b5c5",
T3: "#65e2dc",
/* skirt */
K0: "#701e4d",
K1: "#a92d5d",
K2: "#df3f68",
K3: "#ff6680",
/* eyes */
E0: "#f5fff0",
E1: "#65ff77",
/* environment */
NIGHT0: "#120c27",
NIGHT1: "#211343",
NIGHT2: "#35205f",
NIGHT3: "#5a286c",
STAR: "#ffd75e"
};
/* =========================================================
BASIC PIXEL HELPERS
========================================================= */
function p(x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(
Math.round(x),
Math.round(y),
1,
1
);
}
function rect(
x,
y,
w,
h,
color
) {
ctx.fillStyle = color;
ctx.fillRect(
Math.round(x),
Math.round(y),
Math.round(w),
Math.round(h)
);
}
function pixelLine(
x1,
y1,
x2,
y2,
color
) {
/*
Bresenham-style integer line.
*/
x1 = Math.round(x1);
y1 = Math.round(y1);
x2 = Math.round(x2);
y2 = Math.round(y2);
let dx =
Math.abs(x2 - x1);
let sx =
x1 < x2 ? 1 : -1;
let dy =
-Math.abs(y2 - y1);
let sy =
y1 < y2 ? 1 : -1;
let err =
dx + dy;
while (true) {
p(x1, y1, color);
if (
x1 === x2 &&
y1 === y2
) {
break;
}
const e2 = 2 * err;
if (e2 >= dy) {
err += dy;
x1 += sx;
}
if (e2 <= dx) {
err += dx;
y1 += sy;
}
}
}
/* =========================================================
BACKGROUND
========================================================= */
function background() {
ctx.fillStyle = PAL.NIGHT0;
ctx.fillRect(
0,
0,
256,
160
);
/*
Large horizontal SNES-style bands.
*/
rect(
0,
55,
256,
32,
PAL.NIGHT1
);
rect(
0,
87,
256,
35,
PAL.NIGHT2
);
rect(
0,
122,
256,
38,
PAL.NIGHT0
);
/*
Distant skyline.
*/
const buildings = [
[0, 84, 19],
[22, 72, 27],
[52, 82, 17],
[73, 65, 23],
[102, 78, 29],
[135, 69, 20],
[160, 81, 25],
[191, 61, 31],
[226, 76, 30]
];
for (const b of buildings) {
rect(
b[0],
b[1],
b[2],
60,
PAL.NIGHT1
);
/*
Tiny window pixels.
*/
for (
let y = b[1] + 8;
y < 130;
y += 10
) {
if (
((b[0] + y) % 3) === 0
) {
rect(
b[0] + 4,
y,
2,
2,
PAL.STAR
);
}
}
}
/*
Ground.
*/
rect(
0,
137,
256,
23,
"#0d0920"
);
rect(
0,
137,
256,
2,
PAL.C2
);
/*
Pixel ground markings.
*/
for (
let x = 0;
x < 256;
x += 18
) {
rect(
x,
146,
8,
1,
PAL.NIGHT3
);
rect(
x + 4,
154,
3,
1,
PAL.NIGHT2
);
}
}
/* =========================================================
BOKEH
========================================================= */
const orbs = [
{
x: 26,
y: 38,
r: 8,
color: "#ffca52",
phase: 0
},
{
x: 219,
y: 43,
r: 11,
color: "#45eaff",
phase: 2
},
{
x: 225,
y: 111,
r: 13,
color: "#ff4d9e",
phase: 4
},
{
x: 45,
y: 112,
r: 9,
color: "#9c63ff",
phase: 1
}
];
function bokeh(time) {
for (const o of orbs) {
const pulse =
1 +
Math.sin(
time * .001 +
o.phase
) * .12;
ctx.globalAlpha = .06;
ctx.fillStyle =
o.color;
ctx.beginPath();
ctx.arc(
o.x,
o.y,
o.r * 2.1 * pulse,
0,
Math.PI * 2
);
ctx.fill();
ctx.globalAlpha = .13;
ctx.beginPath();
ctx.arc(
o.x,
o.y,
o.r * pulse,
0,
Math.PI * 2
);
ctx.fill();
ctx.globalAlpha = 1;
}
}
/* =========================================================
METEORS
========================================================= */
const meteorPalette = [
PAL.C,
"#ff4fd4",
"#6eff70",
PAL.STAR,
"#ff6280"
];
const meteors = [];
for (let i = 0; i < 42; i++) {
meteors.push({
x:
Math.random() * 256,
y:
Math.random() * 125,
length:
3 +
Math.floor(
Math.random() * 9
),
speed:
.15 +
Math.random() * .5,
color:
meteorPalette[
Math.floor(
Math.random() *
meteorPalette.length
)
],
phase:
Math.random() *
Math.PI * 2
});
}
function meteorLayer(time) {
for (const m of meteors) {
if (!reducedMotion) {
m.x += m.speed;
m.y +=
m.speed * .42;
if (
m.x > 260 ||
m.y > 135
) {
m.x =
-Math.random() * 20;
m.y =
Math.random() * 100;
m.length =
3 +
Math.floor(
Math.random() * 9
);
m.speed =
.15 +
Math.random() * .5;
m.color =
meteorPalette[
Math.floor(
Math.random() *
meteorPalette.length
)
];
}
}
const alpha =
.35 +
(
Math.sin(
time * .004 +
m.phase
) + 1
) * .25;
ctx.globalAlpha =
alpha;
/*
Pixel staircase meteor.
*/
pixelLine(
m.x,
m.y,
m.x + m.length,
m.y + Math.floor(m.length * .45),
m.color
);
ctx.globalAlpha = 1;
}
}
/* =========================================================
CHARACTER SPRITE FRAMES
=========================================================
These are deliberately NOT rotated body parts.
Each pose is a different pixel arrangement.
The renderer treats each frame like a classic game
sprite.
========================================================= */
/*
Draw one pixel-art character.
x/y = sprite origin.
frame = animation frame.
dir = direction.
*/
function characterSprite(
x,
y,
frame,
dir = 1
) {
/*
Side-scroller animation.
0 = contact
1 = passing
2 = opposite contact
3 = passing
*/
const poses = [
{
arm: -2,
legA: 3,
legB: -3,
body: 0
},
{
arm: 0,
legA: 0,
legB: 0,
body: -1
},
{
arm: 2,
legA: -3,
legB: 3,
body: 0
},
{
arm: 0,
legA: 0,
legB: 0,
body: -1
}
];
const pose =
poses[
frame %
poses.length
];
const bob =
pose.body;
/*
Character dimensions:
width ~38px
height ~58px
*/
ctx.save();
ctx.translate(
Math.round(x),
Math.round(
y + bob
)
);
if (dir < 0) {
ctx.translate(38, 0);
ctx.scale(-1, 1);
}
/* =======================================================
BACK ARM
======================================================= */
pixelLine(
11,
26 + pose.arm,
5,
39 + pose.arm,
PAL.C
);
pixelLine(
12,
26 + pose.arm,
6,
39 + pose.arm,
PAL.O
);
pixelLine(
12,
27 + pose.arm,
7,
39 + pose.arm,
PAL.S2
);
p(
7,
39 + pose.arm,
PAL.S3
);
/* =======================================================
BACK LEG
======================================================= */
pixelLine(
16,
44,
12 + pose.legB,
55,
PAL.C
);
pixelLine(
16,
44,
12 + pose.legB,
55,
PAL.O
);
pixelLine(
16,
44,
12 + pose.legB,
55,
PAL.S2
);
/* =======================================================
BACK FOOT
======================================================= */
rect(
8 + pose.legB,
54,
7,
3,
PAL.C
);
rect(
9 + pose.legB,
54,
6,
2,
PAL.S1
);
/* =======================================================
SKIRT
======================================================= */
/*
Cyan silhouette first.
*/
rect(
7,
39,
26,
13,
PAL.C
);
/*
Main skirt.
*/
rect(
9,
40,
22,
10,
PAL.K1
);
/*
Pixel folds.
*/
rect(
10,
41,
4,
9,
PAL.K0
);
rect(
17,
40,
3,
11,
PAL.K3
);
rect(
25,
41,
5,
9,
PAL.K2
);
/*
Uneven hem.
*/
rect(
7,
50,
5,
2,
PAL.K0
);
rect(
13,
51,
6,
2,
PAL.K1
);
rect(
20,
50,
7,
2,
PAL.K3
);
rect(
28,
49,
5,
2,
PAL.K1
);
/* =======================================================
TORSO
======================================================= */
rect(
10,
22,
21,
19,
PAL.C
);
rect(
12,
23,
17,
16,
PAL.T1
);
/*
Shirt highlight.
*/
rect(
23,
24,
5,
13,
PAL.T3
);
/*
Shirt shadow.
*/
rect(
12,
34,
6,
5,
PAL.T0
);
/* =======================================================
COLLAR
======================================================= */
rect(
17,
22,
8,
4,
PAL.STAR
);
rect(
19,
25,
4,
2,
PAL.STAR
);
/* =======================================================
FRONT ARM
======================================================= */
pixelLine(
29,
25,
35,
38 + pose.arm,
PAL.C
);
pixelLine(
29,
25,
35,
38 + pose.arm,
PAL.O
);
pixelLine(
29,
26,
34,
38 + pose.arm,
PAL.S2
);
/*
Hand.
*/
rect(
32,
37 + pose.arm,
5,
4,
PAL.C
);
rect(
33,
37 + pose.arm,
3,
3,
PAL.S3
);
/* =======================================================
FRONT LEG
======================================================= */
pixelLine(
23,
45,
27 + pose.legA,
55,
PAL.C
);
pixelLine(
23,
45,
27 + pose.legA,
55,
PAL.O
);
pixelLine(
23,
45,
27 + pose.legA,
55,
PAL.S2
);
/* =======================================================
FRONT FOOT
======================================================= */
rect(
25 + pose.legA,
54,
8,
3,
PAL.C
);
rect(
26 + pose.legA,
54,
7,
2,
PAL.S1
);
/* =======================================================
NECK
======================================================= */
rect(
18,
18,
8,
7,
PAL.C
);
rect(
20,
18,
5,
6,
PAL.S2
);
/* =======================================================
HEAD SILHOUETTE
======================================================= */
/*
Hair/cyan silhouette.
*/
rect(
8,
2,
25,
18,
PAL.C
);
/*
Face.
*/
rect(
11,
5,
19,
14,
PAL.S2
);
/*
Face light.
*/
rect(
18,
6,
11,
10,
PAL.S3
);
/*
Jaw shadow.
*/
rect(
12,
15,
9,
4,
PAL.S1
);
/* =======================================================
HAIR
======================================================= */
rect(
8,
2,
23,
7,
PAL.H0
);
rect(
10,
4,
19,
6,
PAL.H1
);
/*
Hair silhouette around face.
*/
rect(
8,
7,
5,
13,
PAL.H0
);
rect(
27,
7,
6,
13,
PAL.H0
);
/*
Hair highlights.
*/
rect(
13,
4,
7,
2,
PAL.H3
);
rect(
20,
5,
6,
2,
PAL.H2
);
/* =======================================================
EYES
======================================================= */
rect(
17,
10,
5,
4,
PAL.E0
);
rect(
25,
10,
5,
4,
PAL.E0
);
rect(
20,
10,
2,
4,
PAL.E1
);
rect(
27,
10,
2,
4,
PAL.E1
);
/*
Eye pixels.
*/
p(21, 11, PAL.O);
p(28, 11, PAL.O);
/*
Eye highlights.
*/
p(20, 10, PAL.E0);
p(27, 10, PAL.E0);
/* =======================================================
NOSE
======================================================= */
rect(
29,
14,
3,
2,
PAL.S0
);
/* =======================================================
MOUTH
======================================================= */
rect(
21,
17,
7,
1,
PAL.O
);
/* =======================================================
PIXEL SKIN HIGHLIGHTS
======================================================= */
rect(
23,
7,
3,
1,
PAL.S4
);
rect(
28,
15,
2,
1,
PAL.S4
);
ctx.restore();
}
/* =========================================================
CHARACTER SHADOW
========================================================= */
function characterShadow(
x,
y
) {
ctx.globalAlpha = .45;
ctx.fillStyle =
PAL.O;
ctx.fillRect(
Math.round(x - 7),
Math.round(y),
48,
3
);
ctx.globalAlpha = 1;
}
/* =========================================================
GAME STATE
========================================================= */
const player = {
x: 111,
y: 78,
direction: 1,
frame: 0,
frameTimer: 0,
frameRate: 115,
moving: true
};
/* =========================================================
REDUCED MOTION
========================================================= */
const motionQuery =
window.matchMedia(
"(prefers-reduced-motion: reduce)"
);
let reducedMotion =
motionQuery.matches;
motionQuery.addEventListener(
"change",
e => {
reducedMotion =
e.matches;
}
);
/* =========================================================
PLAYER ANIMATION
========================================================= */
function updatePlayer(dt) {
if (reducedMotion) {
player.frame = 1;
return;
}
/*
We don't rotate limbs.
We simply select the next authored sprite pose.
*/
player.frameTimer += dt;
if (
player.frameTimer >=
player.frameRate
) {
player.frameTimer = 0;
player.frame =
(
player.frame + 1
) % 4;
}
/*
Small horizontal movement gives the impression that
the character is traversing the scene.
This is intentionally discrete rather than floating
the entire body like a 3D rig.
*/
player.x +=
.012 * dt;
if (
player.x > 270
) {
player.x = -35;
}
}
/* =========================================================
CAMERA PARALLAX
========================================================= */
let targetCameraX = 0;
let cameraX = 0;
let pointerX = .5;
let pointerY = .5;
const frame =
document.querySelector(
".game-frame"
);
frame.addEventListener(
"pointermove",
e => {
const r =
frame.getBoundingClientRect();
pointerX =
(
e.clientX -
r.left
) / r.width;
pointerY =
(
e.clientY -
r.top
) / r.height;
}
);
frame.addEventListener(
"pointerleave",
() => {
pointerX = .5;
pointerY = .5;
}
);
/* =========================================================
MAIN RENDER
========================================================= */
let lastTime = 0;
function render(time) {
const dt =
Math.min(
40,
time - lastTime
);
lastTime = time;
/*
Update.
*/
updatePlayer(dt);
targetCameraX =
(
pointerX -
.5
) * 5;
cameraX +=
(
targetCameraX -
cameraX
) * .03;
/*
Background.
*/
background();
bokeh(time);
meteorLayer(time);
/*
Camera layer.
The world shifts slightly,
but the sprite itself remains
a discrete game sprite.
*/
ctx.save();
ctx.translate(
Math.round(cameraX),
0
);
/*
Ground shadow.
*/
characterShadow(
player.x,
136
);
/*
Actual SNES character.
*/
characterSprite(
player.x,
player.y,
player.frame,
player.direction
);
ctx.restore();
requestAnimationFrame(
render
);
}
requestAnimationFrame(
render
);
</script>
</body>
</html>