Last active
February 5, 2025 21:18
-
-
Save jomido/5969a670db7a4a0eaf94dc925aa9070d to your computer and use it in GitHub Desktop.
Simple American Football Score Simulator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <html> | |
| <head> | |
| <meta name="viewport" content="width=device-width, user-scalable=no"> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, body { | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| margin: 0; | |
| padding: 0; | |
| background: #012; | |
| color: cyan; | |
| font-family: Consolas, monospace; | |
| user-select: none; | |
| } | |
| .root { | |
| width: 100%; | |
| height: 100%; | |
| padding: 1rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-evenly; | |
| flex-direction: column; | |
| gap: 1rem; | |
| } | |
| .root.done { | |
| background: rgba(0, 255, 155, 0.2); | |
| } | |
| .scores { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-around; | |
| width: 100%; | |
| pointer-events: none; | |
| } | |
| .home, .away { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| font-size: 1rem; | |
| width: 5rem; | |
| height: 5rem; | |
| gap: 0.5rem; | |
| background: rgba(0, 255, 255, 0.1); | |
| border-radius: 50%; | |
| border: 3px solid transparent; | |
| transition: 0.3s ease-in-out transform; | |
| /* hacks to fix blurry font on mobile while transforming */ | |
| /* backface-visibility: hidden; | |
| -webkit-font-smoothing: subpixel-antialiased; */ | |
| } | |
| .home.tied, .away.tied { | |
| border: 3px solid rgba(0, 255, 255, 0.2); | |
| } | |
| .home.winning, .away.winning { | |
| /* border: 3px solid cyan; */ | |
| background: rgba(0, 255, 255, 0.2); | |
| } | |
| .overtime { | |
| background: rgba(0, 255, 255, 0.2); | |
| padding: 1rem; | |
| border-radius: 2rem; | |
| opacity: 0; | |
| } | |
| .overtime.show { | |
| opacity: 1; | |
| } | |
| .buttons { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 100%; | |
| gap: 1rem; | |
| } | |
| button { | |
| border: 2px solid cyan; | |
| outline: none; | |
| padding: 1.5rem; | |
| background: transparent; | |
| color: cyan; | |
| cursor: pointer; | |
| user-select: none; | |
| font-size: 1.5rem; | |
| margin-top: auto; | |
| } | |
| button:hover { | |
| background: rgba(0, 255, 255, 0.2); | |
| } | |
| button:active { | |
| transform: translateY(1px); | |
| background: rgba(0, 255, 255, 0.3); | |
| } | |
| button.next.disabled { | |
| opacity: 0.2; | |
| pointer-events: none; | |
| } | |
| </style> | |
| <script> | |
| // rendering | |
| let raf = null | |
| const withRender = (fn) => { | |
| return (...args) => { | |
| fn(...args) | |
| if (!raf) { | |
| raf = requestAnimationFrame(() => { | |
| render(state) | |
| raf = null | |
| }) | |
| } | |
| return update | |
| } | |
| } | |
| const render = (state) => { | |
| renderRoot(state) | |
| renderHome(state) | |
| renderAway(state) | |
| renderOvertime(state) | |
| renderButtons(state) | |
| } | |
| </script> | |
| <script> | |
| // actions | |
| const actions = { | |
| onNext: () => { | |
| const index = state.scoreIndex + 1 | |
| update.scoreIndex(index) | |
| }, | |
| onReset: () => { | |
| const [scores, ot] = generateGame() | |
| update.scores(scores).scoreIndex(0).overtime(ot) | |
| if (ot) { | |
| console.log("OT") | |
| } | |
| } | |
| } | |
| </script> | |
| <script> | |
| // state | |
| const state = { | |
| overtime: false, | |
| scores: [], | |
| scoreIndex: 0, | |
| } | |
| const update = { | |
| overtime: withRender((ot) => { | |
| state.overtime = ot | |
| }), | |
| scores: withRender((scores) => { | |
| state.scores = scores | |
| }), | |
| scoreIndex: withRender((index) => { | |
| state.scoreIndex = index | |
| }) | |
| } | |
| </script> | |
| <script> | |
| // outcome generator | |
| const odds = { | |
| none: 0.575, | |
| fg: 0.3, | |
| td: 0.125, | |
| } | |
| const pts = { | |
| none: 0, | |
| fg: 3, | |
| td: 7, | |
| } | |
| const drives = 24 | |
| const generateGame = () => { | |
| let s = [0, 0] | |
| const g = [[0, 0]] | |
| let side = Math.random() <= 0.5 ? 0 : 1 | |
| const run = () => { | |
| const prob = Math.random() | |
| let o | |
| if (prob <= odds.td) { | |
| o = 'td' | |
| } else if (prob <= odds.td + odds.fg) { | |
| o = 'fg' | |
| } else { | |
| o = 'none' | |
| } | |
| const p = pts[o] | |
| if (p > 0) { | |
| if (side === 0) { | |
| s = [s[0] + p, s[1]] | |
| } | |
| else { | |
| s = [s[0], s[1] + p] | |
| } | |
| g.push([s[0], s[1]]) | |
| } | |
| side = side === 1 ? 0 : 1 | |
| } | |
| Array.from({ length: drives }, () => { | |
| run() | |
| }) | |
| let last = g[g.length - 1] | |
| let ot = false | |
| while (last[0] === last[1]) { | |
| ot = true | |
| side = Math.random() <= 0.5 ? 0 : 1 | |
| run() | |
| last = g[g.length - 1] | |
| } | |
| return [g, ot] | |
| } | |
| const pbp = () => { | |
| const [g, ot] = generateGame() | |
| g.forEach((score, i) => { | |
| if (i === g.length - 1 && ot) { | |
| console.log("------OT------") | |
| } | |
| console.log(`${i + 1}: ${score}`) | |
| }) | |
| } | |
| </script> | |
| <script> | |
| // DOM | |
| const makeDom = () => { | |
| const root = document.querySelector(".root") | |
| const scores = document.querySelector(".scores") | |
| const home = document.querySelector(".home") | |
| const homeTitle = home.querySelector(".title") | |
| const homeScore = home.querySelector(".score") | |
| const away = document.querySelector(".away") | |
| const awayTitle = away.querySelector(".title") | |
| const awayScore = away.querySelector(".score") | |
| const overtime = document.querySelector(".overtime") | |
| const buttons = document.querySelector(".buttons") | |
| const buttonNext = buttons.querySelector(".next") | |
| const buttonReset = buttons.querySelector(".reset") | |
| window.dom = { | |
| root, | |
| scores, | |
| home, | |
| homeTitle, | |
| homeScore, | |
| away, | |
| awayTitle, | |
| awayScore, | |
| overtime, | |
| buttons, | |
| buttonNext, | |
| buttonReset, | |
| } | |
| } | |
| </script> | |
| <script> | |
| // DOM: home | |
| const renderHome = (state) => { | |
| const myLastScore = state.scores[state.scoreIndex - 1]?.[0] | |
| const myScore = state.scores[state.scoreIndex][0] | |
| const theirScore = state.scores[state.scoreIndex][1] | |
| dom.homeScore.innerText = myScore | |
| dom.home.style.transform = utils.transform(myScore) | |
| if (myScore === 0 && theirScore === 0) { | |
| dom.home.classList = 'home' | |
| return | |
| } | |
| if (myScore > theirScore) { | |
| dom.home.classList = `home winning` | |
| } else if (myScore === theirScore) { | |
| dom.home.classList = `home tied` | |
| } else { | |
| dom.home.classList = `home` | |
| } | |
| } | |
| </script> | |
| <script> | |
| // DOM: root | |
| const renderRoot = () => { | |
| if (state.scoreIndex + 1 === state.scores.length) { | |
| dom.root.classList = 'root done' | |
| } else { | |
| dom.root.classList = 'root' | |
| } | |
| } | |
| </script> | |
| <script> | |
| // DOM: away | |
| const renderAway = (state) => { | |
| const myLastScore = state.scores[state.scoreIndex - 1]?.[1] | |
| const myScore = state.scores[state.scoreIndex][1] | |
| const theirScore = state.scores[state.scoreIndex][0] | |
| dom.awayScore.innerText = myScore | |
| dom.away.style.transform = utils.transform(myScore) | |
| if (myScore === 0 && theirScore === 0) { | |
| dom.away.classList = 'away' | |
| return | |
| } | |
| if (myScore > theirScore) { | |
| dom.away.classList = `away winning` | |
| } else if (myScore === theirScore){ | |
| dom.away.classList = `away tied` | |
| } else { | |
| dom.away.classList = `away` | |
| } | |
| } | |
| </script> | |
| <script> | |
| // DOM: overtime | |
| const renderOvertime = (state) => { | |
| if (state.scoreIndex + 2 >= state.scores.length && state.overtime) { | |
| dom.overtime.classList = "overtime show" | |
| } else { | |
| dom.overtime.classList = 'overtime' | |
| } | |
| } | |
| </script> | |
| <script> | |
| // DOM: buttons | |
| const renderButtons = (state) => { | |
| const next = dom.buttonNext | |
| if (state.scoreIndex + 1 >= state.scores.length) { | |
| next.classList = 'next disabled' | |
| } else { | |
| next.classList = 'next' | |
| } | |
| } | |
| </script> | |
| <script> | |
| // utils | |
| const utils = { | |
| transform: (score) => { | |
| const factor = score / 10 + 1 | |
| return `scale(${factor}) translateZ(0)` | |
| } | |
| } | |
| </script> | |
| <script> | |
| // misc | |
| /** | |
| * Attempt to prevent dbl-tap zoom on mobile | |
| */ | |
| document.addEventListener( | |
| "dblclick", | |
| function (event) { | |
| event.preventDefault(); | |
| }, | |
| { passive: false } | |
| ); | |
| </script> | |
| <script> | |
| // entrypoint | |
| document.addEventListener('DOMContentLoaded', function() { | |
| makeDom() | |
| actions.onReset() | |
| }) | |
| </script> | |
| </head> | |
| <body> | |
| <div class="root"> | |
| <div class="scores"> | |
| <div class="home"> | |
| <div class="title">HOME</div> | |
| <div class="score">0</div> | |
| </div> | |
| <div class="away"> | |
| <div class="title">AWAY</div> | |
| <div class="score">0</div> | |
| </div> | |
| </div> | |
| <div class="overtime">-*[ OVERTIME ]*-</div> | |
| <div class="buttons"> | |
| <button class="next" onClick="actions.onNext()">next</button> | |
| <button class="reset" onClick="actions.onReset()">reset</button> | |
| </div> | |
| </div> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment