JavaScript · 6822 bytes Raw Blame History
1 // Animal Crossing-style dialog system for dougk
2 // Typewriter text effect with character portraits
3
4 import { playDialogBlip } from '../sounds.js'
5
6 let dialogOverlay = null
7 let currentDialog = null
8 let currentLineIndex = 0
9 let currentCharIndex = 0
10 let isTyping = false
11 let typeInterval = null
12 let onCompleteCallback = null
13
14 const TYPE_SPEED = 35 // ms per character
15 const FAST_TYPE_SPEED = 10 // when holding/clicking during type
16
17 const STYLES = `
18 .dialog-overlay {
19 position: fixed;
20 bottom: 0;
21 left: 0;
22 right: 0;
23 display: flex;
24 justify-content: center;
25 padding: 20px;
26 z-index: 1500;
27 pointer-events: none;
28 }
29
30 .dialog-box {
31 background: linear-gradient(180deg, #2a2218 0%, #1a1510 100%);
32 border: 4px solid #8b6914;
33 border-radius: 16px;
34 padding: 16px 20px;
35 max-width: 600px;
36 width: 90%;
37 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
38 pointer-events: auto;
39 cursor: pointer;
40 position: relative;
41 }
42
43 .dialog-header {
44 display: flex;
45 align-items: center;
46 gap: 12px;
47 margin-bottom: 12px;
48 }
49
50 .dialog-portrait {
51 width: 48px;
52 height: 48px;
53 border-radius: 50%;
54 display: flex;
55 align-items: center;
56 justify-content: center;
57 font-size: 28px;
58 flex-shrink: 0;
59 }
60
61 .dialog-portrait.donny {
62 background: linear-gradient(135deg, #7a9eb8 0%, #5a7e98 100%);
63 border: 3px solid #d4af37;
64 }
65
66 .dialog-portrait.ollie {
67 background: linear-gradient(135deg, #7b4b94 0%, #5b2b74 100%);
68 border: 3px solid #d4af37;
69 }
70
71 .dialog-name {
72 font-family: 'Courier New', monospace;
73 font-size: 18px;
74 font-weight: bold;
75 color: #ffd700;
76 text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
77 }
78
79 .dialog-text {
80 font-family: 'Courier New', monospace;
81 font-size: 16px;
82 color: #e8d8c8;
83 line-height: 1.5;
84 min-height: 48px;
85 }
86
87 .dialog-continue {
88 position: absolute;
89 bottom: 12px;
90 right: 16px;
91 color: #ffd700;
92 font-size: 14px;
93 opacity: 0;
94 transition: opacity 0.3s;
95 animation: bounce 0.6s ease-in-out infinite;
96 }
97
98 .dialog-continue.visible {
99 opacity: 1;
100 }
101
102 @keyframes bounce {
103 0%, 100% { transform: translateY(0); }
104 50% { transform: translateY(-4px); }
105 }
106
107 .dialog-box.shake {
108 animation: dialogShake 0.1s ease-in-out;
109 }
110
111 @keyframes dialogShake {
112 0%, 100% { transform: translateX(0); }
113 25% { transform: translateX(-2px); }
114 75% { transform: translateX(2px); }
115 }
116 `
117
118 const CHARACTER_INFO = {
119 donny: {
120 name: 'Donny',
121 emoji: '🦄',
122 portrait: 'donny'
123 },
124 ollie: {
125 name: 'Ollie',
126 emoji: '🐙',
127 portrait: 'ollie'
128 }
129 }
130
131 function injectStyles() {
132 if (document.getElementById('dialog-styles')) return
133 const style = document.createElement('style')
134 style.id = 'dialog-styles'
135 style.textContent = STYLES
136 document.head.appendChild(style)
137 }
138
139 function createDialogOverlay(character) {
140 injectStyles()
141
142 const charInfo = CHARACTER_INFO[character]
143
144 const overlay = document.createElement('div')
145 overlay.className = 'dialog-overlay'
146 overlay.innerHTML = `
147 <div class="dialog-box">
148 <div class="dialog-header">
149 <div class="dialog-portrait ${charInfo.portrait}">${charInfo.emoji}</div>
150 <div class="dialog-name">${charInfo.name}</div>
151 </div>
152 <div class="dialog-text"></div>
153 <div class="dialog-continue">▼</div>
154 </div>
155 `
156
157 const box = overlay.querySelector('.dialog-box')
158 box.addEventListener('click', handleAdvance)
159
160 // Also handle keyboard
161 document.addEventListener('keydown', handleKeyDown)
162
163 return overlay
164 }
165
166 function handleKeyDown(e) {
167 if (e.key === ' ' || e.key === 'Enter') {
168 e.preventDefault()
169 handleAdvance()
170 }
171 }
172
173 function handleAdvance() {
174 if (!currentDialog) return
175
176 if (isTyping) {
177 // Skip to end of current line
178 finishCurrentLine()
179 } else {
180 // Advance to next line
181 advanceDialog()
182 }
183 }
184
185 function finishCurrentLine() {
186 if (typeInterval) {
187 clearInterval(typeInterval)
188 typeInterval = null
189 }
190
191 const textEl = dialogOverlay.querySelector('.dialog-text')
192 textEl.textContent = currentDialog.lines[currentLineIndex]
193 isTyping = false
194
195 // Show continue indicator
196 const continueEl = dialogOverlay.querySelector('.dialog-continue')
197 continueEl.classList.add('visible')
198 }
199
200 function advanceDialog() {
201 currentLineIndex++
202
203 if (currentLineIndex >= currentDialog.lines.length) {
204 // Dialog complete - save callback before closing (closeDialog clears it)
205 const callback = onCompleteCallback
206 closeDialog()
207 if (callback) {
208 callback()
209 }
210 return
211 }
212
213 // Hide continue indicator
214 const continueEl = dialogOverlay.querySelector('.dialog-continue')
215 continueEl.classList.remove('visible')
216
217 // Start typing next line
218 startTypingLine()
219 }
220
221 function startTypingLine() {
222 const textEl = dialogOverlay.querySelector('.dialog-text')
223 const line = currentDialog.lines[currentLineIndex]
224
225 textEl.textContent = ''
226 currentCharIndex = 0
227 isTyping = true
228
229 typeInterval = setInterval(() => {
230 if (currentCharIndex < line.length) {
231 const char = line[currentCharIndex]
232 textEl.textContent += char
233 currentCharIndex++
234
235 // Play blip sound for letters (skip spaces/punctuation, play every 2 chars)
236 if (char.match(/[a-zA-Z]/) && currentCharIndex % 2 === 0) {
237 playDialogBlip()
238 }
239
240 // Add slight shake on punctuation for emphasis
241 if (['.', '!', '?', '...'].some(p => line.substring(0, currentCharIndex).endsWith(p))) {
242 const box = dialogOverlay.querySelector('.dialog-box')
243 box.classList.add('shake')
244 setTimeout(() => box.classList.remove('shake'), 100)
245 }
246 } else {
247 // Line complete
248 clearInterval(typeInterval)
249 typeInterval = null
250 isTyping = false
251
252 // Show continue indicator
253 const continueEl = dialogOverlay.querySelector('.dialog-continue')
254 continueEl.classList.add('visible')
255 }
256 }, TYPE_SPEED)
257 }
258
259 export function showDialog(character, lines, container, onComplete) {
260 if (dialogOverlay) {
261 closeDialog()
262 }
263
264 currentDialog = { character, lines }
265 currentLineIndex = 0
266 currentCharIndex = 0
267 onCompleteCallback = onComplete
268
269 dialogOverlay = createDialogOverlay(character)
270 container.appendChild(dialogOverlay)
271
272 // Start typing first line
273 startTypingLine()
274 }
275
276 export function closeDialog() {
277 if (typeInterval) {
278 clearInterval(typeInterval)
279 typeInterval = null
280 }
281
282 document.removeEventListener('keydown', handleKeyDown)
283
284 if (dialogOverlay) {
285 dialogOverlay.remove()
286 dialogOverlay = null
287 }
288
289 currentDialog = null
290 currentLineIndex = 0
291 currentCharIndex = 0
292 isTyping = false
293 onCompleteCallback = null
294 }
295
296 export function isDialogOpen() {
297 return dialogOverlay !== null
298 }