| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Shy Guy Simulator - LLM Edition</title> |
| <style> |
| body { |
| font-family: Arial, sans-serif; |
| max-width: 1200px; |
| margin: 20px auto; |
| padding: 20px; |
| background-color: #1a1a1a; |
| color: #fff; |
| } |
| |
| .game-layout { |
| display: grid; |
| grid-template-columns: 2fr 3fr; |
| gap: 20px; |
| } |
| |
| #game-container { |
| background-color: #2a2a2a; |
| padding: 20px; |
| border-radius: 8px; |
| box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); |
| } |
| |
| .chat-container { |
| height: 300px; |
| overflow-y: auto; |
| margin: 20px 0; |
| padding: 10px; |
| background-color: #333; |
| border-radius: 4px; |
| } |
| |
| .message { |
| margin: 10px 0; |
| padding: 10px; |
| border-radius: 4px; |
| word-wrap: break-word; |
| } |
| |
| .wingman-msg { |
| background-color: #2c5282; |
| margin-right: 20%; |
| } |
| |
| .shyguy { |
| background-color: #4a5568; |
| margin-left: 20%; |
| } |
| |
| .error { |
| background-color: #c53030; |
| text-align: center; |
| } |
| |
| .viewport { |
| width: 800px; |
| height: 800px; |
| overflow: hidden; |
| position: relative; |
| border: 2px solid #4a5568; |
| border-radius: 4px; |
| } |
| |
| .grid-container { |
| width: 10240px; |
| height: 10240px; |
| position: relative; |
| background-color: #1a1a1a; |
| background-image: linear-gradient(#2d3748 1px, transparent 1px), |
| linear-gradient(90deg, #2d3748 1px, transparent 1px); |
| background-size: 20px 20px; |
| } |
| |
| #player, #wingman { |
| width: 16px; |
| height: 16px; |
| border-radius: 50%; |
| position: absolute; |
| transition: all 0.1s ease; |
| z-index: 2; |
| } |
| |
| #player { |
| background-color: #4299e1; |
| } |
| |
| #wingman { |
| background-color: #48bb78; |
| } |
| |
| .location-marker { |
| position: absolute; |
| width: 20px; |
| height: 20px; |
| border-radius: 4px; |
| } |
| |
| .bar { background-color: #744210; } |
| .dj { background-color: #2c5282; } |
| .girl { background-color: #d53f8c; } |
| .sister { background-color: #805ad5; } |
| .obstacle { background-color: #4a5568; } |
| |
| .legend { |
| display: flex; |
| gap: 10px; |
| margin-top: 10px; |
| flex-wrap: wrap; |
| } |
| |
| .legend-item { |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| } |
| |
| .legend-color { |
| width: 20px; |
| height: 20px; |
| border-radius: 4px; |
| } |
| |
| #input-container { |
| display: flex; |
| gap: 10px; |
| margin-top: 20px; |
| } |
| |
| #user-input { |
| flex-grow: 1; |
| padding: 10px; |
| border: none; |
| border-radius: 4px; |
| background-color: #4a4a4a; |
| color: white; |
| } |
| |
| button { |
| padding: 10px 20px; |
| background-color: #4299e1; |
| color: white; |
| border: none; |
| border-radius: 4px; |
| cursor: pointer; |
| } |
| |
| button:hover { |
| background-color: #3182ce; |
| } |
| |
| #stats { |
| margin-top: 20px; |
| padding: 10px; |
| background-color: #333; |
| border-radius: 4px; |
| display: flex; |
| justify-content: space-between; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| |
| .drunk-effect { |
| animation: wobble 1s infinite; |
| } |
| |
| @keyframes wobble { |
| 0% { transform: translate(0, 0) rotate(0deg); } |
| 15% { transform: translate(-5%, 0) rotate(-5deg); } |
| 30% { transform: translate(5%, 0) rotate(5deg); } |
| 45% { transform: translate(-5%, 0) rotate(-3deg); } |
| 60% { transform: translate(5%, 0) rotate(3deg); } |
| 75% { transform: translate(-5%, 0) rotate(-1deg); } |
| 100% { transform: translate(0, 0) rotate(0deg); } |
| } |
| |
| #api-key-container { |
| margin-bottom: 20px; |
| } |
| |
| .tooltip { |
| position: fixed; |
| background: rgba(0, 0, 0, 0.8); |
| padding: 5px; |
| border-radius: 4px; |
| font-size: 12px; |
| pointer-events: none; |
| z-index: 100; |
| display: none; |
| } |
| |
| .win-screen { |
| position: fixed; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| background: rgba(0, 0, 0, 0.9); |
| padding: 20px; |
| border-radius: 10px; |
| text-align: center; |
| z-index: 1000; |
| } |
| |
| .controls-info { |
| margin-top: 10px; |
| padding: 10px; |
| background-color: #333; |
| border-radius: 4px; |
| } |
| </style> |
| </head> |
| <body> |
| <div id="game-container"> |
| <h1>Shy Guy Simulator - LLM Edition</h1> |
| |
| <div id="api-key-container"> |
| <input type="password" id="api-key" placeholder="Enter your Mistral API key" style="width: 100%; padding: 10px; margin-bottom: 10px;"> |
| <button onclick="initializeGame()" id="start-button">Start Game</button> |
| </div> |
|
|
| <div id="game-content" style="display: none;"> |
| <div id="stats"> |
| <span>Confidence: <span id="confidence">0</span>%</span> |
| <span>Anxiety: <span id="anxiety">100</span>%</span> |
| <span>Drinks: <span id="drinks">0</span></span> |
| <span>Time: <span id="time">8:00 PM</span></span> |
| </div> |
|
|
| <div class="game-layout"> |
| <div class="chat-side"> |
| <div class="chat-container" id="chat-container"></div> |
| <div id="input-container"> |
| <input type="text" id="user-input" placeholder="Talk to your shy friend..."> |
| <button onclick="handleUserInput()">Send</button> |
| </div> |
| <div class="controls-info"> |
| Use arrow keys to move the wingman (green dot).<br> |
| Get close to push the shy guy (blue dot) in the right direction! |
| </div> |
| </div> |
|
|
| <div class="game-side"> |
| <div class="viewport"> |
| <div class="grid-container" id="party-grid"></div> |
| </div> |
| |
| <div class="legend"> |
| <div class="legend-item"> |
| <div class="legend-color" style="background-color: #4299e1;"></div> |
| <span>Shy Guy</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background-color: #48bb78;"></div> |
| <span>Wingman (You)</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background-color: #744210;"></div> |
| <span>Bar</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background-color: #2c5282;"></div> |
| <span>DJ</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background-color: #d53f8c;"></div> |
| <span>Girl</span> |
| </div> |
| <div class="legend-item"> |
| <div class="legend-color" style="background-color: #805ad5;"></div> |
| <span>Sister</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="tooltip" id="tooltip"></div> |
|
|
| <script> |
| class ShyGuySimulator { |
| constructor(apiKey) { |
| this.apiKey = apiKey; |
| this.state = { |
| confidence: 0, |
| anxiety: 100, |
| drinks: 0, |
| time: new Date(2024, 0, 1, 20, 0), |
| playerPos: { x: 50, y: 450 }, |
| wingmanPos: { x: 40, y: 450 }, |
| isProcessing: false, |
| hasSpokenToGirl: false, |
| emotion: 'anxious', |
| locations: { |
| bar: [{ x: 450, y: 50 }, { x: 451, y: 50 }], |
| dj: [{ x: 250, y: 20 }, { x: 251, y: 20 }], |
| girl: [{ x: 450, y: 450 }], |
| sister: [{ x: 150, y: 250 }], |
| obstacles: [ |
| { x: 200, y: 200 }, { x: 201, y: 200 }, |
| { x: 300, y: 300 }, { x: 301, y: 300 } |
| ] |
| } |
| }; |
| |
| this.context = [{ |
| role: 'system', |
| content: `You are roleplaying as a shy guy at a party, providing both dialogue and movement decisions. |
| The party is on a 512x512 grid. Movement values should be between -30 and 30 units per move. |
| ALWAYS structure your responses in this exact format: |
| { |
| "dialogue": "Your spoken response here", |
| "movement": { |
| "x": number (-30 to 30), |
| "y": number (-30 to 30) |
| }, |
| "emotion": "anxious|nervous|slightly_confident|confident" |
| } |
| |
| Rules: |
| 1. When drinks > 2, be more likely to move toward the girl |
| 2. When confidence < 30, prefer to move away or stay still |
| 3. Keep dialogue natural and brief (1-2 sentences) |
| 4. Movement should reflect emotional state |
| 5. Consider current position and avoid obstacles |
| 6. React to encouragement from the wingman |
| 7. If near the girl and confidence/drinks are low, move away` |
| }]; |
| |
| this.initialize(); |
| this.initializeGrid(); |
| this.setupKeyboardControls(); |
| } |
| |
| initialize() { |
| this.addMessage("Hey! I'll be your wingman tonight. I see that girl you like over there - let's help you talk to her!", 'wingman-msg'); |
| this.addMessage("I... I don't know about this. Maybe I should just go home...", 'shyguy'); |
| this.updateStats(); |
| } |
| |
| setupKeyboardControls() { |
| document.addEventListener('keydown', (e) => { |
| const moveDistance = 5; |
| let dx = 0, dy = 0; |
| |
| switch(e.key) { |
| case 'ArrowLeft': |
| dx = -moveDistance; |
| break; |
| case 'ArrowRight': |
| dx = moveDistance; |
| break; |
| case 'ArrowUp': |
| dy = -moveDistance; |
| break; |
| case 'ArrowDown': |
| dy = moveDistance; |
| break; |
| } |
| |
| if (dx !== 0 || dy !== 0) { |
| this.moveWingman(dx, dy); |
| } |
| }); |
| } |
| |
| moveWingman(dx, dy) { |
| let newX = this.state.wingmanPos.x + dx; |
| let newY = this.state.wingmanPos.y + dy; |
| |
| |
| newX = Math.max(0, Math.min(511, newX)); |
| newY = Math.max(0, Math.min(511, newY)); |
| |
| |
| const distanceToPlayer = Math.hypot( |
| newX - this.state.playerPos.x, |
| newY - this.state.playerPos.y |
| ); |
| |
| if (distanceToPlayer < 20) { |
| |
| const pushForce = 10; |
| const pushDx = (this.state.playerPos.x - newX) / distanceToPlayer * pushForce; |
| const pushDy = (this.state.playerPos.y - newY) / distanceToPlayer * pushForce; |
| |
| |
| this.movePlayer(pushDx, pushDy, true); |
| } |
| |
| |
| this.state.wingmanPos = { x: newX, y: newY }; |
| this.updateCharacterPositions(); |
| } |
| |
| async movePlayer(dx, dy, isPush = false) { |
| let newX = this.state.playerPos.x + dx; |
| let newY = this.state.playerPos.y + dy; |
| |
| |
| newX = Math.max(0, Math.min(511, newX)); |
| newY = Math.max(0, Math.min(511, newY)); |
| |
| |
| if (this.isLocationNearby(newX, newY, 'obstacles', 15)) { |
| return; |
| } |
| |
| |
| if (this.state.drinks >= 3 && !isPush) { |
| const stumbleChance = (this.state.drinks - 2) * 0.1; |
| if (Math.random() < stumbleChance) { |
| const randomDir = Math.random() < 0.5 ? 1 : -1; |
| if (Math.random() < 0.5) { |
| newX += randomDir * 10; |
| } else { |
| newY += randomDir * 10; |
| } |
| newX = Math.max(0, Math.min(511, newX)); |
| newY = Math.max(0, Math.min(511, newY)); |
| } |
| } |
| |
| |
| this.state.playerPos = { x: newX, y: newY }; |
| |
| if (!isPush) { |
| |
| await this.handleLocationInteraction(newX, newY); |
| this.state.time = new Date(this.state.time.getTime() + 2 * 60000); |
| } |
| |
| this.updateStats(); |
| this.updateCharacterPositions(); |
| } |
| |
| isLocationNearby(x, y, type, radius = 10) { |
| return this.state.locations[type].some(pos => |
| Math.hypot(x - pos.x * 20, y - pos.y * 20) < radius |
| ); |
| } |
| |
| updateCharacterPositions() { |
| const player = document.getElementById('player'); |
| const wingman = document.getElementById('wingman'); |
| |
| if (player) { |
| player.style.left = `${this.state.playerPos.x}px`; |
| player.style.top = `${this.state.playerPos.y}px`; |
| if (this.state.drinks >= 3) { |
| player.classList.add('drunk-effect'); |
| } else { |
| player.classList.remove('drunk-effect'); |
| } |
| } |
| |
| if (wingman) { |
| wingman.style.left = `${this.state.wingmanPos.x}px`; |
| wingman.style.top = `${this.state.wingmanPos.y}px`; |
| } |
| |
| |
| const viewport = document.querySelector('.viewport'); |
| if (viewport) { |
| const scrollX = this.state.playerPos.x - viewport.clientWidth / 2; |
| const scrollY = this.state.playerPos.y - viewport.clientHeight / 2; |
| viewport.scrollTo(scrollX, scrollY); |
| } |
| } |
| |
| initializeGrid() { |
| const grid = document.getElementById('party-grid'); |
| grid.innerHTML = ''; |
| |
| |
| Object.entries(this.state.locations).forEach(([type, positions]) => { |
| positions.forEach(pos => { |
| const marker = document.createElement('div'); |
| marker.className = `location-marker ${type}`; |
| marker.style.left = `${pos.x * 20}px`; |
| marker.style.top = `${pos.y * 20}px`; |
| grid.appendChild(marker); |
| }); |
| }); |
| |
| |
| const player = document.createElement('div'); |
| player.id = 'player'; |
| if (this.state.drinks >= 3) player.classList.add('drunk-effect'); |
| grid.appendChild(player); |
| |
| const wingman = document.createElement('div'); |
| wingman.id = 'wingman'; |
| grid.appendChild(wingman); |
| |
| |
| this.updateCharacterPositions(); |
| } |
| |
| async handleLocationInteraction(x, y) { |
| x = Math.floor(x / 20); |
| y = Math.floor(y / 20); |
| |
| if (this.isLocation(x, y, 'bar')) { |
| this.state.drinks++; |
| this.state.confidence = Math.min(100, this.state.confidence + 15); |
| this.state.anxiety = Math.max(0, this.state.anxiety - 10); |
| await this.addMessage("*Takes another drink from the bar*", 'shyguy'); |
| |
| if (this.state.drinks > 5) { |
| this.state.confidence = Math.max(0, this.state.confidence - 5); |
| await this.addMessage("*Starting to feel really dizzy...*", 'shyguy'); |
| } |
| } |
| |
| if (this.isLocation(x, y, 'sister')) { |
| this.state.confidence = Math.min(100, this.state.confidence + 20); |
| this.state.anxiety = Math.max(0, this.state.anxiety - 15); |
| await this.addMessage("*Gets some encouragement from sister*", 'shyguy'); |
| } |
| |
| if (this.isLocation(x, y, 'dj')) { |
| this.state.confidence = Math.min(100, this.state.confidence + 10); |
| this.state.anxiety = Math.max(0, this.state.anxiety - 5); |
| await this.addMessage("*Vibing to the music*", 'shyguy'); |
| } |
| |
| if (this.isLocation(x, y, 'girl')) { |
| if (this.state.confidence >= 70 && this.state.anxiety <= 50) { |
| await this.addMessage("*Finally gathered the courage to talk to her!*", 'shyguy'); |
| this.gameWon(); |
| } else { |
| await this.addMessage("*Gets too nervous and quickly walks away*", 'shyguy'); |
| this.state.playerPos = { |
| x: Math.max(0, x - 2) * 20, |
| y: Math.max(0, y - 2) * 20 |
| }; |
| this.state.anxiety += 15; |
| this.state.confidence = Math.max(0, this.state.confidence - 10); |
| } |
| } |
| } |
| |
| isLocation(x, y, type) { |
| return this.state.locations[type].some(pos => pos.x === x && pos.y === y); |
| } |
| |
| async handleInput(userInput) { |
| if (this.state.isProcessing) return; |
| this.state.isProcessing = true; |
| |
| try { |
| if (!userInput.trim()) throw new Error("Please enter some text"); |
| |
| this.addMessage(userInput, 'wingman-msg'); |
| this.addLoadingMessage(); |
| |
| const currentState = `Current state: |
| Confidence: ${this.state.confidence}%, |
| Anxiety: ${this.state.anxiety}%, |
| Drinks: ${this.state.drinks}, |
| Location: ${this.getLocationDescription()}, |
| Position: (${Math.floor(this.state.playerPos.x / 20)},${Math.floor(this.state.playerPos.y / 20)}), |
| Emotion: ${this.state.emotion}`; |
| |
| this.context.push({ |
| role: 'user', |
| content: `${userInput}\n\n${currentState}` |
| }); |
| |
| const llmResponse = await this.callMistralAPI(); |
| const dialogue = await this.processLLMResponse(llmResponse); |
| |
| this.removeLoadingMessage(); |
| this.addMessage(dialogue, 'shyguy'); |
| |
| this.context.push({ |
| role: 'assistant', |
| content: llmResponse |
| }); |
| |
| if (this.context.length > 10) { |
| this.context = [ |
| this.context[0], |
| ...this.context.slice(-4) |
| ]; |
| } |
| } catch (error) { |
| this.removeLoadingMessage(); |
| this.addMessage(`Error: ${error.message}`, 'error'); |
| console.error('Error:', error); |
| } finally { |
| this.state.isProcessing = false; |
| } |
| } |
| |
| async processLLMResponse(response) { |
| try { |
| const jsonMatch = response.match(/\{[\s\S]*\}/); |
| if (!jsonMatch) { |
| throw new Error("Invalid response format"); |
| } |
| |
| const parsedResponse = JSON.parse(jsonMatch[0]); |
| |
| if (!parsedResponse.dialogue || !parsedResponse.movement || !parsedResponse.emotion) { |
| throw new Error("Missing required fields in response"); |
| } |
| |
| this.state.emotion = parsedResponse.emotion; |
| |
| await this.movePlayer( |
| parsedResponse.movement.x, |
| parsedResponse.movement.y |
| ); |
| |
| this.updateEmotionalState(parsedResponse.emotion); |
| |
| return parsedResponse.dialogue; |
| } catch (error) { |
| console.error("Error processing LLM response:", error); |
| return "I'm not sure what to do right now..."; |
| } |
| } |
| |
| updateEmotionalState(emotion) { |
| const emotionEffects = { |
| 'anxious': { confidence: -5, anxiety: 5 }, |
| 'nervous': { confidence: -2, anxiety: 2 }, |
| 'slightly_confident': { confidence: 5, anxiety: -5 }, |
| 'confident': { confidence: 10, anxiety: -10 } |
| }; |
| |
| const effect = emotionEffects[emotion] || { confidence: 0, anxiety: 0 }; |
| |
| this.state.confidence = Math.max(0, Math.min(100, this.state.confidence + effect.confidence)); |
| this.state.anxiety = Math.max(0, Math.min(100, this.state.anxiety + effect.anxiety)); |
| } |
| |
| async callMistralAPI() { |
| try { |
| const response = await fetch('https://api.mistral.ai/v1/chat/completions', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'Authorization': `Bearer ${this.apiKey}` |
| }, |
| body: JSON.stringify({ |
| model: 'mistral-large-latest', |
| messages: this.context, |
| max_tokens: 150, |
| temperature: 0.7 |
| }) |
| }); |
| |
| if (!response.ok) { |
| const error = await response.json(); |
| throw new Error(error.error?.message || 'API request failed'); |
| } |
| |
| const data = await response.json(); |
| return data.choices[0].message.content; |
| } catch (error) { |
| if (error.message.includes('API key')) { |
| throw new Error('Invalid API key. Please check your API key and try again.'); |
| } |
| throw new Error('Failed to get response from AI. Please try again.'); |
| } |
| } |
| |
| getLocationDescription() { |
| const x = Math.floor(this.state.playerPos.x / 20); |
| const y = Math.floor(this.state.playerPos.y / 20); |
| |
| if (this.isLocation(x, y, 'bar')) return 'at the bar'; |
| if (this.isLocation(x, y, 'dj')) return 'near the DJ'; |
| if (this.isLocation(x, y, 'sister')) return 'with sister'; |
| if (this.isLocation(x, y, 'girl')) return 'near the girl'; |
| return 'in the room'; |
| } |
| |
| gameWon() { |
| this.addMessage("Congratulations! You successfully talked to her, and she seems interested! The party wasn't so scary after all.", 'wingman-msg'); |
| this.state.hasSpokenToGirl = true; |
| |
| const winScreen = document.createElement('div'); |
| winScreen.className = 'win-screen'; |
| winScreen.innerHTML = ` |
| <h2>You did it!</h2> |
| <p>Final Stats:</p> |
| <p>Confidence: ${this.state.confidence}%</p> |
| <p>Anxiety: ${this.state.anxiety}%</p> |
| <p>Drinks: ${this.state.drinks}</p> |
| <p>Time taken: ${this.getTimeDifference()}</p> |
| <button onclick="location.reload()">Play Again</button> |
| `; |
| document.body.appendChild(winScreen); |
| } |
| |
| getTimeDifference() { |
| const startTime = new Date(2024, 0, 1, 20, 0); |
| const timeDiff = this.state.time - startTime; |
| const minutes = Math.floor(timeDiff / 60000); |
| return `${minutes} minutes`; |
| } |
| |
| addMessage(text, type) { |
| const chat = document.getElementById('chat-container'); |
| const messageDiv = document.createElement('div'); |
| messageDiv.className = `message ${type}`; |
| messageDiv.textContent = text; |
| chat.appendChild(messageDiv); |
| chat.scrollTop = chat.scrollHeight; |
| } |
| |
| addLoadingMessage() { |
| const chat = document.getElementById('chat-container'); |
| const loadingDiv = document.createElement('div'); |
| loadingDiv.className = 'message shyguy typing'; |
| loadingDiv.id = 'loading-message'; |
| loadingDiv.textContent = 'Thinking...'; |
| chat.appendChild(loadingDiv); |
| chat.scrollTop = chat.scrollHeight; |
| } |
| |
| removeLoadingMessage() { |
| const loadingMessage = document.getElementById('loading-message'); |
| if (loadingMessage) { |
| loadingMessage.remove(); |
| } |
| } |
| |
| updateStats() { |
| document.getElementById('confidence').textContent = Math.round(this.state.confidence); |
| document.getElementById('anxiety').textContent = Math.round(this.state.anxiety); |
| document.getElementById('drinks').textContent = this.state.drinks; |
| document.getElementById('time').textContent = |
| this.state.time.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); |
| } |
| } |
| |
| let game; |
| |
| function initializeGame() { |
| const apiKey = document.getElementById('api-key').value.trim(); |
| if (!apiKey) { |
| alert('Please enter your Mistral API key'); |
| return; |
| } |
| |
| document.getElementById('api-key-container').style.display = 'none'; |
| document.getElementById('game-content').style.display = 'block'; |
| |
| game = new ShyGuySimulator(apiKey); |
| } |
| |
| async function handleUserInput() { |
| if (!game) return; |
| |
| const input = document.getElementById('user-input'); |
| const text = input.value.trim(); |
| if (text) { |
| await game.handleInput(text); |
| input.value = ''; |
| } |
| } |
| |
| document.getElementById('user-input')?.addEventListener('keypress', function(e) { |
| if (e.key === 'Enter') { |
| handleUserInput(); |
| } |
| }); |
| </script> |
| </body> |
| </html> |