Files
quickdraw-game/quickdraw-game.html
T
2026-08-03 15:15:59 -05:00

897 lines
31 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: sans-serif;
}
:root {
--bg: #161616;
}
html {
height: 100vh;
width: 100vw;
background-color: var(--bg);
font-size: 24px;
overflow-x: hidden;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
padding: 1rem;
}
#frame {
width: 60%;
height: 75%;
/** border: 1px solid white; */
}
#center {
height: 50px;
width: 50px;
background-color: red;
/** border: 5px solid red; */
border-radius: 50%;
position: absolute;
border-radius: 50%;
}
.target {
height: 50px;
width: 50px;
background-color: var(--bg);
border: 10px solid red;
position: absolute;
border-radius: 50%;
}
.target:active {
background-color: orange;
}
.target::after {
position: absolute;
background-color: red;
border-radius: 2rem;
height: 10px;
width: 10px;
top: 10px;
left: 10px;
content: "";
}
.red-dot {
height: 50px;
width: 50px;
background-color: red;
position: absolute;
border-radius: 50%;
}
.hidden {
display: none;
opacity: 0;
}
.fade {
transition: opacity 1s;
}
.title {
font-weight: bold;
font-size: 2rem;
}
#title-container {
justify-self: center;
justify-content: center;
flex-direction: row;
width: 100%;
gap: 1rem;
}
#text-container {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 1rem;
min-height: 15%;
width: 100%;
color: red;
text-align: center;
line-height: 1rem;
margin-top: 2rem;
}
#game-modes {
}
.button-container {
/** display: flex; */
align-content: center;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
}
#game-over-buttons {
gap: 2rem;
}
button {
background-color: var(--bg);
color: red;
padding: 12px 12px;
border: 2px solid red;
border-radius: 1rem;
font-size: .75rem;
font-weight: bold;
}
button:hover {
cursor: pointer;
text-decoration: underline;
}
button:active {
background-color: red;
color: var(--bg);
font-style: italic;
}
@media screen and (max-width: 1024px) {
html {
font-size: 32px;
}
body {
padding: .75rem .25rem;
}
#frame {
width: 90%;
}
#text-container {
margin-top: 1.25rem;
}
#game-modes {
gap: .5rem;
}
#game-text {
font-size: .8rem;
}
.title { font-size: 1.5rem; }
button { font-size: .75rem; }
}
</style>
</head>
<body>
<!-- where the targets + bullseye are drawn -->
<div id="frame">
</div>
<!-- where we place text + buttons -->
<div id="text-container">
<!-- intro related -->
<div id="title-container">
<span id="title1" class="hidden title">QUICK</span>
<span id="title2" class="hidden title">DRAW</span>
</div>
<!-- gamemode menu buttons-->
<div id="game-modes" class="button-container hidden fade">
<button id="normal" onclick="handleNormalMode()">normal</button>
<button id="countdown" onclick="handleCountdownMode()">countdown</button>
<!-- <button id="infinite">infinite</button> -->
</div>
<!-- normal mode sub-modes -->
<div id="normal-sub-modes" class="button-container hidden fade">
<button id="normal-3-rounds" onclick="setupNormalMode(3)">3 rounds</button>
<button id="normal-6-rounds" onclick="setupNormalMode(6)">6 rounds</button>
<button id="normal-10-rounds" onclick="setupNormalMode(10)">10 rounds</button>
</div>
<!-- countdown mode sub-modes -->
<div id="countdown-sub-modes" class="button-container hidden fade">
<button id="countdown-15s" onclick="setupCountdownMode(15)">15 seconds</button>
<button id="countdown-30s" onclick="setupCountdownMode(30)">30 seconds</button>
<button id="countdown-60s" onclick="setupCountdownMode(60)">60 seconds</button>
</div>
<!-- pre-round text -->
<span id="game-text" class="hidden fade"></span>
<!-- post game buttons -->
<div id="game-over-buttons" class="button-container hidden fade">
<button id="play-again" onclick="handlePlayAgain()">play again</button>
<button id="reset-game" onclick="handleReset()">back to main menu</button>
</div>
<!-- countdown text -->
<span id="ready" class="hidden title">READY?</span>
<span id="countdown3" class="hidden title">3</span>
<span id="countdown2" class="hidden title">2</span>
<span id="countdown1" class="hidden title">1</span>
<span id="draw" class="hidden title">DRAW!</span>
</div>
<script>
// SCREEN / SETUP VARIABLES
let gameBorderWidth = 1;
let targetWidth = 50;
let targetHeight = 50;
let pageHeight;
let pageWidth;
let frameElement;
let frameRect;
let frameHeight;
let frameWidth;
let center;
let ratio;
let xFrameAdjustment;
let yFrameAdjustment;
// UX HELPERS
let drawingCircle = false;
let needsResizeAdjustment = false;
// GAME STATE VARIABLES
let gamemode = ""; // can be: "normal" or "countdown"
let submode = "";
let countingDown = false;
let hovering = false;
let hoverCount = 0; // number of times the cursor has hovered over bullseye. used to cleanly handle round starting / cancelling
// GAMEPLAY VARIABLES
let highScore = 0;
let targetsClicked = 0;
let numTargets = 0;
// "NORMAL" MODE ONLY VARIABLES
let currentRound = 0;
let maxNormalRounds = 0;
let totalTime = 0.0;
let roundSeconds = 0.0;
let roundTimeStart = 0;
let roundTimeEnd = 0;
let normalBestTimes = {};
let normalBullseyeNeeded = false;
// "COUNTDOWN" MODE ONLY VARIABLES
let countdownBullseyeNeeded = false;
let countdownTime = 0;
let countdownHighScores = {};
// menu / text elements
let titleContainer = document.getElementById("title-container");
let title1 = document.getElementById("title1");
let title2 = document.getElementById("title2");
let gameModes = document.getElementById("game-modes");
let normalSubModes = document.getElementById("normal-sub-modes");
let countdownSubModes = document.getElementById("countdown-sub-modes");
let gameText = document.getElementById("game-text");
let gameOverButtons = document.getElementById("game-over-buttons");
let ready = document.getElementById("ready");
let one = document.getElementById("countdown1");
let two = document.getElementById("countdown2");
let three = document.getElementById("countdown3");
let draw = document.getElementById("draw");
// setup initialization
window.addEventListener("load", initGame);
async function initGame() {
// determine screen parameters + listen for changes
setParameters();
window.addEventListener("resize", handlePageResize);
center = generateTarget(xFrameAdjustment, yFrameAdjustment);
center.id = "center";
document.body.insertAdjacentElement("afterbegin", center);
await drawCircle(2);
if (needsResizeAdjustment) {
// auto-reset screen if page was resized during animation
handlePageResize();
}
await fadeMainMenuIn();
if (localStorage.getItem("normalBestTimes") != null) {
normalBestTimes = JSON.parse(localStorage.getItem("normalBestTimes"));
}
if (localStorage.getItem("countdownHighScores") != null) {
countdownHighScores = JSON.parse(localStorage.getItem("countdownHighScores"));
}
}
// helper function to determine screen-size + ratios for rendering targets
function setParameters(event) {
pageHeight = window.innerHeight;
pageWidth = window.innerWidth;
frameElement = document.getElementById("frame");
frameRect = frameElement.getBoundingClientRect();
frameHeight = frameElement.offsetHeight;
frameWidth = frameElement.offsetWidth;
// ratio = (frameHeight - (gameBorderWidth*2)) / (frameWidth - (gameBorderWidth*2));
ratio = frameHeight / frameWidth;
// adjustments to find center of frame in absolute coords (for origin of target cirlce)
xFrameAdjustment = ((frameWidth / 2) + frameRect.left) - (targetWidth / 2);
yFrameAdjustment = ((frameHeight / 2) + frameRect.top) - (targetWidth / 2);
// init logs:
console.log('page: ' + pageWidth + ' x ' + pageHeight);
console.log('frame: ' + frameWidth + ' x ' + frameHeight);
}
async function handleNormalMode(e) {
gamemode = "normal";
normalBullseyeNeeded = true;
await fadeMainMenuOut();
normalSubModes.style.display = "flex";
normalSubModes.style.opacity = "100";
}
async function setupNormalMode(numRounds) {
// fade buttons out
normalSubModes.style.opacity = "0";
sleep(500);
normalSubModes.style.display = "none";
// ensure mode parameters are [re]set
currentRound = 1;
maxNormalRounds = numRounds;
totalTime = 0;
submode = numRounds;
if (Object.keys(normalBestTimes).indexOf(submode.toString()) == -1) {
highScore = 0;
}
else {
highScore = normalBestTimes[submode];
}
// set game instruction text + fade it in
gameText.innerText = "hover on middle circle to start the countdown,\nthen click the targets as fast as possible!";
gameText.style.opacity = "0";
gameText.style.display = "inline";
await sleep(500);
gameText.style.opacity = "100";
// enable player to start first round
center.addEventListener("mouseover", handleHoverStart);
center.addEventListener("mouseleave", handleHoverEnd);
center.addEventListener("touchend", handleHoverEnd);
}
async function startNormalRound(roundNumber) {
if (roundNumber > maxNormalRounds) {
handleReset({});
return;
}
// if user cancels hover countdown, don't start round
if (!await drawCountdown(hoverCount)) {
console.log("player canceled coundown");
return;
}
targetsClicked = 0;
if (roundNumber == maxNormalRounds) {
numTargets = roundNumber * 2;
}
else {
numTargets = roundNumber;
}
// effectively begin the round
console.log("starting round " + roundNumber);
center.remove();
normalBullseyeNeeded = false;
roundTimeStart = Date.now();
randomTargetGenerator(numTargets);
while (targetsClicked < numTargets) {
await sleep(10);
if (targetsClicked == numTargets) break;
}
roundTimeEnd = Date.now();
roundSeconds = (roundTimeEnd - roundTimeStart) / 1000;
totalTime += roundSeconds;
// auto-reset bullseye if page gets resized at all
if (needsResizeAdjustment) {
setParameters();
}
// ensure "DRAW" isn't still lingering on screen after round
draw.style.display = "none";
draw.style.opacity = "0";
// GAME OVER STUFF
if (roundNumber == maxNormalRounds) {
if (totalTime < highScore || highScore == 0) {
if (highScore == 0) {
alert("high score set!");
}
else {
alert("NEW FASTEST TIME!!! previous best: " + highScore.toFixed(3) + "s. congratulations cowboy.");
}
highScore = totalTime;
normalBestTimes[submode] = highScore;
localStorage.setItem("normalBestTimes", JSON.stringify(normalBestTimes));
}
gameText.innerText = "ROUND: " + roundSeconds.toFixed(3) +
"s\nTOTAL: " + totalTime.toFixed(3) +
"s\nBEST " + submode.toString() + "-ROUND TIME: " + highScore.toFixed(3) + "s";
numTargets = 0;
targetsClicked =0;
gameOverButtons.style.display = "flex";
gameOverButtons.style.opacity = "100";
}
// NORMAL END OF ROUND STUFF
else {
center = generateTarget(xFrameAdjustment, yFrameAdjustment);
center.id = "center";
document.body.insertAdjacentElement("afterbegin", center);
console.log("ROUND: " + roundSeconds.toFixed(3) + "s\nTOTAL: " + totalTime.toFixed(3) + "\nhover on circle to start round " + currentRound);
gameText.innerText = "ROUND: " + roundSeconds.toFixed(3) + "s\nTOTAL: " + totalTime.toFixed(3) + "s\nhover on circle to start round " + currentRound;
center.addEventListener("mouseover", handleHoverStart);
center.addEventListener("mouseleave", handleHoverEnd);
center.addEventListener("touchend", handleHoverEnd);
normalBullseyeNeeded = true;
currentRound++;
}
gameText.style.display = "inline";
gameText.style.opacity = "100";
return;
}
async function setupCountdownMode(gameDuration) {
// fade buttons out
countdownSubModes.style.opacity = "0";
sleep(500);
countdownSubModes.style.display = "none";
// [re]set game parameters
targetsClicked = 0;
numTargets = 0;
countdownTime = gameDuration;
submode = gameDuration;
if (Object.keys(countdownHighScores).indexOf(submode.toString()) == -1) {
highScore = 0;
}
else {
highScore = countdownHighScores[submode];
}
// set game instruction text + fade it in
gameText.innerText = "hover on middle circle to start the countdown,\nthen click as many targets as possible!";
gameText.style.opacity = "0";
gameText.style.display = "inline";
await sleep(500);
gameText.style.opacity = "100";
// enable player to start game
center.addEventListener("mouseover", handleHoverStart);
center.addEventListener("mouseleave", handleHoverEnd);
center.addEventListener("touchend", handleHoverEnd);
}
async function handleCountdownMode(e) {
gamemode = "countdown";
countdownBullseyeNeeded = true;
await fadeMainMenuOut();
countdownSubModes.style.display = "flex";
countdownSubModes.style.opacity = "100";
}
async function handleHoverStart(e) {
hovering = true;
hoverCount++;
console.log('hover count: ' + hoverCount);
console.log('current round: ' + currentRound);
// remove all targets
frameElement.replaceChildren();
gameText.style.display = "none";
if (gamemode == "normal") {
await startNormalRound(currentRound);
}
else if (gamemode == "countdown") {
await startCountdownMode();
}
// if (infinityMode)
}
async function startCountdownMode() {
// if user cancels hover countdown, don't start round
if (!await drawCountdown(hoverCount)) {
console.log("player canceled coundown");
return;
}
// clear screen + render countdown / score
center.remove();
gameText.innerText = countdownTime.toFixed(3) + "s\ntargets clicked: " + targetsClicked;
// delay reveal of score until after "DRAW" fades
setTimeout(() => {
gameText.style.display = "inline";
gameText.style.opacity = "100";}, 1000);
// begin actual game loop!
randomTargetGenerator(1);
numTargets++;
countdownBullseyeNeeded = false;
while (countdownTime > 0.0) {
gameText.innerText = countdownTime.toFixed(2) + "s\ntargets clicked: " + targetsClicked;
// auto-reset target spawn paremeters if page was resized
if (needsResizeAdjustment) {
handlePageResize();
}
// generate new target if last one was clicked
if (targetsClicked == numTargets) {
randomTargetGenerator(1);
numTargets++;
}
countdownTime = countdownTime - .01;
await sleep(10);
}
// ensure no targets are still on screen after timer ends
frameElement.replaceChildren();
// show end of round text. if high score, alert player and save
if (targetsClicked > highScore || highScore == 0) {
if (targetsClicked == 0) {
alert("NEW HIGHEST SCORE!!! congratulations cowboy.");
}
else {
alert("NEW HIGHEST SCORE!!! previous best: " + highScore + " targets. congratulations cowboy.");
}
highScore = targetsClicked;
countdownHighScores[submode] = highScore;
localStorage.setItem("countdownHighScores", JSON.stringify(countdownHighScores));
}
gameText.innerText = "TARGETS CLICKED: " + targetsClicked +
"\n" + submode.toString() + "-SECOND HIGH SCORE: " + highScore;
numTargets = 0;
targetsClicked =0;
gameOverButtons.style.display = "flex";
gameOverButtons.style.opacity = "100";
}
/** HELPER TO KICK-OFF COUNTDOWNS FROM HOVER */
async function drawCountdown(hoverNumber) {
gameText.style.display = "none";
countingDown = true;
// this if-check prevents countdown from continuing if user removes / reapplies hover while we're sleeping
if (!hovering || hoverNumber != hoverCount) return false;
ready.style.display = "inline";
ready.style.opacity = "100";
await sleep(1000);
if (!hovering || hoverNumber != hoverCount) return false;
ready.style.display = "none";
ready.style.opacity = "0";
if (!hovering || hoverNumber != hoverCount) return false;
one.style.opacity = "0";
two.style.opacity = "0";
three.style.opacity = "0";
if (!hovering || hoverNumber != hoverCount) return false;
one.style.display = "inline";
two.style.display = "inline";
three.style.display = "inline";
if (!hovering || hoverNumber != hoverCount) return false;
three.style.opacity = "100";
await sleep(500);
if (!hovering || hoverNumber != hoverCount) return false;
two.style.opacity = "100";
await sleep(500);
if (!hovering || hoverNumber != hoverCount) return false;
one.style.opacity = "100";
await sleep(500);
if (!hovering || hoverNumber != hoverCount) return false;
one.style.display = "none";
two.style.display = "none";
three.style.display = "none";
one.style.opacity = "0";
two.style.opacity = "0";
three.style.opacity = "0";
draw.style.display = "inline";
draw.style.opacity = "100";
countingDown = false;
// keep "DRAW" on screen for first second of the round
setTimeout(() => {draw.style.display = "none"; draw.style.opacity = "0"; }, 1000);
return true;
}
function handleHoverEnd(e) {
hovering = false;
if (countingDown) {
ready.style.display = "none";
one.style.display = "none";
two.style.display = "none";
three.style.display = "none";
draw.style.display = "none";
ready.style.opacity = "0";
one.style.opacity = "0";
two.style.opacity = "0";
three.style.opacity = "0";
draw.style.opacity = "0";
if (gamemode == "normal") {
if (currentRound == 1) {
gameText.innerText = "hover on middle circle to start the countdown,\nthen click the targets as fast as possible!";
}
else {
gameText.innerText = "ROUND: " + roundSeconds.toFixed(3) + "s\nTOTAL: " + totalTime.toFixed(3) + "s\nhover on circle to start round " + currentRound;
}
}
else if (gamemode == "countdown") {
gameText.innerText = "hover on middle circle to start the countdown,\nthen click as many targets as possible!";
}
gameText.style.display = "inline";
countingDown = false;
}
}
async function handlePlayAgain(e) {
// hide game over buttons
gameOverButtons.style.display = "none";
gameOverButtons.style.opacity = "0";
// remove game text from DOM flow
gameText.style.display = "none";
gameText.style.opacity = "0";
setParameters();
center = generateTarget(xFrameAdjustment, yFrameAdjustment);
center.id = "center";
document.body.insertAdjacentElement("afterbegin", center);
if (gamemode == "normal") {
handleNormalMode(e);
}
else if (gamemode == "countdown") {
handleCountdownMode(e);
}
else {
alert("cannot play again, gamemode does not exit?");
}
}
async function handleReset(e) {
// bullseye won't be on screen after a game
setParameters();
center = generateTarget(xFrameAdjustment, yFrameAdjustment);
center.id = "center";
document.body.insertAdjacentElement("afterbegin", center);
// refresh game state variables
gamemode = "";
currentRound = 0;
// hide buttons
gameOverButtons.style.display = "none";
gameOverButtons.style.opacity = "0";
// remove game text from DOM flow
gameText.style.display = "none";
gameText.style.opacity = "0";
// reintroduce main menu elements into DOM flow
await sleep(800);
await drawCircle(2);
await fadeMainMenuIn();
// auto-reset bullseye if page was resized during animation
if (needsResizeAdjustment) {
handlePageResize();
}
}
function handleTargetClick(e) {
targetsClicked++;
e.target.remove();
}
function handlePageResize(event) {
// take note if page was resized during animation or mid gameplay to auto-reset at earliest convenience
if (drawingCircle || (targetsClicked < numTargets)) {
needsResizeAdjustment = true;
return;
}
frameElement.replaceChildren();
setParameters();
// if at main menu, move bullseye
if (gamemode == "") {
center.style.top = yFrameAdjustment + "px";
center.style.left = xFrameAdjustment + "px";
drawCircle(0);
}
// if inbetween gameplay rounds, move bullseye w/ game event listener
else if ((gamemode == "normal" && normalBullseyeNeeded) ||
(gamemode == "countdown" && countdownBullseyeNeeded)
) {
center.style.top = yFrameAdjustment + "px";
center.style.left = xFrameAdjustment + "px";
}
console.log('page: ' + pageWidth + ' x ' + pageHeight);
console.log('frame: ' + frameWidth + ' x ' + frameHeight);
needsResizeAdjustment = false;
}
async function drawCircle(drawDurationSeconds) {
drawingCircle = true;
let r, x, y;
let radians;
let target;
for (let i = 0; i < 360; i = i + 2) {
r = (frameWidth / 2) - (targetWidth / 2) - (gameBorderWidth * 2);
radians = degreesToRadians(i);
x = (r * 1 * Math.cos(radians)) + xFrameAdjustment;
y = (r * ratio * Math.sin(radians)) + yFrameAdjustment;
target = generateTarget(x, y);
target.classList.add("red-dot");
document.getElementById("frame").insertAdjacentElement("afterbegin", target);
if (drawDurationSeconds) {
target.classList.add("fade");
await sleep((drawDurationSeconds * 1000) / 180);
}
}
drawingCircle = false;
}
// helpers for rendering main menu stuff
async function fadeMainMenuIn() {
title1.classList.remove("fade");
title2.classList.remove("fade");
// add main menu elements to DOM flow
titleContainer.style.display = "flex";
title1.style.display = "inline";
title2.style.display = "inline";
gameModes.style.display = "flex";
// make title elements actually visible
title1.style.opacity = "100";
await sleep(500);
title2.style.opacity = "100";
await sleep(1500);
// add fade class for when elements are removed
title1.classList.add("fade");
title2.classList.add("fade");
// fade in gamemodes
await sleep(500);
gameModes.style.opacity = "100";
}
async function fadeMainMenuOut() {
let targets = document.getElementsByClassName("red-dot");
for (let i = 0; i < targets.length; i++) {
console.log(targets[i]);
targets[i].style.opacity = 0;
};
title1.style.opacity = "0";
title2.style.opacity = "0";
gameModes.style.opacity = "0";
await sleep(500);
frame.replaceChildren();
titleContainer.style.display = "none";
gameModes.style.display = "none";
title1.style.display = "none";
title2.style.display = "none";
}
function degreesToRadians(degrees) {
return degrees * (Math.PI / 180)
}
// function to place a target somewhere random on the circle
function randomTargetGenerator(numTargets) {
let radians, radius
let x, y;
let target;
for (let i = 0; i < numTargets; i++) {
radians = degreesToRadians(getRandomInt(0, 360));
radius = (frameWidth / 2) - (targetWidth / 2) - (gameBorderWidth * 2);
x = (radius * 1 * Math.cos(radians)) + xFrameAdjustment;
y = (radius * ratio * Math.sin(radians)) + yFrameAdjustment;
target = generateTarget(x, y);
target.classList.add("target");
target.addEventListener("click", handleTargetClick);
document.getElementById("frame").insertAdjacentElement("afterbegin", target);
}
}
function generateTarget(x, y) {
const target = document.createElement("div");
target.style.top = y +'px';
target.style.left = x + 'px';
return target;
}
function getRandomInt(min, max) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
</script>
</body>
</html>