Spaces:
Sleeping
Sleeping
<html> | |
<head> | |
<title>Get Me Out! - Apartment Escape Game with Chat</title> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js"></script> | |
<script src="/static/game/gameState.js"></script> | |
<style> | |
/* Layout */ | |
body { | |
margin: 0; | |
font-family: Arial, sans-serif; | |
display: flex; | |
height: 100vh; | |
} | |
#gameContainer { | |
display: flex; | |
width: 100%; | |
height: 100%; | |
} | |
#mapSection { | |
flex: 1; | |
position: relative; | |
background: #f5f5f5; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
} | |
/* We'll place the P5 canvas in here */ | |
.map-wrapper { | |
margin: 20px; | |
transform-origin: top left; | |
} | |
#chatSection { | |
width: 400px; | |
border-left: 1px solid #ddd; | |
display: flex; | |
flex-direction: column; | |
background: white; | |
} | |
#chatHistory { | |
flex: 1; | |
overflow-y: auto; | |
padding: 20px; | |
background: #f8f9fa; | |
} | |
#chatControls { | |
padding: 20px; | |
border-top: 1px solid #ddd; | |
background: white; | |
display: flex; | |
gap: 10px; | |
} | |
.chat-message { | |
padding: 10px; | |
margin: 5px 0; | |
border-radius: 16px; | |
max-width: 80%; | |
word-wrap: break-word; | |
} | |
.user-message { | |
background: #007AFF; | |
color: white; | |
margin-left: auto; | |
border-radius: 16px 16px 4px 16px; | |
} | |
.assistant-message { | |
background: #E9E9EB; | |
color: black; | |
margin-right: auto; | |
border-radius: 16px 16px 16px 4px; | |
} | |
input { | |
flex: 1; | |
padding: 10px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
font-family: inherit; | |
} | |
button { | |
padding: 8px 16px; | |
background: #007AFF; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
} | |
button:hover { | |
background: #0056b3; | |
} | |
.loading-dots { | |
display: inline-flex; | |
gap: 4px; | |
padding: 5px; | |
margin: 5px 0; | |
} | |
.dot { | |
width: 8px; | |
height: 8px; | |
background: #6c757d; | |
border-radius: 50%; | |
animation: wave 1.3s linear infinite; | |
} | |
.dot:nth-child(2) { animation-delay: -1.1s; } | |
.dot:nth-child(3) { animation-delay: -0.9s; } | |
@keyframes wave { | |
0%, 60%, 100% { transform: translateY(0); } | |
30% { transform: translateY(-4px); } | |
} | |
/* Modal for API key (if needed) */ | |
#apiKeyModal { | |
display: none; | |
position: fixed; | |
top: 0; left: 0; | |
width: 100%; height: 100%; | |
background: rgba(0,0,0,0.5); | |
justify-content: center; | |
align-items: center; | |
} | |
.modal-content { | |
background: white; | |
padding: 20px; | |
border-radius: 8px; | |
width: 300px; | |
} | |
#apiKey { | |
width: 100%; | |
margin: 10px 0; | |
padding: 8px; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="gameContainer"> | |
<div id="mapSection"> | |
<div id="mapWrapper" class="map-wrapper"> | |
<!-- P5.js canvas goes here --> | |
</div> | |
</div> | |
<div id="chatSection"> | |
<div id="chatHistory"></div> | |
<div id="chatControls"> | |
<input type="text" id="prompt" placeholder="Type your message..."> | |
<button onclick="Message()">▶</button> | |
</div> | |
</div> | |
</div> | |
<div id="apiKeyModal"> | |
<div class="modal-content"> | |
<h3>Enter Mistral API Key</h3> | |
<input type="password" id="apiKey" placeholder="Enter your API key"> | |
<button onclick="saveApiKey()">Save</button> | |
</div> | |
</div> | |
<script> | |
/* | |
* This script loads ./apt.json to set up the map, | |
* draws it in p5.js, and provides a chat interface on the right. | |
*/ | |
// Constants and variables for the map | |
const CELL_SIZE = 30; | |
let GRID_COLS = 0; | |
let GRID_ROWS = 0; | |
let grid = []; | |
let rooms = new Map(); | |
let characterPos = null; | |
let path = []; | |
let isMoving = false; | |
let moveInterval = null; | |
// Initialize game state | |
const gameState = new GameState(); | |
// Chat variables | |
let apiKey = localStorage.getItem('mistralApiKey'); | |
let chatMessages = []; // Array to store chat history | |
// Load apt.json upon page start | |
async function loadAptJson() { | |
try { | |
const response = await fetch('/static/game/apt.json'); | |
if (!response.ok) { | |
alert("Failed to load apt.json!"); | |
return; | |
} | |
const data = await response.json(); | |
// Update local variables from apt.json | |
GRID_COLS = data.gridCols || 40; | |
GRID_ROWS = data.gridRows || 20; | |
grid = data.grid || []; | |
// Ensure each cell has the color property if it exists in the data | |
for (let y = 0; y < GRID_ROWS; y++) { | |
for (let x = 0; x < GRID_COLS; x++) { | |
if (!grid[y]) grid[y] = []; | |
if (!grid[y][x]) { | |
grid[y][x] = { type: 'empty', color: null }; | |
} else if (typeof grid[y][x] === 'string') { | |
// Handle old format where grid cells were just strings | |
grid[y][x] = { type: grid[y][x], color: null }; | |
} | |
} | |
} | |
rooms = new Map(data.rooms); | |
characterPos = data.characterPos || { x: 0, y: 0 }; | |
// Once loaded, initialize the P5 canvas with correct dims | |
let canvas = createCanvas(GRID_COLS * CELL_SIZE, GRID_ROWS * CELL_SIZE); | |
canvas.parent('mapWrapper'); | |
adjustScale(); | |
} catch (e) { | |
console.error("Error loading apt.json:", e); | |
} | |
} | |
function adjustScale() { | |
const availableWidth = window.innerWidth - 400 - 40; // space for chat + margins | |
const actualCanvasWidth = GRID_COLS * CELL_SIZE; | |
const scale = availableWidth / actualCanvasWidth; | |
const mapWrapper = document.querySelector('#mapSection .map-wrapper'); | |
if (mapWrapper) { | |
// use CSS zoom or transform | |
mapWrapper.style.zoom = scale; | |
} | |
} | |
window.addEventListener('resize', adjustScale); | |
// p5.js setup | |
function setup() { | |
// We'll wait to createCanvas until apt.json is loaded | |
loadAptJson(); | |
} | |
// p5.js draw loop | |
function draw() { | |
if (!grid || grid.length === 0) { | |
// No grid loaded yet or apt.json not ready | |
return; | |
} | |
background(255); | |
drawGrid(); | |
drawRooms(); | |
drawWallsAndDoors(); | |
drawPath(); | |
drawCharacter(); | |
} | |
function drawGrid() { | |
stroke(200); | |
for (let x = 0; x <= GRID_COLS; x++) { | |
line(x * CELL_SIZE, 0, x * CELL_SIZE, GRID_ROWS*CELL_SIZE); | |
} | |
for (let y = 0; y <= GRID_ROWS; y++) { | |
line(0, y * CELL_SIZE, GRID_COLS*CELL_SIZE, y * CELL_SIZE); | |
} | |
} | |
function drawRooms() { | |
for (let [roomName, cells] of rooms) { | |
const hue = stringToHue(roomName); | |
fill(hue, 30, 95, 0.3); | |
noStroke(); | |
for (let cell of cells) { | |
rect(cell.x * CELL_SIZE, cell.y * CELL_SIZE, CELL_SIZE, CELL_SIZE); | |
} | |
if (cells.length > 0) { | |
fill(0); | |
textAlign(CENTER, CENTER); | |
textSize(10); | |
text(roomName, | |
cells[0].x * CELL_SIZE + CELL_SIZE/2, | |
cells[0].y * CELL_SIZE + CELL_SIZE/2 | |
); | |
} | |
} | |
} | |
function drawWallsAndDoors() { | |
for (let y = 0; y < GRID_ROWS; y++) { | |
for (let x = 0; x < GRID_COLS; x++) { | |
const cell = grid[y][x]; | |
if (cell.type === 'wall') { | |
fill(0); | |
noStroke(); | |
rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); | |
} else if (cell.type === 'door') { | |
fill(139, 69, 19); // Brown color for doors | |
noStroke(); | |
rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); | |
} else if (cell.color) { | |
noStroke(); | |
switch(cell.color) { | |
case 'yellow': | |
fill('#ffeb3b'); | |
break; | |
case 'blue': | |
fill('#2196f3'); | |
break; | |
case 'green': | |
fill('#4caf50'); | |
break; | |
case 'red': | |
fill('#f44336'); | |
break; | |
} | |
rect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE); | |
} | |
} | |
} | |
} | |
function drawPath() { | |
if (path.length > 0 && isMoving) { | |
noFill(); | |
stroke(0, 255, 0); | |
strokeWeight(2); | |
line( | |
characterPos.x * CELL_SIZE + CELL_SIZE/2, | |
characterPos.y * CELL_SIZE + CELL_SIZE/2, | |
path[0].x * CELL_SIZE + CELL_SIZE/2, | |
path[0].y * CELL_SIZE + CELL_SIZE/2 | |
); | |
for (let i = 0; i < path.length - 1; i++) { | |
line( | |
path[i].x * CELL_SIZE + CELL_SIZE/2, | |
path[i].y * CELL_SIZE + CELL_SIZE/2, | |
path[i + 1].x * CELL_SIZE + CELL_SIZE/2, | |
path[i + 1].y * CELL_SIZE + CELL_SIZE/2 | |
); | |
} | |
strokeWeight(1); | |
} | |
} | |
function drawCharacter() { | |
if (characterPos) { | |
textSize(CELL_SIZE * 0.8); | |
textAlign(CENTER, CENTER); | |
text('👧', | |
characterPos.x * CELL_SIZE + CELL_SIZE/2, | |
characterPos.y * CELL_SIZE + CELL_SIZE/2 | |
); | |
} | |
} | |
function stringToHue(str) { | |
let hash = 0; | |
for (let i = 0; i < str.length; i++) { | |
hash = str.charCodeAt(i) + ((hash << 5) - hash); | |
} | |
return hash % 360; | |
} | |
// Simple BFS for pathfinding | |
function findPath(start, end) { | |
const queue = [[start]]; | |
const visited = new Set(); | |
const key = pos => `${pos.x},${pos.y}`; | |
visited.add(key(start)); | |
while (queue.length > 0) { | |
const currentPath = queue.shift(); | |
const current = currentPath[currentPath.length - 1]; | |
if (current.x === end.x && current.y === end.y) { | |
return currentPath; | |
} | |
const neighbors = [ | |
{ x: current.x, y: current.y - 1 }, | |
{ x: current.x+1, y: current.y }, | |
{ x: current.x, y: current.y+1 }, | |
{ x: current.x-1, y: current.y } | |
]; | |
for (const next of neighbors) { | |
if (next.x < 0 || next.x >= GRID_COLS || next.y < 0 || next.y >= GRID_ROWS) continue; | |
if (visited.has(key(next))) continue; | |
const cell = grid[next.y][next.x]; | |
if (cell.type === 'wall') continue; | |
visited.add(key(next)); | |
queue.push([...currentPath, next]); | |
} | |
} | |
return []; | |
} | |
function moveToRoom(roomName) { | |
const targetRoom = Array.from(rooms.entries()) | |
.find(([name]) => name.toLowerCase() === roomName.toLowerCase()); | |
if (!targetRoom || !characterPos) return false; | |
const [_, cells] = targetRoom; | |
if (cells.length === 0) return false; | |
path = findPath(characterPos, cells[0]); | |
if (path.length > 0) { | |
isMoving = true; | |
moveCharacterAlongPath(); | |
return true; | |
} | |
return false; | |
} | |
function moveCharacterAlongPath() { | |
if (moveInterval) clearInterval(moveInterval); | |
moveInterval = setInterval(() => { | |
if (path.length === 0) { | |
isMoving = false; | |
clearInterval(moveInterval); | |
return; | |
} | |
const nextPos = path.shift(); | |
characterPos = nextPos; | |
}, 200); | |
} | |
/* Chat / Mistral-related code */ | |
function createLoadingIndicator() { | |
const loadingDiv = document.createElement('div'); | |
loadingDiv.className = 'chat-message assistant-message'; | |
loadingDiv.innerHTML = ` | |
<div class="loading-dots"> | |
<div class="dot"></div> | |
<div class="dot"></div> | |
<div class="dot"></div> | |
</div> | |
`; | |
return loadingDiv; | |
} | |
function addMessageToChat(role, content) { | |
const chatHistory = document.getElementById('chatHistory'); | |
const messageDiv = document.createElement('div'); | |
messageDiv.className = `chat-message ${role}-message`; | |
messageDiv.textContent = content; | |
chatHistory.appendChild(messageDiv); | |
chatHistory.scrollTop = chatHistory.scrollHeight; | |
// Store message in chat history | |
chatMessages.push({ role, content }); | |
} | |
async function Message() { | |
const prompt = document.getElementById('prompt').value.trim(); | |
if (!prompt) return; | |
if (!apiKey) { | |
alert('Please enter your Mistral API key first'); | |
document.getElementById('apiKeyModal').style.display = 'flex'; | |
return; | |
} | |
addMessageToChat('user', prompt); | |
document.getElementById('prompt').value = ''; | |
const chatHistory = document.getElementById('chatHistory'); | |
const loadingIndicator = createLoadingIndicator(); | |
chatHistory.appendChild(loadingIndicator); | |
try { | |
// Get last 5 messages from chat history | |
const recentMessages = chatMessages.slice(-5); | |
// Prepare messages array for Mistral | |
const messages = [ | |
{ role: 'system', content: gameState.getPrompt() }, | |
...recentMessages, | |
{ role: 'user', content: prompt } | |
]; | |
const response = await fetch('https://api.mistral.ai/v1/chat/completions', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${apiKey}` | |
}, | |
body: JSON.stringify({ | |
model: 'mistral-large-latest', | |
messages: messages | |
}) | |
}); | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`); | |
} | |
const data = await response.json(); | |
console.log('Mistral response:', data.choices[0].message.content); | |
const assistantResponse = data.choices[0].message.content || ""; | |
try { | |
const jsonStart = assistantResponse.indexOf('{'); | |
const jsonEnd = assistantResponse.lastIndexOf('}') + 1; | |
const jsonContent = assistantResponse.substring(jsonStart, jsonEnd); | |
const jsonResponse = JSON.parse(jsonContent); | |
console.log('Parsed JSON response:', jsonResponse); | |
if (jsonResponse.textMessage) { | |
addMessageToChat('assistant', jsonResponse.textMessage); | |
} | |
if (jsonResponse.action === 'go' && jsonResponse.to) { | |
moveToRoom(jsonResponse.to); | |
} | |
} catch (e) { | |
console.error('Error parsing JSON from response:', e); | |
addMessageToChat('assistant', 'Sorry, I had trouble understanding that response.'); | |
} | |
} catch (error) { | |
addMessageToChat('assistant', `Error: ${error.message}`); | |
} finally { | |
loadingIndicator.remove(); | |
} | |
} | |
function saveApiKey() { | |
const key = document.getElementById('apiKey').value.trim(); | |
if (key) { | |
apiKey = key; | |
localStorage.setItem('mistralApiKey', key); | |
document.getElementById('apiKeyModal').style.display = 'none'; | |
} | |
} | |
// Allow ing message with Enter | |
document.addEventListener('DOMContentLoaded', () => { | |
document.getElementById('prompt').addEventListener('keypress', function(e) { | |
if (e.key === 'Enter') { | |
e.preventDefault(); | |
Message(); | |
} | |
}); | |
if (!apiKey) { | |
document.getElementById('apiKeyModal').style.display = 'flex'; | |
} | |
}); | |
</script> | |
</body> | |
</html> |