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 |
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 |
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 |
42 |
43 | {/* non-hard mode */}
44 | {showAudio && (
45 |
46 |
52 |
53 |
54 | )}
55 |
56 | {/* hard mode */}
57 | {!showAudio && (
58 |
59 |
}
61 | snippetLength={currentPreferences.hardModeLength}
62 | />
63 |
64 | playSnippet(audioRef, currentPreferences.hardModeLength)}
67 | data-tooltip-id={`reload-tooltip`}
68 | data-tooltip-content={'Reload Audio'}
69 | />
70 |
71 |
72 |
73 | )}
74 |
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 | {}}
142 | classes={'guess-btn guess-btn-no-border'}
143 | disabled={!isAudioReady || isClipPlaying}
144 | />
145 |
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 |
confirmGuess()}
135 | disabled={!gameState.clickedPosition}
136 | />
137 | );
138 | } else {
139 | return Place your pin on the map ;
140 | }
141 | })
142 | .with(GameStatus.AnswerRevealed, () => (
143 |
148 | ))
149 | .with(GameStatus.GameOver, () => {
150 | throw new Error('Unreachable');
151 | })
152 | .exhaustive()}
153 |
154 |
158 |
159 |
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 | handleGoBack(e)}
193 | classes='go-up-btn'
194 | />,
195 | GoBackButtonRef.current,
196 | )}
197 | {showCorrectPolygon && (
198 | {
202 | return featureData.mapId === currentMapId;
203 | })!.feature
204 | }
205 | style={() => ({
206 | color: '#0d6efd', // Outline color
207 | fillColor: '#0d6efd', // Fill color
208 | weight: 5, // Outline thickness
209 | fillOpacity: 0.5, // Opacity of fill
210 | transition: 'all 2000ms',
211 | })}
212 | />
213 | )}
214 |
218 |
226 | >
227 | );
228 | }
229 |
--------------------------------------------------------------------------------
/src/components/side-menu/HistoryModalButton.tsx:
--------------------------------------------------------------------------------
1 | import Chip from '@mui/material/Chip';
2 | import { Fragment, useState } from 'react';
3 | import { FaChevronDown, FaQuestionCircle } from 'react-icons/fa';
4 | import { Tooltip } from 'react-tooltip';
5 | import 'react-tooltip/dist/react-tooltip.css';
6 | import useSWR from 'swr';
7 | import { ASSETS } from '../../constants/assets';
8 | import { getAverages } from '../../data/jingle-api';
9 | import '../../style/modal.css';
10 | import { calcDailyAvgColor } from '../../utils/string-utils';
11 | import Modal from '../Modal';
12 | import IconButton from './IconButton';
13 |
14 | const HistoryModalButton = () => {
15 | const [open, setOpen] = useState(false);
16 | const [expandedId, setExpandedId] = useState(null);
17 |
18 | const { data: averages } = useSWR(`/api/averages`, getAverages);
19 | const localStorageItems = { ...localStorage };
20 | const dailies = Object.entries(localStorageItems).filter(([key]) => key.includes('jingle-'));
21 | const dailiesAsObjects = dailies.map(([key, value]) => ({
22 | key,
23 | value: JSON.parse(value),
24 | dailyNumber: parseInt(key.match(/\d+/)?.[0] || '0'),
25 | }));
26 |
27 | let dailyTotal = 0;
28 | for (const daily of dailiesAsObjects) {
29 | let songTotal = 0;
30 | for (const score of daily.value.scores) {
31 | songTotal += parseInt(score);
32 | }
33 | dailyTotal += songTotal;
34 | }
35 | const dailyAvg = dailyTotal / dailiesAsObjects.length;
36 |
37 | dailiesAsObjects.sort((a, b) => b.dailyNumber - a.dailyNumber);
38 |
39 | const toggleDaily = (key: string) => {
40 | setExpandedId((prev) => {
41 | if (prev == key) return null;
42 | return key;
43 | });
44 | };
45 |
46 | return (
47 |
48 |
setOpen(true)}
50 | img={ASSETS['historyIcon']}
51 | >
52 |
setOpen(false)}
55 | >
56 |
60 |
67 |
History
68 |
69 |
74 |
75 |
76 |
77 |
78 |
79 | Jingles Completed
80 | {dailiesAsObjects.length}
81 |
82 |
83 | Avg Score
84 | {dailyAvg ? dailyAvg.toFixed(0) : '-'}
85 |
86 |
87 | By Day
88 | {dailiesAsObjects.length ? (
89 |
90 |
91 |
92 |
96 | Jingle
97 |
98 |
102 | Personal
103 |
104 |
108 | Glob. Avg
109 |
110 |
114 | Date
115 |
116 |
117 |
118 |
119 | {dailiesAsObjects.map((dailyObject: any) => {
120 | const startTime = dailyObject.value?.startTime ?? dailyObject.value?.startTimeMs;
121 | const dateString = startTime ? new Date(startTime).toISOString().split('T')[0] : '';
122 |
123 | const formattedDate = dateString ? new Date(dateString).toLocaleDateString() : '';
124 |
125 | const dailyKey = dailyObject.key;
126 | const isExpanded = expandedId === dailyKey;
127 | const dailyAvg = averages ? averages[dateString] : null;
128 | const score = dailyObject.value?.scores?.reduce((a: number, b: number) => a + b);
129 |
130 | return (
131 |
132 |
133 |
134 | #{dailyObject.dailyNumber}
135 | toggleDaily(dailyKey)}
137 | className={isExpanded ? 'rotated' : ''}
138 | pointerEvents={'auto'}
139 | />
140 |
141 |
142 | {score && (
143 |
148 | )}
149 |
150 | {/* time taken - maybe bring it later some time */}
151 | {/*
152 | {dailyObject.value?.timeTaken && (
153 |
158 | )}
159 | */}
160 |
161 | {' '}
162 | {dailyAvg && (
163 |
168 | )}
169 |
170 |
171 | {' '}
172 | {formattedDate && (
173 |
178 | )}
179 |
180 |
181 |
189 | {dailyObject.value.songs.map((song: string) => (
190 |
195 | {song}
196 |
197 | {dailyObject.value.scores[dailyObject.value.songs.indexOf(song)] ?? '-'}
198 |
199 |
200 | ))}
201 |
202 |
203 | );
204 | })}
205 |
206 |
207 | ) : (
208 | No data yet, maybe try playing a daily challenge first?
209 | )}
210 |
211 |
212 | );
213 | };
214 |
215 | export default HistoryModalButton;
216 |
--------------------------------------------------------------------------------
/src/components/DailyJingle.tsx:
--------------------------------------------------------------------------------
1 | import { sum } from 'ramda';
2 | import { RefObject, useEffect, useRef, useState } from 'react';
3 | import { match } from 'ts-pattern';
4 | import { LOCAL_STORAGE } from '../constants/localStorage';
5 | import {
6 | incrementGlobalGuessCounter,
7 | incrementSongFailureCount,
8 | incrementSongSuccessCount,
9 | postDailyChallengeResult,
10 | } from '../data/jingle-api';
11 | import useGameLogic from '../hooks/useGameLogic';
12 | import {
13 | DailyChallenge,
14 | GameSettings,
15 | GameState,
16 | GameStatus,
17 | Page,
18 | UserPreferences,
19 | } from '../types/jingle';
20 | import {
21 | incrementLocalGuessCount,
22 | loadGameStateFromBrowser,
23 | loadPreferencesFromBrowser,
24 | sanitizePreferences,
25 | savePreferencesToBrowser,
26 | updateGuessStreak,
27 | } from '../utils/browserUtil';
28 | import { getCurrentDateInBritain } from '../utils/date-utils';
29 | import { copyResultsToClipboard, getJingleNumber } from '../utils/jingle-utils';
30 | import { playSong } from '../utils/playSong';
31 | import AudioControls from './AudioControls';
32 | import Footer from './Footer';
33 | import GameOver from './GameOver';
34 | import RoundResult from './RoundResult';
35 | import RunescapeMap from './RunescapeMap';
36 | import HistoryModalButton from './side-menu/HistoryModalButton';
37 | import HomeButton from './side-menu/HomeButton';
38 | import NewsModalButton from './side-menu/NewsModalButton';
39 | import SettingsModalButton from './side-menu/PreferencesModalButton';
40 | import StatsModalButton from './side-menu/StatsModalButton';
41 |
42 | interface DailyJingleProps {
43 | dailyChallenge: DailyChallenge;
44 | }
45 |
46 | sanitizePreferences();
47 | export default function DailyJingle({ dailyChallenge }: DailyJingleProps) {
48 | const jingleNumber = getJingleNumber(dailyChallenge);
49 | const currentPreferences = loadPreferencesFromBrowser();
50 | const goBackButtonRef = useRef(null);
51 | const [finalPercentile, setFinalPercentile] = useState(null);
52 |
53 | // this is to prevent loading the game state from localStorage multiple times
54 | const [initialized, setInitialized] = useState(false);
55 | useEffect(() => setInitialized(true), []);
56 |
57 | const initialGameState: GameState = (() => {
58 | return loadGameStateFromBrowser(jingleNumber, dailyChallenge);
59 | })();
60 | const jingle = useGameLogic(initialGameState);
61 | const gameState = jingle.gameState;
62 |
63 | const saveGameState = (gameState: GameState) => {
64 | if (!gameState) {
65 | throw new Error('trying to save undefined game state');
66 | }
67 | localStorage.setItem(LOCAL_STORAGE.gameState(jingleNumber), JSON.stringify(gameState));
68 | };
69 |
70 | const audioRef = useRef(null);
71 | useEffect(() => {
72 | playSong(
73 | audioRef,
74 | initialGameState.songs[gameState.round],
75 | initialGameState.settings.oldAudio,
76 | ...(currentPreferences.preferHardMode ? [currentPreferences.hardModeLength] : []),
77 | );
78 | // eslint-disable-next-line react-hooks/exhaustive-deps
79 | }, []);
80 |
81 | const confirmGuess = (latestGameState?: GameState) => {
82 | const gameState = jingle.confirmGuess(latestGameState);
83 | saveGameState(gameState);
84 |
85 | // update statistics
86 | incrementGlobalGuessCounter();
87 | const currentSong = gameState.songs[gameState.round];
88 | const correct = gameState.scores[gameState.round] === 1000;
89 | if (correct) {
90 | incrementLocalGuessCount(true);
91 | incrementSongSuccessCount(currentSong);
92 | updateGuessStreak(true);
93 | } else {
94 | incrementLocalGuessCount(false);
95 | incrementSongFailureCount(currentSong);
96 | updateGuessStreak(false);
97 | }
98 |
99 | const isLastRound = gameState.round === gameState.songs.length - 1;
100 | if (isLastRound) {
101 | localStorage.setItem(LOCAL_STORAGE.dailyComplete, getCurrentDateInBritain());
102 | postDailyChallengeResult(sum(gameState.scores), Date.now() - gameState.startTimeMs).then(
103 | (data) => {
104 | setFinalPercentile(data?.percentile);
105 | },
106 | );
107 | }
108 | };
109 |
110 | const endGame = () => {
111 | const gameState = jingle.endGame();
112 | saveGameState(gameState);
113 | };
114 |
115 | const nextSong = () => {
116 | const gameState = jingle.nextSong();
117 | saveGameState(gameState);
118 |
119 | const songName = gameState.songs[gameState.round];
120 | playSong(
121 | audioRef,
122 | songName,
123 | gameState.settings.oldAudio,
124 | ...(currentPreferences.preferHardMode ? [currentPreferences.hardModeLength] : []),
125 | );
126 | };
127 |
128 | const updateGameSettings = (preferences: UserPreferences) => {
129 | const newSettings: GameSettings = {
130 | hardMode: preferences.preferHardMode,
131 | oldAudio: preferences.preferOldAudio,
132 | };
133 | jingle.updateGameSettings(newSettings);
134 |
135 | savePreferencesToBrowser(preferences);
136 | };
137 |
138 | const button = (props: { label: string; disabled?: boolean; onClick: () => any }) => (
139 |
145 | {props.label}
146 |
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 |
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 |
--------------------------------------------------------------------------------