diff options
author | Nat Lasseter <user@4574.co.uk> | 2025-03-31 16:20:12 +0100 |
---|---|---|
committer | Nat Lasseter <user@4574.co.uk> | 2025-03-31 16:20:12 +0100 |
commit | 1a4fca51bf12a38e45220113347199e283c12751 (patch) | |
tree | 0b5b2e48be5a2e79f8417a9224fba35efc438643 /static/bs | |
parent | 9e498357dfac69a3037933b46746a470fb9c8491 (diff) |
[bracket shitty] add tutorial
Diffstat (limited to 'static/bs')
-rw-r--r-- | static/bs/bracket.js | 2 | ||||
-rw-r--r-- | static/bs/tutorial/tutorial-init.js | 22 | ||||
-rw-r--r-- | static/bs/tutorial/tutorial.css | 1014 | ||||
-rw-r--r-- | static/bs/tutorial/tutorial.js | 1011 |
4 files changed, 2048 insertions, 1 deletions
diff --git a/static/bs/bracket.js b/static/bs/bracket.js index 8e346f1..d3284b7 100644 --- a/static/bs/bracket.js +++ b/static/bs/bracket.js @@ -3522,7 +3522,7 @@ generateSpecialKeyHTML(keyItem) { if (confirm('Would you like to start the tutorial?')) { localStorage.setItem('tutorialSeen', 'true'); this.saveState(); - window.location.href = '/tutorial'; + window.location.href = 'tutorial'; } }); } diff --git a/static/bs/tutorial/tutorial-init.js b/static/bs/tutorial/tutorial-init.js new file mode 100644 index 0000000..ade15a5 --- /dev/null +++ b/static/bs/tutorial/tutorial-init.js @@ -0,0 +1,22 @@ +startTutorialMode(); + + // Helper function to manually start the tutorial mode + function startTutorialMode() { + const container = document.getElementById('bracket-city-container'); + + if (container) { + // Clear any existing content + container.innerHTML = ''; + + // Initialize the tutorial + const tutorial = new BracketCityTutorial(container); + + // Store the tutorial instance in case we need to access it later + window.bracketCityTutorial = tutorial; + + return tutorial; + } else { + console.error('Could not find container element for Bracket Shitty tutorial'); + return null; + } + } diff --git a/static/bs/tutorial/tutorial.css b/static/bs/tutorial/tutorial.css new file mode 100644 index 0000000..f0e9d9a --- /dev/null +++ b/static/bs/tutorial/tutorial.css @@ -0,0 +1,1014 @@ +/* =================== + Root Variables and Resets + =================== */ + :root { + --main-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --highlight-color: #fefcbf; + } + + *, *::before, *::after { + box-sizing: border-box; + } + + /* =================== + Container Structure + =================== */ + .puzzle-container { + width: 100%; + margin: 0 auto; + border: 1px solid #e2e8f0; + border-radius: 0.5rem; + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 0; + display: flex; + flex-direction: column; + } + + @media (min-width: 1024px) { + .puzzle-container { + max-width: 42rem; + } + } + + /* =================== + Header Styles + =================== */ + /* Header Base Styles */ + .puzzle-header { + background: white; + border-bottom: 1px solid #e2e8f0; + box-sizing: border-box; + padding: 0.6rem; + position: relative; /* Desktop default */ + text-align: center; + z-index: 10; + } + + /* Mobile-specific header styles */ + @media (max-width: 640px) { + .puzzle-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + padding: max(0.4rem, env(safe-area-inset-top)); + } + } + + /* Header title */ + .puzzle-header h1 { + margin: 0 0 0.5rem 0; + text-align: center; + font-size: clamp(2rem, 8vw, 3rem); + cursor: pointer; + font-family: var(--main-font); + font-variant: small-caps; + color: #1a202c; + font-weight: 600; + } + + @media (max-width: 640px) { + .puzzle-header h1 { + margin: 0; + font-size: clamp(1.8rem, 7vw, 2.8rem); + } + } + + /* Help button */ + .puzzle-header .exit-button { + position: absolute; + top: 10px; + right: 20px; + background: none; + border: 2px solid #333; + border-radius: 50%; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-weight: bold; + font-size: 1.2rem; + color: #333; + } + + @media (max-width: 640px) { + .puzzle-header .exit-button { + right: 10px; + } + } + + /* Navigation container */ + .puzzle-header .nav-container { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + } + + /* Navigation buttons */ + .puzzle-header .nav-button { + padding: 4px 12px; + border: none; + border-radius: 4px; + background: none; + cursor: pointer; + font-weight: bold; + font-size: 1.5rem; + color: #333; + } + + .puzzle-header .nav-button:disabled { + opacity: 0.3; + cursor: default; + } + + /* Puzzle date */ + .puzzle-header .puzzle-date { + margin: 0; + font-size: 20px; + font-weight: 600; + font-family: var(--main-font); + font-variant: small-caps; + color: #1a202c; + } + + /* =================== + Main Content Area + =================== */ + /* New rules for loading state */ + + .loading { + text-align: center; + padding: 2rem; + } + + .puzzle-content { + padding: 0rem 1rem 1rem 1rem; + display: flex; + flex-direction: column; + gap: 0rem; + width: 100%; + } + + /* =================== + Puzzle Display + =================== */ + .puzzle-display { + width: 100%; + min-height: 40vh; + max-height: 60vh; + background: #f1f5f9; + border-radius: 0.5rem; + padding: 1.5rem; + font-family: var(--mono-font); + font-size: 24px; + line-height: 1.5; + overflow-y: auto; + word-break: keep-all; + overflow-wrap: break-word; + hyphens: none; + transition: all 0.3s ease; + } + + .puzzle-display.completed { + min-height: auto; + max-height: none; + height: auto; + } + + /* =================== + Active Clue & Mark Styles + =================== */ + .active-clue { + background-color: rgba(255, 255, 0, 0.2); + border-radius: 3px; + padding: 2px 4px; + margin: -2px -4px; + transition: background-color 0.3s ease; + cursor: pointer; + } + + .active-clue:hover { + background-color: rgba(255, 255, 0, 0.3); + } + + mark.solved { + background-color: rgba(0, 255, 0, 0.2); + border-radius: 3px; + padding: 2px 4px; + margin: -2px -4px; + } + + mark { + background-color: transparent; + color: inherit; + } + + /* =================== + Input Styles + =================== */ + .input-container { + width: 100%; + margin: 10px 0 0 0; + position: relative; + } + + .answer-input { + width: 100%; + padding: 16px; + font-size: 16px !important; + border: 1px solid #e2e8f0; + border-radius: 6px; + margin-bottom: 0px; + box-sizing: border-box; + font-family: var(--mono-font); + } + + /* =================== + Message Styles + =================== */ + .message { + padding: 0.5rem; + margin: 0.5rem 0; + border-radius: 0.375rem; + text-align: center; + font-family: var(--mono-font); + display: none; + } + + .message.success { + background-color: #c6f6d5; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1.2rem; + display: block; + padding: 20px; + line-height: 2rem; + } + + .message.success .completion-link { + color: #2563eb; + text-decoration: underline; + } + + .message.error { + background-color: #fee2e2; + color: #ef4444; + display: block; + } + + /* =================== + Completion Message + =================== */ + .completion-message { + margin: 0.5rem 0; + padding: 0rem; + border-radius: 0.375rem; + text-align: center; + font-family: var(--mono-font); + font-size: 1.2rem; + line-height: 1.5; + } + + /* =================== + Solved Expressions + =================== */ + .solved-expressions { + margin-top: 0rem; + } + + .solved-expressions h3 { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 0.75rem; + font-family: var(--main-font); + color: #1a202c; + } + + .expression-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1.2rem; + font-family: var(--mono-font); + line-height: 1.5; + } + + .expression-item .solution { + font-weight: 600; + color: #1a202c; + } + + /* =================== + Email Form Styles + =================== */ + + .email-signup-container { + margin: 1rem 0.5rem 0.5rem 0.5rem; + } + + .email-signup-form label { + display: block; + margin-bottom: 0.75rem; + font-weight: 500; + text-align: center; + font-family: var(--mono-font); + font-size: 1.1rem; + } + + .email-signup-form label span + { + background-color: rgba(255, 255, 0, 0.2); padding: 0.2rem 0.4rem; + } + + .email-input-container { + display: flex; + gap: 0.5rem; + } + + .email-signup-form input { + flex: 1; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 0.375rem; + font-size: 16px; + gap: 0.5rem + } + + .email-signup-form button { + padding: 0.5rem 1rem; + background-color: #2563eb; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + font-weight: 500; + font-size: 16px; + white-space: nowrap; + } + .email-signup-message { + border-radius: 0.375rem; + margin-top: 0.75rem; + text-align: center; + font-family: var(--mono-font); + } + + .email-signup-message .success { + padding: 1rem; + background-color: #ecfdf5; + border: 1px solid #6ee7b7; + border-radius: 0.375rem; + color: #065f46; + } + + .email-signup-message .error { + padding: 1rem; + background-color: #fef2f2; + border: 1px solid #fca5a5; + border-radius: 0.375rem; + color: #991b1b; + } + + /* =================== + Stats & Completion + =================== */ + .stats-content { + margin-top: 0rem; + } + + .stat-items { + margin: 0.5rem 0; + } + + .stat-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1rem; + line-height: 1.5; + font-family: var(--main-font); + } + + /* =================== + Share Button Styles + =================== */ + .share-buttons { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .share-button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + font-family: var(--main-font); + background: #1a202c; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + } + + .share-button.reset { + background-color: #ff4444; + } + + .share-button:hover { + background: #2d3748; + } + + .share-message { + font-family: var(--main-font); + color: #1a202c; + text-align: center; + padding: 0.5rem; + margin-top: 0.5rem; + background: var(--highlight-color); + border-radius: 0.375rem; + } + + /* =================== + Rank Display + =================== */ + .rank-display { + font-family: var(--mono-font); + position: relative; + overflow: hidden; + padding: 1rem; + text-align: center; + font-size: 1.2em; + font-weight: 500; + border-radius: 0.375rem; + margin: 0rem 0rem 1rem 0rem; + border: 1px solid rgba(255,255,255,0.4); + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + cursor: pointer; + transition: transform 0.2s; + user-select: none; + } + + .rank-display:active { + transform: scale(0.95); + } + .rank-display .next-rank { + font-size: 0.8em; + font-weight: normal; + } + + .rank-display .share-hint { + font-size: 0.7em; + margin-top: 0.5em; + color: #666; + } + + /* Rank gradients */ + .rank-display[data-rank="Tourist"] { background: linear-gradient(135deg, #f0fdf4 0%, #6ee7b7 100%); } + .rank-display[data-rank="Commuter"] { background: linear-gradient(135deg, #fff7ed 0%, #fbd38d 100%); } + .rank-display[data-rank="Resident"] { background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%); } + .rank-display[data-rank="Council Member"] { background: linear-gradient(135deg, #fafaf9 0%, #d6d3d1 100%); } + .rank-display[data-rank="Chief of Police"] { background: linear-gradient(135deg, #f0f9ff 0%, #93c5fd 100%); } + .rank-display[data-rank="Mayor"] { background: linear-gradient(135deg, #fff1f2 0%, #fda4af 100%); } + .rank-display[data-rank="Power Broker"] { background: linear-gradient(135deg, #fefce8 0%, #92400e 100%); } + .rank-display[data-rank="Kingmaker"] { background: linear-gradient(135deg, #fffbeb 0%, #fcd34d 100%); } + .rank-display[data-rank="Puppet Master"] { background: linear-gradient(135deg, #faf5ff 0%, #d8b4fe 100%); } + + /* =================== + Shimmer Animation + =================== */ + @keyframes shimmer { + 0% { transform: translateX(-150%) rotate(45deg); } + 100% { transform: translateX(150%) rotate(45deg); } + } + + .rank-display::after { + content: ''; + position: absolute; + top: -50%; + left: -100%; + width: 300%; + height: 200%; + background: linear-gradient( + to bottom right, + rgba(255,255,255,0) 0%, + rgba(255,255,255,0.3) 50%, + rgba(255,255,255,0) 100% + ); + transform: rotate(45deg); + animation: shimmer 4s infinite linear; + pointer-events: none; + } + + /* =================== + Mobile Styles + =================== */ + @media (max-width: 640px) { + body { + margin: 0; + padding: 0; + height: 100vh; + font-size: 14px; + overflow: hidden; + } + + .message.success { + background-color: #c6f6d5; + color: #1a202c; + overflow-wrap: anywhere; + font-size: 1rem; + display: block; + padding: 10px; + line-height: 1.4rem; + margin-top: 0.2rem; + } + + /* Mobile container setup */ + .puzzle-container { + height: 100vh; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + } + + /* Mobile content area */ + .puzzle-content { + position: absolute; + top: 70px; + left: 0; + right: 0; + bottom: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0; + gap: 0rem; + } + + @font-face { + font-family: 'CustomMono'; + /* Use ui-monospace for every character except underscore */ + src: local("ui-monospace"); + /* All Unicode except underscore (U+005F) */ + unicode-range: U+0000-005E, U+0060-10FFFF; + } + + @font-face { + font-family: 'CustomMono'; + /* Use Menlo for underscore only */ + src: local("Menlo"); + unicode-range: U+005F; + } + + .puzzle-display { + height: 155px; + padding: 0.5rem 1rem 1rem 1rem; + margin: 0; + font-size: 1.2rem; + background: #f1f5f9; + padding-bottom: 200px; + font-family: 'CustomMono', ui-monospace; + } + + .puzzle-display.completed { + padding-bottom: 1rem !important; + } + + + .rank-display { + margin: 0rem 0.5rem 1rem 0.5rem; + } + + + /* Mobile input container */ + .input-container { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + z-index: 10; + width: 100%; + border-top: 1px solid #e2e8f0; + box-shadow: 0 -2px 6px rgba(0,0,0,0.1); + padding: 0.5rem calc(0.5rem + env(safe-area-inset-left)) calc(0.5rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-right)); + box-sizing: border-box; + } + + .custom-input { + border: 1px solid #e2e8f0; + padding: 0.63rem; + border-radius: 6px; + margin-bottom: 0.125rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 1.2rem; + min-height: 2.4rem; + background: white; + width: 100%; + color: #000; + } + + .placeholder { + color: #9ca3af; + } + + /* Mobile keyboard */ + .custom-keyboard { + width: 100%; + padding: .25rem 0 0 0; + box-sizing: border-box; + background-color: white; + touch-action: none; + } + + .keyboard-row { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 0.02rem; + margin-bottom: 0.125rem; + justify-content: center; + width: 100%; + } + + .keyboard-key { + height: 3.3rem; + min-height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + margin: 0.1rem 0.15rem 0.1rem 0.15rem; + cursor: pointer; + color: black; + background-color:rgb(233, 233, 233); + box-sizing: border-box; + touch-action: manipulation; + text-align: center; + font-size: 1.125rem; + font-weight: 600; + border: 1px solid rgb(200, 200, 200); + user-select: none; + -webkit-tap-highlight-color: transparent; + -webkit-appearance: none !important; + appearance: none !important; + border-radius: 4px !important; + outline: none !important; + -webkit-tap-highlight-color: transparent !important; + transition: background-color 0.1s ease-out, + transform 0.1s ease-out; + -webkit-user-select: none; + user-select: none; + -webkit-transform: translateZ(0); + transform: translateZ(0); + } + + .keyboard-key:active { + background-color: #94a3b8; + transform: translateY(2px); + } + + .stat-item { + background: #f1f5f9; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 1rem; + line-height: 1.5; + } + + /* Hide desktop elements */ + .bottom-controls, + .reset-button, + .solved-expressions, + .keystroke-stats, + .share-button.copy { + display: none; + } + + /* Mobile completion state */ + .puzzle-container.completed .puzzle-content { + position: static; + height: auto; + overflow: visible; + margin-top: 85px; + padding: 1rem; + } + + .puzzle-container.completed { + position: static; + height: auto; + min-height: 100vh; + overflow-y: auto; + } + + .puzzle-display.completed .input-container { + display: none; + } + + .puzzle-container.completed .solved-expressions, + .puzzle-container.completed .keystroke-stats { + display: block; + margin: 1rem; + } + + .keystroke-stats { + margin-top: 0rem; + } + } + + /* =================== + iOS-specific Styles + =================== */ + @supports (-webkit-touch-callout: none) { + .keyboard-key:active { + background-color: #94a3b8 !important; + transform: translateY(2px) !important; + } + } + +/* Tutorial Mode Styles */ +.tutorial-container { + --tutorial-guidance-bg: #ffe8fa; + --tutorial-guidance-border: #4a6fa5; + --tutorial-highlight-border: #f6b73c; + --tutorial-completion-bg: #e9f7ef; + --tutorial-completion-border: #27ae60; + --tutorial-button-bg: #2980b9; + --tutorial-button-hover: #3498db; + } + + /* Tutorial Guidance */ + .tutorial-guidance { + background-color: #ffe8fa !important; + border-radius: 8px; + font-family: var(--main-font); + border-left: 4px solid #ab57a8; + border-right: 4px solid #ab57a8; + height: 130px !important; + overflow: hidden; + padding: 16px; + margin: 16px 0; + vertical-align: middle; + } + + .small-break { + min-height: 1rem; /* adjust as needed */ + } + + @media (max-width: 640px) { + .small-break { + min-height: 0.8rem; /* adjust as needed */ + } + } + + .tutorial-guidance.emerge { + animation: emerge 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } + + @keyframes emerge { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + .tutorial-guidance.highlight { + background-color: #fff8e5; + border-left-color: #f6b73c; + } + + @media (max-width: 640px) { + .tutorial-guidance { + margin: 0.625rem 0.625rem 0.625rem 0.625rem !important; /* 10px */ + /*padding: 1rem 0.3125rem !important; /* 16px 5px */ + font-size: 0.9rem; + height: 140px !important; + vertical-align: middle; + } + } + + /* Hide guidance when puzzle is completed */ + .tutorial-guidance.completed, + .puzzle-display.completed ~ .tutorial-guidance { + /*display: none;*/ + } + + /* Tutorial Message */ + .tutorial-message { + font-size: 16px; + } + + .tutorial-message strong { + font-weight: 600; + } + + .tutorial-message a { + text-decoration: none; + border-bottom: 1px solid; + padding-bottom: 1px; + transition: color 0.2s; + } + + .tutorial-message a:hover { + opacity: 0.8; + } + + .tutorial-message .emoji { + font-size: 1.2em; + } + + @media (max-width: 640px) { + .tutorial-message { + font-size: 14px; + } + } + + /* Tutorial Puzzle Display */ + .tutorial-puzzle-display { + height: 155px !important; + min-height: 155px !important; + max-height: 155px !important; + overflow-y: auto; + background: #f1f5f9; + border-radius: 0.5rem; + padding: 1rem; + font-family: var(--mono-font); + line-height: 1.7; + word-break: keep-all; + overflow-wrap: break-word; + transition: none; + } + + .tutorial-puzzle-display.completed { + height: 155px !important; + min-height: 155px !important; + max-height: 155px !important; + } + + @media (max-width: 640px) { + .tutorial-puzzle-display { + height: 155px !important; + min-height: 155px !important; + max-height: 155px !important; + padding: 0.8rem; + } + } + + /* Tutorial Highlights and Animations */ + .spotlight { + background-color: #fff9c4; + box-shadow: 0 0 0 2px #fff9c4; + border-radius: 4px; + } + + .correct { + background-color: rgba(0, 255, 0, 0.25); + box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); + border-radius: 4px; + } + + @keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.7); + transform: scale(1); + } + 50% { + box-shadow: 0 0 0 6px rgba(255, 193, 7, 0); + transform: scale(1.05); + } + 100% { + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0); + transform: scale(1); + } + } + + @keyframes tap-hint { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } + } + + .active-clue.tutorial-highlight { + box-shadow: 0 0 0 2px #ffc107; + animation: pulse 2.5s infinite; + background-color: rgba(255, 255, 0, 0.2); + font-weight: 500; + } + + .active-clue.tap-hint { + animation: tap-hint 2s infinite; + } + + /* Tutorial Completion */ + .tutorial-completion { + background-color: #e9f7ef; + padding: 20px; + border-radius: 8px; + margin: 10px 10px 0px 10px; + text-align: center; + border: 2px solid #27ae60; + font-family: var(--mono-font); + animation: fade-in 0.5s ease-in-out; + } + + .tutorial-completion-title { + font-size: 20px; + margin-bottom: 10px; + } + + .tutorial-completion-text { + font-size: 16px; + } + + .tutorial-completion-wrapper { + margin: 0px; + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Play Game Button */ + #playGameButton { + margin-top: 15px; + padding: 10px 20px; + background-color: #2980b9; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s, transform 0.1s; + } + + #playGameButton:hover { + background-color: #3498db; + } + + #playGameButton:active { + transform: scale(0.98); + } + + /* First Letter Hint */ + .first-letter-hint { + font-weight: bold; + color: #0c63e4; + } + + /* Submit button and input wrapper */ + .input-submit-wrapper { + display: flex; + gap: 0.5rem; + width: 100%; + } + + .submit-answer { + padding: 0 1.5rem; + height: 60px; + background-color: #2563eb; + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + font-weight: 500; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; + font-size: 1rem; + } + + .submit-answer:hover { + background-color: #1d4ed8; + } + + .submit-answer:active { + background-color: #1e40af; + transform: scale(0.98); + } + + .mobile-submit-button { + background-color: #2563eb !important; + color: white !important; + font-size: 1rem !important; + font-weight: 500 !important; + }
\ No newline at end of file diff --git a/static/bs/tutorial/tutorial.js b/static/bs/tutorial/tutorial.js new file mode 100644 index 0000000..3e89fdc --- /dev/null +++ b/static/bs/tutorial/tutorial.js @@ -0,0 +1,1011 @@ +class BracketCityTutorial { + /** + * Constructor for the tutorial mode + * @param {HTMLElement} rootElement - The container element for the tutorial + */ + constructor(rootElement) { + this.root = rootElement; + + // Detect device type + this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + + // Track keyboard layout for mobile + this.isAltKeyboard = false; + + // Hard-coded tutorial puzzle data + this.PUZZLE_DATA = { + initialPuzzle: "[where [opposite of clean] dishes pile up] or [exercise in a [game played with a cue ball]]", + puzzleDate: "Tutorial Mode", + completionText: "🎉 Tutorial Complete! 🎉", + solutions: { + "exercise in a pool": "swim", + "game played with a cue ball": "pool", + "where dirty dishes pile up": "sink", + "opposite of clean": "dirty" + } + }; + + // Initialize the game state + this.state = { + displayState: this.PUZZLE_DATA.initialPuzzle, + solvedExpressions: new Set(), + solvedOrder: [], // New array to track order + message: '', + totalKeystrokes: 0, + activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), + hintModeClues: new Set(), + peekedClues: new Set(), + megaPeekedClues: new Set(), + tutorialStep: 0 + }; + + // Tutorial guidance messages for each step + this.tutorialSteps = [ + { + message: "In Bracket City you can solve <strong>any clue</strong> just by submitting an answer<div class='small-break'></div>No need to click, just type the answer to any <span class='spotlight'>highlighted clue</span> and hit enter!<div class='small-break'></div>Keep guessing until you get one!", + highlight: "clue" + }, + { + message: "Nice! <span class='correct'>pool</span> is correct!<div class='small-break'></div>Clues are often <strong>nested</strong> within other clues<div class='small-break'></div>You'll need to solve <span class='spotlight'>opposite of clean</span> to reveal its parent clue about dishes", + condition: expressions => expressions.size === 1 && expressions.has("game played with a cue ball"), + highlight: "none" + }, + { + message: "Nice! <span class='correct'>dirty</span> is correct!<div class='small-break'></div>Clues are often <strong>nested</strong> within other clues<div class='small-break'></div>Now solve <span class='spotlight'>game played with a cue ball</span> to reveal the parent clue about exercise", + condition: expressions => expressions.size === 1 && expressions.has("opposite of clean"), + highlight: "none" + }, + { + message: "Excellent! <span class='correct'>pool</span> is correct!<div class='small-break'></div>Now both <strong>parent clues</strong> are revealed, so they are both <span class='spotlight'>highlighted</span> and solvable<div class='small-break'></div>Go ahead and solve one", + condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && !expressions.has("where dirty dishes pile up") && !expressions.has("exercise in a pool") && this.state.solvedOrder[0] === "opposite of clean", + highlight: "none" + }, + { + message: "Excellent! <span class='correct'>dirty</span> is correct!<div class='small-break'></div>Now both <strong>parent clues</strong> are revealed, so they are both <span class='spotlight'>highlighted</span> and solvable<div class='small-break'></div>Go ahead and solve one", + condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && !expressions.has("where dirty dishes pile up") && !expressions.has("exercise in a pool") && this.state.solvedOrder[0] === "game played with a cue ball", + highlight: "none" + }, + { + message: "Ok fine you got <span class='correct'>sink</span> instead<div class='small-break'></div>You still need to solve <span class='spotlight'>game played with a cue ball</span><div class='small-break'></div>Looking at the <strong>parent clue</strong> text can help...", + condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("where dirty dishes pile up") && !expressions.has("game played with a cue ball") && !expressions.has("exercise in a pool"), + highlight: "none" + }, + { + message: "Ok fine you got <span class='correct'>swim</span> instead<div class='small-break'></div>You still need to solve <span class='spotlight'>opposite of clean</span><div class='small-break'></div>Looking at the <strong>parent clue</strong> text can help...", + condition: expressions => expressions.size === 2 && expressions.has("exercise in a pool") && expressions.has("game played with a cue ball") && !expressions.has("opposite of clean") && !expressions.has("where dirty dishes pile up"), + highlight: "none" + }, + { + message: "Yee-haw <span class='correct'>swim</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!", + condition: expressions => expressions.size === 3 && expressions.has("exercise in a pool") && expressions.has("game played with a cue ball") && expressions.has("opposite of clean") && !expressions.has("where dirty dishes pile up") && this.state.solvedOrder[2] === "exercise in a pool", + highlight: "none" + }, + { + message: "Yee-haw <span class='correct'>sink</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!", + condition: expressions => expressions.size === 3 && expressions.has("where dirty dishes pile up") && expressions.has("game played with a cue ball") && expressions.has("opposite of clean") && !expressions.has("exercise in a pool") && this.state.solvedOrder[2] === "where dirty dishes pile up", + highlight: "none" + }, + { + message: "Yee-haw <span class='correct'>dirty</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!", + condition: expressions => expressions.size === 3 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && expressions.has("exercise in a pool") && this.state.solvedOrder[2] === "opposite of clean", + highlight: "none" + }, + { + message: "Yee-haw <span class='correct'>pool</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!", + condition: expressions => expressions.size === 3 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && expressions.has("where dirty dishes pile up") && this.state.solvedOrder[2] === "game played with a cue ball", + highlight: "none" + }, + { + message: "<strong>Just to recap:</strong><div class='small-break'></div>* you can solve any <span class='spotlight'>highlighted clue</span> \u{2013} just type your guess and hit enter<div class='small-break'></div>* click once on a clue to <strong>peek</strong> at the first letter, twice to <span class='correct'>reveal</span> the answer", + condition: expressions => !this.state.displayState.includes('['), + highlight: "none", + isComplete: true + } + ]; + + // Initialize the UI + this.initializeGame(); + } + + + /** + * Initializes the tutorial game + */ + initializeGame() { + // Generate the initial HTML + this.root.innerHTML = this.generateInitialHTML(); + + // Setup input handlers + this.setupInputHandlers(); + + // Render the initial state + this.render(); + + // Setup tutorial guidance + this.updateTutorialGuidance(); + + // Setup clue click handlers for peeking + this.setupClueClickHandlers(); + } + + /** + * Generates the initial HTML for the tutorial + */ + generateInitialHTML() { + return ` + <div class="puzzle-container"> + ${this.generateHeaderHTML()} + <div class="puzzle-content"> + ${this.generateTutorialGuidanceHTML()} + ${this.generatePuzzleDisplayHTML()} + + ${this.generateInputContainerHTML()} + </div> + </div> + `; + } + + /** + * Generates the header HTML + */ + generateHeaderHTML() { + return ` + <div class="puzzle-header"> + <h1>[Bracket City]</h1> + <button class="exit-button">X</button> + <div class="nav-container"> + <div class="puzzle-date">How To Play</div> + </div> + </div> + `; + } + + /** + * Generates the puzzle display HTML with fixed height + */ + generatePuzzleDisplayHTML() { + return `<div class="puzzle-display tutorial-puzzle-display"></div>`; + } + + /** + * Generates the tutorial guidance container HTML + */ + generateTutorialGuidanceHTML() { + return ` + <div class="tutorial-guidance"> + <div class="tutorial-message"></div> + </div> + `; + } + + /** + * Generates the input container HTML based on device type + */ + generateInputContainerHTML() { + if (this.isMobile) { + return ` + <div class="input-container"> + <div class="message"></div> + <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;"> + <div class="custom-input" style="flex-grow: 1; height: 44px; display: flex; align-items: center;"> + <span class="placeholder">type any answer...</span> + </div> + <button + class="mobile-submit-button" + style="height: 44px; background-color: #2563eb; color: white; padding: 0 1rem; border-radius: 6px; font-weight: 500; border: none;" + > + [submit] + </button> + </div> + <div class="custom-keyboard"> + ${this.generateKeyboardButtonsHTML()} + </div> + </div> + `; + } + + return ` + <div class="input-container"> + <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem;"> + <input + type="text" + placeholder="type any answer..." + class="answer-input" + autocomplete="off" + autocapitalize="off" + > + <button + class="submit-answer" + > + [submit] + </button> + </div> + <div class="message"></div> + </div> + `; + } + + /** + * Generates mobile keyboard buttons HTML + */ + generateKeyboardButtonsHTML() { + const mainLayout = [ + { keys: 'qwertyuiop', columns: 10 }, + { keys: 'asdfghjkl', columns: 10 }, + { keys: [ + { key: '123', display: '123', small: true }, + 'z', 'x', 'c', 'v', 'b', 'n', 'm', + { key: 'backspace', display: '\u{232B}', wide: true } + ], columns: 10 } + ]; + + const altLayout = [ + { keys: '1234567890', columns: 10 }, + { keys: '-/:;()$&@"', columns: 10 }, + { keys: [ + { key: 'ABC', display: 'ABC', small: true }, + '.', ',', '?', '!', "'", "=", "+", + { key: 'backspace', display: '\u{232B}', wide: true } + ], columns: 10 } + ]; + + const rows = this.isAltKeyboard ? altLayout : mainLayout; + + let html = ``; + + rows.forEach((row, rowIndex) => { + // Adding margin for middle row on default layout since it's only 9 keys vs 10 + const leftPadding = (rowIndex === 1 && !this.isAltKeyboard) ? 'margin-left: 5%' : ''; + html += `<div class="keyboard-row" style="${leftPadding}">`; + + const keys = Array.isArray(row.keys) ? row.keys : row.keys.split(''); + keys.forEach(keyItem => { + if (typeof keyItem === 'string') { + html += this.generateKeyboardKeyHTML(keyItem); + } else { + html += this.generateSpecialKeyHTML(keyItem); + } + }); + + html += '</div>'; + }); + + return html; + } + + /** + * Generates a special keyboard key HTML + */ + generateSpecialKeyHTML(keyItem) { + const fontSize = keyItem.small ? '0.875rem' : '1.125rem'; + + return ` + <button class="keyboard-key" data-key="${keyItem.key}" style=" + grid-column: ${keyItem.wide ? 'span 2' : 'span 1'}; + font-size: ${fontSize}; + "> + ${keyItem.display} + </button> + `; + } + + /** + * Generates a regular keyboard key HTML + */ + generateKeyboardKeyHTML(key) { + return ` + <button class="keyboard-key" data-key="${key}"> + ${key} + </button> + `; + } + + /** + * Sets up input handlers for desktop or mobile + */ + setupInputHandlers() { + if (this.isMobile) { + this.setupMobileInput(); + } else { + this.setupDesktopInput(); + } + + // Setup exit button (formerly help button) + const exitButton = this.root.querySelector('.exit-button'); + if (exitButton) { + exitButton.addEventListener('click', () => { + if (confirm('Are you sure you want to exit the tutorial?')) { + window.location.href = '/'; + } + }); + } + } + + /** + * Sets up desktop input handling + */ + setupDesktopInput() { + this.answerInput = this.root.querySelector('.answer-input'); + const submitButton = this.root.querySelector('.submit-answer'); + + if (!this.answerInput || !submitButton) { + console.error('Required input elements not found'); + return; + } + + // Handle submit button click + submitButton.addEventListener('click', () => { + this.handleSubmission(); + }); + + // Handle Enter key with visual feedback + this.answerInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + + // Apply visual feedback style directly to the button + submitButton.style.transform = 'scale(0.95)'; + submitButton.style.backgroundColor = '#1e40af'; + + // Process the submission + this.handleSubmission(); + + // Remove the styles after a short delay + setTimeout(() => { + submitButton.style.transform = ''; + submitButton.style.backgroundColor = ''; + }, 85); // Slightly longer for better visibility + } + }); + + this.answerInput.addEventListener('keydown', (e) => { + this.handleKeyDown(e); + }); + + this.answerInput.addEventListener('paste', (e) => { + e.preventDefault(); + }); + + this.answerInput.focus(); + } + + /** + * Sets up mobile input handling + */ + setupMobileInput() { + this.customInputValue = ''; + this.customInputEl = this.root.querySelector('.custom-input'); + + if (!this.customInputEl) { + console.error('Custom input display element not found'); + return; + } + + // Clean up old placeholder if it exists + const oldPlaceholder = this.customInputEl.querySelector('.placeholder'); + if (oldPlaceholder) { + oldPlaceholder.remove(); + } + + // Create fresh placeholder + const placeholderEl = document.createElement('span'); + placeholderEl.className = 'placeholder'; + placeholderEl.style.color = '#9ca3af'; + placeholderEl.textContent = 'type any answer...'; + this.customInputEl.appendChild(placeholderEl); + + // Set up the mobile submit button + const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); + if (mobileSubmitButton) { + mobileSubmitButton.addEventListener('click', () => { + this.handleSubmission(); + }); + } + + // Attach fresh click listeners to keyboard keys + const keyboardKeys = this.root.querySelectorAll('.keyboard-key'); + keyboardKeys.forEach(keyEl => { + keyEl.addEventListener('click', () => { + const key = keyEl.getAttribute('data-key'); + if (key === 'backspace') { + this.customInputValue = this.customInputValue.slice(0, -1); + } else if (key === '123' || key === 'ABC') { + this.isAltKeyboard = !this.isAltKeyboard; + // Re-render the keyboard + const keyboardContainer = this.root.querySelector('.custom-keyboard'); + if (keyboardContainer) { + keyboardContainer.innerHTML = this.generateKeyboardButtonsHTML(); + } + // Re-attach event listeners while preserving input + this.setupMobileInput(); + return; + } else { + this.customInputValue += key; + this.state.totalKeystrokes++; + } + + this.updateCustomInputDisplay(); + }); + + // Prevent dragging/swiping on the key + keyEl.addEventListener('touchmove', (e) => { + e.preventDefault(); + }, { passive: false }); + }); + + // Enable :active states on iOS + document.addEventListener('touchstart', () => {}, false); + + // Update display to show current input value + this.updateCustomInputDisplay(); + } + + handleSubmission() { + const input = this.isMobile ? this.customInputValue : this.answerInput.value; + if (!input || !input.trim()) return; + + const normalizedInput = this.normalizeInput(input); + const match = this.findMatchingExpression(normalizedInput); + + if (match) { + this.solveExpression(match); + } + + // Clear input regardless of match + if (this.isMobile) { + this.customInputValue = ''; + this.updateCustomInputDisplay(); + } else { + this.answerInput.value = ''; + this.answerInput.focus(); + } + } + /** + * Updates the custom input display for mobile + */ + updateCustomInputDisplay() { + if (!this.customInputEl) return; + + // Find or create placeholder + let placeholderEl = this.customInputEl.querySelector('.placeholder'); + if (!placeholderEl) { + placeholderEl = document.createElement('span'); + placeholderEl.className = 'placeholder'; + placeholderEl.style.color = '#9ca3af'; + placeholderEl.textContent = 'start typing answers...'; + } + + // Clear the input + this.customInputEl.innerHTML = ''; + + // Show either placeholder or input value + if (this.customInputValue.length > 0) { + this.customInputEl.textContent = this.customInputValue; + } else { + this.customInputEl.appendChild(placeholderEl); + } + } + + /** + * Sets up clue click handlers for peeking functionality + */ + setupClueClickHandlers() { + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (!puzzleDisplay) return; + + puzzleDisplay.addEventListener('click', (event) => { + const clueElement = event.target.closest('.active-clue'); + if (!clueElement) return; + + // Find the clicked expression + const cleanText = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); + const activeClues = this.findActiveClues(cleanText); + const cluePosition = Array.from(puzzleDisplay.querySelectorAll('.active-clue')).indexOf(clueElement); + + if (cluePosition >= 0 && cluePosition < activeClues.length) { + const expression = activeClues[cluePosition].expression.trim(); + this.toggleHintMode(expression); + + // Return focus to input on desktop + if (!this.isMobile && this.answerInput) { + this.answerInput.focus(); + } + } + }); + } + + /** + * Shows the help dialog + */ + showHelp() { + const helpContent = ` + <div style="text-align: center; font-weight: bold; margin: 0 0 0.5em 0;">Welcome to the Bracket City Tutorial</div> + <div style="margin: 0.9em 0;">* In Bracket City, you solve <mark style="background-color: #fff9c4;">[active clues]</mark> by typing the answers</div> + <div style="margin: 0.9em 0;">* You can tap any <mark style="background-color: #fff9c4;">[active clue]</mark> to peek at its first letter, or tap again to reveal the full answer</div> + <div style="margin: 0.9em 0;">* This tutorial will guide you through the basics</div> + <div style="margin: 0.9em 0;">* Tap anywhere to continue</div> + `; + + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (!puzzleDisplay) return; + + // Store the current content + this.previousDisplayContent = puzzleDisplay.innerHTML; + + // Show help content + puzzleDisplay.innerHTML = helpContent; + + // Add click handler to close help + const closeHandler = () => { + puzzleDisplay.innerHTML = this.previousDisplayContent; + puzzleDisplay.removeEventListener('click', closeHandler); + // Return focus to input on desktop + if (!this.isMobile && this.answerInput) { + this.answerInput.focus(); + } + }; + + puzzleDisplay.addEventListener('click', closeHandler); + } + + /** + * Main render method to update the UI + */ + render() { + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + if (!puzzleDisplay) return; + + // Apply highlighting to active clues + const highlightedState = this.applyActiveClueHighlights(this.state.displayState); + puzzleDisplay.innerHTML = highlightedState; + + // Check if puzzle is complete + if (this.isPuzzleComplete()) { + this.renderCompletionState(); + } + } + + /** + * Applies highlighting to active clues + * @param {string} text - Current puzzle text + * @returns {string} HTML with highlights applied + */ + applyActiveClueHighlights(text) { + const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, ''); + const activeClues = this.findActiveClues(cleanText); + + // Sort clues by start position in reverse order + activeClues.sort((a, b) => b.start - a.start); + + let highlightedText = cleanText; + + activeClues.forEach(clue => { + const expressionText = clue.expression.trim(); + const before = highlightedText.slice(0, clue.start); + let clueText = highlightedText.slice(clue.start, clue.end); + const after = highlightedText.slice(clue.end); + + // Handle different clue states + if (!clueText.includes('[') && !this.state.hintModeClues.has(expressionText)) { + // Already solved clue + highlightedText = before + clueText + after; + } else { + // Active or hint mode clue + if (this.state.hintModeClues.has(expressionText)) { + const solution = this.PUZZLE_DATA.solutions[expressionText]; + // Show just the first letter + if (solution && !clueText.includes(`(`)) { + // Get the first letter of the solution + const firstLetter = solution.charAt(0).toUpperCase(); + // Remove closing bracket, add first letter hint, re-add closing bracket + clueText = clueText.slice(0, -1) + ` (${firstLetter})]`; + } + } + + // Ensure we're only wrapping the exact clue text with brackets included + highlightedText = before + `<span class="active-clue">${clueText}</span>` + after; + } + }); + + return highlightedText; + } + + /** + * Renders the completion state while maintaining fixed height + */ + renderCompletionState() { + const inputContainer = this.root.querySelector('.input-container'); + const puzzleDisplay = this.root.querySelector('.puzzle-display'); + + if (!inputContainer || !puzzleDisplay) return; + + // Hide input container + inputContainer.style.display = 'none'; + + // Add completion class but ensure height is preserved + puzzleDisplay.classList.add('completed'); + + // Add a tutorial completion message at the top + const completionHTML = ` + <div class="tutorial-completion"> + <div class="tutorial-completion-title">🎉 Tutorial Complete! 🎉</div> + <div class="tutorial-completion-text">Ready to play the full game?</div> + <button id="playGameButton">Play Bracket City</button> + </div> + `; + + // Get the puzzle content container + const puzzleContent = this.root.querySelector('.puzzle-content'); + + // Create a wrapper for the completion message + const completionWrapper = document.createElement('div'); + completionWrapper.className = 'tutorial-completion-wrapper'; + completionWrapper.innerHTML = completionHTML; + + // Insert the completion message at the top of puzzle content + if (puzzleContent) { + puzzleContent.insertBefore(completionWrapper, puzzleContent.firstChild); + } + + // Add event listener to the play button + const playButton = this.root.querySelector('#playGameButton'); + if (playButton) { + playButton.addEventListener('click', () => { + window.location.href = '/'; + }); + } + } + + /** + * Updates the tutorial guidance based on the current state + */ + updateTutorialGuidance() { + const tutorialGuidance = this.root.querySelector('.tutorial-guidance'); + if (!tutorialGuidance) return; + + // Find the appropriate tutorial step + let currentStep = this.tutorialSteps[0]; // Default to first step + + for (let i = this.tutorialSteps.length - 1; i >= 0; i--) { + const step = this.tutorialSteps[i]; + + // If this step has a condition and it's satisfied, use this step + if (step.condition && step.condition(this.state.solvedExpressions)) { + currentStep = step; + break; + } + } + + // Clone and replace the guidance element to restart animation + const newGuidance = tutorialGuidance.cloneNode(true); + const messageElement = document.createElement('div'); + messageElement.className = 'tutorial-message'; + messageElement.innerHTML = currentStep.message; + + newGuidance.innerHTML = ''; // Clear existing content + newGuidance.appendChild(messageElement); + newGuidance.classList.add('emerge'); + + tutorialGuidance.parentNode.replaceChild(newGuidance, tutorialGuidance); + + // Remove all highlight classes first + const allClues = this.root.querySelectorAll('.active-clue'); + allClues.forEach(clue => { + clue.classList.remove('tutorial-highlight', 'tap-hint'); + }); + + // Add highlighting based on the current step + if (currentStep.highlight === 'clue') { + // Highlight active clues more prominently using CSS class + const activeClues = this.root.querySelectorAll('.active-clue'); + activeClues.forEach(clue => { + clue.classList.add('tutorial-highlight'); + }); + } else if (currentStep.highlight === 'hint') { + // Add a subtle animation to suggest tapping a clue using CSS class + const activeClues = this.root.querySelectorAll('.active-clue'); + if (activeClues.length > 0) { + activeClues[0].classList.add('tap-hint'); + } + } + } + + /** + * Determines if a keystroke should be counted + * @param {KeyboardEvent} event - The keyboard event + * @returns {boolean} Whether the keystroke should be counted + */ + isCountableKeystroke(event) { + // Only count single printable characters + return event.key.length === 1 && + /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/? ]$/.test(event.key); + } + + /** + * Finds all active clues in the current puzzle state + * @param {string} puzzleText - Current puzzle text + * @returns {Array} Array of clue objects + */ + findActiveClues(puzzleText) { + const activeClues = []; + + function findClues(str, startOffset = 0) { + let i = 0; + while (i < str.length) { + if (str[i] === '[') { + const startIndex = i; + let bracketCount = 1; + let hasNestedBrackets = false; + i++; + + let innerContent = ''; + while (i < str.length && bracketCount > 0) { + if (str[i] === '[') { + bracketCount++; + hasNestedBrackets = true; + } else if (str[i] === ']') { + bracketCount--; + } + innerContent += str[i]; + i++; + } + + innerContent = innerContent.slice(0, -1); + + if (!hasNestedBrackets) { + // Clean expression text of HTML markup + const cleanExpression = innerContent.replace(/<\/?[^>]+(>|$)/g, ''); + activeClues.push({ + start: startOffset + startIndex, + end: startOffset + i, + text: str.substring(startIndex, i), + expression: cleanExpression + }); + } + + if (hasNestedBrackets) { + findClues(innerContent, startOffset + startIndex + 1); + } + } else { + i++; + } + } + } + + const cleanText = puzzleText.replace(/<\/?span[^>]*(>|$)/g, ''); + findClues(cleanText); + return activeClues; + } + + /** + * Finds available expressions that can be solved + * @param {string} text - Current puzzle text + * @returns {Array} Array of expression objects + */ + findAvailableExpressions(text) { + const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, ''); + const results = []; + const regex = /\[([^\[\]]+?)\]/g; + const matchedPositions = new Set(); + let match; + + while ((match = regex.exec(cleanText)) !== null) { + const startIdx = match.index; + const endIdx = startIdx + match[0].length; + + if (!matchedPositions.has(startIdx)) { + matchedPositions.add(startIdx); + results.push({ + expression: match[1], + startIndex: startIdx, + endIndex: endIdx + }); + } + } + + return results; + } + + /** + * Handles user input for solving expressions + * @param {string} input - User input string + */ + handleInput(input) { + const normalizedInput = this.normalizeInput(input); + if (!normalizedInput) { + return; + } + + const match = this.findMatchingExpression(normalizedInput); + if (!match) { + return; + } + + this.solveExpression(match); + } + + /** + * Normalizes and validates user input + * @param {string} input - Raw user input + * @returns {string|null} Normalized input or null if invalid + */ + normalizeInput(input) { + if (!input?.trim()) { + return null; + } + return input.trim().toLowerCase(); + } + + /** + * Finds an unsolved expression matching the input + * @param {string} normalizedInput - Normalized user input + * @returns {Object|null} Matching expression info or null if not found + */ + findMatchingExpression(normalizedInput) { + const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); + const availableExpressions = this.findAvailableExpressions(cleanState); + + for (const {expression, startIndex, endIndex} of availableExpressions) { + const solution = this.PUZZLE_DATA.solutions[expression]; + + if (solution?.toLowerCase() === normalizedInput && + !this.state.solvedExpressions.has(expression)) { + return { + expression, + solution, + startIndex, + endIndex + }; + } + } + + return null; + } + + /** + * Processes a correct solution and updates game state + * @param {Object} match - Expression match information + */ + solveExpression(match) { + const { expression, solution, startIndex, endIndex } = match; + + // Generate the new puzzle display state by replacing the solved expression + const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); + const escapedSolution = solution + .replace(/"/g, '"') + .replace(/'/g, '''); + + const newDisplayState = + cleanState.slice(0, startIndex) + + `<mark class="solved">${escapedSolution}</mark>` + + cleanState.slice(endIndex); + + // Add to the solved expressions set + const updatedExpressions = new Set([...this.state.solvedExpressions, expression]); + this.state.solvedOrder.push(expression); + // Update game state + this.state.displayState = newDisplayState; + this.state.solvedExpressions = updatedExpressions; + + // Clear the input based on device type + if (this.isMobile) { + this.customInputValue = ''; + this.updateCustomInputDisplay(); + } else { + if (this.answerInput) { + this.answerInput.value = ''; + } + } + + // Re-render the puzzle display + this.render(); + + // Update tutorial guidance + this.updateTutorialGuidance(); + } + + /** + * Toggles hint mode for an expression + * @param {string} expression - The expression to toggle hints for + */ + toggleHintMode(expression) { + // Early validation + if (!expression || this.state.megaPeekedClues.has(expression)) { + return; + } + + // Handle mega peek (second click) + if (this.state.hintModeClues.has(expression)) { + this.handleMegaPeek(expression); + return; + } + + // Handle first peek + this.handleFirstPeek(expression); + } + + /** + * Handles the first peek at an expression (shows first letter) + * @param {string} expression - The expression to peek at + */ + handleFirstPeek(expression) { + this.state.hintModeClues.add(expression); + this.state.peekedClues.add(expression); + + this.render(); + this.updateTutorialGuidance(); + } + + /** + * Handles the mega peek action (reveals answer) + * @param {string} expression - The expression to reveal + */ + handleMegaPeek(expression) { + const solution = this.PUZZLE_DATA.solutions[expression]; + if (!solution) { + console.error('No solution found for expression:', expression); + return; + } + + const newDisplayState = this.processSolutionReveal(expression, solution); + if (!newDisplayState) { + console.error('Could not find expression in puzzle state'); + return; + } + + // Update state + this.state.displayState = newDisplayState; + this.state.megaPeekedClues.add(expression); + this.state.solvedExpressions.add(expression); + this.state.hintModeClues.delete(expression); + + // Re-render + this.render(); + + // Update tutorial guidance + this.updateTutorialGuidance(); + } + + /** + * Processes a solution reveal for megapeek + * @param {Object} expression - Expression to reveal + * @param {string} solution - Solution text + * @returns {string} New display state + */ + processSolutionReveal(expression, solution) { + // Clean the display state first + const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); + const availableExpressions = this.findAvailableExpressions(cleanState); + + for (const {startIndex, endIndex} of availableExpressions) { + const currentExpression = cleanState.slice(startIndex + 1, endIndex - 1).trim(); + + if (expression === currentExpression) { + const escapedSolution = solution + .replace(/"/g, '"') + .replace(/'/g, '''); + + const newDisplayState = + cleanState.slice(0, startIndex) + + `<mark class="solved">${escapedSolution}</mark>` + + cleanState.slice(endIndex); + + return newDisplayState; + } + } + return null; + } + + /** + * Checks if the puzzle is complete + * @returns {boolean} Whether the puzzle is complete + */ + isPuzzleComplete() { + return !this.state.displayState.includes('['); + } + } + + // Export the class for use in other files + if (typeof module !== 'undefined' && module.exports) { + module.exports = { BracketCityTutorial }; + } |