├── CNAME ├── public ├── _redirects ├── ads.txt ├── favicon.ico ├── assets │ ├── news.png │ ├── stats.png │ ├── Jingle.png │ ├── settings.png │ ├── Settings.webp │ ├── background.jpg │ ├── homeButton.png │ ├── newspaper.png │ ├── old-home.webp │ ├── osrsButton.png │ ├── osrs_frame.png │ ├── osrs_house.png │ ├── osrs_button.png │ ├── osrs_history.png │ ├── osrs_skills.png │ ├── osrs_wrench.png │ ├── osrsButtonWide.png │ ├── osrsGuessButton.png │ ├── osrsNewsButton.png │ ├── osrsStatsButton.png │ ├── osrs_frame_dark.png │ ├── osrs_newspaper.png │ ├── osrsButtonCredits.png │ ├── osrsSettingsButton.png │ ├── osrs_chat_button.png │ ├── osrs_close_button.png │ ├── osrs_map_link_icon.png │ ├── osrs_map_zoom_in.webp │ ├── osrs_map_zoom_out.webp │ ├── osrs_button_disabled.png │ └── osrs_dungeon_map_link_icon.png ├── fonts │ └── runescape.ttf ├── mapicons │ ├── 10001.png │ ├── 10002.png │ ├── 10003.png │ ├── 10004.png │ ├── 10005.png │ ├── 10006.png │ ├── 10007.png │ ├── 10008.png │ ├── 10009.png │ ├── 10010.png │ ├── 10011.png │ ├── 10012.png │ └── 10013.png └── tilemapresource.xml ├── src ├── vite-env.d.ts ├── utils │ ├── css-utils.ts │ ├── isMobile.ts │ ├── assert.ts │ ├── countSelectedSongs.ts │ ├── sanitizeSongName.ts │ ├── date-utils.ts │ ├── jingle-utils.ts │ ├── playSong.ts │ ├── string-utils.ts │ ├── getRandomSong.ts │ ├── browserUtil.ts │ └── map-config.ts ├── constants │ ├── jingleSettings.ts │ ├── links.ts │ ├── assets.ts │ ├── localStorage.ts │ ├── defaults.ts │ ├── news.ts │ └── regions.ts ├── components │ ├── side-menu │ │ ├── HomeButton.tsx │ │ ├── IconButton.tsx │ │ ├── NewsModalButton.tsx │ │ ├── StatsModalButton.tsx │ │ ├── HistoryModalButton.tsx │ │ └── PreferencesModalButton.tsx │ ├── ui-util │ │ ├── TooltipIcon.tsx │ │ ├── Button.tsx │ │ ├── CheckboxRow.tsx │ │ ├── ExpandableSection.tsx │ │ └── CustomRangeInput.tsx │ ├── Footer.tsx │ ├── LayerPortals.tsx │ ├── Modal.tsx │ ├── GameOver.tsx │ ├── RoundResult.tsx │ ├── AdSenseComponent.tsx │ ├── MainMenu.tsx │ ├── AudioControls.tsx │ ├── Practice.tsx │ ├── RunescapeMap.tsx │ ├── DailyJingle.tsx │ └── NextDailyCountdown.tsx ├── main.tsx ├── style │ ├── adComponent.css │ ├── resultMessage.css │ ├── footer.css │ ├── audio.css │ ├── leaflet.css │ ├── iconButton.css │ ├── osrs-ui.css │ ├── resultScreen.css │ ├── mainMenu.css │ ├── uiBox.css │ └── modal.css ├── .eslintrc.json ├── scripts │ ├── music │ │ ├── readme.txt │ │ ├── GetGeoJSONData.py │ │ └── ConvertMusicPolys.py │ └── filter-regions.ts ├── hooks │ ├── useCountdown.ts │ └── useGameLogic.ts ├── App.css ├── index.css ├── App.tsx ├── types │ └── jingle.ts └── data │ ├── jingle-api.ts │ └── audio2004.ts ├── vite.config.ts ├── tsconfig.json ├── .prettierrc ├── tsconfig.node.json ├── .gitignore ├── tsconfig.app.json ├── eslint.config.js ├── README.md ├── package.json └── index.html /CNAME: -------------------------------------------------------------------------------- 1 | jingle.rs -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/ads.txt: -------------------------------------------------------------------------------- 1 | google.com, pub-9264325141836527, DIRECT, f08c47fec0942fa0 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/utils/css-utils.ts: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | export const cn = clsx; 4 | -------------------------------------------------------------------------------- /public/assets/news.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/news.png -------------------------------------------------------------------------------- /public/assets/stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/stats.png -------------------------------------------------------------------------------- /public/assets/Jingle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/Jingle.png -------------------------------------------------------------------------------- /public/assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/settings.png -------------------------------------------------------------------------------- /public/fonts/runescape.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/fonts/runescape.ttf -------------------------------------------------------------------------------- /public/mapicons/10001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10001.png -------------------------------------------------------------------------------- /public/mapicons/10002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10002.png -------------------------------------------------------------------------------- /public/mapicons/10003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10003.png -------------------------------------------------------------------------------- /public/mapicons/10004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10004.png -------------------------------------------------------------------------------- /public/mapicons/10005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10005.png -------------------------------------------------------------------------------- /public/mapicons/10006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10006.png -------------------------------------------------------------------------------- /public/mapicons/10007.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10007.png -------------------------------------------------------------------------------- /public/mapicons/10008.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10008.png -------------------------------------------------------------------------------- /public/mapicons/10009.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10009.png -------------------------------------------------------------------------------- /public/mapicons/10010.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10010.png -------------------------------------------------------------------------------- /public/mapicons/10011.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10011.png -------------------------------------------------------------------------------- /public/mapicons/10012.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10012.png -------------------------------------------------------------------------------- /public/mapicons/10013.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/mapicons/10013.png -------------------------------------------------------------------------------- /public/assets/Settings.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/Settings.webp -------------------------------------------------------------------------------- /public/assets/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/background.jpg -------------------------------------------------------------------------------- /public/assets/homeButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/homeButton.png -------------------------------------------------------------------------------- /public/assets/newspaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/newspaper.png -------------------------------------------------------------------------------- /public/assets/old-home.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/old-home.webp -------------------------------------------------------------------------------- /public/assets/osrsButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsButton.png -------------------------------------------------------------------------------- /public/assets/osrs_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_frame.png -------------------------------------------------------------------------------- /public/assets/osrs_house.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_house.png -------------------------------------------------------------------------------- /public/assets/osrs_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_button.png -------------------------------------------------------------------------------- /public/assets/osrs_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_history.png -------------------------------------------------------------------------------- /public/assets/osrs_skills.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_skills.png -------------------------------------------------------------------------------- /public/assets/osrs_wrench.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_wrench.png -------------------------------------------------------------------------------- /public/assets/osrsButtonWide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsButtonWide.png -------------------------------------------------------------------------------- /public/assets/osrsGuessButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsGuessButton.png -------------------------------------------------------------------------------- /public/assets/osrsNewsButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsNewsButton.png -------------------------------------------------------------------------------- /public/assets/osrsStatsButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsStatsButton.png -------------------------------------------------------------------------------- /public/assets/osrs_frame_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_frame_dark.png -------------------------------------------------------------------------------- /public/assets/osrs_newspaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_newspaper.png -------------------------------------------------------------------------------- /public/assets/osrsButtonCredits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsButtonCredits.png -------------------------------------------------------------------------------- /public/assets/osrsSettingsButton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrsSettingsButton.png -------------------------------------------------------------------------------- /public/assets/osrs_chat_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_chat_button.png -------------------------------------------------------------------------------- /public/assets/osrs_close_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_close_button.png -------------------------------------------------------------------------------- /public/assets/osrs_map_link_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_map_link_icon.png -------------------------------------------------------------------------------- /public/assets/osrs_map_zoom_in.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_map_zoom_in.webp -------------------------------------------------------------------------------- /public/assets/osrs_map_zoom_out.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_map_zoom_out.webp -------------------------------------------------------------------------------- /src/constants/jingleSettings.ts: -------------------------------------------------------------------------------- 1 | export const JINGLE_SETTINGS = { 2 | dailySongs: 5, 3 | hardModeSeconds: 2, 4 | }; 5 | -------------------------------------------------------------------------------- /public/assets/osrs_button_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_button_disabled.png -------------------------------------------------------------------------------- /src/constants/links.ts: -------------------------------------------------------------------------------- 1 | export const cdnURL = 'https://cdn.mahloola.com'; 2 | export const imageCdnURL = 'https://jingle.mahloola.com'; 3 | -------------------------------------------------------------------------------- /public/assets/osrs_dungeon_map_link_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mahloola/Jingle/HEAD/public/assets/osrs_dungeon_map_link_icon.png -------------------------------------------------------------------------------- /src/utils/isMobile.ts: -------------------------------------------------------------------------------- 1 | export const isMobile = navigator.userAgent.match( 2 | /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i, 3 | ); 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | export function assertNotNil(value: T | null, name = 'value'): asserts value is T { 2 | if (value === null) { 3 | throw new Error(`Expected ${name} to not be null`); 4 | } 5 | if (value === undefined) { 6 | throw new Error(`Expected ${name} to not be undefined`); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], 4 | "compilerOptions": { 5 | "target": "ESNext", 6 | "module": "CommonJS", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/side-menu/HomeButton.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { ASSETS } from '../../constants/assets'; 3 | import IconButton from './IconButton'; 4 | 5 | function HomeButton() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default HomeButton; 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App.tsx'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /src/constants/assets.ts: -------------------------------------------------------------------------------- 1 | import { cdnURL } from './links'; 2 | 3 | export const ASSETS = { 4 | home: '/assets/osrs_house.png', 5 | newsIcon: '/assets/osrs_newspaper.png', 6 | settingsIcon: '/assets/osrs_wrench.png', 7 | historyIcon: '/assets/osrs_history.png', 8 | stats: '/assets/osrs_skills.png', 9 | label: `${cdnURL}/osrsButton.png`, 10 | labelWide: `${cdnURL}/osrsButtonWide.png`, 11 | }; 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100, 8 | "endOfLine": "lf", 9 | "singleAttributePerLine": true, 10 | "bracketSameLine": false, 11 | "arrowParens": "always", 12 | "overrides": [ 13 | { 14 | "files": "*.html", 15 | "options": { 16 | "printWidth": 80 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ui-util/TooltipIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FaQuestionCircle } from 'react-icons/fa'; 2 | import { Tooltip } from 'react-tooltip'; 3 | 4 | interface TooltipIconProps { 5 | id: string; 6 | content: string; 7 | } 8 | 9 | export const TooltipIcon = ({ id, content }: TooltipIconProps) => ( 10 | <> 11 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /src/constants/localStorage.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_STORAGE = { 2 | gameState: (jingleNumber: number) => `jingle-${jingleNumber}-gameState`, 3 | preferences: 'preferences', 4 | dailyComplete: 'dailyComplete', 5 | currentStreak: 'currentStreak', 6 | seenAnnouncementId: 'announcementSeenId', 7 | openModalId: 'openModalId', 8 | maxStreak: 'maxStreak', 9 | correctGuessCount: 'correctGuessCount', 10 | incorrectGuessCount: 'incorrectGuessCount', 11 | }; 12 | -------------------------------------------------------------------------------- /src/style/adComponent.css: -------------------------------------------------------------------------------- 1 | .ad-component-container { 2 | min-width: 50%; 3 | min-height: 80px; 4 | position: absolute; 5 | transform: translate(-50%, -50%); 6 | left: 50%; 7 | top: 50px; 8 | } 9 | 10 | .ad-component { 11 | min-width: 100%; 12 | min-height: 100%; 13 | position: absolute; 14 | transform: translate(-50%, -50%); 15 | left: 50%; 16 | top: 50px; 17 | } 18 | @media (max-width: 768px) { 19 | .ad-component-container { 20 | min-width: 70%; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 13, 17 | "sourceType": "module" 18 | }, 19 | "plugins": ["react", "@typescript-eslint"], 20 | "rules": {} 21 | } 22 | -------------------------------------------------------------------------------- /src/scripts/music/readme.txt: -------------------------------------------------------------------------------- 1 | GetGeoJSONData scrapes the wiki song by song and saves it 2 | 3 | compositemapconverter is Fawlty's (wiki guy) script to convert the world defs dumped from cache 4 | 5 | ConvertMusicPolys. converts all song polys into the format we're using now with convertedGeometry. It needs our geojsons, and convertedWorldDefs, which Fawlty's script outputs. 6 | 7 | Still gonna need to extract the worldDefs from cache for new updates though, to run compositemapconverter. For this use runelite cache tools or the wiki's map exporter wrapper of it -------------------------------------------------------------------------------- /src/style/resultMessage.css: -------------------------------------------------------------------------------- 1 | .result-message { 2 | z-index: 400; 3 | top: 40%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | font-size: 7rem; 7 | font-style: italic; 8 | pointer-events: none; 9 | position: absolute; 10 | display: block; 11 | transition: opacity 0.3s; 12 | transition: margin-top 1s; 13 | text-shadow: 2px 2px 5px black; 14 | color: var(--primary-yellow); 15 | } 16 | 17 | @media (max-width: 768px) { 18 | .result-message { 19 | font-size: 4rem; 20 | } 21 | } 22 | 23 | .streak { 24 | color: rgb(255, 134, 134); 25 | font-size: 0.5em; 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "target": "ES2022", 6 | "lib": ["ES2023"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | /* Linting */ 16 | "strict": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["vite.config.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | 11 | 12 | public/rsmap-tiles/mapIdTiles 13 | osrs_map_full.png 14 | /misc 15 | hidden-utils/ 16 | songs/ 17 | data.csv 18 | musicList.ts 19 | 20 | .env 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | build/ 27 | node_modules 28 | dist 29 | dist-ssr 30 | *.local 31 | 32 | # Editor directories and files 33 | .vscode/* 34 | !.vscode/extensions.json 35 | .idea 36 | .DS_Store 37 | *.suo 38 | *.ntvs* 39 | *.njsproj 40 | *.sln 41 | *.sw? 42 | -------------------------------------------------------------------------------- /src/components/side-menu/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../../style/iconButton.css'; 3 | 4 | interface IconButtonProps extends React.ButtonHTMLAttributes { 5 | img: string; 6 | unseenAnnouncement?: boolean; 7 | } 8 | export default function IconButton({ img, unseenAnnouncement, ...props }: IconButtonProps) { 9 | return ( 10 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ui-util/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Button = (props: { 4 | label: string; 5 | disabled?: boolean; 6 | classes?: string; 7 | onClick: (e: React.MouseEvent) => void; 8 | }) => { 9 | const baseClasses = 'osrs-btn'; 10 | const combinedClasses = props.classes ? `${baseClasses} ${props.classes}` : baseClasses; 11 | 12 | return ( 13 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/countSelectedSongs.ts: -------------------------------------------------------------------------------- 1 | import { Region, REGIONS, UNDERGROUND_TRACKS } from '../constants/regions'; 2 | import { UserPreferences } from '../types/jingle'; 3 | 4 | export const countSelectedSongs = (preferences: UserPreferences, region: Region) => { 5 | const regionSongs = REGIONS[region] || []; 6 | const regionUndergroundSongs = UNDERGROUND_TRACKS.filter((song) => regionSongs.includes(song)); 7 | let count = 0; 8 | if (preferences.undergroundSelected) { 9 | count += regionUndergroundSongs.length; 10 | } 11 | if (preferences.surfaceSelected) { 12 | count += regionSongs.length - regionUndergroundSongs.length; 13 | } 14 | return count; 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | /* Linting */ 18 | "strict": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useCountdown.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import dayjs, { Dayjs } from 'dayjs'; 3 | import duration from 'dayjs/plugin/duration'; 4 | import { calculateTimeDifference } from '../utils/date-utils'; 5 | 6 | dayjs.extend(duration); 7 | 8 | export default function useCountdown(end: Dayjs) { 9 | const [countdown, setCountdown] = useState(formatCountdown(end)); 10 | useEffect(() => { 11 | const interval = setInterval(() => { 12 | setCountdown(formatCountdown(end)); 13 | }, 1000); 14 | return () => clearInterval(interval); 15 | }, [end]); 16 | return countdown; 17 | } 18 | 19 | function formatCountdown(end: Dayjs): string { 20 | return calculateTimeDifference(Date.now(), end.valueOf()); 21 | } 22 | -------------------------------------------------------------------------------- /src/style/footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | width: 100%; 3 | background-image: url('https://storage.googleapis.com/jingle-media/osrsButtonCredits.png') !important; 4 | z-index: 999 !important; 5 | padding: 8px 0 12px 0; 6 | color: var(--primary-yellow); 7 | box-shadow: 0px 1px 2px black; 8 | font-size: 1rem; 9 | } 10 | 11 | .link { 12 | color: #f9ffa0; 13 | text-decoration: none; 14 | transition: text-decoration 0.3s; 15 | } 16 | 17 | .link:hover { 18 | color: var(--primary-yellow-light) !important; 19 | } 20 | 21 | .icon { 22 | display: inline-block; 23 | width: 40px; 24 | } 25 | .icon svg { 26 | width: 25px; 27 | color: var(--primary-yellow); 28 | font-size: 17px; 29 | } 30 | 31 | .icon svg:hover { 32 | color: #f9ffa0 !important; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/sanitizeSongName.ts: -------------------------------------------------------------------------------- 1 | export const sanitizeSongName = (songName: string): string => { 2 | const trimmedName = songName.trim(); 3 | const sanitizedName = trimmedName 4 | .replace(/<[^>]+>/g, '') 5 | // Replace HTML entities (like ') 6 | .replace(/&[#\w]+;/g, (match: string) => { 7 | const entityMap: Record = { 8 | ''': "'", 9 | '&': '&', 10 | '"': '"', 11 | '<': '<', 12 | '>': '>', 13 | }; 14 | return entityMap[match] || match; 15 | }) 16 | // Remove underscores 17 | .replace(/_/g, ' ') 18 | // Remove parentheticals like (music track) 19 | .replace(/\s*\([^)]*\)\s*/g, ' ') 20 | // Trim whitespace 21 | .trim(); 22 | return sanitizedName; 23 | }; 24 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Runescape UF'; 3 | src: url('/fonts/runescape.ttf'); 4 | } 5 | 6 | .App { 7 | text-align: center; 8 | font-family: 'Runescape UF'; 9 | width: 100vw; 10 | height: 100dvh; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | position: absolute; 15 | } 16 | 17 | .App-inner { 18 | width: 100vw; 19 | height: 100dvh; 20 | display: flex; 21 | justify-content: center; 22 | align-items: flex-end; 23 | position: absolute; 24 | padding-bottom: 5vh; 25 | } 26 | 27 | .absolute { 28 | position: absolute; 29 | } 30 | 31 | input[type='checkbox'] { 32 | accent-color: var(--primary-yellow); 33 | height: 1.1em; 34 | width: 1.1em; 35 | } 36 | input[type='checkbox']:hover { 37 | transform: scale(1.4); 38 | transition: all 0.1s; 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ui-util/CheckboxRow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface CheckboxRowProps { 4 | label: string; 5 | name: string; 6 | checked: boolean; 7 | onChange: (e: React.ChangeEvent) => void; 8 | disabled?: boolean; 9 | count?: number; 10 | } 11 | 12 | export const CheckboxRow = ({ 13 | label, 14 | name, 15 | checked, 16 | onChange, 17 | disabled = false, 18 | count, 19 | }: CheckboxRowProps) => ( 20 | 21 | 22 | {label} {count !== undefined && `(${count})`} 23 | 24 | 25 |
26 | 33 |
34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-yellow: #edfd07; 3 | --primary-yellow-dark: color-mix(in srgb, var(--primary-yellow) 90%, black 20%); 4 | --primary-yellow-light: color-mix(in srgb, var(--primary-yellow) 90%, white 60%); 5 | --primary-green: #00ff00; 6 | --primary-red: #ff0000; 7 | --primary-red-light: color-mix(in srgb, var(--primary-red) 90%, white 70%); 8 | } 9 | 10 | body { 11 | margin: 0; 12 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 13 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | code { 19 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 20 | } 21 | 22 | li { 23 | list-style: none; 24 | } 25 | 26 | button { 27 | background-color: transparent; 28 | border: none; 29 | } 30 | -------------------------------------------------------------------------------- /src/style/audio.css: -------------------------------------------------------------------------------- 1 | .hide-scrubber::-webkit-media-controls-timeline, 2 | .hide-scrubber::-webkit-media-controls-current-time-display, 3 | .hide-scrubber::-webkit-media-controls-time-remaining-display { 4 | display: none; 5 | opacity: 0; 6 | } 7 | 8 | .hide-audio { 9 | display: none; 10 | } 11 | 12 | .audio-container { 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | } 17 | .reload-audio-container { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | margin-left: 5px; 22 | height: 40px; 23 | width: 40px; 24 | background: rgba(29, 29, 29, 0.8); 25 | box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 26 | backdrop-filter: blur(5px); 27 | -webkit-backdrop-filter: blur(5px); 28 | } 29 | .reload-audio-btn { 30 | height: 17px; 31 | width: 17px; 32 | color: white !important; 33 | transition: all 0.3s; 34 | } 35 | .reload-audio-btn:hover { 36 | filter: brightness(0.8); 37 | transition: all 0.3s; 38 | } 39 | -------------------------------------------------------------------------------- /src/style/leaflet.css: -------------------------------------------------------------------------------- 1 | .leaflet-container { 2 | height: calc(100dvh - 400px); 3 | width: 100%; 4 | 5 | .leaflet-tile { 6 | image-rendering: pixelated; 7 | } 8 | } 9 | 10 | .leaflet-control-zoom { 11 | box-shadow: none !important; 12 | border: none !important; 13 | 14 | .leaflet-control-zoom-out, 15 | .leaflet-control-zoom-in { 16 | background-size: contain !important; 17 | color: transparent !important; 18 | background-color: transparent !important; 19 | border: none !important; 20 | width: 43.88px !important; 21 | height: 30px !important; 22 | margin-bottom: 3px !important; 23 | 24 | &:hover { 25 | filter: brightness(1.2) !important; 26 | cursor: pointer !important; 27 | } 28 | } 29 | 30 | .leaflet-control-zoom-out { 31 | background-image: url(/assets/osrs_map_zoom_out.webp) !important; 32 | } 33 | 34 | .leaflet-control-zoom-in { 35 | background-image: url(/assets/osrs_map_zoom_in.webp) !important; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...js.configs.recommended.rules, 22 | ...reactHooks.configs.recommended.rules, 23 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 24 | 'no-unused-vars': 'off', 25 | '@typescript-eslint/no-unused-vars': 'off', 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | }, 28 | }, 29 | ); 30 | -------------------------------------------------------------------------------- /src/style/iconButton.css: -------------------------------------------------------------------------------- 1 | .osrs-btn.icon-button { 2 | position: relative; 3 | width: 62px; 4 | height: 42px; 5 | z-index: 99999; 6 | border-image-width: 14px; 7 | 8 | padding: 6px; 9 | 10 | img { 11 | width: auto; 12 | height: 100%; 13 | } 14 | &:hover { 15 | transform: translate(-5px, 0px); 16 | } 17 | @media (max-width: 768px) { 18 | width: 55px; 19 | height: 38px; 20 | } 21 | } 22 | 23 | .notification-badge { 24 | position: absolute; 25 | top: -4px; /* Adjust to position vertically */ 26 | right: -4px; /* Adjust to position horizontally */ 27 | min-width: 18px; /* Ensures circle stays round */ 28 | height: 19px; 29 | background-color: #ff0000; 30 | color: white; 31 | border-radius: 50%; 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | font-size: 12px; 36 | padding: 2px; 37 | border: 2px solid white; /* Optional: adds contrast */ 38 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); /* Optional: subtle shadow */ 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/date-utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import duration from 'dayjs/plugin/duration'; 3 | import timezone from 'dayjs/plugin/timezone'; 4 | import utc from 'dayjs/plugin/utc'; 5 | 6 | dayjs.extend(utc); 7 | dayjs.extend(timezone); 8 | dayjs.extend(duration); 9 | 10 | export function getNextUkMidnight() { 11 | const now = dayjs().tz('Europe/London'); 12 | const nextMidnight = 13 | now.hour() < 0 14 | ? now.hour(0).minute(0).second(0).millisecond(0) 15 | : now.add(1, 'day').hour(0).minute(0).second(0).millisecond(0); 16 | return nextMidnight; 17 | } 18 | 19 | export const calculateTimeDifference = (startMs: number, endMs: number) => { 20 | const d = dayjs.duration(dayjs(endMs).diff(dayjs(startMs))); 21 | return [d.hours() > 0 && d.hours(), d.minutes(), d.seconds()] 22 | .filter((value) => value !== false) 23 | .map((value) => value.toString().padStart(2, '0')) 24 | .join(':'); 25 | }; 26 | 27 | export function getCurrentDateInBritain() { 28 | return dayjs().tz('Europe/London').format('YYYY-MM-DD'); 29 | } 30 | -------------------------------------------------------------------------------- /public/tilemapresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | rsmap.png 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![image](https://github.com/mahloola/osrs-music/assets/61226619/e95fd1f5-263e-47db-aee6-e96da23bffa0)](https://jingle.rs) 2 | 3 | ## Gameplay 4 | 5 | [Jingle](https://jingle.rs) is a GeoGuessr-inspired music guessing game for Oldschool RuneScape - the goal is to drop a pin on the map at the location where the current OSRS song is played in-game. 6 | 7 | At the moment, there are two modes: 8 | 9 | - Practice Mode (unlimited) 10 | - Daily Jingle (a global daily challenge consisting of 5 songs) 11 | 12 | The Daily Jingle resets at 00:00 in London, UK (GMT/BST) 13 | 14 | ## Development 15 | 16 | I opened a [Discord](https://discord.gg/AR2FDmWggU) for suggestions/issues, as well as pull request guidance. 17 | 18 |
19 | 20 | ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) 21 | ![Firebase](https://img.shields.io/badge/firebase-a08021?style=for-the-badge&logo=firebase&logoColor=ffcd34) 22 | ![Adobe Photoshop](https://img.shields.io/badge/adobe%20photoshop-%2331A8FF.svg?style=for-the-badge&logo=adobe%20photoshop&logoColor=white) 23 | 24 |
25 | 26 | ## Demo 27 | 28 | https://github.com/mahloola/osrs-music/assets/61226619/8fab475a-db16-4d2e-b123-5d2967c63c1e 29 | -------------------------------------------------------------------------------- /src/constants/defaults.ts: -------------------------------------------------------------------------------- 1 | import { GameState, GameStatus, UserPreferences } from '../types/jingle'; 2 | 3 | export const MAX_MIN_HISTORY_COLORS: [number, number] = [4500, 0]; // completely arbitrary, imo 5000 would show too much red and discourage people 4 | export const CENTER_COORDINATES: [number, number] = [3222, 3218]; 5 | export const DEFAULT_PREFERENCES: UserPreferences = { 6 | preferHardMode: false, 7 | preferOldAudio: false, 8 | preferConfirmation: true, 9 | regions: { 10 | Misthalin: true, 11 | Karamja: true, 12 | Asgarnia: true, 13 | Fremennik: true, 14 | Kandarin: true, 15 | Desert: true, 16 | Morytania: true, 17 | Tirannwn: true, 18 | Wilderness: true, 19 | Kourend: true, 20 | Varlamore: true, 21 | }, 22 | hardModeLength: 2, 23 | undergroundSelected: true, 24 | surfaceSelected: true, 25 | }; 26 | 27 | // IF USING THIS, PROVIDE SONGS[] AFTER YOU CREATE A DEFAULT OBJECT 28 | export const DEFAULT_GAME_STATE: GameState = { 29 | settings: { hardMode: false, oldAudio: false }, 30 | status: GameStatus.Guessing, 31 | round: 0, // 0-4 32 | songs: [], 33 | scores: [], 34 | startTimeMs: Date.now(), 35 | timeTaken: '', 36 | clickedPosition: null, 37 | navigationStack: [], 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/ui-util/ExpandableSection.tsx: -------------------------------------------------------------------------------- 1 | import { FaChevronDown } from 'react-icons/fa6'; 2 | import { IoWarning } from 'react-icons/io5'; 3 | import { Tooltip } from 'react-tooltip'; 4 | 5 | interface ExpandableSectionProps { 6 | title: string; 7 | expanded: boolean; 8 | onToggle: () => void; 9 | tooltip?: string; 10 | disabled?: boolean; 11 | } 12 | 13 | export const ExpandableSection = ({ 14 | title, 15 | expanded, 16 | onToggle, 17 | tooltip, 18 | disabled = false, 19 | }: ExpandableSectionProps) => ( 20 | 21 | 22 | {title}{' '} 23 | 28 | {disabled && tooltip && ( 29 | <> 30 | 39 | 40 | 41 | )} 42 | 43 | 44 | ); 45 | -------------------------------------------------------------------------------- /src/style/osrs-ui.css: -------------------------------------------------------------------------------- 1 | .osrs-btn { 2 | color: var(--primary-yellow); 3 | text-shadow: 1px 1px 1px black; 4 | white-space: nowrap; 5 | 6 | transition: all 0.4s; 7 | 8 | border-image-source: url('/assets/osrs_button.png'); 9 | border-image-slice: 36 fill; 10 | border-image-repeat: round; 11 | border-image-width: 10px; 12 | 13 | &:hover:not(:disabled) { 14 | filter: brightness(1.2); 15 | } 16 | &:disabled { 17 | border-image-source: url('/assets/osrs_button_disabled.png'); 18 | color: #6d6f4f; 19 | text-shadow: none; 20 | } 21 | } 22 | .osrs-btn .snippet-player button { 23 | &:disabled { 24 | border-image-source: url('/assets/osrs_button_disabled.png') !important; 25 | color: #99a13c !important; 26 | text-shadow: none; 27 | } 28 | } 29 | 30 | div.osrs-stone-btn { 31 | background: url('/assets/osrs_chat_button.png'); 32 | background-size: contain; 33 | aspect-ratio: 57/24; 34 | } 35 | 36 | .osrs-frame { 37 | border-image-source: url('/assets/osrs_frame.png'); 38 | border-image-slice: 36 fill; 39 | border-image-repeat: repeat; 40 | border-image-width: 8px; 41 | } 42 | 43 | .osrs-frame-dark { 44 | border-image-source: url('/assets/osrs_frame_dark.png'); 45 | border-image-slice: 36 fill; 46 | border-image-repeat: repeat; 47 | border-image-width: 8px; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { FaDiscord, FaDonate, FaGithub } from 'react-icons/fa'; 2 | import '../style/footer.css'; 3 | 4 | export default function Footer() { 5 | return ( 6 |
7 |
8 | developed by{' '} 9 | 13 | mahloola 14 | 15 | {', '} 16 | 20 | FunOrange 21 | 22 | {', and '} 23 | Kunito Moe 24 |
25 | 26 | 32 | 33 | 34 | 38 | 39 | 40 | 44 | 45 | 46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/css/bootstrap.min.css'; 2 | import { Route, Routes } from 'react-router-dom'; 3 | import useSWR from 'swr'; 4 | import './App.css'; 5 | import DailyJingle from './components/DailyJingle'; 6 | import MainMenu from './components/MainMenu'; 7 | import Practice from './components/Practice'; 8 | import { getDailyChallenge } from './data/jingle-api'; 9 | import './style/audio.css'; 10 | import './style/leaflet.css'; 11 | import './style/osrs-ui.css'; 12 | import './style/uiBox.css'; 13 | import { getCurrentDateInBritain } from './utils/date-utils'; 14 | 15 | function App() { 16 | const { data: dailyChallenge } = useSWR( 17 | `/api/daily-challenges/${getCurrentDateInBritain()}`, 18 | () => getDailyChallenge(getCurrentDateInBritain()), 19 | ); 20 | 21 | return ( 22 |
29 | 30 | } 33 | /> 34 | } 37 | /> 38 | } 41 | /> 42 | 43 |
44 | ); 45 | } 46 | 47 | export default App; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jingle", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.14.0", 14 | "@emotion/styled": "^11.14.1", 15 | "@mui/material": "^7.3.5", 16 | "@turf/turf": "^7.2.0", 17 | "axios": "^1.8.4", 18 | "bootstrap": "^5.3.3", 19 | "clsx": "^2.1.1", 20 | "dayjs": "^1.11.13", 21 | "dayjs-plugin-utc": "^0.1.2", 22 | "geojson": "^0.5.0", 23 | "leaflet": "^1.9.4", 24 | "ramda": "^0.30.1", 25 | "react": "^19.0.0", 26 | "react-dom": "^19.0.0", 27 | "react-icons": "^5.5.0", 28 | "react-leaflet": "^5.0.0", 29 | "react-modal": "^3.16.3", 30 | "react-router-dom": "^7.5.0", 31 | "react-tooltip": "^5.28.0", 32 | "swr": "^2.3.3", 33 | "ts-pattern": "^5.6.2" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.21.0", 37 | "@types/leaflet": "^1.9.16", 38 | "@types/node": "^24.0.8", 39 | "@types/ramda": "^0.30.2", 40 | "@types/react": "^19.0.10", 41 | "@types/react-dom": "^19.0.4", 42 | "@types/react-modal": "^3.16.3", 43 | "@typescript-eslint/eslint-plugin": "^8.28.0", 44 | "@typescript-eslint/parser": "^8.28.0", 45 | "@vitejs/plugin-react": "^4.3.4", 46 | "eslint": "^9.21.0", 47 | "eslint-plugin-react": "^7.37.4", 48 | "eslint-plugin-react-hooks": "^5.1.0", 49 | "eslint-plugin-react-refresh": "^0.4.19", 50 | "globals": "^15.15.0", 51 | "ts-node": "^10.9.2", 52 | "tsx": "^4.20.3", 53 | "typescript": "~5.7.2", 54 | "typescript-eslint": "^8.24.1", 55 | "vite": "^6.2.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/jingle-utils.ts: -------------------------------------------------------------------------------- 1 | import { sum } from 'ramda'; 2 | import { DailyChallenge, GameState, Song } from '../types/jingle'; 3 | 4 | export function getJingleNumber(dailyChallenge: Pick) { 5 | const dailyChallengeDate = dailyChallenge.date; 6 | const currentDate = new Date(dailyChallengeDate); 7 | const targetDate = new Date('2024-05-17'); 8 | return (currentDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24); 9 | } 10 | 11 | export function copyResultsToClipboard( 12 | gameState: GameState, 13 | percentile: number | null, 14 | jingleNumber: number, 15 | ) { 16 | const score = sum(gameState.scores); 17 | const hardMode = gameState.settings.hardMode === true; 18 | 19 | let messageString = `I scored ${score} on Jingle challenge #${jingleNumber}! I finished in ${gameState.timeTaken} and `; 20 | if (!percentile) { 21 | messageString += `achieved first place! You can't beat me. https://jingle.rs\n\n`; 22 | } else { 23 | messageString += `placed in the top ${percentile.toFixed( 24 | 1, 25 | )}%, can you beat me? https://jingle.rs\n\n`; 26 | } 27 | 28 | const scoresString = gameState.scores 29 | .map((score) => (score === 0 ? '0 🔴' : score === 1000 ? '1000 🟢' : score + ' 🟡')) 30 | .join('\n'); 31 | 32 | navigator.clipboard.writeText(messageString + scoresString); 33 | alert(`Copied results to clipboard!`); 34 | } 35 | 36 | export function calculateSuccessRate(song: Song) { 37 | if (!song) return 0; 38 | 39 | const successRate = song.successRate ?? 0; 40 | const totalGuesses = song.successCount + song.failureCount; 41 | const successRateAverage = ( 42 | (successRate * totalGuesses + successRate) / 43 | (totalGuesses + 1) 44 | ).toFixed(3); 45 | return Number(successRateAverage); 46 | } 47 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 14 | 18 | 22 | 26 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 60 | Jingle 61 | 62 | 63 |
64 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/components/LayerPortals.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Marker, useMap } from 'react-leaflet'; 3 | import L from 'leaflet'; 4 | import mapMetadata from '../data/map-metadata'; 5 | import { groupedLinks, MapLink } from '../data/map-links'; 6 | import { match } from 'ts-pattern'; 7 | 8 | export interface LayerPortalsProps { 9 | currentmapId: number; 10 | onPortalClick: (link: MapLink) => void; 11 | } 12 | export default function LayerPortals({ currentmapId, onPortalClick }: LayerPortalsProps) { 13 | const map = useMap(); 14 | const [zoom, setZoom] = useState(map.getZoom()); 15 | map.on('zoomend', () => setZoom(map.getZoom())); 16 | 17 | const iconSize = match(zoom) 18 | .with(0, () => 16) 19 | .with(1, () => 24) 20 | .with(2, () => 28) 21 | .with(3, () => 46) 22 | .otherwise(() => 32); 23 | const offset = match(zoom) 24 | .with(0, () => ({ x: -9, y: 9 })) 25 | .with(1, () => ({ x: -68 / 10, y: 68 / 10 })) 26 | .with(2, () => ({ x: -41 / 10, y: 40 / 10 })) 27 | .with(3, () => ({ x: -34 / 10, y: 31 / 10 })) 28 | .otherwise(() => ({ x: 0, y: 0 })); 29 | 30 | const currentMap = mapMetadata.find(map=>map.mapId == currentmapId)!; 31 | const mapIdLinks = groupedLinks[currentMap.name]; 32 | 33 | return mapIdLinks?.map((link, i) => ( 34 | onPortalClick(link) }} 37 | position={[link.start.y + offset.y, link.start.x + offset.x]} 38 | opacity={0} 39 | icon={ 40 | new L.Icon({ 41 | iconUrl: 42 | currentmapId === 0 43 | ? '/assets/osrs_dungeon_map_link_icon.png' 44 | : '/assets/osrs_map_link_icon.png', 45 | iconSize: [iconSize, iconSize], 46 | iconAnchor: [0, 0], 47 | }) 48 | } 49 | /> 50 | )); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactModal, { Styles } from 'react-modal'; 3 | import '../style/modal.css'; 4 | 5 | interface ModalProps { 6 | open: boolean; 7 | onClose: () => void; 8 | children: React.ReactNode; 9 | onApplySettings?: () => void; 10 | saveDisabled?: boolean; 11 | } 12 | 13 | const isMobile = window.innerWidth <= 480; 14 | const modalStyles: Styles = { 15 | content: { 16 | display: 'flex', 17 | padding: '16px 32px 24px 32px', 18 | position: 'fixed', 19 | width: isMobile ? '340px' : '370px', 20 | height: 'auto', 21 | left: '50%', 22 | top: '50%', 23 | transform: 'translate(-50%, -50%)', 24 | color: '#edfd07', 25 | zIndex: 9999999, 26 | fontFamily: 'Runescape UF', 27 | flexDirection: 'column', 28 | justifyContent: 'center', 29 | alignItems: 'center', 30 | transition: 'all 0.3s ease', 31 | }, 32 | }; 33 | 34 | ReactModal.setAppElement('#root'); 35 | 36 | export default function Modal({ 37 | open, 38 | onClose, 39 | onApplySettings, 40 | saveDisabled, 41 | children, 42 | }: ModalProps) { 43 | return ( 44 | 51 | {children} 52 |
53 | 59 | {onApplySettings && ( 60 | 67 | )} 68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ui-util/CustomRangeInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../../style/uiBox.css'; 3 | 4 | interface CustomRangeInputProps { 5 | onValueChange: (newValue: number) => void; 6 | currentValue: number; 7 | visible: boolean; 8 | } 9 | const CustomRangeInput: React.FC = ({ 10 | onValueChange, 11 | currentValue, 12 | visible, 13 | }) => { 14 | const [value, setValue] = useState(currentValue); 15 | const [displayValue, setDisplayValue] = useState(currentValue.toFixed(1).toString()); 16 | 17 | const valueToStepIndex = (val: number) => { 18 | const index = steps.findIndex((step) => step === val); 19 | return index >= 0 ? index : 0; // Default to first step if not found 20 | }; 21 | React.useEffect(() => { 22 | const newIndex = valueToStepIndex(currentValue); 23 | setValue(newIndex); 24 | setDisplayValue(currentValue % 1 === 0 ? currentValue.toString() : currentValue.toFixed(1)); 25 | }, [currentValue]); 26 | 27 | const steps = [0.2, 0.5, 1.0, 2.0, 3, 5, 10]; 28 | 29 | const handleChange = (e: React.ChangeEvent) => { 30 | const selectedIndex = Number(e.target.value); 31 | const selectedValue: number = steps[selectedIndex]; 32 | setValue(selectedIndex); 33 | setDisplayValue(selectedValue % 1 === 0 ? selectedValue.toString() : selectedValue.toFixed(1)); 34 | onValueChange(selectedValue); 35 | }; 36 | 37 | return ( 38 |
39 | 48 |
49 | {displayValue} second{value == 2 ? '' : 's'} 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default CustomRangeInput; 56 | -------------------------------------------------------------------------------- /src/constants/news.ts: -------------------------------------------------------------------------------- 1 | export const NEWS_POSTS = [ 2 | { 3 | id: '4', 4 | title: `Making History`, 5 | date: 'Dec 4, 2025', 6 | content: ` 7 | If you've ever wondered: 8 |

9 |

10 | - What's the global average? 11 |
- How many dailies have I done? 12 |
- Can I check my old scores? 13 |

14 | This update is for you.
15 | Retroactive from Apr 7 2025, check the history tab for some data reveals ⏪ 16 | `, 17 | }, 18 | { 19 | id: '3', 20 | title: `Harder Mode II`, 21 | date: 'Jul 14, 2025', 22 | content: ` 23 | - Hard mode 'snippets' are now RANDOMIZED 24 |
- Snippet length is customizable 🔧 25 |
- Timestamps are revealed after guesses 26 | `, 27 | }, 28 | { 29 | id: '2', 30 | title: `Dungeons!!`, 31 | date: 'Jul 8, 2025', 32 | content: ` 33 |

34 | 200+ dungeon tracks have been added 🎉 35 |
36 | (as if the game wasn't hard enough already) 37 |
- You can disable them in the settings 38 |
- Surface locations are off if surface is off 39 |
- Dailies will include dungeon picks 40 | `, 41 | }, 42 | { 43 | id: '1', 44 | title: `QOL`, 45 | date: 'Apr 7, 2025', 46 | content: ` 47 |

48 | - Varlamore added 49 |
- Stats (success%, max streak, etc.) 50 |
- Preference settings: 51 |
52 |  - Region selection 53 |
54 |  - 2004 audio 55 |
56 |  - Hard mode 57 |
58 |  - Confirmation button (no more misclicks) 59 |
- Misc: 60 |
61 |  - Edge distance calculation (vs center)
62 |  - Result copying stats fixed for mobile 63 |

64 | `, 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /src/utils/playSong.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | import { cdnURL } from '../constants/links'; 3 | import { audio2004 } from '../data/audio2004'; 4 | import { SongService } from './getRandomSong'; 5 | 6 | export const playSong = ( 7 | audioRef: RefObject, 8 | songName: string, 9 | oldAudio: boolean, 10 | hardModeLength?: number, 11 | ) => { 12 | let src; 13 | const songService = SongService.Instance(); 14 | if (oldAudio) { 15 | const oldAudioExists = songName in audio2004; 16 | src = oldAudioExists 17 | ? `${cdnURL}/${songName.trim().replace(/ /g, '_')}_(v1).mp3` 18 | : `${cdnURL}/${songName.trim().replace(/ /g, '_')}.mp3`; 19 | } else { 20 | src = `${cdnURL}/${songName.trim().replace(/ /g, '_')}.mp3`; 21 | } 22 | 23 | audioRef.current!.src = src; 24 | audioRef.current!.load(); 25 | 26 | if (hardModeLength && songService) { 27 | songService.resetSnippet(); 28 | playSnippet(audioRef, hardModeLength); 29 | } else { 30 | audioRef.current!.play(); 31 | } 32 | }; 33 | 34 | export const playSnippet = (audioRef: RefObject, length: number) => { 35 | const audioPlayer = audioRef.current; 36 | const songService = SongService.Instance(); 37 | if (!audioPlayer) return; 38 | 39 | const startPlayback = () => { 40 | const [start, end] = songService.getSnippet(audioRef, length)!; 41 | 42 | const stopTime = () => { 43 | if (audioPlayer.currentTime >= end) { 44 | audioPlayer.removeEventListener('timeupdate', stopTime); 45 | audioPlayer.pause(); 46 | audioPlayer.currentTime = start; 47 | } 48 | }; 49 | 50 | audioPlayer.addEventListener('timeupdate', stopTime); 51 | audioPlayer.currentTime = start; 52 | audioPlayer.play(); 53 | }; 54 | 55 | if (audioPlayer.readyState >= 3) { 56 | // Already ready 57 | startPlayback(); 58 | } else { 59 | // Wait until it's ready 60 | const onCanPlay = () => { 61 | audioPlayer.removeEventListener('canplay', onCanPlay); 62 | startPlayback(); 63 | }; 64 | audioPlayer.addEventListener('canplay', onCanPlay); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/style/resultScreen.css: -------------------------------------------------------------------------------- 1 | .result-screen-parent { 2 | position: absolute; 3 | left: 50%; 4 | top: 40%; 5 | transform: translate(-50%, -50%); 6 | z-index: 400; 7 | } 8 | 9 | .result-screen { 10 | font-size: 4vw; 11 | min-width: 30vw; 12 | white-space: nowrap; 13 | position: absolute; 14 | top: 25%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | z-index: 1200; 18 | font-size: 3vw; 19 | font-style: italic; 20 | color: var(--primary-yellow); 21 | text-shadow: 2px 2px 10px black; 22 | } 23 | 24 | .result-screen-results { 25 | padding: 20px; 26 | /* From https://css.glass */ 27 | background: rgba(0, 0, 0, 0.3); 28 | border-radius: 16px; 29 | box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 30 | backdrop-filter: blur(5px); 31 | -webkit-backdrop-filter: blur(5px); 32 | border: 1px solid rgba(0, 0, 0, 0.35); 33 | } 34 | 35 | .result-screen-title { 36 | display: flex; 37 | justify-content: center; 38 | } 39 | 40 | .result-screen-data-row { 41 | max-width: 90%; 42 | margin: 0 auto; 43 | display: flex; 44 | justify-content: space-between; 45 | font-size: 2vw; 46 | } 47 | 48 | .result-screen-option { 49 | font-size: 3vw; 50 | z-index: 9999; 51 | width: 50%; 52 | text-wrap: wrap; 53 | color: inherit; 54 | text-decoration: none; 55 | } 56 | 57 | .result-screen-option:hover { 58 | cursor: pointer; 59 | color: #f8ff98; 60 | } 61 | 62 | .result-screen-time-row { 63 | display: flex; 64 | justify-content: center; 65 | align-items: center; 66 | } 67 | 68 | .clock-svg { 69 | max-height: 3.5vw; 70 | } 71 | 72 | .result-screen-link-container { 73 | display: flex; 74 | flex-direction: row; 75 | width: 100%; 76 | justify-content: center; 77 | font-size: 3vw; 78 | } 79 | 80 | @media (max-width: 600px) { 81 | .result-screen-data-row { 82 | font-size: 5vw; 83 | } 84 | 85 | .result-screen-title { 86 | font-size: 7vw; 87 | } 88 | 89 | .result-screen { 90 | min-width: 60vw; 91 | font-size: 5vw; 92 | } 93 | 94 | .result-screen-option { 95 | font-size: 4vw !important; 96 | } 97 | 98 | .result-screen-time-row { 99 | margin-top: 3%; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/GameOver.tsx: -------------------------------------------------------------------------------- 1 | import { sum } from 'ramda'; 2 | import '../style/resultScreen.css'; 3 | import { DailyChallenge, GameState } from '../types/jingle'; 4 | import { getNextUkMidnight } from '../utils/date-utils'; 5 | import { isMobile } from '../utils/isMobile'; 6 | import { copyResultsToClipboard, getJingleNumber } from '../utils/jingle-utils'; 7 | import NextDailyCountdown from './NextDailyCountdown'; 8 | 9 | interface GameOverProps { 10 | gameState: GameState; 11 | dailyChallenge: DailyChallenge; 12 | percentile: number | null; 13 | } 14 | 15 | export default function GameOver({ gameState, dailyChallenge, percentile }: GameOverProps) { 16 | const jingleNumber = getJingleNumber(dailyChallenge); 17 | const score = sum(gameState.scores); 18 | 19 | return ( 20 |
21 |
22 |
Jingle #{jingleNumber}
23 |
24 |
Score
25 |
{score}
26 |
27 |
28 |
Time Taken
29 |
{gameState.timeTaken}
30 |
31 |
32 |
Top%
33 |
{percentile ? percentile.toFixed(1) + '%' : 'First Place'}
34 |
35 |
36 |
Next in
37 |
38 | 39 |
40 |
41 |
42 |
43 | {!isMobile && ( 44 |
copyResultsToClipboard(gameState, percentile, jingleNumber)} 47 | > 48 | Copy Results 49 |
50 | )} 51 | 52 | 56 | Back to Home 57 | 58 |
59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/RoundResult.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import '../style/resultMessage.css'; 3 | import { GameState, GameStatus } from '../types/jingle'; 4 | import { loadPersonalStatsFromBrowser } from '../utils/browserUtil'; 5 | 6 | interface ResultMessageProps { 7 | gameState: GameState; 8 | } 9 | 10 | export default function RoundResult({ gameState }: ResultMessageProps) { 11 | const { currentStreak, maxStreak } = loadPersonalStatsFromBrowser(); 12 | const [previousRecord, setPreviousRecord] = useState(undefined); 13 | const streakOfFive = currentStreak % 5 === 0 && currentStreak > 0; 14 | const justBeatPreviousRecord = currentStreak > 1 && currentStreak === previousRecord; 15 | 16 | useEffect(() => { 17 | const isNewRecord = currentStreak === maxStreak; 18 | if (isNewRecord && previousRecord === undefined) { 19 | // when the previous record is hit, capture it once and do not update it again 20 | setPreviousRecord(currentStreak); 21 | } 22 | }, [currentStreak, maxStreak, previousRecord]); 23 | 24 | // capture values to display on positive edge 25 | const [score, setScore] = useState(0); 26 | const [song, setSong] = useState(gameState.songs[0]); 27 | useEffect(() => { 28 | if (gameState.status === GameStatus.AnswerRevealed) { 29 | setSong(gameState.songs[gameState.round]); 30 | setScore(gameState.scores[gameState.round]); 31 | } 32 | }, [gameState]); 33 | 34 | const show = gameState.status === GameStatus.AnswerRevealed; 35 | return ( 36 |
51 | +{score} 52 |
{song}
53 | {(streakOfFive || justBeatPreviousRecord) && ( 54 |
58 | {currentStreak} streak 🔥 59 | {justBeatPreviousRecord && ( 60 | <> 61 |
62 | New record! 🎉 63 | 64 | )} 65 |
66 | )} 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/AdSenseComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import '../style/adComponent.css/'; 3 | interface AdSenseCommand { 4 | // Define known properties Google might expect 5 | [key: string]: unknown; 6 | } 7 | 8 | declare global { 9 | interface Window { 10 | adsbygoogle?: AdSenseCommand[]; 11 | } 12 | } 13 | 14 | // Usage with explicit typing 15 | const initializeAd = () => { 16 | const adsbygoogle = window.adsbygoogle || []; 17 | window.adsbygoogle = adsbygoogle; 18 | 19 | // Push a properly typed object 20 | adsbygoogle.push({ 21 | // You can add known AdSense properties here if needed 22 | }); 23 | }; 24 | 25 | interface AdSenseComponentProps { 26 | className?: string; 27 | style?: React.CSSProperties; 28 | adFormat?: string; 29 | adLayoutKey?: string; 30 | fullWidthResponsive?: boolean; 31 | } 32 | 33 | const AdSenseComponent: React.FC = ({ 34 | style = {}, 35 | adFormat = 'auto', 36 | adLayoutKey, 37 | fullWidthResponsive = true, 38 | }) => { 39 | useEffect(() => { 40 | // Check if script is already loaded 41 | const scriptId = 'adsbygoogle-script'; 42 | if (document.getElementById(scriptId)) return; 43 | 44 | const loadAdSense = () => { 45 | try { 46 | initializeAd(); 47 | } catch (error) { 48 | console.error('AdSense error:', error); 49 | } 50 | }; 51 | 52 | // Create and append script 53 | const script = document.createElement('script'); 54 | script.id = scriptId; 55 | script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9264325141836527`; 56 | script.async = true; 57 | script.crossOrigin = 'anonymous'; 58 | script.onload = loadAdSense; 59 | document.head.appendChild(script); 60 | 61 | // Fallback in case onload doesn't fire 62 | const timeoutId = setTimeout(loadAdSense, 1000); 63 | 64 | return () => { 65 | // Cleanup 66 | clearTimeout(timeoutId); 67 | const existingScript = document.getElementById(scriptId); 68 | if (existingScript) { 69 | document.head.removeChild(existingScript); 70 | } 71 | }; 72 | }, []); 73 | 74 | return ( 75 |
79 | 87 |
88 | ); 89 | }; 90 | 91 | export default AdSenseComponent; 92 | -------------------------------------------------------------------------------- /src/components/side-menu/NewsModalButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { ASSETS } from '../../constants/assets'; 3 | import { NEWS_POSTS } from '../../constants/news'; 4 | import '../../style/modal.css'; 5 | import { 6 | loadSeenAnnouncementIdFromBrowser, 7 | setSeenAnnouncementIdToBrowser, 8 | } from '../../utils/browserUtil'; 9 | import Modal from '../Modal'; 10 | import IconButton from './IconButton'; 11 | 12 | export default function NewsModalButton() { 13 | const seenAnnouncementId = loadSeenAnnouncementIdFromBrowser() || '0'; 14 | const latestAnnouncementId = NEWS_POSTS.length; 15 | const [open, setOpen] = useState(parseInt(seenAnnouncementId) < latestAnnouncementId); 16 | const closeModal = () => { 17 | setOpen(false); 18 | setSeenAnnouncementIdToBrowser(latestAnnouncementId.toString()); 19 | }; 20 | 21 | return ( 22 | <> 23 | setOpen(true)} 25 | img={ASSETS['newsIcon']} 26 | unseenAnnouncement={seenAnnouncementId === null} 27 | /> 28 | 32 | 36 |
48 | {NEWS_POSTS.map((post) => ( 49 |
53 |
60 |

{post.title}

61 |
62 |
63 | {

} 64 |

65 |
69 | {post.date} 70 |
71 | {parseInt(post.id) !== 1 &&
} 72 |
73 | ))} 74 |
75 |
76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/scripts/filter-regions.ts: -------------------------------------------------------------------------------- 1 | import geojsondata from '../data/GeoJSON.ts'; 2 | import { groupedLinks } from '../data/map-links.ts'; 3 | import { sanitizeSongName } from '../utils/sanitizeSongName.ts'; 4 | 5 | const borders = { 6 | zeah: [ 7 | [2880, 3195], 8 | [3072, 3544], 9 | ], 10 | }; 11 | 12 | export const filterDungeons = ({ strict }: { strict: boolean }) => { 13 | const features = geojsondata.features; 14 | const titles = []; 15 | let dungeonFeatures; 16 | if (strict) { 17 | dungeonFeatures = features.filter((feature) => { 18 | return feature.convertedGeometry.every((geometry) => geometry.mapId > 0); 19 | }); 20 | } else { 21 | dungeonFeatures = features.filter((feature) => { 22 | return feature.convertedGeometry.some((geometry) => geometry.mapId > 0); 23 | }); 24 | } 25 | for (const dungeonFeature of dungeonFeatures) { 26 | const sanitizedTitle = sanitizeSongName(dungeonFeature?.properties?.title); 27 | titles.push(sanitizedTitle); 28 | const locations = []; 29 | for (const geometry of dungeonFeature.convertedGeometry) { 30 | locations.push(geometry.mapName); 31 | } 32 | } 33 | return titles; 34 | }; 35 | 36 | export const filterDungeonOnly = () => { 37 | const features = geojsondata.features; 38 | const titles = []; 39 | const dungeonFeatures = features.filter((feature) => { 40 | return feature.convertedGeometry.some((geometry) => geometry.mapId > 0); 41 | }); 42 | for (const dungeonFeature of dungeonFeatures) { 43 | const sanitizedTitle = sanitizeSongName(dungeonFeature?.properties?.title); 44 | titles.push(sanitizedTitle); 45 | const locations = []; 46 | for (const geometry of dungeonFeature.convertedGeometry) { 47 | locations.push(geometry.mapName); 48 | } 49 | } 50 | return titles; 51 | }; 52 | const filterRegions = () => { 53 | console.log(groupedLinks); 54 | const features = geojsondata.features; 55 | const zeahFeatures = new Set(); 56 | features.forEach((feature) => { 57 | feature.geometry.coordinates.forEach((coordinates) => { 58 | coordinates.forEach((coordinate) => { 59 | if ( 60 | coordinate[0] >= 2880 && 61 | coordinate[1] >= 3195 && 62 | coordinate[0] <= 3072 && 63 | coordinate[1] <= 3544 64 | ) { 65 | zeahFeatures.add(extractSongTitle(feature?.properties?.title)); 66 | } 67 | }); 68 | }); 69 | }); 70 | console.log(zeahFeatures); 71 | }; 72 | 73 | function extractSongTitle(htmlString: any) { 74 | // Regex to match content inside `title="..."` attribute 75 | const regex = /title="([^"]+)"/; 76 | const match = htmlString.match(regex); 77 | 78 | // Return the captured group (the title) or null if no match 79 | return match ? match[1] : null; 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/string-utils.ts: -------------------------------------------------------------------------------- 1 | import { MAX_MIN_HISTORY_COLORS } from '../constants/defaults'; 2 | 3 | export const decodeHTML = (encodedString: string) => { 4 | const parser = new DOMParser(); 5 | return parser.parseFromString(encodedString, 'text/html').body.textContent; 6 | }; 7 | 8 | // Helper function that handles both HH:MM and HH:MM:SS formats 9 | export const formatTimeTaken = (timeTaken: string | number | undefined): string => { 10 | if (!timeTaken) return '--:--'; 11 | 12 | const str = String(timeTaken).trim(); 13 | 14 | // Already in correct format: "04:34" or "04:34:12" 15 | if (/^\d{2}:\d{2}(:\d{2})?$/.test(str)) { 16 | return str; 17 | } 18 | 19 | // Check if it's just a number (total seconds or minutes) 20 | if (/^\d{1,6}$/.test(str)) { 21 | const totalSeconds = parseInt(str); 22 | 23 | // If number is small (< 7200 = 2 hours), assume minutes 24 | // If larger, assume seconds 25 | if (totalSeconds < 7200) { 26 | // Assume it's minutes (like "54") 27 | const minutes = totalSeconds; 28 | const hours = Math.floor(minutes / 60); 29 | const mins = minutes % 60; 30 | return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; 31 | } else { 32 | // Assume it's seconds (like "145" seconds = 2:25) 33 | const hours = Math.floor(totalSeconds / 3600); 34 | const minutes = Math.floor((totalSeconds % 3600) / 60); 35 | const seconds = totalSeconds % 60; 36 | return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String( 37 | seconds, 38 | ).padStart(2, '0')}`; 39 | } 40 | } 41 | 42 | // Invalid format, return placeholder 43 | return '--:--'; 44 | }; 45 | 46 | export const calcDailyAvgColor = (val: number): string => { 47 | const [max, min] = MAX_MIN_HISTORY_COLORS; 48 | const normalized = Math.min(Math.max((val - min) / (max - min), 0), 1); 49 | 50 | // RGB values for our gradient stops 51 | const colors = [ 52 | { r: 255, g: 0, b: 0 }, 53 | { r: 237, g: 253, b: 7 }, // Yellow at 0.5 54 | { r: 0, g: 255, b: 0 }, 55 | ]; 56 | 57 | let r, g, b; 58 | 59 | if (normalized <= 0.5) { 60 | // Between green and yellow 61 | const ratio = normalized * 2; 62 | r = Math.round(colors[0].r + (colors[1].r - colors[0].r) * ratio); 63 | g = Math.round(colors[0].g + (colors[1].g - colors[0].g) * ratio); 64 | b = Math.round(colors[0].b + (colors[1].b - colors[0].b) * ratio); 65 | } else { 66 | // Between yellow and red 67 | const ratio = (normalized - 0.5) * 2; 68 | r = Math.round(colors[1].r + (colors[2].r - colors[1].r) * ratio); 69 | g = Math.round(colors[1].g + (colors[2].g - colors[1].g) * ratio); 70 | b = Math.round(colors[1].b + (colors[2].b - colors[1].b) * ratio); 71 | } 72 | 73 | return `rgb(${r}, ${g}, ${b})`; 74 | }; 75 | -------------------------------------------------------------------------------- /src/scripts/music/GetGeoJSONData.py: -------------------------------------------------------------------------------- 1 | #i didn't test this after making changes coz my internet sucks, but it should work. sorry. 2 | 3 | import requests 4 | import re 5 | import json 6 | from bs4 import BeautifulSoup 7 | import os 8 | 9 | def extract_kartographer_data(page_name): 10 | page_name = page_name.lstrip() 11 | page_name = page_name.rstrip() 12 | url = f"https://oldschool.runescape.wiki/w/{page_name.replace(' ', '_')}" 13 | headers = {"User-Agent": "Mozilla/5.0"} 14 | response = requests.get(url, headers=headers) 15 | 16 | if response.status_code != 200: 17 | print(f"Failed to fetch page: {response.status_code}") 18 | return None 19 | 20 | # Find the start of the JSON object 21 | start = response.text.find('"wgKartographerLiveData":') 22 | if start == -1: 23 | print("wgKartographerLiveData not found in: ", page_name, "\n") 24 | return None 25 | 26 | # Extract text from the start of the object 27 | substring = response.text[start + len('"wgKartographerLiveData":'):] 28 | 29 | #match brackets 30 | brace_count = 0 31 | json_str = '' 32 | for c in substring.strip(): 33 | json_str += c 34 | if c == '{': 35 | brace_count += 1 36 | elif c == '}': 37 | brace_count -= 1 38 | if brace_count == 0: 39 | break 40 | 41 | try: 42 | data = json.loads(json_str) 43 | return data 44 | except json.JSONDecodeError as e: 45 | print(f"JSON decode error: {e}") 46 | return None 47 | 48 | def get_non_holiday_tracks(): 49 | url = "https://oldschool.runescape.wiki/w/Music" 50 | headers = { 51 | "User-Agent": "///" 52 | } 53 | 54 | response = requests.get(url, headers=headers) 55 | soup = BeautifulSoup(response.text, "html.parser") 56 | 57 | tracks = [] 58 | 59 | # Loop over all rows with a data-music-track-name attribute 60 | for row in soup.find_all("tr", attrs={"data-music-track-name": True}): 61 | # Check if first contains tag (which indicates a holiday track) 62 | first_td = row.find("td") 63 | if first_td.find("i"): # Holiday track 64 | continue 65 | 66 | track_name = first_td.find("a")["title"] 67 | tracks.append(track_name) 68 | 69 | return tracks 70 | 71 | 72 | def scrape_geojson(data): 73 | 74 | geojsondata = [] 75 | 76 | if True: 77 | for i, page in enumerate(data): 78 | page_title = page 79 | 80 | result = extract_kartographer_data(page_title) 81 | if result: 82 | geojsondata.append({'title': page_title, 'data': result}) 83 | 84 | 85 | with open(f'GeoJSONCombined.json', 'w') as f: 86 | json.dump(geojsondata, f, indent=2) 87 | print("Saved combined geojson") 88 | 89 | 90 | data = get_non_holiday_tracks() 91 | scrape_geojson(data) -------------------------------------------------------------------------------- /src/style/mainMenu.css: -------------------------------------------------------------------------------- 1 | .main-menu-container { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | width: 80vw; 7 | z-index: 3 !important; 8 | height: 70vh; 9 | max-width: 70vw; 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | } 15 | 16 | @keyframes fadeIn { 17 | from { 18 | opacity: 0%; 19 | } 20 | to { 21 | opacity: 100%; 22 | } 23 | } 24 | 25 | .main-menu-image { 26 | width: 70vw; 27 | } 28 | 29 | .main-menu-text { 30 | animation: fadeIn 1s; 31 | position: absolute; 32 | top: 28%; 33 | left: 50%; 34 | transform: translate(-50%, -50%); 35 | font-size: 15vw; 36 | color: var(--primary-yellow); 37 | text-shadow: 5px 5px 10px black; 38 | pointer-events: none; 39 | /* text-shadow: 0px 0px 100px rgb(0, 0, 0); */ 40 | } 41 | 42 | .main-menu-option { 43 | animation: fadeIn 1s; 44 | position: absolute; 45 | transform: translate(-50%, -50%); 46 | font-size: 3.5vw; 47 | font-style: italic; 48 | color: var(--primary-yellow); 49 | filter: brightness(90%); 50 | text-shadow: 2px 2px 10px black; 51 | text-decoration: none; 52 | max-width: 50%; 53 | } 54 | 55 | .main-menu-option:hover { 56 | filter: brightness(100%); 57 | cursor: pointer; 58 | } 59 | 60 | .menu-statistics { 61 | animation: fadeIn 1s; 62 | width: 50%; 63 | display: flex; 64 | justify-content: space-around; 65 | font-size: 2vw; 66 | color: var(--primary-yellow); 67 | font-style: italic; 68 | text-shadow: 2px 2px 10px black; 69 | position: absolute; 70 | transform: translate(-50%, -50%); 71 | top: 92%; 72 | left: 50%; 73 | } 74 | 75 | .main-menu-icon-container { 76 | animation: fadeIn 1s; 77 | position: absolute; 78 | bottom: -10%; 79 | display: flex; 80 | background: rgba(0, 0, 0, 0.2); 81 | border-radius: 16px; 82 | box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 83 | backdrop-filter: blur(5px); 84 | -webkit-backdrop-filter: blur(5px); 85 | border: 1px solid rgba(0, 0, 0, 0.1); 86 | } 87 | 88 | .main-menu-icon { 89 | padding: 2%; 90 | width: 80px; 91 | color: var(--primary-yellow); 92 | font-size: 1.7vw; 93 | } 94 | 95 | .main-menu-icon:hover { 96 | color: var(--primary-yellow-light) !important; 97 | } 98 | 99 | @media screen and (max-width: 800px) { 100 | .main-menu-container { 101 | height: 23vh; 102 | } 103 | 104 | .main-menu-text { 105 | font-size: 110px; 106 | top: 0%; 107 | } 108 | 109 | .main-menu-option { 110 | font-size: 25px; 111 | } 112 | 113 | .main-menu-option:hover { 114 | font-size: 25px; 115 | } 116 | 117 | .menu-statistics { 118 | top: 120%; 119 | font-size: 20px; 120 | } 121 | 122 | .main-menu-icon-container { 123 | position: absolute; 124 | bottom: -80%; 125 | } 126 | 127 | .main-menu-icon { 128 | font-size: 5vw; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/style/uiBox.css: -------------------------------------------------------------------------------- 1 | .ui-box { 2 | color: var(--primary-yellow); 3 | text-shadow: 1px 1px 1px black; 4 | top: 75%; 5 | left: 40%; 6 | width: 100%; 7 | max-width: 380px; 8 | z-index: 999 !important; 9 | } 10 | 11 | .score-label { 12 | width: 100%; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | } 17 | 18 | .guess-btn { 19 | width: 100%; 20 | max-width: 250px; 21 | padding: 10px 10px; 22 | } 23 | .guess-btn-no-border { 24 | border: none !important; 25 | height: 40px !important; 26 | border-radius: 5px; 27 | padding: 0 !important; 28 | } 29 | .snippet-player { 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | border: none; 34 | padding: 0px 7%; 35 | } 36 | 37 | .custom-range-input { 38 | transition: all 0.3s ease-in-out; 39 | overflow: hidden; 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: center; 43 | align-items: center; 44 | } 45 | 46 | .custom-range-input.hidden { 47 | max-height: 0; 48 | opacity: 0; 49 | margin: 0; 50 | padding: 0; 51 | } 52 | 53 | /* Gradient slider styles */ 54 | .gradient-slider { 55 | width: 100%; 56 | height: 4px; 57 | -webkit-appearance: none; 58 | appearance: none; 59 | background: linear-gradient(to right, #ff0000, #00ff00); 60 | border-radius: 4px; 61 | outline: none; 62 | } 63 | 64 | /* Webkit browsers (Chrome, Safari) */ 65 | .gradient-slider::-webkit-slider-thumb { 66 | -webkit-appearance: none; 67 | appearance: none; 68 | width: 18px; 69 | height: 18px; 70 | background: #ffffff; 71 | border: 2px solid #333; 72 | border-radius: 50%; 73 | cursor: pointer; 74 | } 75 | 76 | /* Firefox */ 77 | .gradient-slider::-moz-range-thumb { 78 | width: 18px; 79 | height: 18px; 80 | background: #ffffff; 81 | border: 2px solid #333; 82 | border-radius: 50%; 83 | cursor: pointer; 84 | } 85 | 86 | /* IE/Edge */ 87 | .gradient-slider::-ms-thumb { 88 | width: 18px; 89 | height: 18px; 90 | background: #ffffff; 91 | border: 2px solid #333; 92 | border-radius: 50%; 93 | cursor: pointer; 94 | } 95 | 96 | .custom-range-input.visible { 97 | max-height: 100px; /* Adjust based on your content height */ 98 | opacity: 1; 99 | margin: 8px 0; /* Adjust spacing as needed */ 100 | padding: 8px 0; 101 | } 102 | 103 | .value-display { 104 | display: flex; 105 | justify-content: center; 106 | margin-top: 8px; 107 | } 108 | 109 | .below-map { 110 | display: flex; 111 | flex-direction: column; 112 | align-items: center; 113 | gap: 10px; 114 | } 115 | 116 | .above-map { 117 | font-family: 'Runescape UF' !important; 118 | font-size: 1.5rem; 119 | position: absolute; 120 | width: 300px; 121 | top: 50px; 122 | left: 50%; 123 | transform: translate(-50%, -50%); 124 | z-index: 9999999; 125 | pointer-events: none !important; 126 | } 127 | .above-map > button { 128 | padding: 15px 40px; 129 | pointer-events: auto; 130 | } 131 | -------------------------------------------------------------------------------- /src/types/jingle.ts: -------------------------------------------------------------------------------- 1 | import { Position } from 'geojson'; 2 | import { Region } from '../constants/regions'; 3 | 4 | export enum Page { 5 | MainMenu = '/', 6 | DailyJingle = '/daily', 7 | Practice = '/practice', 8 | } 9 | 10 | export enum GameStatus { 11 | Guessing = 'guessing', 12 | AnswerRevealed = 'answer-revealed', 13 | GameOver = 'game-over', 14 | } 15 | 16 | export enum ModalType { 17 | Stats = 'stats', 18 | News = 'news', 19 | Settings = 'settings', 20 | } 21 | 22 | export interface NavigationEntry { 23 | mapId: number; 24 | coordinates: [number, number]; 25 | } 26 | export interface GameState { 27 | settings: GameSettings; 28 | status: GameStatus; 29 | round: number; // 0-4 30 | songs: string[]; 31 | scores: number[]; 32 | startTimeMs: number; 33 | timeTaken: string | null; 34 | clickedPosition: ClickedPosition | null; 35 | navigationStack: NavigationEntry[] | null; 36 | } 37 | export interface ClickedPosition { 38 | xy: Position; 39 | mapId: number; 40 | } 41 | 42 | // if we make changes to GameState schema, we can invalidate the old game state saved in user's local storage to prevent crashes 43 | export const isValidGameState = (object: unknown): object is GameState => { 44 | if (!object) return false; 45 | if (typeof (object as any).status !== 'string') return false; 46 | if (typeof (object as any).round !== 'number') return false; 47 | if (!Array.isArray((object as any).songs)) return false; 48 | if (!Array.isArray((object as any).scores)) return false; 49 | if ('guess' in (object as any)) return false; 50 | if ( 51 | (object as any).status === GameStatus.AnswerRevealed && 52 | !('clickedPosition' in (object as any)) 53 | ) 54 | return false; 55 | if ('clickedPosition' in (object as any)) { 56 | const isNull = (object as any).clickedPosition === null; 57 | const xyDefined = (object as any).clickedPosition?.xy !== undefined; 58 | const mapIdDefined = (object as any).clickedPosition?.mapId !== undefined; 59 | return isNull || (xyDefined && mapIdDefined); 60 | } 61 | return true; 62 | }; 63 | 64 | export interface GameSettings { 65 | hardMode: boolean; 66 | oldAudio: boolean; 67 | } 68 | 69 | export interface PersonalStats { 70 | correctGuesses: number; 71 | incorrectGuesses: number; 72 | maxStreak: number; 73 | currentStreak: number; 74 | } 75 | export interface DailyChallenge { 76 | date: string; // YYYY-MM-DD 77 | songs: string[]; 78 | submissions: number; 79 | results: number[]; 80 | } 81 | 82 | export interface Statistics { 83 | guesses: number; 84 | } 85 | 86 | export interface UserPreferences { 87 | preferHardMode: boolean; 88 | preferOldAudio: boolean; 89 | preferConfirmation: boolean; 90 | hardModeLength: number; 91 | regions: Record; 92 | undergroundSelected: boolean; 93 | surfaceSelected: boolean; 94 | } 95 | 96 | export interface Song { 97 | name: string; 98 | successRate: number; 99 | successCount: number; 100 | failureCount: number; 101 | } 102 | -------------------------------------------------------------------------------- /src/hooks/useGameLogic.ts: -------------------------------------------------------------------------------- 1 | import { clone } from 'ramda'; 2 | import { useState } from 'react'; 3 | import { ClickedPosition, GameSettings, GameState, GameStatus } from '../types/jingle'; 4 | import { calculateTimeDifference } from '../utils/date-utils'; 5 | import { findNearestPolygonWhereSongPlays } from '../utils/map-utils'; 6 | 7 | export default function useGameLogic(initialGameState: GameState) { 8 | const [gameState, setGameState] = useState(initialGameState); 9 | 10 | const setClickedPosition = (clickedPosition: ClickedPosition): GameState => { 11 | const newGameState = { ...gameState, clickedPosition }; 12 | setGameState(newGameState); 13 | return newGameState; 14 | }; 15 | 16 | // latestGameState is required when called immediately after setGuess 17 | const confirmGuess = (latestGameState?: GameState): GameState => { 18 | const newGameState = latestGameState ?? gameState; 19 | if (newGameState.clickedPosition === null) { 20 | throw new Error('clickedPosition cannot be null'); 21 | } 22 | 23 | const song = newGameState.songs[newGameState.round]; 24 | const { distance } = findNearestPolygonWhereSongPlays(song, newGameState.clickedPosition); 25 | const decayRate = 0.00544; // adjust scoring strictness (higher = more strict) 26 | const score = Math.round(1000 / Math.exp(decayRate * distance)); 27 | newGameState.status = GameStatus.AnswerRevealed; 28 | newGameState.scores.push(score); 29 | setGameState(clone(newGameState)); 30 | 31 | const isLastRound = newGameState.round === newGameState.songs.length - 1; 32 | if (isLastRound) { 33 | newGameState.timeTaken = calculateTimeDifference(newGameState.startTimeMs, Date.now()); 34 | setGameState(clone(newGameState)); 35 | } 36 | 37 | return newGameState; 38 | }; 39 | 40 | // latestGameState is required when called immediately after addSong 41 | const nextSong = (latestGameState?: GameState): GameState => { 42 | const prev = latestGameState ?? gameState; 43 | const newGameState = { 44 | ...prev, 45 | round: prev.round + 1, 46 | status: GameStatus.Guessing, 47 | clickedPosition: null, 48 | }; 49 | setGameState(newGameState); 50 | return newGameState; 51 | }; 52 | 53 | // PRACTICE MODE ONLY 54 | const addSong = (song: string): GameState => { 55 | const newGameState = { 56 | ...gameState, 57 | songs: [...gameState.songs, song], 58 | }; 59 | setGameState(newGameState); 60 | return newGameState; 61 | }; 62 | 63 | /// DAILY JINGLE MODE ONLY 64 | const endGame = (): GameState => { 65 | const newGameState = { ...gameState, status: GameStatus.GameOver }; 66 | setGameState(newGameState); 67 | return newGameState; 68 | }; 69 | 70 | const updateGameSettings = (newSettings: GameSettings) => { 71 | setGameState((prev) => ({ 72 | ...prev, 73 | settings: { 74 | ...prev.settings, 75 | ...newSettings, 76 | }, 77 | })); 78 | }; 79 | 80 | return { 81 | gameState, 82 | setClickedPosition, 83 | confirmGuess, 84 | addSong, 85 | nextSong, 86 | endGame, 87 | updateGameSettings, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/components/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | import { FaDiscord, FaDonate, FaGithub } from 'react-icons/fa'; 2 | import { Link } from 'react-router-dom'; 3 | import useSWR from 'swr'; 4 | import { cdnURL } from '../constants/links'; 5 | import { LOCAL_STORAGE } from '../constants/localStorage'; 6 | import { getStatistics } from '../data/jingle-api'; 7 | import '../style/mainMenu.css'; 8 | import { DailyChallenge } from '../types/jingle'; 9 | import { getCurrentDateInBritain, getNextUkMidnight } from '../utils/date-utils'; 10 | import NextDailyCountdown from './NextDailyCountdown'; 11 | 12 | interface MainMenuProps { 13 | dailyChallenge: DailyChallenge | undefined; 14 | } 15 | export default function MainMenu({ dailyChallenge }: MainMenuProps) { 16 | const dailyCompleted = 17 | localStorage.getItem(LOCAL_STORAGE.dailyComplete) === getCurrentDateInBritain(); 18 | 19 | const { data: statistics } = useSWR('/api/statistics', getStatistics, { 20 | refreshInterval: 2000, 21 | }); 22 | 23 | const leftSideStyle = { left: '17vw', top: '70%' }; 24 | 25 | return ( 26 |
27 | Jingle 32 | 33 |

Jingle

34 | 35 | {/* Daily Jingle Option */} 36 | {dailyChallenge ? ( 37 | 42 | Daily Jingle 43 | {dailyCompleted && } 44 | {!dailyCompleted &&
Ready
}{' '} 45 |
46 | {dailyChallenge?.results?.length?.toLocaleString()} Completions 47 |
48 | 49 | ) : ( 50 |

54 | Loading... 55 |

56 | )} 57 | 58 | 63 | Unlimited Practice 64 |
65 | 66 | 67 |
68 |
69 | {statistics?.guesses.toLocaleString()} 70 |
Global Guesses
71 |
72 |
73 | 74 | 96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/data/jingle-api.ts: -------------------------------------------------------------------------------- 1 | import { DailyChallenge, Song, Statistics } from '../types/jingle'; 2 | 3 | const apiHost = import.meta.env.VITE_API_HOST; 4 | 5 | class FetchError extends Error { 6 | response: Response; 7 | constructor(response: Response) { 8 | super(response.statusText); 9 | this.name = 'FetchError'; 10 | this.response = response; 11 | } 12 | } 13 | 14 | async function get(path: string) { 15 | const response = await fetch(apiHost + path); 16 | if (response.ok) { 17 | return (await response.json()) as T; 18 | } else { 19 | throw new FetchError(response); 20 | } 21 | } 22 | 23 | async function post(path: string, body: any) { 24 | const response = await fetch(apiHost + path, { 25 | method: 'POST', 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | }, 29 | body: JSON.stringify(body), 30 | }); 31 | if (response.ok) { 32 | return (await response.json()) as T; 33 | } else { 34 | throw new FetchError(response); 35 | } 36 | } 37 | 38 | export async function getSong(songName: string) { 39 | return await get(`/api/songs/${songName}`); 40 | } 41 | 42 | export async function getAverages() { 43 | return await get(`/api/averages`); 44 | } 45 | 46 | export async function getSongList() { 47 | return await get('/api/songs'); 48 | } 49 | 50 | export async function getDailyChallenge(formattedDate: string) { 51 | return await get(`/api/daily-challenges/${formattedDate}`); 52 | } 53 | 54 | export async function getWeekStats() { 55 | const weekDatesFormatted = [ 56 | '2024-05-27', 57 | '2024-05-28', 58 | '2024-05-29', 59 | '2024-05-30', 60 | '2024-05-31', 61 | '2024-06-01', 62 | '2024-06-02', 63 | ]; 64 | const weekStats: { 65 | date: string; 66 | submissions: number; 67 | average: number; 68 | songSuccessRates: Record; 69 | }[] = []; 70 | 71 | for (const date of weekDatesFormatted) { 72 | const dailyChallenge = await getDailyChallenge(date); 73 | const results = dailyChallenge?.results ?? []; 74 | const average = results.reduce((a, b) => a + b, 0) / results.length; 75 | 76 | const songSuccessRates: Record = {}; 77 | for (const songName of dailyChallenge.songs) { 78 | const song = await getSong(songName); 79 | const songSuccessRate = ( 80 | (song.successCount / (song.successCount + song.failureCount)) * 81 | 100 82 | ).toFixed(1); 83 | songSuccessRates[song.name] = songSuccessRate; 84 | } 85 | 86 | weekStats.push({ 87 | date: date, 88 | submissions: dailyChallenge.submissions, 89 | average: average, 90 | songSuccessRates, 91 | }); 92 | } 93 | 94 | return weekStats; 95 | } 96 | 97 | interface DailyChallengeResponse { 98 | percentile: number; 99 | } 100 | export async function postDailyChallengeResult( 101 | result: number, 102 | timeTaken: number, 103 | ): Promise { 104 | // Returns the percentile 105 | return await post(`/api/daily-challenge/result`, { result, timeTaken }); 106 | } 107 | 108 | export async function getStatistics() { 109 | return await get('/api/statistics'); 110 | } 111 | 112 | export async function incrementGlobalGuessCounter() { 113 | await post('/api/statistics/increment', {}); 114 | } 115 | 116 | export async function incrementSongSuccessCount(songName: string) { 117 | await post(`/api/songs/${songName}/success`, {}); 118 | } 119 | 120 | export async function incrementSongFailureCount(songName: string) { 121 | await post(`/api/songs/${songName}/failure`, {}); 122 | } 123 | -------------------------------------------------------------------------------- /src/components/side-menu/StatsModalButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { FaQuestionCircle } from 'react-icons/fa'; 3 | import { Tooltip } from 'react-tooltip'; 4 | import useSWRImmutable from 'swr/immutable'; 5 | import { ASSETS } from '../../constants/assets'; 6 | import { getSongList } from '../../data/jingle-api'; 7 | import '../../style/modal.css'; 8 | import { Song } from '../../types/jingle'; 9 | import { loadPersonalStatsFromBrowser } from '../../utils/browserUtil'; 10 | import Modal from '../Modal'; 11 | import IconButton from './IconButton'; 12 | 13 | export default function StatsModalButton() { 14 | const [open, setOpen] = useState(false); 15 | const closeModal = () => { 16 | setOpen(false); 17 | setSearchString(''); // reset filter when modal is closed 18 | }; 19 | 20 | const { correctGuessCount, incorrectGuessCount, maxStreak, currentStreak } = 21 | loadPersonalStatsFromBrowser(); 22 | const totalGuessCount = correctGuessCount + incorrectGuessCount; 23 | const personalSuccessRate: number | undefined = !totalGuessCount 24 | ? undefined 25 | : parseFloat((((correctGuessCount ?? 0) / totalGuessCount) * 100).toFixed(2)); 26 | 27 | const { data: songs } = useSWRImmutable('/api/songs', getSongList); 28 | const [searchString, setSearchString] = useState(''); 29 | const successRate = (song: Song) => 30 | (100 * song.successCount) / (song.successCount + song.failureCount); 31 | const sortedAndFilteredSongs = 32 | songs 33 | ?.filter((song) => Boolean(successRate(song))) 34 | ?.filter((song) => 35 | searchString.trim() ? song.name.toLowerCase().includes(searchString.toLowerCase()) : true, 36 | ) 37 | ?.sort((a, b) => successRate(b) - successRate(a)) ?? []; 38 | 39 | return ( 40 | <> 41 | setOpen(true)} 43 | img={ASSETS['stats']} 44 | /> 45 | 46 | 50 | 54 |
61 |

Your Stats

62 | 63 | 68 | 69 | 70 |
71 | 72 |
73 | Songs Guessed 74 | {totalGuessCount} 75 |
76 |
77 | Success Rate 78 | 79 | {personalSuccessRate === undefined ? 'Not Played' : `${personalSuccessRate}%`} 80 | 81 |
82 |
83 | Current Streak 84 | {currentStreak} 85 |
86 |
87 | Best Streak 88 | {maxStreak} 89 |
90 |

Global Song%

91 | setSearchString(e.target.value)} 97 | /> 98 | 99 |
100 |
101 | {sortedAndFilteredSongs.map((song) => ( 102 |
106 | {song.name} 107 | {successRate(song).toFixed(2)}% 108 |
109 | ))} 110 |
111 |
112 |
113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/utils/getRandomSong.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | import { Region, REGIONS, UNDERGROUND_TRACKS_STRICT } from '../constants/regions'; 3 | import { UserPreferences } from '../types/jingle'; 4 | import { loadPreferencesFromBrowser } from './browserUtil'; 5 | 6 | export class SongService { 7 | private static instance: SongService; 8 | private songList: string[]; 9 | private snippet: [number, number] | null; 10 | 11 | private constructor(preferences: UserPreferences) { 12 | this.songList = this.getAvailableSongs(preferences); 13 | this.snippet = null; 14 | } 15 | 16 | static Instance(): SongService { 17 | if (!SongService.instance) { 18 | const initialPreferences = loadPreferencesFromBrowser(); 19 | SongService.instance = new SongService(initialPreferences); 20 | } 21 | return SongService.instance; 22 | } 23 | 24 | regenerateSongs(preferences: UserPreferences) { 25 | this.songList = this.getAvailableSongs(preferences); 26 | } 27 | 28 | get songs(): string[] { 29 | return this.songList; 30 | } 31 | 32 | getSnippet = (audioRef: RefObject, length: number) => { 33 | if (!this.snippet) { 34 | this.generateSnippet(audioRef, length); 35 | } 36 | return this.snippet; 37 | }; 38 | 39 | resetSnippet = () => { 40 | this.snippet = null; 41 | }; 42 | 43 | removeSong = (songToRemove: string) => { 44 | this.songList = this.songList.filter((song) => song !== songToRemove); 45 | }; 46 | 47 | addSong = (song: string) => { 48 | this.songList.push(song); 49 | }; 50 | 51 | getAvailableSongs = (preferences: UserPreferences): string[] => { 52 | const enabledRegions = this.getEnabledRegions(preferences); 53 | let allSongs = enabledRegions.flatMap((region) => REGIONS[region]); 54 | allSongs = this.filterSongsByPreference(allSongs, preferences); 55 | return allSongs; 56 | }; 57 | 58 | getRandomSong = (preferences: UserPreferences): string => { 59 | const availableSongs = this.songList.filter((song) => this.songList.includes(song)); 60 | if (availableSongs.length === 0) { 61 | this.songList = this.getAvailableSongs(preferences); 62 | } 63 | const selectedSong = this.selectRandomSong(availableSongs); 64 | return selectedSong; 65 | }; 66 | 67 | // Helper functions 68 | private getEnabledRegions = (preferences: UserPreferences): Region[] => { 69 | return (Object.keys(preferences.regions) as Region[]).filter( 70 | (region) => preferences.regions[region], 71 | ); 72 | }; 73 | 74 | private filterSongsByPreference = (songs: string[], preferences: UserPreferences): string[] => { 75 | const { undergroundSelected, surfaceSelected } = preferences; 76 | 77 | if (undergroundSelected && !surfaceSelected) { 78 | return songs.filter((song) => UNDERGROUND_TRACKS_STRICT.includes(song)); 79 | } 80 | 81 | if (surfaceSelected && !undergroundSelected) { 82 | return songs.filter((song) => !UNDERGROUND_TRACKS_STRICT.includes(song)); 83 | } 84 | 85 | return songs; 86 | }; 87 | 88 | private selectRandomSong = (songs: string[]): string => { 89 | if (songs.length === 0) { 90 | throw new Error('No songs available for selection'); 91 | } 92 | const randomIndex = Math.floor(Math.random() * songs.length); 93 | return songs[randomIndex]; 94 | }; 95 | 96 | private generateSnippet = (audioRef: RefObject, length: number) => { 97 | const audio = audioRef.current; 98 | if (!audio) return; 99 | 100 | const generate = () => { 101 | const songDuration = audio.duration; 102 | const buffer = 10; 103 | 104 | if (songDuration <= 2 * buffer + length) { 105 | console.warn('Song too short for buffered snippet.'); 106 | this.snippet = [0, length]; 107 | return; 108 | } 109 | 110 | const maxStart = songDuration - buffer - length; 111 | const minStart = buffer; 112 | const start = Math.random() * (maxStart - minStart) + minStart; 113 | const end = start + length; 114 | this.snippet = [start, end]; 115 | }; 116 | 117 | if (audio.readyState >= 1) { 118 | // Metadata is already loaded 119 | generate(); 120 | } else { 121 | // Wait for metadata 122 | const onLoadedMetadata = () => { 123 | audio.removeEventListener('loadedmetadata', onLoadedMetadata); 124 | generate(); 125 | }; 126 | audio.addEventListener('loadedmetadata', onLoadedMetadata); 127 | } 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /src/style/modal.css: -------------------------------------------------------------------------------- 1 | .modal-container { 2 | text-shadow: 1px 1px 1px black; 3 | } 4 | .modal-container h2 { 5 | margin-bottom: 10px; 6 | } 7 | .modal-line { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | width: 100%; 12 | line-height: 1.8; 13 | } 14 | .modal-options { 15 | display: flex; 16 | justify-content: center; 17 | gap: 16px; 18 | } 19 | 20 | .history-entry-row { 21 | display: flex; 22 | flex-direction: row; 23 | max-width: 360px; 24 | padding-right: 5px; 25 | line-height: 2.2; 26 | } 27 | .history-entry-td:nth-child(1) { 28 | display: flex; 29 | flex-direction: row; 30 | justify-content: space-between; 31 | min-width: 65px; 32 | max-width: 65px; 33 | } 34 | .history-entry-td:nth-child(2) { 35 | max-width: 70px; 36 | } 37 | .history-entry-td:nth-child(3) { 38 | max-width: 90px; 39 | } 40 | 41 | .history-entry-td { 42 | display: flex; 43 | justify-content: center; 44 | align-items: center; 45 | width: 80px; 46 | } 47 | th .history-entry-td { 48 | padding: 0px 8px; 49 | } 50 | .history-stats { 51 | display: flex; 52 | flex-direction: column; 53 | max-height: 240px !important; 54 | width: 100%; 55 | overflow-y: auto; 56 | scrollbar-width: thin; 57 | } 58 | .history-stats-entry { 59 | overflow: hidden; 60 | margin-left: 0px; 61 | background: rgba(255, 255, 255, 0.05); 62 | border-radius: 5px; 63 | box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); 64 | backdrop-filter: blur(5px); 65 | -webkit-backdrop-filter: blur(5px); 66 | transition: 0.3s; 67 | max-height: 0px; /* this gets overwritten by min-width when expanded */ 68 | } 69 | 70 | .regions-table { 71 | display: flex; 72 | transition: all 0.1s; 73 | justify-content: space-between; 74 | align-items: center; 75 | width: 100%; 76 | flex-wrap: wrap; 77 | overflow: hidden; 78 | max-height: 200px !important; 79 | } 80 | 81 | .settings-table { 82 | width: 100%; 83 | } 84 | .settings-table tr { 85 | height: 35px; 86 | display: flex; 87 | justify-content: space-between; 88 | align-items: center; 89 | width: 100%; 90 | } 91 | .settings-table td { 92 | display: flex; 93 | align-items: center; 94 | } 95 | 96 | .hard-mode-row { 97 | display: flex; 98 | justify-content: center !important; 99 | transition: all 0.3s ease-in-out; 100 | overflow: hidden; 101 | height: 70px !important; 102 | } 103 | .hard-mode-row:not(:has(.custom-range-input.visible)) { 104 | max-height: 0; 105 | border: none; 106 | } 107 | 108 | .checkbox-container { 109 | display: flex; 110 | align-items: center; 111 | } 112 | 113 | .tooltip-icon { 114 | color: #928270; 115 | font-size: 15px !important; 116 | margin-left: 9.6px; 117 | } 118 | .tooltip-icon:hover { 119 | filter: drop-shadow(0 0 3px #ffffff); 120 | cursor: pointer; 121 | transition: 0.2s; 122 | } 123 | .react-tooltip { 124 | font-size: 1em !important; 125 | } 126 | 127 | .ReactModal__Overlay { 128 | position: fixed !important; 129 | /* Force override */ 130 | z-index: 1000; 131 | background-color: rgba(0, 0, 0, 0.5) !important; 132 | } 133 | .ReactModal__Content svg { 134 | color: #928270; 135 | font-size: 0.9em !important; 136 | margin-left: 8px; 137 | } 138 | .ReactModal__Content svg:hover { 139 | filter: drop-shadow(0 0 3px #ffffff); 140 | cursor: pointer; 141 | transition: 0.2s; 142 | } 143 | 144 | ::-webkit-scrollbar { 145 | width: 3px !important; 146 | } 147 | 148 | .song-stats { 149 | height: 120px; 150 | width: 100%; 151 | overflow-y: auto; 152 | scrollbar-width: thin; 153 | } 154 | 155 | .modal-bg-image { 156 | z-index: -1; 157 | opacity: 0.15; 158 | position: fixed; 159 | min-height: 100px; 160 | } 161 | 162 | /* create a rule to style text input fields across the app - make them smaller */ 163 | 164 | input[type='text'], 165 | input[type='number'], 166 | input[type='search'] { 167 | padding: 5px; 168 | padding-left: 5px; 169 | border-radius: 2px; 170 | border: none; 171 | background-color: #928270; 172 | color: var(--primary-yellow); 173 | width: 90%; 174 | margin-bottom: 5px; 175 | height: 32px; 176 | } 177 | input::placeholder { 178 | color: var(--primary-yellow-dark); 179 | } 180 | 181 | input:focus { 182 | outline: none; 183 | } 184 | 185 | .MuiChip-sizeSmall { 186 | padding: 0px 3px; 187 | margin: 0px 2px; 188 | } 189 | .osrs-btn.modal-action-btn { 190 | padding: 4px 16px; 191 | margin-top: 10px; 192 | } 193 | 194 | .modal-buttons-container { 195 | display: flex; 196 | flex-direction: column; 197 | position: fixed; 198 | top: 10px; 199 | right: 10px; 200 | gap: 10px; 201 | } 202 | 203 | .rotated { 204 | transform: rotate(-180deg); 205 | } 206 | -------------------------------------------------------------------------------- /src/utils/browserUtil.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_GAME_STATE, DEFAULT_PREFERENCES } from '../constants/defaults'; 2 | import { LOCAL_STORAGE } from '../constants/localStorage'; 3 | import { DailyChallenge, GameState, isValidGameState, UserPreferences } from '../types/jingle'; 4 | import { SongService } from './getRandomSong'; 5 | 6 | export const saveGameStateToBrowser = (jingleNumber: number, gameState: GameState) => { 7 | localStorage.setItem(LOCAL_STORAGE.gameState(jingleNumber), JSON.stringify(gameState)); 8 | }; 9 | 10 | export const savePreferencesToBrowser = (preferences: UserPreferences) => { 11 | localStorage.setItem(LOCAL_STORAGE.preferences, JSON.stringify(preferences)); 12 | SongService.Instance().regenerateSongs(preferences); 13 | }; 14 | 15 | export const sanitizePreferences = () => { 16 | const currentPreferencesJSON = localStorage.getItem(LOCAL_STORAGE.preferences); 17 | const currentPreferences = currentPreferencesJSON 18 | ? JSON.parse(currentPreferencesJSON) 19 | : DEFAULT_PREFERENCES; 20 | const sanitizedPreferences = { 21 | ...DEFAULT_PREFERENCES, 22 | ...currentPreferences, 23 | }; // fill any 'missing gaps' in their preferences with defaults 24 | localStorage.setItem(LOCAL_STORAGE.preferences, JSON.stringify(sanitizedPreferences)); 25 | }; 26 | 27 | export const loadSeenAnnouncementIdFromBrowser = () => { 28 | const seenAnnouncementId: string | null = 29 | localStorage.getItem(LOCAL_STORAGE.seenAnnouncementId) ?? null; 30 | return seenAnnouncementId; 31 | }; 32 | 33 | export const setSeenAnnouncementIdToBrowser = (id: string) => { 34 | localStorage.setItem(LOCAL_STORAGE.seenAnnouncementId, id.toString()); 35 | }; 36 | 37 | const getSafeGameState = (jingleNumber: number) => { 38 | try { 39 | const stored = localStorage.getItem(LOCAL_STORAGE.gameState(jingleNumber)); 40 | return stored ? JSON.parse(stored) : null; 41 | } catch { 42 | return null; 43 | } 44 | }; 45 | 46 | export const loadGameStateFromBrowser = ( 47 | jingleNumber: number, 48 | dailyChallenge: DailyChallenge, 49 | ): GameState => { 50 | const browserGameState = getSafeGameState(jingleNumber); 51 | const defaultState = DEFAULT_GAME_STATE; 52 | defaultState.songs = dailyChallenge.songs; 53 | 54 | const gameState = { 55 | ...defaultState, 56 | ...browserGameState, 57 | }; // fill any non-existing fields with the defaults 58 | 59 | try { 60 | if (!isValidGameState(gameState)) { 61 | console.warn( 62 | 'Saved game state for Jingle #' + jingleNumber + ' is invalid, using a default game state.', 63 | gameState, 64 | ); 65 | return defaultState; 66 | } 67 | return gameState; 68 | } catch (e) { 69 | console.warn( 70 | 'Failed to parse saved game state for Jingle #' + 71 | jingleNumber + 72 | ', using a default game state.', 73 | ); 74 | return defaultState; 75 | } 76 | }; 77 | 78 | export const loadPreferencesFromBrowser = (): UserPreferences => { 79 | try { 80 | const browserPreferencesJson = localStorage.getItem(LOCAL_STORAGE.preferences); 81 | const browserPreferences = browserPreferencesJson 82 | ? JSON.parse(browserPreferencesJson) 83 | : DEFAULT_PREFERENCES; 84 | 85 | const userPreferences = { 86 | ...DEFAULT_PREFERENCES, 87 | ...browserPreferences, 88 | }; 89 | 90 | return userPreferences; 91 | } catch (e) { 92 | console.error('Failed to parse saved settings, returning default settings.'); 93 | return DEFAULT_PREFERENCES; 94 | } 95 | }; 96 | 97 | export const incrementLocalGuessCount = (correct: boolean) => { 98 | const key = correct ? LOCAL_STORAGE.correctGuessCount : LOCAL_STORAGE.incorrectGuessCount; 99 | const currentCount = parseInt(localStorage.getItem(key) ?? '0'); 100 | localStorage.setItem(key, (currentCount + 1).toString()); 101 | }; 102 | 103 | export const updateGuessStreak = (success: boolean) => { 104 | let currentStreak = parseInt(localStorage.getItem(LOCAL_STORAGE.currentStreak) ?? '0'); 105 | let maxStreak = parseInt(localStorage.getItem(LOCAL_STORAGE.maxStreak) ?? '0'); 106 | 107 | if (success) { 108 | currentStreak += 1; 109 | maxStreak = Math.max(currentStreak, maxStreak); 110 | } else { 111 | currentStreak = 0; 112 | } 113 | localStorage.setItem(LOCAL_STORAGE.currentStreak, currentStreak.toString()); 114 | localStorage.setItem(LOCAL_STORAGE.maxStreak, maxStreak.toString()); 115 | }; 116 | 117 | export const loadPersonalStatsFromBrowser = () => { 118 | const correctGuessCount = parseInt(localStorage.getItem(LOCAL_STORAGE.correctGuessCount) ?? '0'); 119 | const incorrectGuessCount = parseInt( 120 | localStorage.getItem(LOCAL_STORAGE.incorrectGuessCount) ?? '0', 121 | ); 122 | const currentStreak = parseInt(localStorage.getItem(LOCAL_STORAGE.currentStreak) ?? '0'); 123 | const maxStreak = parseInt(localStorage.getItem(LOCAL_STORAGE.maxStreak) ?? '0'); 124 | return { 125 | correctGuessCount, 126 | incorrectGuessCount, 127 | maxStreak, 128 | currentStreak, 129 | }; 130 | }; 131 | -------------------------------------------------------------------------------- /src/utils/map-config.ts: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | import React from 'react'; 3 | import mapMetadata from '../data/map-metadata'; 4 | import { GameState } from '../types/jingle'; 5 | 6 | export interface InternalMapState { 7 | gameState: GameState; 8 | onMapClick: (clickedPosition: L.LatLng) => void; 9 | className?: string; 10 | 11 | setMapCenter: React.Dispatch>; 12 | 13 | currentMapId: number; 14 | setCurrentMapId: React.Dispatch>; 15 | 16 | zoom: number; 17 | setZoom: React.Dispatch>; 18 | 19 | markerState: { 20 | markerPosition: L.LatLng | null; 21 | markerMapId: number; 22 | }; 23 | setMarkerState: React.Dispatch< 24 | React.SetStateAction<{ 25 | markerPosition: L.LatLng | null; 26 | markerMapId: number; 27 | }> 28 | >; 29 | } 30 | 31 | export enum MapIds { 32 | Surface = 0, 33 | ArdoungeUnderground = 2, 34 | DorgeshKaan = 5, 35 | MiscUnderground = 11, 36 | MisthalinUnderground = 12, 37 | MorytaniaUnderground = 14, 38 | MorUlRek = 23, 39 | TarnsLair = 24, 40 | Zanaris = 28, 41 | Prifddinas = 29, 42 | KourendUnderground = 32, 43 | PrifddinasUnderground = 34, 44 | PrifddinasGrandLibrary = 35, 45 | TutorialIsland = 37, 46 | LMSWildVarrock = 38, 47 | RuinsOfCamdozaal = 39, 48 | Abyss = 40, 49 | LassarUndercity = 41, 50 | DesertUnderground = 42, 51 | CamTorum = 44, 52 | Neypotzli = 45, 53 | ArdentOceanUnderground = 46, 54 | UnquietOceanUnderground = 47, 55 | ShroudedOceanUnderground = 48, 56 | SunsetOceanUnderground = 49, 57 | WesternOceanUnderground = 50, 58 | NorthernOceanUnderground = 51, 59 | GuardiansOfTheRift = 1001, 60 | TheScar = 1002, 61 | GhorrockDungeon = 1003, 62 | GhorrockPrison = 1004, 63 | NightmareArena = 1005, 64 | GuthixianTemple = 1006, 65 | GoblinTemple = 1007, 66 | SkotizoLair = 1009, 67 | CosmicAltar = 1016, 68 | DeathAltar = 1020, 69 | BloodAltar = 1021, 70 | PuroPuro = 1024, 71 | MournerTunnels = 1025, 72 | EvilChickenLair = 1027, 73 | ChaosTunnelsAltar = 1033, 74 | UndergroundPassUpper = 1035, 75 | UndergroundPassLower = 1036, 76 | ToA = 1037, 77 | } 78 | 79 | //separated for simplicity's sake. 80 | export const NESTED_MAP_IDS = [ 81 | MapIds.DorgeshKaan, 82 | MapIds.MorUlRek, 83 | MapIds.Neypotzli, 84 | MapIds.PrifddinasGrandLibrary, 85 | MapIds.PrifddinasUnderground, 86 | MapIds.LassarUndercity, 87 | MapIds.GuardiansOfTheRift, 88 | MapIds.TheScar, 89 | MapIds.GhorrockDungeon, 90 | MapIds.GhorrockPrison, 91 | MapIds.GoblinTemple, 92 | MapIds.GuthixianTemple, 93 | MapIds.CosmicAltar, 94 | MapIds.DeathAltar, 95 | MapIds.BloodAltar, 96 | MapIds.PuroPuro, 97 | MapIds.EvilChickenLair, 98 | MapIds.ChaosTunnelsAltar, 99 | MapIds.SkotizoLair, 100 | MapIds.UndergroundPassLower, 101 | MapIds.ToA, 102 | MapIds.NightmareArena, 103 | ]; 104 | 105 | export const NESTED_GROUPS = [ 106 | [MapIds.MisthalinUnderground, MapIds.DorgeshKaan], 107 | [MapIds.ArdentOceanUnderground, MapIds.MorUlRek], 108 | [MapIds.CamTorum, MapIds.Neypotzli], 109 | [MapIds.Prifddinas, MapIds.PrifddinasGrandLibrary], 110 | [MapIds.Prifddinas, MapIds.PrifddinasUnderground], 111 | [MapIds.RuinsOfCamdozaal, MapIds.LassarUndercity], 112 | [MapIds.MisthalinUnderground, MapIds.GuardiansOfTheRift, MapIds.TheScar], 113 | [MapIds.MiscUnderground, MapIds.GhorrockDungeon, MapIds.GhorrockPrison], 114 | [MapIds.ArdoungeUnderground, MapIds.GoblinTemple], 115 | [MapIds.MisthalinUnderground, MapIds.GuthixianTemple], 116 | [MapIds.Zanaris, MapIds.CosmicAltar], 117 | [MapIds.Zanaris, MapIds.PuroPuro], 118 | [MapIds.MorytaniaUnderground, MapIds.BloodAltar], 119 | [MapIds.MournerTunnels, MapIds.DeathAltar], 120 | [MapIds.KourendUnderground, MapIds.SkotizoLair], 121 | [MapIds.Zanaris, MapIds.EvilChickenLair], 122 | [MapIds.MisthalinUnderground, MapIds.ChaosTunnelsAltar], 123 | [MapIds.UndergroundPassUpper, MapIds.UndergroundPassLower], 124 | [MapIds.DesertUnderground, MapIds.ToA], 125 | [MapIds.MorytaniaUnderground, MapIds.NightmareArena], 126 | ]; 127 | 128 | export const LINKLESS_MAP_IDS = [ 129 | MapIds.LMSWildVarrock, //island map is more iconic. 130 | MapIds.TarnsLair, 131 | MapIds.Abyss, 132 | MapIds.TutorialIsland, 133 | ]; 134 | 135 | //use for map selector 136 | export const mapSelectBaseMaps = [ 137 | ...mapMetadata.filter((m) => m.name === 'Gielinor Surface'), 138 | ...mapMetadata 139 | .filter((m) => m.name !== 'Gielinor Surface') 140 | .sort((a, b) => a.name.localeCompare(b.name)), 141 | ]; 142 | 143 | export const ConfigureNearestNeighbor = (map: L.Map) => { 144 | //crispy nearest neighbor scaling for high zoom levels. 145 | const updateZoomClass = () => { 146 | const zoom = map.getZoom(); 147 | const container = map.getContainer(); 148 | 149 | container.classList.remove('zoom-level-2', 'zoom-level-3'); 150 | if (zoom === 2) container.classList.add('zoom-level-2'); 151 | if (zoom === 3) container.classList.add('zoom-level-3'); 152 | }; 153 | 154 | map.on('zoomend', updateZoomClass); 155 | updateZoomClass(); // Set initial 156 | 157 | //cleanup 158 | return () => { 159 | if (map) { 160 | map.off('zoomend', updateZoomClass); 161 | } 162 | }; 163 | }; 164 | 165 | export const HandleMapZoom = ( 166 | map: L.Map, 167 | setZoom: React.Dispatch>, 168 | ) => { 169 | const updateZoom = () => setZoom(map.getZoom()); 170 | map.on('zoomend', updateZoom); 171 | 172 | return () => { 173 | if (map) { 174 | map.off('zoomend', updateZoom); 175 | } 176 | }; 177 | }; 178 | -------------------------------------------------------------------------------- /src/components/AudioControls.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, RefObject, useEffect, useState } from 'react'; 2 | import { FiRefreshCcw } from 'react-icons/fi'; 3 | import { Tooltip } from 'react-tooltip'; 4 | import '../style/uiBox.css'; 5 | import { GameState, GameStatus } from '../types/jingle'; 6 | import { loadPreferencesFromBrowser } from '../utils/browserUtil'; 7 | import { SongService } from '../utils/getRandomSong'; 8 | import { playSnippet } from '../utils/playSong'; 9 | import { Button } from './ui-util/Button'; 10 | 11 | interface AudioControlsProps { 12 | gameState: GameState; 13 | } 14 | 15 | const AudioControls = forwardRef((props, ref) => { 16 | const currentPreferences = loadPreferencesFromBrowser(); 17 | const answerRevealed = props.gameState.status == GameStatus.AnswerRevealed; 18 | const hardMode = currentPreferences.preferHardMode; 19 | const showAudio = !hardMode || answerRevealed; 20 | const audioRef = ref as RefObject; 21 | 22 | useEffect(() => { 23 | if (hardMode && answerRevealed) { 24 | audioRef.current?.play(); 25 | } 26 | }, [props.gameState.status]); 27 | 28 | const reloadAudio = () => { 29 | const audioRef = ref as RefObject; 30 | audioRef.current?.load(); 31 | audioRef.current?.play(); 32 | }; 33 | 34 | return ( 35 |
36 |
75 | ); 76 | }); 77 | 78 | const SnippetPlayer = (props: { 79 | audioRef: RefObject; 80 | snippetLength: number; 81 | }) => { 82 | const [isAudioReady, setIsAudioReady] = useState(false); 83 | const [isClipPlaying, setIsClipPlaying] = useState(false); 84 | const songService = SongService.Instance(); 85 | const audio = props.audioRef.current; 86 | 87 | //is audio ready 88 | useEffect(() => { 89 | const audio = props.audioRef.current; 90 | if (!audio) return; 91 | 92 | const handleCanPlay = () => setIsAudioReady(true); 93 | 94 | if (audio.readyState >= 3) { 95 | setIsAudioReady(true); 96 | } else { 97 | audio.addEventListener('canplay', handleCanPlay); 98 | return () => audio.removeEventListener('canplay', handleCanPlay); 99 | } 100 | }, [props.audioRef]); 101 | 102 | useEffect(() => { 103 | const audio = props.audioRef.current; 104 | if (!audio) return; 105 | 106 | const handlePause = () => setIsClipPlaying(false); 107 | const handlePlay = () => setIsClipPlaying(true); 108 | 109 | // Set initial state 110 | setIsClipPlaying(!audio.paused); 111 | 112 | audio.addEventListener('pause', handlePause); 113 | audio.addEventListener('play', handlePlay); 114 | 115 | return () => { 116 | audio.removeEventListener('pause', handlePause); 117 | audio.removeEventListener('play', handlePlay); 118 | }; 119 | }, [props.audioRef]); 120 | 121 | return ( 122 |
{ 125 | if (!isAudioReady || isClipPlaying) { 126 | return; 127 | } 128 | playSnippet(props.audioRef, props.snippetLength); 129 | }} 130 | style={{ 131 | display: 'flex', 132 | justifyContent: 'center', 133 | alignItems: 'center', 134 | height: '40px', 135 | width: '300px', 136 | }} 137 | > 138 |
139 |
146 | 147 |
148 | ); 149 | }; 150 | 151 | const VolumeControl = (props: { audioRef: RefObject }) => { 152 | return ( 153 |
e.stopPropagation()} 156 | > 157 |
🔊
158 | { 165 | if (props.audioRef.current) { 166 | props.audioRef.current.volume = parseFloat(e.target.value); 167 | } 168 | }} 169 | defaultValue='1' 170 | /> 171 |
172 | ); 173 | }; 174 | 175 | export default AudioControls; 176 | -------------------------------------------------------------------------------- /src/components/Practice.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from 'react'; 2 | import { match } from 'ts-pattern'; 3 | import { 4 | incrementGlobalGuessCounter, 5 | incrementSongFailureCount, 6 | incrementSongSuccessCount, 7 | } from '../data/jingle-api'; 8 | import useGameLogic from '../hooks/useGameLogic'; 9 | import { GameSettings, GameState, GameStatus, Page, UserPreferences } from '../types/jingle'; 10 | import { 11 | incrementLocalGuessCount, 12 | loadPreferencesFromBrowser, 13 | sanitizePreferences, 14 | savePreferencesToBrowser, 15 | updateGuessStreak, 16 | } from '../utils/browserUtil'; 17 | import { SongService } from '../utils/getRandomSong'; 18 | import { playSong } from '../utils/playSong'; 19 | import AudioControls from './AudioControls'; 20 | import Footer from './Footer'; 21 | import RoundResult from './RoundResult'; 22 | import RunescapeMap from './RunescapeMap'; 23 | import HistoryModalButton from './side-menu/HistoryModalButton'; 24 | import HomeButton from './side-menu/HomeButton'; 25 | import NewsModalButton from './side-menu/NewsModalButton'; 26 | import SettingsModalButton from './side-menu/PreferencesModalButton'; 27 | import StatsModalButton from './side-menu/StatsModalButton'; 28 | import { Button } from './ui-util/Button'; 29 | 30 | sanitizePreferences(); 31 | 32 | const songService: SongService = SongService.Instance(); 33 | // starting song list - put outside component so it doesn't re-construct with rerenders 34 | 35 | export default function Practice() { 36 | const goBackButtonRef = useRef(null); 37 | const currentPreferences = loadPreferencesFromBrowser(); 38 | 39 | const initialGameState = { 40 | settings: { 41 | hardMode: currentPreferences.preferHardMode, 42 | oldAudio: currentPreferences.preferOldAudio, 43 | }, 44 | status: GameStatus.Guessing, 45 | round: 0, 46 | songs: [songService.getRandomSong(currentPreferences)], 47 | scores: [], 48 | startTimeMs: Date.now(), 49 | timeTaken: null, 50 | clickedPosition: null, 51 | navigationStack: [], 52 | }; 53 | 54 | const jingle = useGameLogic(initialGameState); 55 | const gameState = jingle.gameState; 56 | 57 | const audioRef = useRef(null); 58 | useEffect(() => { 59 | const songName = gameState.songs[gameState.round]; 60 | playSong( 61 | audioRef, 62 | songName, 63 | currentPreferences.preferOldAudio, 64 | ...(currentPreferences.preferHardMode ? [currentPreferences.hardModeLength] : []), 65 | ); 66 | songService.removeSong(songName); 67 | // eslint-disable-next-line react-hooks/exhaustive-deps 68 | }, []); 69 | 70 | const confirmGuess = (latestGameState?: GameState) => { 71 | const gameState = jingle.confirmGuess(latestGameState); 72 | 73 | // update statistics 74 | incrementGlobalGuessCounter(); 75 | const currentSong = gameState.songs[gameState.round]; 76 | const correct = gameState.scores[gameState.round] === 1000; 77 | if (correct) { 78 | incrementSongSuccessCount(currentSong); 79 | incrementLocalGuessCount(true); 80 | updateGuessStreak(true); 81 | } else { 82 | incrementSongFailureCount(currentSong); 83 | incrementLocalGuessCount(false); 84 | updateGuessStreak(false); 85 | } 86 | }; 87 | 88 | const nextSong = () => { 89 | const newSong = songService.getRandomSong(currentPreferences); 90 | const gameState = jingle.addSong(newSong); 91 | jingle.nextSong(gameState); 92 | songService.removeSong(newSong); 93 | playSong( 94 | audioRef, 95 | newSong, 96 | currentPreferences.preferOldAudio, 97 | ...(currentPreferences.preferHardMode ? [currentPreferences.hardModeLength] : []), 98 | ); 99 | }; 100 | 101 | const updatePreferences = (preferences: UserPreferences) => { 102 | const newSettings: GameSettings = { 103 | hardMode: preferences.preferHardMode, 104 | oldAudio: preferences.preferOldAudio, 105 | }; 106 | jingle.updateGameSettings(newSettings); 107 | savePreferencesToBrowser(preferences); 108 | }; 109 | 110 | return ( 111 | <> 112 |
113 |
114 |
115 | 116 | updatePreferences(preferences)} 119 | page={Page.Practice} 120 | /> 121 | 122 | 123 | 124 |
125 | 126 |
127 | {match(gameState.status) 128 | .with(GameStatus.Guessing, () => { 129 | if (currentPreferences.preferConfirmation) { 130 | return ( 131 |
160 |
161 |
162 | 163 | { 166 | const newGameState = jingle.setClickedPosition(clickedPosition); 167 | if (!currentPreferences.preferConfirmation) { 168 | confirmGuess(newGameState); // confirm immediately 169 | } 170 | }} 171 | GoBackButtonRef={goBackButtonRef as RefObject} 172 | /> 173 |
177 | 178 | 179 | 180 | ); 181 | } 182 | -------------------------------------------------------------------------------- /src/components/RunescapeMap.tsx: -------------------------------------------------------------------------------- 1 | import L, { CRS, Icon } from 'leaflet'; 2 | import markerIconPng from 'leaflet/dist/images/marker-icon.png'; 3 | import 'leaflet/dist/leaflet.css'; 4 | import React, { useEffect, useMemo, useRef, useState } from 'react'; 5 | import { createPortal } from 'react-dom'; 6 | import { GeoJSON, MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet'; 7 | import { CENTER_COORDINATES } from '../constants/defaults'; 8 | import { MapLink } from '../data/map-links'; 9 | import mapMetadata from '../data/map-metadata'; 10 | import '../style/uiBox.css'; 11 | import { ClickedPosition, GameState, GameStatus } from '../types/jingle'; 12 | import { assertNotNil } from '../utils/assert'; 13 | import { 14 | convert, 15 | findNearestPolygonWhereSongPlays, 16 | panMapToLinkPoint, 17 | recalculateNavigationStack, 18 | switchLayer, 19 | } from '../utils/map-utils'; 20 | import LayerPortals from './LayerPortals'; 21 | import { Button } from './ui-util/Button'; 22 | 23 | interface RunescapeMapProps { 24 | gameState: GameState; 25 | onMapClick: (clickedPosition: ClickedPosition) => void; 26 | GoBackButtonRef: React.RefObject; 27 | } 28 | 29 | export default function RunescapeMapWrapper(props: RunescapeMapProps) { 30 | return ( 31 | 40 | 41 | 42 | ); 43 | } 44 | 45 | function RunescapeMap({ gameState, onMapClick, GoBackButtonRef }: RunescapeMapProps) { 46 | const map = useMap(); 47 | const tileLayerRef = useRef(null); 48 | const [currentMapId, setCurrentMapId] = useState(0); 49 | 50 | useMapEvents({ 51 | click: async (e) => { 52 | if (gameState.status !== GameStatus.Guessing) return; 53 | const point = convert.ll_to_xy(e.latlng); 54 | onMapClick({ xy: point, mapId: currentMapId }); 55 | }, 56 | }); 57 | 58 | const [isUnderground, setIsUnderground] = useState(false); 59 | 60 | const onGuessConfirmed = () => { 61 | assertNotNil(gameState.clickedPosition, 'gameState.clickedPosition'); 62 | 63 | // get current song and calculate position 64 | const song = gameState.songs[gameState.round]; 65 | const { mapId, panTo } = findNearestPolygonWhereSongPlays(song, gameState.clickedPosition); 66 | 67 | // handle map layer switching if needed 68 | if (currentMapId !== mapId) { 69 | switchLayer(map, tileLayerRef.current!, mapId); 70 | 71 | recalculateNavigationStack(mapId, panTo, gameState.navigationStack, setIsUnderground); 72 | } 73 | 74 | // update map position and state 75 | map.panTo(convert.xy_to_ll(panTo)); 76 | 77 | setCurrentMapId(mapId); 78 | }; 79 | 80 | useEffect(() => { 81 | if (gameState.status === GameStatus.AnswerRevealed) { 82 | onGuessConfirmed(); 83 | } 84 | // eslint-disable-next-line react-hooks/exhaustive-deps 85 | }, [map, gameState.status]); 86 | 87 | const showGuessMarker = 88 | ((gameState.status === GameStatus.Guessing && gameState.clickedPosition) || 89 | gameState.status === GameStatus.AnswerRevealed) && 90 | gameState.clickedPosition?.mapId === currentMapId; 91 | 92 | const { correctFeaturesData, correctMapId } = useMemo(() => { 93 | const song = gameState.songs[gameState.round]; 94 | if (!map || !song || !gameState.clickedPosition) return {}; 95 | 96 | const { featuresData, mapId } = findNearestPolygonWhereSongPlays( 97 | song, 98 | gameState.clickedPosition!, 99 | ); 100 | 101 | return { correctFeaturesData: featuresData, correctMapId: mapId }; 102 | }, [map, gameState]); 103 | 104 | const showCorrectPolygon = 105 | correctFeaturesData && 106 | correctFeaturesData.some((featureData) => { 107 | return featureData.mapId == currentMapId; 108 | }) && 109 | gameState.status === GameStatus.AnswerRevealed; 110 | 111 | // initially load the first tile layer 112 | useEffect(() => { 113 | setTimeout(() => { 114 | if (map && tileLayerRef.current) { 115 | switchLayer(map, tileLayerRef.current, currentMapId); 116 | } 117 | }, 0); // this waits until tileLayerRef is set (this is why i'm a senior dev) 118 | // eslint-disable-next-line react-hooks/exhaustive-deps 119 | }, []); 120 | 121 | const onPortalClick = (link: MapLink) => { 122 | const { start, end } = link; 123 | const { navigationStack } = gameState; 124 | const lastNavEntry = navigationStack?.[navigationStack.length - 1]; 125 | 126 | // handle case where we're returning to previous location 127 | if (end.mapId === lastNavEntry?.mapId) { 128 | navigationStack?.pop(); 129 | switchLayer(map, tileLayerRef.current!, end.mapId); 130 | panMapToLinkPoint(map, end); 131 | setCurrentMapId(end.mapId); 132 | 133 | if (!navigationStack?.length) { 134 | setIsUnderground(false); 135 | } 136 | return; 137 | } 138 | 139 | // handle new location transition 140 | if (start.mapId !== end.mapId) { 141 | switchLayer(map, tileLayerRef.current!, end.mapId); 142 | gameState.navigationStack?.push({ 143 | mapId: start.mapId, 144 | coordinates: [start.y, start.x], 145 | }); 146 | } 147 | 148 | panMapToLinkPoint(map, end); 149 | setCurrentMapId(end.mapId); 150 | setIsUnderground(true); 151 | }; 152 | 153 | const handleGoBack = (e: React.MouseEvent) => { 154 | const mostRecentNavEntry = gameState.navigationStack?.pop(); 155 | if (!mostRecentNavEntry) { 156 | console.warn('No navigation history to go back to'); 157 | return; 158 | } 159 | const [x, y] = [mostRecentNavEntry?.coordinates[1], mostRecentNavEntry?.coordinates[0]]; 160 | const mapId = mostRecentNavEntry?.mapId; 161 | const mapName = mapMetadata.find((mapData) => mapData.mapId == mapId)!.name; 162 | 163 | switchLayer(map, tileLayerRef.current!, mapId); 164 | panMapToLinkPoint(map, { x, y, mapId, name: mapName }); 165 | setCurrentMapId(mapId); 166 | if (gameState.navigationStack?.length === 0) { 167 | setIsUnderground(false); 168 | } 169 | e.stopPropagation(); 170 | }; 171 | 172 | return ( 173 | <> 174 | {showGuessMarker && ( 175 | 186 | )} 187 | {isUnderground && 188 | GoBackButtonRef.current && 189 | createPortal( 190 | 147 | ); 148 | 149 | const scoreLabel = (score: number | undefined) => ( 150 |
{score ?? '-'}
151 | ); 152 | 153 | return ( 154 | <> 155 |
156 |
157 |
158 | 159 | updateGameSettings(preferences)} 162 | page={Page.DailyJingle} 163 | /> 164 | 165 | 166 | 167 |
168 |
169 | {match(gameState.status) 170 | .with(GameStatus.Guessing, () => { 171 | if (currentPreferences.preferConfirmation) { 172 | return button({ 173 | label: 'Confirm guess', 174 | onClick: () => confirmGuess(), 175 | disabled: !gameState.clickedPosition, 176 | }); 177 | } else { 178 | return
Place your pin on the map
; 179 | } 180 | }) 181 | .with(GameStatus.AnswerRevealed, () => { 182 | if (gameState.round < gameState.songs.length - 1) { 183 | return button({ label: 'Next Song', onClick: nextSong }); 184 | } else { 185 | return button({ label: 'End Game', onClick: endGame }); 186 | } 187 | }) 188 | .with(GameStatus.GameOver, () => 189 | button({ 190 | label: 'Copy Results', 191 | onClick: () => { 192 | copyResultsToClipboard(gameState, finalPercentile, jingleNumber); 193 | }, 194 | }), 195 | ) 196 | .exhaustive()} 197 | 198 |
199 | {scoreLabel(gameState.scores[0])} 200 | {scoreLabel(gameState.scores[1])} 201 | {scoreLabel(gameState.scores[2])} 202 | {scoreLabel(gameState.scores[3])} 203 | {scoreLabel(gameState.scores[4])} 204 |
205 | 206 | 210 | 211 |
212 |
213 |
214 |
215 | 216 | { 219 | const newGameState = jingle.setClickedPosition(clickedPosition); 220 | if (!currentPreferences.preferConfirmation) { 221 | confirmGuess(newGameState); // confirm immediately 222 | } 223 | }} 224 | GoBackButtonRef={goBackButtonRef as RefObject} 225 | /> 226 |
230 | 231 | {gameState.status === GameStatus.GameOver && 232 | (finalPercentile !== null ? ( 233 | 238 | ) : ( 239 |

Loading...

240 | ))} 241 | 242 | ); 243 | } 244 | -------------------------------------------------------------------------------- /src/data/audio2004.ts: -------------------------------------------------------------------------------- 1 | // this shows which audio files have valid old versions 2 | interface SongList { 3 | [key: string]: any; // Index signature 4 | } 5 | export const audio2004: SongList = { 6 | '7th Realm': true, 7 | Alone: true, 8 | 'Al Kharid': true, 9 | 'Ambient Jungle': true, 10 | Anywhere: true, 11 | Arabian: true, 12 | 'Arabian 2': true, 13 | 'Arabian 3': true, 14 | Arabique: true, 15 | 'Army of Darkness': true, 16 | Arrival: true, 17 | Artistry: true, 18 | 'Attack 1': true, 19 | 'Attack 2': true, 20 | 'Attack 3': true, 21 | 'Attack 4': true, 22 | 'Attack 5': true, 23 | 'Attack 6': true, 24 | Attention: true, 25 | 'Autumn Voyage': true, 26 | 'Awful Anthem': true, 27 | 'Aye Car Rum Ba': true, 28 | Background: true, 29 | 'Back to Life': true, 30 | 'Ballad of Enchantment': true, 31 | 'Bandit Camp': true, 32 | Baroque: true, 33 | 'Beetle Juice': true, 34 | Beyond: true, 35 | 'Big Chords': true, 36 | 'Blistering Barnacles': true, 37 | 'Body Parts': true, 38 | 'Bone Dance': true, 39 | 'Bone Dry': true, 40 | 'Book of Spells': true, 41 | Borderland: true, 42 | 'Brew Hoo Hoo!': true, 43 | 'Bubble and Squeak': true, 44 | 'Cabin Fever': true, 45 | Camelot: true, 46 | 'Castle Wars': true, 47 | Cavern: true, 48 | 'Cave of Beasts': true, 49 | 'Cave of the Goblins': true, 50 | 'Cellar Song': true, 51 | 'Chain of Command': true, 52 | Chamber: true, 53 | 'Chef Surprise': true, 54 | 'Chickened Out': true, 55 | 'Chompy Hunt': true, 56 | Claustrophobia: true, 57 | 'Close Quarters': true, 58 | Competition: true, 59 | Contest: true, 60 | 'Corporal Punishment': true, 61 | 'Corridors of Power': true, 62 | 'Crest of a Wave': true, 63 | 'Crystal Castle': true, 64 | 'Crystal Cave': true, 65 | 'Crystal Sword': true, 66 | Cursed: true, 67 | 'Dagannoth Dawn': true, 68 | 'Dance of the Undead': true, 69 | Dangerous: true, 70 | 'Dangerous Road': true, 71 | 'Dangerous Way': true, 72 | Dark: true, 73 | Deadlands: true, 74 | 'Dead Quiet': true, 75 | 'Deep Down': true, 76 | 'Deep Wildy': true, 77 | 'Desert Voyage': true, 78 | 'Distillery Hilarity': true, 79 | Doorways: true, 80 | 'Down Below': true, 81 | 'Dragontooth Island': true, 82 | Dreamstate: true, 83 | Dream: true, 84 | Dunjun: true, 85 | 'Dwarf Theme': true, 86 | Dynasty: true, 87 | Egypt: true, 88 | 'Elven Mist': true, 89 | Emotion: true, 90 | Emperor: true, 91 | Escape: true, 92 | Etceteria: true, 93 | Everywhere: true, 94 | Expanse: true, 95 | Expecting: true, 96 | Expedition: true, 97 | Exposed: true, 98 | Faerie: true, 99 | Faithless: true, 100 | Fanfare: true, 101 | 'Fanfare 2': true, 102 | 'Fanfare 3': true, 103 | 'Fangs for the Memory': true, 104 | 'Far Away': true, 105 | 'Fear and Loathing': true, 106 | 'Find My Way': true, 107 | Fishing: true, 108 | 'Flute Salad': true, 109 | Forbidden: true, 110 | Forest: true, 111 | Forever: true, 112 | 'Forgettable Melody': true, 113 | Forgotten: true, 114 | Frogland: true, 115 | Frostbite: true, 116 | 'Fruits de Mer': true, 117 | Gaol: true, 118 | Garden: true, 119 | Gnomeball: true, 120 | 'Gnome King': true, 121 | 'Gnome Village': true, 122 | 'Gnome Village 2': true, 123 | 'Goblin Game': true, 124 | 'Goblin Village': true, 125 | 'Golden Touch': true, 126 | Greatness: true, 127 | 'Grip of the Talon': true, 128 | Grotto: true, 129 | Grumpy: true, 130 | 'H.A.M. Fisted': true, 131 | Harmony: true, 132 | 'Harmony 2': true, 133 | 'Haunted Mine': true, 134 | 'Head to Head': true, 135 | 'Heart and Mind': true, 136 | Hermit: true, 137 | 'High Seas': true, 138 | Horizon: true, 139 | Iban: true, 140 | 'Ice Melody': true, 141 | Incantation: true, 142 | 'Insect Queen': true, 143 | Inspiration: true, 144 | 'Into the Abyss': true, 145 | Intrepid: true, 146 | 'In the Manor': true, 147 | 'Island Life': true, 148 | 'Isle of Everywhere': true, 149 | 'Jolly R': true, 150 | 'Jungle Island': true, 151 | 'Jungle Troubles': true, 152 | 'Jungly 1': true, 153 | 'Jungly 2': true, 154 | 'Jungly 3': true, 155 | 'Karamja Jam': true, 156 | Kingdom: true, 157 | Labyrinth: true, 158 | Lair: true, 159 | Landlubber: true, 160 | 'Land Down Under': true, 161 | 'Land of the Dwarves': true, 162 | Lasting: true, 163 | 'Last Stand': true, 164 | 'La Mort': true, 165 | Legend: true, 166 | Legion: true, 167 | Lighthouse: true, 168 | Lightness: true, 169 | Lightwalk: true, 170 | 'Little Cave of Horrors': true, 171 | Lonesome: true, 172 | 'Long Ago': true, 173 | 'Long Way Home': true, 174 | 'Lost Soul': true, 175 | Lullaby: true, 176 | 'Mad Eadgar': true, 177 | 'Mage Arena': true, 178 | 'Magical Journey': true, 179 | 'Magic Dance': true, 180 | 'Making Waves': true, 181 | March: true, 182 | Marzipan: true, 183 | Masquerade: true, 184 | Mastermindless: true, 185 | Mausoleum: true, 186 | Medieval: true, 187 | Mellow: true, 188 | Melodrama: true, 189 | Meridian: true, 190 | 'Method of Madness': true, 191 | 'Miles Away': true, 192 | 'Miracle Dance': true, 193 | Mirage: true, 194 | Miscellania: true, 195 | 'Monarch Waltz': true, 196 | 'Monkey Madness': true, 197 | 'Monster Melee': true, 198 | Moody: true, 199 | Morytania: true, 200 | 'Mudskipper Melody': true, 201 | Neverland: true, 202 | 'Newbie Melody': true, 203 | Nightfall: true, 204 | 'Night of the Vampyre': true, 205 | Nomad: true, 206 | 'No Way Out': true, 207 | 'Organ Music 1': true, 208 | 'Organ Music 2': true, 209 | Oriental: true, 210 | Overpass: true, 211 | Overture: true, 212 | Parade: true, 213 | Pathways: true, 214 | 'Path of Peril': true, 215 | 'Pest Control': true, 216 | Phasmatys: true, 217 | 'Pinball Wizard': true, 218 | 'Poles Apart': true, 219 | Quest: true, 220 | 'Rat a Tat Tat': true, 221 | 'Ready for Battle': true, 222 | Regal: true, 223 | Reggae: true, 224 | 'Reggae 2': true, 225 | Rellekka: true, 226 | Righteousness: true, 227 | Riverside: true, 228 | 'Roll the Bones': true, 229 | 'Romancing the Crone': true, 230 | Royale: true, 231 | 'Rune Essence': true, 232 | 'Sad Meadow': true, 233 | Saga: true, 234 | Sarcophagus: true, 235 | 'Scape Cave': true, 236 | 'Scape Ground': true, 237 | 'Scape Hunter': true, 238 | 'Scape Original': true, 239 | 'Scape Sad': true, 240 | 'Scape Soft': true, 241 | 'Scape Wild': true, 242 | Scarab: true, 243 | 'Sea Shanty': true, 244 | Serenade: true, 245 | Serene: true, 246 | Settlement: true, 247 | Shadowland: true, 248 | Shine: true, 249 | Shining: true, 250 | Shipwrecked: true, 251 | Showdown: true, 252 | 'Slither and Thither': true, 253 | Sojourn: true, 254 | Soundscape: true, 255 | Sphinx: true, 256 | Spirit: true, 257 | Splendour: true, 258 | Spooky: true, 259 | 'Spooky Jungle': true, 260 | Stagnant: true, 261 | Starlight: true, 262 | Start: true, 263 | Stillness: true, 264 | 'Still Night': true, 265 | 'Storm Brew': true, 266 | Stranded: true, 267 | Stratosphere: true, 268 | Sunburn: true, 269 | Suspicious: true, 270 | 'Tale of Keldagrim': true, 271 | 'Talking Forest': true, 272 | 'Tears of Guthix': true, 273 | Technology: true, 274 | Temple: true, 275 | 'Temple of Light': true, 276 | Theme: true, 277 | 'The Cellar Dwellers': true, 278 | 'The Chosen': true, 279 | 'The Depths': true, 280 | 'The Desert': true, 281 | 'The Desolate Isle': true, 282 | 'The Enchanter': true, 283 | 'The Fairy Dragon': true, 284 | 'The Far Side': true, 285 | 'The Golem': true, 286 | 'The Lost Tribe': true, 287 | 'The Mad Mole': true, 288 | 'The Monsters Below': true, 289 | 'The Navigator': true, 290 | 'The Noble Rodent': true, 291 | 'The Other Side': true, 292 | 'The Power of Tears': true, 293 | 'The Quizmaster': true, 294 | 'The Shadow': true, 295 | 'The Slayer': true, 296 | 'The Terrible Tower': true, 297 | 'The Tower': true, 298 | 'The Trade Parade': true, 299 | 'Time Out': true, 300 | 'Time to Mine': true, 301 | 'Tomb Raider': true, 302 | Tomorrow: true, 303 | 'Too Many Cooks...': true, 304 | Trawler: true, 305 | 'Trawler Minor': true, 306 | 'Tree Spirits': true, 307 | Tremble: true, 308 | Tribal: true, 309 | 'Tribal 2': true, 310 | 'Tribal Background': true, 311 | Trinity: true, 312 | Troubled: true, 313 | 'Trouble Brewing': true, 314 | Twilight: true, 315 | 'TzHaar!': true, 316 | Underground: true, 317 | 'Underground Pass': true, 318 | Understanding: true, 319 | 'Unknown Land': true, 320 | Upcoming: true, 321 | Venture: true, 322 | 'Venture 2': true, 323 | 'Victory is Mine': true, 324 | Village: true, 325 | Vision: true, 326 | 'Voodoo Cult': true, 327 | Voyage: true, 328 | Wander: true, 329 | Warrior: true, 330 | Waterfall: true, 331 | Waterlogged: true, 332 | Wayward: true, 333 | 'Well of Voyage': true, 334 | 'Where Eagles Lair': true, 335 | Wilderness: true, 336 | 'Wilderness 2': true, 337 | 'Wilderness 3': true, 338 | 'Wild Isle': true, 339 | 'Wild Side': true, 340 | Witching: true, 341 | 'Woe of the Wyvern': true, 342 | Wonderous: true, 343 | Wonder: true, 344 | Woodland: true, 345 | Workshop: true, 346 | 'Wrath and Ruin': true, 347 | Yesteryear: true, 348 | Zealot: true, 349 | }; 350 | -------------------------------------------------------------------------------- /src/scripts/music/ConvertMusicPolys.py: -------------------------------------------------------------------------------- 1 | #needs "OurGeoJSON.json", processed version of wiki music map format, with different features for same songs combined. 2 | #also needs "CombinedGeoJSON.json", scraped geojson data song page by song page. 3 | 4 | import json 5 | import re 6 | import html 7 | from typing import List, Tuple 8 | 9 | def shift_polygon(polygon: List[Tuple[int, int]], old_coords: Tuple[int, int], new_coords: Tuple[int, int]) -> List[Tuple[int,int]]: 10 | 11 | # Calculate the shift (dx, dy) 12 | dx = new_coords[0] - old_coords[0] 13 | dy = new_coords[1] - old_coords[1] 14 | 15 | # Apply the same shift to all points 16 | new_polygon = [(x + dx, y + dy) for x, y in polygon] 17 | 18 | return new_polygon 19 | 20 | def GetTranslatedCoordsFromSquare (name, x, y, z, displaySquareX, displaySquareZ, displayZoneX, displayZoneZ): 21 | xSquareOffset = x % 64 22 | ySquareOffset = y % 64 23 | xZoneOffset = x % 8 24 | yZoneOffset = y % 8 25 | 26 | #if whole square translated 27 | if(displayZoneX == -1 and displayZoneZ == -1): 28 | translatedX = (displaySquareX * 64) + xSquareOffset 29 | translatedY = (displaySquareZ * 64) + ySquareOffset 30 | return translatedX, translatedY, name 31 | 32 | #if both square and zone translated 33 | translatedX = (displaySquareX * 64) + (displayZoneX * 8) + xZoneOffset 34 | translatedY = (displaySquareZ * 64) + (displayZoneZ * 8) + yZoneOffset 35 | return translatedX, translatedY, name, 36 | 37 | def GetSquareFromCoords (x, y, z, areas): 38 | xSquare = x // 64 39 | ySquare = y // 64 40 | xZone = (x - (xSquare * 64)) // 8 41 | yZone = (y - (ySquare * 64)) // 8 42 | 43 | translatedXSquare = -1 44 | translatedYSquare = -1 45 | translatedXZone = -1 46 | translatedYZone = -1 47 | selectedArea = "undefined" 48 | selectedId = -666 49 | 50 | for area in areas: 51 | combined_definitions = area.get("mapSquareDefinitions", []) + area.get("zoneDefinitions", []) 52 | for mapSquare in combined_definitions: 53 | 54 | if(z < mapSquare["level"] or z >= mapSquare["level"] + mapSquare["totalLevels"]): 55 | continue 56 | 57 | # if same level, then compare squares 58 | #WHO TF WILL ACTUALLY CHECK THE LEVEL YOU MORONNNN SO MUCH TIME WASTED MY GOD. adding this now, everything better work well. 59 | 60 | if(mapSquare["sourceSquareX"] == xSquare and mapSquare["sourceSquareZ"] == ySquare): 61 | translatedXSquare = mapSquare["displaySquareX"] 62 | translatedYSquare = mapSquare["displaySquareZ"] 63 | selectedArea = area["name"] 64 | selectedId = area["fileId"] 65 | 66 | #if same square, 67 | if "sourceZoneX" not in mapSquare and "sourceZoneZ" not in mapSquare: 68 | continue 69 | 70 | if(mapSquare["sourceZoneX"] == xZone and mapSquare["sourceZoneZ"] == yZone): 71 | translatedXZone = mapSquare["displayZoneX"] 72 | translatedYZone = mapSquare["displayZoneZ"] 73 | return selectedArea, selectedId, translatedXSquare, translatedYSquare, translatedXZone, translatedYZone 74 | 75 | return selectedArea, selectedId, translatedXSquare, translatedYSquare, translatedXZone, translatedYZone 76 | 77 | def ConvertCoord(x, y, z, areas): 78 | selectedArea, selectedId, translatedXSquare, translatedYSquare, translatedXZone, translatedYZone = GetSquareFromCoords(x, y, z, areas) 79 | translatedX, translatedY, name = GetTranslatedCoordsFromSquare(selectedArea, x, y, z, translatedXSquare, translatedYSquare, translatedXZone, translatedYZone) 80 | return translatedX, translatedY, name, selectedId 81 | 82 | def find_plane_by_polygon(wikiGeoJsonList, songTitle, polygon): 83 | def polygons_match(poly1, poly2): 84 | return poly1 == poly2 85 | 86 | # Find the entry with the matching songTitle 87 | for song_entry in wikiGeoJsonList: 88 | if song_entry.get("title") == songTitle: 89 | data_dict = song_entry.get("data", {}) 90 | for song_poly_list in data_dict.values(): # list 91 | for song_poly_data in song_poly_list: # dict 92 | for feature in song_poly_data.get("features", []): 93 | for candidate_polygon in feature.get("geometry", {}).get("coordinates", []): 94 | if polygons_match(candidate_polygon, polygon): 95 | return int(feature.get("properties", {}).get("plane", 0)) 96 | print("No poly matched for: ", polygon, "\n\n\n") 97 | return 0 # Song found but polygon not matched 98 | 99 | print("Song title not found: ", songTitle) 100 | return 0 # Song title not found 101 | 102 | def add_plane_to_polys(ourGeoJsonList, wikiGeoJsonList): 103 | for song in ourGeoJsonList["features"]: 104 | 105 | fullTitle = song["properties"]["title"] 106 | title = extract_title(fullTitle) 107 | updated_coords = [] 108 | 109 | for poly in song["geometry"]["coordinates"]: 110 | plane = find_plane_by_polygon(wikiGeoJsonList, title, poly) 111 | updated_coords.append({ 112 | "polygon": poly, 113 | "plane": plane 114 | }) 115 | #overwrite 116 | song["geometry"]["coordinates"] = updated_coords 117 | 118 | with open("planeAddedGeoJson.json", "w") as f: 119 | json.dump(ourGeoJsonList, f, indent=2) 120 | 121 | def extract_title(input_str): 122 | match = re.search(r']*title="([^"]*)"[^>]*>', input_str) 123 | if match: 124 | title = html.unescape(match.group(1)) # Convert HTML 125 | return title.replace('_', ' ') # Replace underscores with spaces 126 | return None 127 | 128 | #for debugging 129 | def translate_one_poly(areas): 130 | poly = [[3328,11456],[3328,11520],[3392,11520],[3392,11456],[3328,11456]] 131 | 132 | centerX, centerY = polygon_centroid(poly) 133 | print(centerX, centerY) 134 | newX, newY, name, mapId = ConvertCoord(centerX, centerY, 0, areas) 135 | print(newX, newY, name, mapId ) 136 | 137 | def add_planes_to_our_geojson(): 138 | with open("OurGeoJSON.json", "r") as f: 139 | ourGeoJsonList = json.load(f) 140 | 141 | with open("GeoJSONCombined.json", "r") as f: 142 | wikiGeoJsonList = json.load(f) 143 | 144 | add_plane_to_polys(ourGeoJsonList, wikiGeoJsonList) 145 | 146 | def polygon_centroid(points): 147 | """ 148 | points: list of (x, y) tuples 149 | returns: (centroid_x, centroid_y) 150 | """ 151 | if not points: 152 | return None 153 | 154 | x_list, y_list = zip(*points) 155 | n = len(points) 156 | area = 0 157 | cx = 0 158 | cy = 0 159 | 160 | for i in range(n): 161 | x0, y0 = points[i] 162 | x1, y1 = points[(i + 1) % n] 163 | cross = x0 * y1 - x1 * y0 164 | area += cross 165 | cx += (x0 + x1) * cross 166 | cy += (y0 + y1) * cross 167 | 168 | area *= 0.5 169 | if area == 0: 170 | return sum(x_list) / n, sum(y_list) / n # fallback to average 171 | 172 | cx /= (6 * area) 173 | cy /= (6 * area) 174 | return (cx, cy) 175 | 176 | 177 | def main(): 178 | 179 | add_planes_to_our_geojson() 180 | 181 | with open("planeAddedGeoJson.json", "r") as f: 182 | music = json.load(f) 183 | 184 | with open("ConvertedWorldDefs.json", "r") as f: 185 | areas = json.load(f) 186 | 187 | features = music["features"] 188 | 189 | for song in features: 190 | converted_polygons = {} 191 | original_polygons = [] 192 | 193 | for polygonData in song["geometry"]["coordinates"]: 194 | region_polygons = [] 195 | polygon = polygonData["polygon"] 196 | original_polygons.append(polygon) 197 | #try shifting polygon at each point 198 | for i in range (len(polygon) + 1): 199 | 200 | if(i == len(polygon)): #lazy center handling 201 | centroid = polygon_centroid(polygon) 202 | x = centroid[0] 203 | y = centroid[1] 204 | else: 205 | x = polygon[i][0] 206 | y = polygon[i][1] 207 | z = polygonData["plane"] 208 | 209 | newX, newY, name, mapId = ConvertCoord(x, y, z, areas) 210 | shiftedPolygon = shift_polygon(polygon, [x, y], [newX, newY]) 211 | 212 | duplicate = False 213 | for (regionPolyMapId, regionPolyName, regionPoly) in region_polygons: 214 | if regionPolyMapId == mapId and regionPoly == shiftedPolygon: 215 | duplicate = True 216 | break # No need to check further 217 | 218 | if not duplicate: 219 | region_polygons.append((mapId, name, shiftedPolygon)) 220 | 221 | #add all duplicated polys for this poly 222 | for(mapId, name, shiftedPolygon) in region_polygons: 223 | if(mapId == -666): #filter out undefined mapIds 224 | continue 225 | if (name, mapId) not in converted_polygons: 226 | converted_polygons[(name, mapId)] = [] 227 | converted_polygons[(name, mapId)].append(shiftedPolygon) 228 | 229 | #add all generated polys for this song into "ConvertedGeometry" 230 | song["convertedGeometry"] = [ 231 | {"mapName": mapName, "mapId": mapId, "coordinates": polygon} 232 | for (mapName, mapId), polygons in converted_polygons.items() 233 | for polygon in polygons # Flattening list of polygons per region 234 | ] 235 | song["geometry"]["coordinates"] = original_polygons 236 | 237 | #remove entries with nothing in them 238 | features = [feature for feature in features if len(feature["convertedGeometry"]) != 0] 239 | music["features"] = features 240 | # Save the new songs data 241 | with open("ConvertedGeoJSON.json", "w") as file: 242 | json.dump(music, file, indent=2) 243 | 244 | 245 | if __name__ == "__main__": 246 | main() 247 | 248 | -------------------------------------------------------------------------------- /src/components/side-menu/PreferencesModalButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { FaQuestionCircle } from 'react-icons/fa'; 3 | import { FaChevronDown } from 'react-icons/fa6'; 4 | import { IoWarning } from 'react-icons/io5'; 5 | import { Tooltip } from 'react-tooltip'; 6 | import 'react-tooltip/dist/react-tooltip.css'; 7 | import { ASSETS } from '../../constants/assets'; 8 | import { 9 | Region, 10 | REGIONS, 11 | TOTAL_TRACK_COUNT, 12 | UNDERGROUND_TRACKS, 13 | UNDERGROUND_TRACKS_STRICT, 14 | } from '../../constants/regions'; 15 | import '../../style/modal.css'; 16 | import { Page, UserPreferences } from '../../types/jingle'; 17 | import { countSelectedSongs } from '../../utils/countSelectedSongs'; 18 | import Modal from '../Modal'; 19 | import CustomRangeInput from '../ui-util/CustomRangeInput'; 20 | import IconButton from './IconButton'; 21 | 22 | interface PreferencesModalButtonProps { 23 | currentPreferences: UserPreferences; 24 | onApplyPreferences: (settings: any) => void; 25 | page: Page.DailyJingle | Page.Practice; 26 | } 27 | 28 | export default function SettingsModalButton({ 29 | currentPreferences, 30 | onApplyPreferences, 31 | page, 32 | }: PreferencesModalButtonProps) { 33 | const [open, setOpen] = useState(false); 34 | 35 | const [preferences, setPreferences] = useState(currentPreferences); 36 | const [regionsOpen, setRegionsOpen] = useState(false); 37 | const toggleRegions = () => { 38 | setRegionsOpen((prev) => !prev); 39 | }; 40 | const [dungeonsOpen, setDungeonsOpen] = useState(false); 41 | const toggleDungeons = () => { 42 | setDungeonsOpen((prev) => !prev); 43 | }; 44 | 45 | const disabled = 46 | JSON.stringify(currentPreferences) === JSON.stringify(preferences) || 47 | Object.values(preferences.regions).every((enabled) => !enabled) || 48 | (!preferences.undergroundSelected && !preferences.surfaceSelected); 49 | 50 | const handleHardModeTimeChange = (value: number) => { 51 | setPreferences((prev) => { 52 | return { 53 | ...prev, 54 | hardModeLength: value, 55 | }; 56 | }); 57 | }; 58 | const handlePreferencesChange = (e: React.ChangeEvent) => { 59 | const { name, checked } = e.target; 60 | if (name.startsWith('regions.')) { 61 | const region = name.split('.')[1] as Region; 62 | setPreferences((prev: UserPreferences) => ({ 63 | ...prev, 64 | regions: { 65 | ...prev.regions, 66 | [region]: checked, 67 | }, 68 | })); 69 | } else { 70 | setPreferences((prev: UserPreferences) => ({ 71 | ...prev, 72 | [name]: checked, 73 | })); 74 | } 75 | }; 76 | 77 | return ( 78 | <> 79 | setOpen(true)} 81 | img={ASSETS['settingsIcon']} 82 | /> 83 | setOpen(false)} 86 | onApplySettings={() => onApplyPreferences(preferences)} 87 | saveDisabled={disabled} 88 | > 89 | 94 |

Settings

95 | 96 | 97 | 98 | 106 | 116 | 117 | 123 | 128 | 129 | 130 | 131 | 139 | 149 | 150 | 151 | 159 | 169 | 170 | 171 | 193 | 194 | {page === Page.Practice && ( 195 |
201 |
208 |
Dungeons ({UNDERGROUND_TRACKS.length})
209 |
210 | { 216 | handlePreferencesChange(e); 217 | }} 218 | > 219 |
220 |
221 |
228 |
Surface ({TOTAL_TRACK_COUNT - UNDERGROUND_TRACKS_STRICT.length})
229 |
230 | { 236 | handlePreferencesChange(e); 237 | }} 238 | > 239 |
240 |
241 |
242 | )} 243 | 244 | 266 | 267 | 268 |
99 | Hard Mode{' '} 100 | 104 | 105 | 107 | { 112 | handlePreferencesChange(e); 113 | }} 114 | > 115 |
132 | 2004 Audio{' '} 133 | 137 | 138 | 140 | { 145 | handlePreferencesChange(e); 146 | }} 147 | > 148 |
152 | Confirmation{' '} 153 | 157 | 158 | 160 | { 165 | handlePreferencesChange(e); 166 | }} 167 | > 168 |
172 | Underground{' '} 173 | 178 | {page !== Page.Practice && ( 179 | <> 180 | 189 | 190 | 191 | )} 192 |
245 | Regions{' '} 246 | 251 | {page !== Page.Practice && ( 252 | <> 253 | 262 | 263 | 264 | )} 265 |
269 | {page === Page.Practice && ( 270 |
276 | {Object.keys(REGIONS).map((region) => ( 277 |
285 |
286 | {region} ({countSelectedSongs(preferences, region as Region)}) 287 |
288 |
289 | { 295 | handlePreferencesChange(e); 296 | }} 297 | > 298 |
299 |
300 | ))} 301 |
302 | )} 303 |
304 | 305 | ); 306 | } 307 | -------------------------------------------------------------------------------- /src/constants/regions.ts: -------------------------------------------------------------------------------- 1 | import { filterDungeons } from '../scripts/filter-regions'; 2 | 3 | export const UNDERGROUND_TRACKS_STRICT = filterDungeons({ strict: true }); // tracks that exist ONLY underground 4 | export const UNDERGROUND_TRACKS = filterDungeons({ strict: false }); 5 | 6 | export const REGIONS = { 7 | Misthalin: [ 8 | 'Vision', 9 | 'Yesteryear', 10 | 'Book of Spells', 11 | 'Unknown Land', 12 | 'Dream', 13 | 'Harmony', 14 | 'Start', 15 | 'Flute Salad', 16 | 'Autumn Voyage', 17 | 'Spooky', 18 | 'Greatness', 19 | 'Expanse', 20 | 'Still Night', 21 | 'Venture', 22 | 'Barbarianism', 23 | 'Spirit', 24 | 'Soul Wars', 25 | 'Garden', 26 | 'Medieval', 27 | 'Forever', 28 | 'The Trade Parade', 29 | 'The Waiting Game', 30 | 'Adventure', 31 | 'Lullaby', 32 | 'Doorways', 33 | 'Parade', 34 | 'Newbie Melody', 35 | 'Looking Back', 36 | 'Preserved', 37 | 'Preservation', 38 | 'Cave of the Goblins', 39 | 'Down Below', 40 | 'Harmony 2', 41 | 'The Lost Melody', 42 | 'Monster Melee', 43 | 'Oh Rats!', 44 | 'The Power of Tears', 45 | 'Safety in Numbers', 46 | 'Scape Cave', 47 | 'Tears of Guthix', 48 | 'Tiptoe', 49 | 'Victory is Mine', 50 | 'Title Fight', 51 | 'Crystal Cave', 52 | 'Dance of Death', 53 | 'Dogs of War', 54 | 'Dorgeshuun City', 55 | 'Faerie', 56 | 'Food for Thought', 57 | 'Fossilised', 58 | 'Lava is Mine', 59 | 'Malady', 60 | 'Colossus of the Deep', 61 | 'Scar Tissue', 62 | 'Temple of the Eye', 63 | 'Guardians of the Rift', 64 | 'The Guardians Prepare', 65 | 'The Sound of Guthix', 66 | 'Temple Desecrated', 67 | 'Zealot', 68 | 'Down to Earth', 69 | 'Miracle Dance', 70 | 'Heart and Mind', 71 | 'Stratosphere', 72 | 'Chickened Out', 73 | 'Clanliness', 74 | 'Roots and Flutes', 75 | 'Alternative Root', 76 | "All's Fairy in Love & War", 77 | ], 78 | Karamja: [ 79 | 'Jungle Island', 80 | 'Sea Shanty', 81 | 'Jolly R', 82 | 'Landlubber', 83 | 'High Seas', 84 | 'Jungly 2', 85 | 'Jungly 3', 86 | 'Tribal Background', 87 | 'Reggae', 88 | 'Reggae 2', 89 | 'Spooky Jungle', 90 | 'Ambient Jungle', 91 | 'Jungle Troubles', 92 | 'The Shadow', 93 | 'Tribal', 94 | 'Tribal 2', 95 | 'Fanfare 2', 96 | 'Jungly 1', 97 | '7th Realm', 98 | 'Attack 2', 99 | 'Aztec', 100 | 'Dangerous Road', 101 | 'Karamja Jam', 102 | 'Nether Realm', 103 | 'Pathways', 104 | 'Fire and Brimstone', 105 | 'In the Pits', 106 | 'Mor Ul Rek', 107 | 'TzHaar!', 108 | 'Inferno', 109 | 'Understanding', 110 | 'Superstition', 111 | 'Voodoo Cult', 112 | 'Lurking Threats', 113 | ], 114 | Asgarnia: [ 115 | 'Alone', 116 | 'Arrival', 117 | 'Attention', 118 | 'Background', 119 | 'Dwarf Theme', 120 | 'Emperor', 121 | 'Fanfare', 122 | 'Goblin Village', 123 | 'Horizon', 124 | 'Ice Melody', 125 | 'Kingdom', 126 | 'Knightmare', 127 | 'Lightness', 128 | 'Long Way Home', 129 | 'The Maze', 130 | 'Miles Away', 131 | 'Mudskipper Melody', 132 | 'Nightfall', 133 | 'Principality', 134 | 'Scape Soft', 135 | 'Sea Shanty 2', 136 | 'Song of the Silent Choir', 137 | 'Splendour', 138 | 'Tomorrow', 139 | 'Wander', 140 | 'Workshop', 141 | 'Frostbite', 142 | 'Stranded', 143 | 'Contest', 144 | 'Tremble', 145 | 'Sojourn', 146 | 'Hells Bells', 147 | 'Romancing the Crone', 148 | 'Pest Control', 149 | 'Null and Void', 150 | "Warriors' Guild", 151 | 'The Ancient Prison', 152 | "The Angel's Fury", 153 | 'Arabique', 154 | 'Armageddon', 155 | 'Armadyl Alliance', 156 | 'Bandos Battalion', 157 | 'Strength of Saradomin', 158 | 'Zamorak Zoo', 159 | 'Beyond', 160 | 'Cave Background', 161 | 'Courage', 162 | 'Dunjun', 163 | 'The Mad Mole', 164 | 'Pick & Shovel', 165 | 'Royale', 166 | 'The Ruins of Camdozaal', 167 | 'Starlight', 168 | 'Zaros Zeitgeist', 169 | 'Righteousness', 170 | 'Woe of the Wyvern', 171 | 'Black of Knight', 172 | 'The Route of All Evil', 173 | 'The Fallen Empire', 174 | 'Serene', 175 | ], 176 | Fremennik: [ 177 | 'Saga', 178 | 'Legend', 179 | 'Rellekka', 180 | 'Borderland', 181 | 'Settlement', 182 | 'Poles Apart', 183 | 'The Desolate Isle', 184 | 'Norse Code', 185 | 'Volcanic Vikings', 186 | 'Island of the Trolls', 187 | 'The Galleon', 188 | 'The Lunar Isle', 189 | 'Isle of Everywhere', 190 | 'The Desolate Isle', 191 | 'Exposed', 192 | 'Eye See You', 193 | 'Have an Ice Day', 194 | 'Miscellania', 195 | 'Etceteria', 196 | 'Barren Land', 197 | 'Lumbering', 198 | 'On Thin Ice', 199 | 'Lighthouse', 200 | 'Corridors of Power', 201 | 'Espionage', 202 | 'Lair of the Basilisk', 203 | 'Land Down Under', 204 | 'Land of the Dwarves', 205 | 'Marzipan', 206 | 'Masquerade', 207 | 'More Than Meets the Eye', 208 | 'The Monsters Below', 209 | 'Ogre the Top', 210 | 'Prison Break', 211 | 'Secrets of the North', 212 | 'The Slayer', 213 | 'Slither and Thither', 214 | 'Tale of Keldagrim', 215 | 'Time to Mine', 216 | 'Prison Break', 217 | 'More Than Meets the Eye', 218 | 'The North', 219 | 'Reign of the Basilisk', 220 | 'The Forsaken', 221 | 'Out of the Deep', 222 | ], 223 | Kandarin: [ 224 | 'Anywhere', 225 | 'Island Life', 226 | 'Attack 1', 227 | 'Attack 4', 228 | 'Ballad of Enchantment', 229 | 'Baroque', 230 | 'Big Chords', 231 | 'Camelot', 232 | 'Castle Wars', 233 | 'Chompy Hunt', 234 | 'Creature Cruelty', 235 | "Eagles' Peak", 236 | 'Emotion', 237 | 'Expecting', 238 | 'Fanfare 3', 239 | 'Fishing', 240 | 'Fruits de Mer', 241 | 'Gaol', 242 | 'Gnome King', 243 | 'Gnome Village', 244 | 'Gnome Village 2', 245 | 'Gnomeball', 246 | 'Grumpy', 247 | 'In the Manor', 248 | 'Joy of the Hunt', 249 | 'Knightly', 250 | 'Lasting', 251 | 'Legion', 252 | 'Lightwalk', 253 | 'Long Ago', 254 | 'Lullaby', 255 | 'Magic Dance', 256 | 'Magic Magic Magic', 257 | 'Magical Journey', 258 | 'March', 259 | 'Marooned', 260 | 'Mellow', 261 | 'Melodrama', 262 | 'The Mollusc Menace', 263 | 'Monarch Waltz', 264 | 'Monkey Madness', 265 | 'Mythical', 266 | 'Neverland', 267 | 'On the Wing', 268 | 'Overpass', 269 | 'Overture', 270 | 'Romper Chomper', 271 | 'Sad Meadow', 272 | 'Serenade', 273 | 'Soundscape', 274 | 'Talking Forest', 275 | 'Theme', 276 | 'The Tower', 277 | 'Tree Spirits', 278 | 'Trinity', 279 | 'Upcoming', 280 | 'Voyage', 281 | 'Waterfall', 282 | 'Wonderous', 283 | 'Work Work Work', 284 | 'Zogre Dance', 285 | 'Making Waves', 286 | 'Jungle Hunt', 287 | 'On the Shore', 288 | 'Altar Ego', 289 | 'Barb Wire', 290 | 'Beneath the Stronghold', 291 | "Brimstail's Scales", 292 | 'The Cellar Dwellers', 293 | 'Escape', 294 | 'Goblin Game', 295 | 'Devils May Care', 296 | "Narnode's Theme", 297 | 'Troubled Waters', 298 | 299 | 'Cursed', 300 | 'Temple of Tribes', 301 | 'Catacombs and Tombs', 302 | 'Monkey Badness', 303 | 'Monkey Business', 304 | 'Fight or Flight', 305 | 'Temple of Light', 306 | 'La Mort', 307 | 'Iban', 308 | 'Underground Pass', 309 | 'Intrepid', 310 | ], 311 | Desert: [ 312 | 'Arabian', 313 | 'Arabian 2', 314 | 'Arabian 3', 315 | 'Desert Heat', 316 | 'Desert Voyage', 317 | 'Egypt', 318 | 'Scarab', 319 | 'Sphinx', 320 | 'Sunburn', 321 | 'The Desert', 322 | 'Over to Nardah', 323 | 'Ruins of Isolation', 324 | 'The Golem', 325 | "Pharaoh's Tomb", 326 | 'Nomad', 327 | 'Bandit Camp', 328 | 'Al Kharid', 329 | "The Emir's Arena", 330 | 'Shine', 331 | 'City of the Dead', 332 | 'Dunes of Eternity', 333 | 'Tempor of the Storm', 334 | 'Dynasty', 335 | 'Back to Life', 336 | 'The Foundry', 337 | 'Insect Queen', 338 | 'Into the Tombs', 339 | 'Bone Dry', 340 | 'Garden of Winter', 341 | 'Garden of Summer', 342 | 'Garden of Autumn', 343 | 'Garden of Spring', 344 | 'Quest', 345 | 'Beneath Cursed Sands', 346 | "A Mother's Curse", 347 | 'Jaws of Gluttony', 348 | 'Sands of Time', 349 | 'Ape-ex Predator', 350 | "Amascut's Promise", 351 | 'Laid to Rest', 352 | 'Test of Strength', 353 | 'Test of Isolation', 354 | 'Test of Companionship', 355 | 'Test of Resourcefulness', 356 | "Sorceress's Garden", 357 | 'What the Shell!', 358 | 'The Tortugan Way', 359 | 'Elder Wisdom', 360 | 'Below the Conch', 361 | ], 362 | Morytania: [ 363 | 'Arboretum', 364 | 'Bloodbath', 365 | 'Bone Dance', 366 | 'Dance of the Undead', 367 | 'Dead Quiet', 368 | 'Deadlands', 369 | 'Distant Land', 370 | 'Dragontooth Island', 371 | "Fenkenstrain's Refrain", 372 | 'Lament for the Hallowed', 373 | 'The Last Shanty', 374 | 'Morytania', 375 | 'Natural', 376 | 'Night of the Vampyre', 377 | 'The Other Side', 378 | 'Shadowland', 379 | 'Shipwrecked', 380 | 'Stagnant', 381 | 'Upir Likhyi', 382 | 'Village', 383 | 'Waterlogged', 384 | 'Welcome to the Theatre', 385 | 'Darkmeyer', 386 | 'Trouble Brewing', 387 | 'Zombiism', 388 | "Life's a Beach!", 389 | 'In the Brine', 390 | 'Distillery Hilarity', 391 | 'Noxious Awakening', 392 | 'Barking Mad', 393 | 'Brew Hoo Hoo!', 394 | 'Body Parts', 395 | 'The Terrible Tunnels', 396 | 'The Terrible Caverns', 397 | 'The Everlasting Slumber', 398 | 'Itsy Bitsy...', 399 | 'Lair', 400 | 'Mausoleum', 401 | 'Phasmatys', 402 | 'Spooky 2', 403 | 'The Terrible Tower', 404 | 'Aye Car Rum Ba', 405 | 'Blistering Barnacles', 406 | 'Little Cave of Horrors', 407 | 'Lament of Meiyerditch', 408 | 409 | "The Maiden's Sorrow", 410 | "The Maiden's Anger", 411 | 'Welcome to my Nightmare', 412 | 'The Nightmare Continues', 413 | 'Dance of the Nylocas', 414 | 'Arachnids of Vampyrium', 415 | 'The Dark Beast Sotetseg', 416 | 'Power of the Shadow Realm', 417 | 'Predator Xarpus', 418 | 'Last King of the Yarasa', 419 | "It's not over 'til...", 420 | 'The Fat Lady Sings', 421 | 'The Curtain Closes', 422 | 'Haunted Mine', 423 | 'Deep Down', 424 | 'Chamber', 425 | 'The Bane of Ashihama', 426 | ], 427 | Tirannwn: [ 428 | 'Everywhere', 429 | 'Elven Seed', 430 | 'Crystal Castle', 431 | 'Woodland', 432 | 'Breeze', 433 | 'Elven Mist', 434 | 'Meridian', 435 | 'Forest', 436 | 'Far Away', 437 | 'Riverside', 438 | 'Exposed', 439 | 'Lost Soul', 440 | 'Thrall of the Serpent', 441 | 'Coil', 442 | 'Sharp End of the Crystal', 443 | 'Song of the Elves', 444 | 'The Spurned Demon', 445 | 'Trahaearn Toil', 446 | ], 447 | Wilderness: [ 448 | 'A Dangerous Game', 449 | 'Army of Darkness', 450 | 'Close Quarters', 451 | 'Crystal Sword', 452 | 'Dangerous', 453 | 'Dark', 454 | 'Dead Can Dance', 455 | 'Deep Wildy', 456 | 'Everlasting Fire', 457 | 'Faithless', 458 | 'Forbidden', 459 | 'Inspiration', 460 | 'Mage Arena', 461 | 'Moody', 462 | 'Pirates of Peril', 463 | 'Regal', 464 | 'Scape Sad', 465 | 'Scape Wild', 466 | 'Shining', 467 | 'Troubled', 468 | 'Undercurrent', 469 | 'Underground', 470 | 'Venomous', 471 | 'Wild Isle', 472 | 'Wild Side', 473 | 'Wilderness', 474 | 'Wilderness 2', 475 | 'Wilderness 3', 476 | 'Wildwood', 477 | 'Witching', 478 | 'Wonder', 479 | 'The Enclave', 480 | 'Bane', 481 | 'Cavern', 482 | 'A Dangerous Game', 483 | 'Revenants', 484 | 'Scorpia Dances', 485 | 'Shining Spirit', 486 | 'Attack 3', 487 | 'Attack 5', 488 | 'Last Man Standing', 489 | 490 | 'Complication', 491 | ], 492 | Kourend: [ 493 | 'Arcane', 494 | 'Beyond the Meadow', 495 | 'Burning Desire', 496 | 'Blood Rush', 497 | 'Country Jig', 498 | 'The Desolate Mage', 499 | 'The Doors of Dinh', 500 | 'Down by the Docks', 501 | "A Farmer's Grind", 502 | 'The Forests of Shayzien', 503 | 'The Forlorn Homestead', 504 | 'The Forsaken Tower', 505 | 'Gill Bill', 506 | 'Grow Grow Grow', 507 | 'Hoe Down', 508 | 'Ice and Fire', 509 | 'Kourend the Magnificent', 510 | 'March of the Shayzien', 511 | 'Military Life', 512 | 'The Militia', 513 | 'Newbie Farming', 514 | 'Not a Moment of Relief', 515 | 'Out at the Mines', 516 | 'Rose', 517 | 'Rugged Terrain', 518 | 'Servants of Strife', 519 | 'Soul Fall', 520 | 'Strangled', 521 | 'Stuck in the Mire', 522 | 'A Walk in the Woods', 523 | 'Ascent', 524 | 'Dwarven Domain', 525 | 'Getting Down to Business', 526 | 'On the Frontline', 527 | 'Beneath the Kingdom', 528 | 'A Forgotten Religion', 529 | 'Sarachnis', 530 | 'Scape Cave', 531 | 'Subterranea', 532 | 'Alchemical Attack!', 533 | 'Creeping Vines', 534 | 'Ful to the Brim', 535 | 'Kanon of Kahlith', 536 | 537 | 'Darkly Altared', 538 | 'A Thorn in My Side', 539 | 'Way of the Wyrm', 540 | 'Dagannoth Dawn', 541 | 'Xenophobe', 542 | 'Darkness in the Depths', 543 | 'Sign Here', 544 | ], 545 | Varlamore: [ 546 | 'Are You Not Entertained', 547 | 'The City of Sun', 548 | 'Emissaries of Twilight', 549 | 'The Feathered Serpent', 550 | 'The Guidance of Ralos', 551 | 'Mistrock', 552 | 'Ready for the Hunt', 553 | 'Stones of Old', 554 | "Varlamore's Sunset", 555 | 'Whispering Wind', 556 | 'Peace and Prosperity', 557 | 'Isle of Serenity', 558 | 'The Undying Light', 559 | 'Creatures of Varlamore', 560 | 'Strangled', 561 | 'Prospering Fortune', 562 | 'Lost to Time', 563 | 'The Moons of Ruin', 564 | 'Under the Mountain', 565 | 'Blood Rush', 566 | 'The Guardian of Tapoyauik', 567 | 568 | 'The Doom', 569 | 'Spiritbloom', 570 | 'Spirit of the Forest', 571 | 'Teklan', 572 | 'The Talkasti People', 573 | 'Call of the Tlati', 574 | 'A Desolate Past', 575 | 'Over the Mountains', 576 | 'Stalkers', 577 | 'Scorching Horizon', 578 | 'Truth', 579 | ], 580 | }; 581 | export const TOTAL_TRACK_COUNT = Object.keys(REGIONS).reduce( 582 | (count, region) => count + REGIONS[region as Region].length, 583 | 0, 584 | ); 585 | export type Region = keyof typeof REGIONS; 586 | -------------------------------------------------------------------------------- /src/components/NextDailyCountdown.tsx: -------------------------------------------------------------------------------- 1 | import '../style/resultScreen.css'; 2 | import useCountdown from '../hooks/useCountdown'; 3 | import { Dayjs } from 'dayjs'; 4 | 5 | interface CountdownProps { 6 | end: Dayjs; 7 | } 8 | const NextDailyCountdown = ({ end }: CountdownProps) => { 9 | const countdown = useCountdown(end); 10 | return ( 11 | 12 | 19 | 20 | 28 | 35 | 42 | 49 | 56 | 63 | 70 | 77 | 84 | 91 | 98 | 105 | 112 | 119 | 126 | 133 | 140 | 147 | 154 | 161 | 168 | 175 | 182 | 189 | 196 | 203 | 210 | 217 | 224 | 231 | 238 | 245 | 252 | 259 | 266 | 273 | 280 | 287 | 294 | 301 | 308 | 315 | 322 | 329 | 336 | 343 | 350 | 357 | 364 | 371 | 378 | 385 | 392 | 399 | 406 | 413 | 420 | 427 | 434 | 441 | 448 | 455 | 462 | 469 | 476 | 483 | 490 | 497 | 504 | 511 | 518 | 525 | 532 | 539 | 546 | 547 | 548 | 553 | 554 | 555 | {countdown} 556 | 557 | ); 558 | }; 559 | 560 | export default NextDailyCountdown; 561 | --------------------------------------------------------------------------------