Skip to content

Commit c937005

Browse files
committed
Heartmeter
1 parent fde762f commit c937005

8 files changed

Lines changed: 262 additions & 11 deletions

File tree

heartmeter/heart.png

7.68 KB
Loading

heartmeter/increment_health.ogg

8.01 KB
Binary file not shown.

heartmeter/index.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
body {
2+
background: rgb(255, 245, 230);
3+
color: black;
4+
font-family: system-ui, sans-serif;
5+
padding: 20px;
6+
}
7+
8+
.controls {
9+
margin-bottom: 16px;
10+
}
11+
12+
input {
13+
width: 80px;
14+
margin-right: 10px;
15+
}
16+
17+
canvas {
18+
background: transparent;
19+
display: block;
20+
}

heartmeter/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Heartmeter</title>
5+
<link rel="stylesheet" href="./index.css">
6+
</head>
7+
<body>
8+
<h1>Heartmeter</h1>
9+
<p>This generates a heart meter in the style of The Legend of Zelda: Breath of the Wild or Tears of the Kingdom.</p>
10+
11+
<div class="controls">
12+
Style:
13+
<select id="style" onchange="render()">
14+
<option value="0">Tears of the Kingdom</option>
15+
<option value="1">Breath of the Wild</option>
16+
</select>
17+
Current: <input id="current" type="number" step="0.25" value="10" onchange="render()">
18+
Max: <input id="max" type="number" value="10" onchange="render()">
19+
Scale: <span id="display">0.5</span> <input type="range" min="0.25" max="1.0" value="0.5" step="0.05" id="scale" onchange="updateScale()">
20+
</div>
21+
22+
<canvas id="canvas"></canvas>
23+
</body>
24+
<script src="index.js"></script>
25+
</html>

heartmeter/index.js

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
const MAX_PER_ROW = 20;
2+
const SPACING = 24;
3+
const SPEED = 6;
4+
5+
const TOTK_COLOR = [248, 108, 50, 255];
6+
const BOTW_COLOR = [253, 58, 56, 255];
7+
const EMPTY_COLOR = [0, 0, 0, 200];
8+
9+
const canvas = document.getElementById("canvas");
10+
const ctx = canvas.getContext("2d");
11+
12+
const heartImg = new Image();
13+
heartImg.src = "heart.png";
14+
heartImg.onload = render;
15+
16+
const healSound = new Audio("increment_health.ogg");
17+
healSound.preload = "auto";
18+
healSound.load();
19+
20+
let displayedValue = parseFloat(document.getElementById("max").value);
21+
let targetValue = displayedValue;
22+
let lastTime = 0;
23+
let scale = 0.5;
24+
25+
function createColoredHeart(colorRGBA) {
26+
const w = heartImg.width;
27+
const h = heartImg.height;
28+
29+
const temp = document.createElement("canvas");
30+
temp.width = w;
31+
temp.height = h;
32+
const tctx = temp.getContext("2d");
33+
34+
// fill solid color
35+
tctx.clearRect(0, 0, w, h);
36+
tctx.fillStyle = `rgba(${colorRGBA[0]},${colorRGBA[1]},${colorRGBA[2]},${colorRGBA[3] / 255})`;
37+
tctx.fillRect(0, 0, w, h);
38+
39+
// use heart alpha as mask
40+
tctx.globalCompositeOperation = "destination-in";
41+
tctx.drawImage(heartImg, 0, 0);
42+
tctx.globalCompositeOperation = "source-over";
43+
44+
return temp;
45+
}
46+
47+
function createEmptyHeart() {
48+
const w = heartImg.width;
49+
const h = heartImg.height;
50+
51+
const temp = document.createElement("canvas");
52+
temp.width = w;
53+
temp.height = h;
54+
const tctx = temp.getContext("2d");
55+
56+
// base color
57+
tctx.clearRect(0, 0, w, h);
58+
tctx.fillStyle = `rgba(${EMPTY_COLOR[0]},${EMPTY_COLOR[1]},${EMPTY_COLOR[2]},${EMPTY_COLOR[3] / 255})`;
59+
tctx.fillRect(0, 0, w, h);
60+
61+
// mask to heart shape
62+
tctx.globalCompositeOperation = "destination-in";
63+
tctx.drawImage(heartImg, 0, 0);
64+
tctx.globalCompositeOperation = "source-over";
65+
66+
return temp;
67+
}
68+
69+
function createPartialHeart(fraction, filledHeart, emptyHeart) {
70+
const w = heartImg.width;
71+
const h = heartImg.height;
72+
73+
const temp = document.createElement("canvas");
74+
temp.width = w;
75+
temp.height = h;
76+
const tctx = temp.getContext("2d");
77+
78+
// draw empty heart first
79+
tctx.drawImage(emptyHeart, 0, 0);
80+
81+
// calculate arc angles
82+
const cx = w / 2;
83+
const cy = h / 2;
84+
const radius = Math.max(w, h); // big enough to cover entire heart
85+
86+
const startAngle = -Math.PI / 2;
87+
const endAngle = startAngle - (Math.PI * 2 * fraction);
88+
89+
// create angular clipping mask, kinda confusing but I got it down
90+
tctx.save();
91+
tctx.beginPath();
92+
tctx.moveTo(cx, cy);
93+
tctx.arc(cx, cy, radius, startAngle, endAngle, true);
94+
tctx.closePath();
95+
tctx.clip();
96+
97+
// draw filled portion inside clip
98+
tctx.drawImage(filledHeart, 0, 0);
99+
tctx.restore();
100+
101+
return temp;
102+
}
103+
104+
function drawHearts(value) {
105+
let max = parseInt(document.getElementById("max").value);
106+
if (max <= 0 || max > 40) { max = Math.ceil(value); }
107+
108+
value = Math.min(value, max);
109+
110+
const w = heartImg.width;
111+
const h = heartImg.height;
112+
113+
const full = Math.floor(value);
114+
const remainder = value - full;
115+
const fraction = Math.max(0, Math.min(1, remainder));
116+
117+
const style = document.getElementById("style").value;
118+
const filledHeart = createColoredHeart(style == "0" ? TOTK_COLOR : BOTW_COLOR);
119+
const emptyHeart = createEmptyHeart();
120+
121+
const hearts = [];
122+
123+
// full hearts
124+
for (let i = 0; i < full; i++) {
125+
hearts.push(filledHeart);
126+
}
127+
128+
// partial heart
129+
if (fraction > 0 && hearts.length < max) {
130+
hearts.push(createPartialHeart(fraction, filledHeart, emptyHeart));
131+
}
132+
133+
// empty hearts
134+
while (hearts.length < max) {
135+
hearts.push(emptyHeart);
136+
}
137+
138+
// layout
139+
const cols = Math.min(hearts.length, MAX_PER_ROW);
140+
const rows = Math.ceil(hearts.length / MAX_PER_ROW);
141+
142+
canvas.width = (cols * w + (cols - 1) * SPACING) * scale;
143+
canvas.height = (rows * h + (rows - 1) * SPACING) * scale;
144+
145+
ctx.clearRect(0, 0, canvas.width, canvas.height);
146+
147+
// draw these hearts yo
148+
hearts.forEach((heart, i) => {
149+
const row = Math.floor(i / MAX_PER_ROW);
150+
const col = i % MAX_PER_ROW;
151+
const x = col * (w + SPACING) * scale;
152+
const y = row * (h + SPACING) * scale;
153+
ctx.drawImage(heart, x, y, heartImg.width * scale, heartImg.height * scale);
154+
});
155+
}
156+
157+
function animate(timestamp) {
158+
if (!lastTime) lastTime = timestamp;
159+
const deltaTime = (timestamp - lastTime) / 1000; // seconds, fixed tickrate is so much easier to work with :pensive:
160+
lastTime = timestamp;
161+
162+
const diff = targetValue - displayedValue;
163+
const direction = Math.sign(diff);
164+
const step = SPEED * deltaTime * direction;
165+
166+
let prevDisplayedValue = displayedValue;
167+
168+
if (Math.abs(step) >= Math.abs(diff)) {
169+
displayedValue = targetValue;
170+
} else {
171+
displayedValue += step;
172+
}
173+
174+
if (Math.floor(prevDisplayedValue) < Math.floor(displayedValue)) {
175+
healSound.cloneNode().play();
176+
}
177+
178+
drawHearts(displayedValue);
179+
180+
requestAnimationFrame(animate);
181+
}
182+
183+
function render() {
184+
if (!heartImg.complete) return;
185+
186+
let current = parseFloat(document.getElementById("current").value);
187+
let max = parseInt(document.getElementById("max").value);
188+
189+
if (current < 0 || current > 40) current = 0;
190+
if (max <= 0 || max > 40) max = Math.ceil(current);
191+
192+
current = Math.min(current, max);
193+
194+
targetValue = current;
195+
}
196+
197+
function updateScale() {
198+
scale = parseFloat(document.getElementById("scale").value);
199+
document.getElementById("display").innerText = scale;
200+
}
201+
202+
updateScale();
203+
render();
204+
requestAnimationFrame(animate);

index.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
<meta http-equiv="X-UA-Compatible" content="IE=edge">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0">
77
<title>Agent's Site</title>
8-
<script src="index.js"></script>
98
</head>
109
<body>
11-
<a href="./precise-age/index.html">Precise Age Calculator</a>
10+
<h1>Agent's Site</h1>
11+
<a href="./precise-age/">Precise Age Calculator</a>
12+
<br>
13+
<a href="./heartmeter/">Heartmeter</a>
1214
</body>
1315
</html>

index.js

Whitespace-only changes.

precise-age/index.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
const secondsInYear = 31536000
2-
const millisecondsToSeconds = 0.001
1+
const SECONDS_IN_YEAR = 31536000;
2+
const TIME_MS_TO_S = 0.001;
33

4-
const dateInput = document.getElementById("date")
5-
const age = document.getElementById("age")
4+
const dateInput = document.getElementById("date");
5+
const age = document.getElementById("age");
66

7-
let interval = false
7+
let interval = false;
88

99
window.calculate = () => {
10-
const date = new Date(dateInput.value)
11-
age.innerHTML = "Age: " + ((Date.now() * millisecondsToSeconds) - (date.getTime() * millisecondsToSeconds)) / secondsInYear
10+
const date = new Date(dateInput.value);
11+
age.innerHTML = "Age: " + ((Date.now() * TIME_MS_TO_S) - (date.getTime() * TIME_MS_TO_S)) / SECONDS_IN_YEAR;
1212

1313
if (!interval) {
14-
interval = true
15-
setInterval(window.calculate, 100)
14+
interval = true;
15+
setInterval(window.calculate, 100);
1616
}
1717
}

0 commit comments

Comments
 (0)