Spaces:
Running
on
T4
Running
on
T4
import { BAR_LABEL, DJ_LABEL, EXIT_LABEL, GIRL_LABEL, SISTER_LABEL, WINGMAN_LABEL, SHYGUY_LABEL } from "./constants"; | |
import { nameToLabel } from "./story_engine.js"; | |
const WINGMAN_SPEED = 5; | |
const SHYGUY_SPEED = 1; | |
const IS_DEBUG = false; | |
class SpriteEntity { | |
constructor(x0, y0, imageSrc, speed = 0, width = 24, height = 64, frameRate = 8, frameCount = 1) { | |
this.x = x0; | |
this.y = y0; | |
this.width = width; | |
this.height = height; | |
this.image = new Image(); | |
this.image.src = imageSrc; | |
this.frameRate = frameRate; | |
this.frameCount = frameCount; | |
// properties for the game engine | |
this.moving = false; | |
this.speed = speed; | |
// frame index in the sprite sheet | |
this.frameX = 0; | |
this.frameY = 0; // 0 for right, 1 for left | |
} | |
stop() { | |
this.moving = false; | |
} | |
start() { | |
this.moving = true; | |
} | |
setSpeed(speed) { | |
this.speed = speed; | |
} | |
} | |
class GuidedSpriteEntity extends SpriteEntity { | |
constructor(x0, y0, imageSrc, speed = 0, width = 24, height = 64, frameRate = 8, frameCount = 1) { | |
super(x0, y0, imageSrc, speed, width, height, frameRate, frameCount); | |
this.target = null; | |
} | |
setTarget(target) { | |
this.target = target; | |
} | |
} | |
class SpriteImage { | |
constructor(imageSrc, width = 32, height = 32) { | |
this.image = new Image(); | |
this.image.src = imageSrc; | |
this.width = width; | |
this.height = height; | |
} | |
} | |
class Target { | |
constructor(label, x, y, width, height, color, enabled = true) { | |
this.label = label; | |
this.x = x; | |
this.y = y; | |
this.width = width; | |
this.height = height; | |
this.debugColor = color; | |
this.enabled = enabled; | |
} | |
} | |
export class GameEngine { | |
static introMessages = [ | |
{ | |
message: | |
"Hey man, this is really not my cup of tea. I see Jessica in the corner, I wonder if I can finally tell her I love her.", | |
character: SHYGUY_LABEL, | |
}, | |
{ | |
message: "Man, tonight is your night. I'll get you through it and you'll go home with Jessica.", | |
character: WINGMAN_LABEL, | |
}, | |
{ | |
message: "Geez, that's impossible! Even if I replay the night a million times, I couldn't do it.", | |
character: SHYGUY_LABEL, | |
}, | |
{ | |
message: "Okay, just follow my advice! I'll push you around if needed.", | |
character: WINGMAN_LABEL, | |
}, | |
]; | |
constructor(shyguy, shyguyLLM, storyEngine, speechToTextClient, elevenLabsClient) { | |
this.shyguy = shyguy; | |
this.shyguyLLM = shyguyLLM; | |
this.storyEngine = storyEngine; | |
this.speechToTextClient = speechToTextClient; | |
this.elevenLabsClient = elevenLabsClient; | |
this.canvasWidth = 960; | |
this.canvasHeight = 640; | |
this.canvas = document.getElementById("gameCanvas"); | |
if (!this.canvas) { | |
console.error("Canvas not found"); | |
} | |
this.ctx = this.canvas.getContext("2d"); | |
// View management | |
this.gameView = document.getElementById("gameView"); | |
this.dialogueView = document.getElementById("dialogueView"); | |
this.currentView = "game"; | |
this.shouldContinue = true; | |
this.gameOver = false; | |
this.gameSuccessful = false; | |
this.gameChatContainer = document.getElementById("chatMessages"); | |
this.messageInput = document.getElementById("messageInput"); | |
this.sendButton = document.getElementById("sendButton"); | |
this.microphoneButton = document.getElementById("micButton"); | |
this.gameOverImage = document.getElementById("gameOverImage"); | |
this.gameOverText = document.getElementById("gameOverText"); | |
this.dialogueChatContainer = document.getElementById("dialogueMessages"); | |
this.dialogueContinueButton = document.getElementById("dialogueContinueButton"); | |
this.dialogueNextButton = document.getElementById("dialogueNextButton"); | |
this.gameFrame = 0; | |
this.keys = { | |
ArrowUp: false, | |
ArrowDown: false, | |
ArrowLeft: false, | |
ArrowRight: false, | |
}; | |
// Bind methods | |
this.switchView = this.switchView.bind(this); | |
this.update = this.update.bind(this); | |
this.draw = this.draw.bind(this); | |
this.run = this.run.bind(this); | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleKeyUp = this.handleKeyUp.bind(this); | |
this.setNewTarget = this.setNewTarget.bind(this); | |
this.checkTargetReached = this.checkTargetReached.bind(this); | |
this.updateGuidedSpriteDirection = this.updateGuidedSpriteDirection.bind(this); | |
this.updateSprite = this.updateSprite.bind(this); | |
this.handleSpriteCollision = this.handleSpriteCollision.bind(this); | |
this.initDebugControls = this.initDebugControls.bind(this); | |
this.stopShyguyAnimation = this.stopShyguyAnimation.bind(this); | |
this.handlePlayAgain = this.handlePlayAgain.bind(this); | |
this.handleMicrophone = this.handleMicrophone.bind(this); | |
this.handleSendMessage = this.handleSendMessage.bind(this); | |
this.handleMicrophone = this.handleMicrophone.bind(this); | |
this.handleDialogueContinue = this.handleDialogueContinue.bind(this); | |
this.handleFirstStartGame = this.handleFirstStartGame.bind(this); | |
this.setGameOver = this.setGameOver.bind(this); | |
this.handleDialogueNext = this.handleDialogueNext.bind(this); | |
this.pushEnabled = false; | |
this.voiceEnabled = !IS_DEBUG; | |
// Debug controls | |
this.initDebugControls(); | |
// if we have other obstacles, we can add them here | |
this.gridMapTypes = { | |
floor: 0, | |
wall: 1, | |
door: 2, | |
}; | |
// load assets for drawing the scene | |
this.wall = new SpriteImage("/assets/assets/wall_sprite.png"); | |
this.floor = new SpriteImage("/assets/assets/floor-tile.png"); | |
this.door = new SpriteImage("/assets/assets/door_sprite.png"); | |
this.gridCols = Math.ceil(this.canvasWidth / this.wall.width); | |
this.gridRows = Math.ceil(this.canvasHeight / this.wall.height); | |
// initialize grid map | |
this.backgroundGridMap = []; | |
this.initBackgroundGridMap(); | |
// initialize players | |
const cx = this.canvasWidth / 2; | |
const cy = this.canvasHeight / 2; | |
this.shyguySprite = new GuidedSpriteEntity(cx, cy, "/assets/assets/shyguy_sprite.png", SHYGUY_SPEED); | |
this.wingmanSprite = new SpriteEntity( | |
this.wall.width, | |
this.canvasHeight - this.wall.height - 64, | |
"/assets/assets/wingman_sprite.png", | |
WINGMAN_SPEED | |
); | |
this.jessicaSprite = new SpriteImage("/assets/assets/jessica_sprite.png", 64, 64); | |
this.djSprite = new SpriteImage("/assets/assets/dj_sprite.png", 64, 64); | |
this.barSprite = new SpriteImage("/assets/assets/bar_sprite.png", 64, 64); | |
this.sisterSprite = new SpriteImage("/assets/assets/sister_sprite.png", 64, 64); | |
this.targets = { | |
exit: new Target(EXIT_LABEL, this.wall.width, this.wall.height, this.wall.width, this.wall.height, "red", true), | |
girl: new Target( | |
GIRL_LABEL, | |
this.canvasWidth - this.wall.width - this.jessicaSprite.width, | |
(this.canvasHeight - this.wall.height - this.jessicaSprite.height) / 2, | |
this.jessicaSprite.width, | |
this.jessicaSprite.height, | |
"pink", | |
true | |
), | |
bar: new Target( | |
BAR_LABEL, | |
(this.canvasWidth - this.wall.width - this.barSprite.width) / 2, | |
this.wall.height, | |
this.barSprite.width, | |
this.barSprite.height, | |
"blue", | |
true | |
), | |
dj: new Target( | |
DJ_LABEL, | |
this.wall.width, | |
(this.canvasHeight - this.wall.height - this.djSprite.height) / 2, | |
this.djSprite.width, | |
this.djSprite.height, | |
"green", | |
true | |
), | |
sister: new Target( | |
SISTER_LABEL, | |
this.canvasWidth - this.wall.width - this.sisterSprite.width, | |
this.wall.height, | |
this.sisterSprite.width, | |
this.sisterSprite.height, | |
"yellow", | |
true | |
), | |
}; | |
// Add game over view | |
this.gameOverView = document.getElementById("gameOverView"); | |
this.playAgainBtn = document.getElementById("playAgainBtn"); | |
this.isRecording = false; | |
// Add these lines | |
this.introView = document.getElementById("introView"); | |
this.startGameBtn = document.getElementById("startGameBtn"); | |
this.backgroundMusic = new Audio("assets/assets/tiny-steps-danijel-zambo-main-version-1433-01-48.mp3"); | |
this.backgroundMusic.loop = true; | |
this.gameOverMusic = new Audio("/assets/assets/game-over-8bit-music-danijel-zambo-1-00-16.mp3"); | |
this.gameOverMusic.loop = false; | |
this.victoryMusic = new Audio("/assets/assets/moonlit-whispers-theo-gerard-main-version-35960-02-34.mp3"); | |
this.victoryMusic.loop = false; | |
// Move character images to class state | |
this.leftCharacterImg = document.getElementById("leftCharacterImg"); | |
this.rightCharacterImg = document.getElementById("rightCharacterImg"); | |
this.hideCharacterImages(); | |
} | |
showCharacterImages() { | |
this.leftCharacterImg.style.display = "block"; | |
this.rightCharacterImg.style.display = "block"; | |
} | |
hideCharacterImages() { | |
this.leftCharacterImg.style.display = "none"; | |
this.rightCharacterImg.style.display = "none"; | |
} | |
init(firstRun = true) { | |
this.canvas.width = this.canvasWidth; | |
this.canvas.height = this.canvasHeight; | |
document.addEventListener("keydown", this.handleKeyDown); | |
document.addEventListener("keyup", this.handleKeyUp); | |
// Initialize with game view | |
this.sendButton.addEventListener("click", this.handleSendMessage); | |
this.dialogueContinueButton.addEventListener("click", this.handleDialogueContinue); | |
this.dialogueNextButton.addEventListener("click", this.handleDialogueNext); | |
this.playAgainBtn.addEventListener("click", this.handlePlayAgain); | |
this.microphoneButton.addEventListener("click", this.handleMicrophone); | |
if (firstRun) { | |
this.startGameBtn.addEventListener("click", this.handleFirstStartGame); | |
this.switchView("intro"); | |
} else { | |
if (this.currentView !== "game") { | |
this.switchView("game"); | |
} | |
this.run(); | |
this.shyguySprite.setTarget(this.targets.exit); | |
} | |
} | |
async handleFirstStartGame() { | |
this.switchView("dialogue"); | |
this.leftCharacterImg.src = "/assets/assets/wingman.jpeg"; | |
this.rightCharacterImg.src = "/assets/assets/shyguy_headshot.jpeg"; | |
this.showCharacterImages(); | |
this.hideContinueButton(); | |
for (const introMessage of GameEngine.introMessages) { | |
const { message, character } = introMessage; | |
this.addChatMessage(this.dialogueChatContainer, message, character, true); | |
if (this.voiceEnabled) { | |
await this.elevenLabsClient.playAudioForCharacter(character, message); | |
} else { | |
await new Promise((resolve) => setTimeout(resolve, 1000)); | |
} | |
} | |
this.showNextButton(); | |
} | |
showNextButton() { | |
if (this.dialogueNextButton) { | |
this.dialogueNextButton.style.display = "block"; | |
} | |
} | |
hideNextButton() { | |
if (this.dialogueNextButton) { | |
this.dialogueNextButton.style.display = "none"; | |
} | |
} | |
handleDialogueNext() { | |
this.clearChat(this.dialogueChatContainer); | |
this.leftCharacterImg.src = ""; | |
this.rightCharacterImg.src = ""; | |
this.hideCharacterImages(); | |
this.hideNextButton(); | |
this.showContinueButton(); | |
this.handleStartGame(); | |
} | |
async handleStartGame() { | |
this.switchView("game"); | |
this.playBackgroundMusic(); | |
this.run(); | |
this.shyguySprite.setTarget(this.targets.exit); | |
} | |
setResetCallback(func) { | |
this.resetCallback = func; | |
} | |
resetGame() { | |
if (this.resetCallback) { | |
this.resetCallback(); | |
} | |
} | |
initBackgroundGridMap() { | |
for (let row = 0; row < this.gridRows; row++) { | |
this.backgroundGridMap[row] = []; | |
for (let col = 0; col < this.gridCols; col++) { | |
// Set walls and obstacles (in future) | |
if (row === 0 || row === this.gridRows - 1 || col === 0 || col === this.gridCols - 1) { | |
this.backgroundGridMap[row][col] = this.gridMapTypes.wall; | |
} else { | |
this.backgroundGridMap[row][col] = this.gridMapTypes.floor; | |
} | |
} | |
} | |
this.backgroundGridMap[0][1] = this.gridMapTypes.door; | |
} | |
checkWallCollision(sprite, newX, newY) { | |
const x = newX; | |
const y = newY; | |
// For a sprite twice as big as grid, divide by half the sprite width/height | |
const gridX = Math.floor(x / (sprite.width * 1.33)); | |
const gridY = Math.floor(y / (sprite.height / 2)); | |
// Check all grid cells the sprite overlaps | |
// For a sprite twice as big, it can overlap up to 4 cells | |
for (let row = gridY; row <= Math.floor((y + sprite.height) / (sprite.height / 2)); row++) { | |
for (let col = gridX; col <= Math.floor((x + sprite.width) / (sprite.width * 1.33)); col++) { | |
if (row >= 0 && row < this.gridRows && col >= 0 && col < this.gridCols) { | |
if (this.backgroundGridMap[row][col] === this.gridMapTypes.wall) { | |
return true; | |
} | |
} | |
} | |
} | |
return false; | |
} | |
checkSpriteCollision(newX, newY, sprite1, sprite2) { | |
return ( | |
newX < sprite2.x + sprite2.width && | |
newX + sprite1.width > sprite2.x && | |
newY < sprite2.y + sprite2.height && | |
newY + sprite1.height > sprite2.y | |
); | |
} | |
handleSpriteCollision(sprite1, sprite2) { | |
if (!this.pushEnabled) { | |
return true; // Return true to block movement as before | |
} | |
// Calculate velocity difference | |
let dx = 0; | |
let dy = 0; | |
if (this.keys.ArrowUp) dy = -sprite1.speed; | |
else if (this.keys.ArrowDown) dy = sprite1.speed; | |
else if (this.keys.ArrowLeft) dx = -sprite1.speed; | |
else if (this.keys.ArrowRight) dx = sprite1.speed; | |
// If arrow player isn't moving, stop button player | |
if (dx === 0 && dy === 0) { | |
return true; | |
} | |
// Calculate effective push speed (difference in velocities) | |
const pushSpeed = Math.max(0, sprite1.speed - sprite2.speed); | |
// If arrow player is faster, push button player | |
if (pushSpeed > 0) { | |
let newX = sprite2.x + (dx !== 0 ? dx : 0); | |
let newY = sprite2.y + (dy !== 0 ? dy : 0); | |
// Only apply the push if it won't result in a wall collision | |
if (!this.checkWallCollision(sprite2, newX, newY)) { | |
sprite2.x = newX; | |
sprite2.y = newY; | |
} | |
} | |
return true; // Still prevent arrow player from moving through button player | |
} | |
updateGuidedSprite() { | |
if (!this.shyguySprite.target) return; | |
const dx = this.shyguySprite.target.x - this.shyguySprite.x; | |
const dy = this.shyguySprite.target.y - this.shyguySprite.y; | |
const distance = Math.sqrt(dx * dx + dy * dy); | |
const moveX = (dx / distance) * this.shyguySprite.speed; | |
const moveY = (dy / distance) * this.shyguySprite.speed; | |
let newX = this.shyguySprite.x + moveX; | |
let newY = this.shyguySprite.y + moveY; | |
// Check wall collision first | |
if (!this.checkWallCollision(this.shyguySprite, newX, newY)) { | |
const willCollide = this.checkSpriteCollision(newX, newY, this.shyguySprite, this.wingmanSprite); | |
if (willCollide) { | |
if (this.pushEnabled) { | |
// Push mechanics enabled - try to push wingman | |
const pushSpeed = Math.max(0, this.shyguySprite.speed - this.wingmanSprite.speed); | |
if (pushSpeed > 0) { | |
let wingmanNewX = this.wingmanSprite.x + moveX; | |
let wingmanNewY = this.wingmanSprite.y + moveY; | |
if (!this.checkWallCollision(this.wingmanSprite, wingmanNewX, wingmanNewY)) { | |
this.wingmanSprite.x = wingmanNewX; | |
this.wingmanSprite.y = wingmanNewY; | |
this.shyguySprite.x = newX; | |
this.shyguySprite.y = newY; | |
this.shyguySprite.moving = true; | |
} | |
} | |
} | |
// If push is disabled or push failed, try to path around | |
if (this.shyguySprite.x === newX && this.shyguySprite.y === newY) { | |
const leftPath = { x: newX - this.wingmanSprite.width, y: newY }; | |
const rightPath = { x: newX + this.wingmanSprite.width, y: newY }; | |
const upPath = { x: newX, y: newY - this.wingmanSprite.height }; | |
const downPath = { x: newX, y: newY + this.wingmanSprite.height }; | |
const paths = [leftPath, rightPath, upPath, downPath]; | |
let bestPath = null; | |
let bestDistance = Infinity; | |
for (const path of paths) { | |
if ( | |
!this.checkWallCollision(this.shyguySprite, path.x, path.y) && | |
!this.checkSpriteCollision(path.x, path.y, this.shyguySprite, this.wingmanSprite) | |
) { | |
const pathDistance = Math.sqrt( | |
Math.pow(this.shyguySprite.target.x - path.x, 2) + Math.pow(this.shyguySprite.target.y - path.y, 2) | |
); | |
if (pathDistance < bestDistance) { | |
bestDistance = pathDistance; | |
bestPath = path; | |
} | |
} | |
} | |
if (bestPath) { | |
this.shyguySprite.x = bestPath.x; | |
this.shyguySprite.y = bestPath.y; | |
this.shyguySprite.moving = true; | |
} | |
} | |
} else { | |
// No collision, proceed normally | |
this.shyguySprite.x = newX; | |
this.shyguySprite.y = newY; | |
this.shyguySprite.moving = true; | |
} | |
} | |
} | |
updateSprite() { | |
let newX = this.wingmanSprite.x; | |
let newY = this.wingmanSprite.y; | |
let isMoving = false; | |
if (this.keys.ArrowUp) { | |
newY -= this.wingmanSprite.speed; | |
isMoving = true; | |
} | |
if (this.keys.ArrowDown) { | |
newY += this.wingmanSprite.speed; | |
isMoving = true; | |
} | |
if (this.keys.ArrowLeft) { | |
newX -= this.wingmanSprite.speed; | |
this.wingmanSprite.frameY = 0; // left | |
isMoving = true; | |
} | |
if (this.keys.ArrowRight) { | |
newX += this.wingmanSprite.speed; | |
this.wingmanSprite.frameY = 1; // right | |
isMoving = true; | |
} | |
// Check wall collision first | |
if (!this.checkWallCollision(this.wingmanSprite, newX, newY)) { | |
// Check collision with shyguy | |
const willCollide = this.checkSpriteCollision(newX, newY, this.wingmanSprite, this.shyguySprite); | |
if (willCollide) { | |
if (this.pushEnabled) { | |
// Try to push shyguy if push is enabled | |
this.handleSpriteCollision(this.wingmanSprite, this.shyguySprite); | |
} | |
// If push is disabled or push failed, don't move | |
return; | |
} | |
// No collision, proceed with movement | |
this.wingmanSprite.x = newX; | |
this.wingmanSprite.y = newY; | |
} | |
this.wingmanSprite.moving = isMoving; | |
} | |
handleKeyDown(e) { | |
if (e.key in this.keys) { | |
this.keys[e.key] = true; | |
this.wingmanSprite.moving = true; | |
} else if (e.key === "Enter" && this.currentView === "game" && !e.shiftKey) { | |
e.preventDefault(); | |
this.handleSendMessage(); | |
} | |
} | |
handleKeyUp(e) { | |
if (e.key in this.keys) { | |
this.keys[e.key] = false; | |
this.wingmanSprite.moving = Object.values(this.keys).some((key) => key); | |
} | |
} | |
setNewTarget(target) { | |
if (target && target.enabled) { | |
this.shyguySprite.setTarget(target); | |
this.updateGuidedSpriteDirection(this.shyguySprite); | |
} | |
if (!target) { | |
this.shyguySprite.setTarget(null); | |
} | |
} | |
checkTargetReached(sprite, target) { | |
// Check if sprite overlaps with target using AABB collision detection | |
const spriteLeft = sprite.x; | |
const spriteRight = sprite.x + sprite.width; | |
const spriteTop = sprite.y; | |
const spriteBottom = sprite.y + sprite.height; | |
const targetLeft = target.x; | |
const targetRight = target.x + target.width; | |
const targetTop = target.y; | |
const targetBottom = target.y + target.height; | |
// Check for overlap on both x and y axes | |
const xOverlap = spriteRight >= targetLeft && spriteLeft <= targetRight; | |
const yOverlap = spriteBottom >= targetTop && spriteTop <= targetBottom; | |
return xOverlap && yOverlap; | |
} | |
updateGuidedSpriteDirection(sprite) { | |
if (!sprite.target) return; | |
const dx = sprite.target.x - sprite.x; | |
// Update direction based only on horizontal movement | |
if (dx !== 0) { | |
sprite.frameY = dx > 0 ? 1 : 0; // 0 for right, 1 for left | |
} | |
} | |
updateSpriteAnimation(sprite) { | |
if (sprite.moving) { | |
if (this.gameFrame % sprite.frameRate === 0) { | |
sprite.frameX = (sprite.frameX + 1) % sprite.frameCount; | |
} | |
} else { | |
sprite.frameX = 0; | |
} | |
} | |
async update() { | |
this.gameFrame++; | |
// Update Shyguy position | |
if (this.shyguySprite.target && this.shyguySprite.target.enabled) { | |
this.updateGuidedSprite(this.shyguySprite); | |
if (this.shyguySprite.moving) { | |
this.updateSpriteAnimation(this.shyguySprite); | |
} | |
} | |
// update Wingman position | |
this.updateSprite(this.wingmanSprite); | |
if (this.wingmanSprite.moving) { | |
this.updateSpriteAnimation(this.wingmanSprite); | |
} | |
for (const target of Object.values(this.targets)) { | |
const isClose = this.checkTargetReached(this.shyguySprite, target); | |
// TODO: reenable the target so the player can visit it again | |
if (!target.enabled) { | |
if (!isClose) { | |
target.enabled = true; | |
} | |
continue; | |
} | |
if (isClose) { | |
// pause the game | |
target.enabled = false; | |
this.stopShyguyAnimation(target); | |
if (target.label === EXIT_LABEL) { | |
this.gameOver = true; | |
this.gameSuccessful = false; | |
this.setGameOver(true); | |
this.switchView("gameOver"); | |
} else { | |
await this.handleDialogueWithStoryEngine(target.label); | |
} | |
break; | |
} | |
} | |
} | |
async handleDialogueWithStoryEngine(label) { | |
this.switchView("dialogue"); | |
this.hideContinueButton(); | |
// Show loading indicator | |
const dialogueBox = document.querySelector(".dialogue-box"); | |
dialogueBox.classList.add("loading"); | |
const response = await this.storyEngine.onEncounter(label); | |
// Hide loading indicator | |
dialogueBox.classList.remove("loading"); | |
// Update character images using class properties | |
if (this.leftCharacterImg && response.char2imgpath) { | |
this.leftCharacterImg.src = response.char2imgpath; | |
this.leftCharacterImg.style.display = "block"; | |
} | |
if (this.rightCharacterImg && response.char1imgpath) { | |
this.rightCharacterImg.src = response.char1imgpath; | |
this.rightCharacterImg.style.display = "block"; | |
} | |
const conversation = response.conversation; | |
// TODO: set the images if they are available | |
for (const message of conversation) { | |
const { role, content } = message; | |
const label = nameToLabel(role); | |
this.addChatMessage(this.dialogueChatContainer, content, label, true); | |
// Only play audio if voice is enabled | |
if (this.voiceEnabled) { | |
try { | |
this.lowerMusicVolumeALot(); | |
await this.elevenLabsClient.playAudioForCharacter(label, content); | |
this.restoreMusicVolume(); | |
} catch (error) { | |
console.error("Error playing audio:", label); | |
} | |
} | |
} | |
if (response.gameSuccesful) { | |
this.gameOver = true; | |
this.gameSuccessful = true; | |
} else if (response.gameOver) { | |
this.gameOver = true; | |
this.gameSuccessful = false; | |
} else { | |
this.gameOver = false; | |
this.gameSuccessful = false; | |
} | |
this.showContinueButton(); | |
} | |
stopShyguyAnimation(target) { | |
this.shyguySprite.moving = false; | |
this.shyguySprite.frameX = 0; | |
this.shyguySprite.target = null; | |
} | |
draw() { | |
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); | |
// Draw grid map | |
for (let row = 0; row < this.gridRows; row++) { | |
for (let col = 0; col < this.gridCols; col++) { | |
const x = col * this.wall.width; | |
const y = row * this.wall.height; | |
if (this.backgroundGridMap[row][col] === this.gridMapTypes.wall) { | |
this.ctx.drawImage(this.wall.image, x, y, this.wall.width, this.wall.height); | |
} else if (this.backgroundGridMap[row][col] === this.gridMapTypes.floor) { | |
this.ctx.drawImage(this.floor.image, x, y, this.floor.width, this.floor.height); | |
} else if (this.backgroundGridMap[row][col] === this.gridMapTypes.door) { | |
this.ctx.drawImage(this.door.image, x, y, this.door.width, this.door.height); | |
} | |
} | |
} | |
this.drawTargetSprite(this.jessicaSprite, this.targets.girl); | |
this.drawTargetSprite(this.barSprite, this.targets.bar); | |
this.drawTargetSprite(this.djSprite, this.targets.dj); | |
this.drawTargetSprite(this.sisterSprite, this.targets.sister); | |
// Draw shyguy | |
this.ctx.drawImage( | |
this.shyguySprite.image, | |
this.shyguySprite.frameX * this.shyguySprite.width, | |
this.shyguySprite.frameY * this.shyguySprite.height, | |
this.shyguySprite.width, | |
this.shyguySprite.height, | |
this.shyguySprite.x, | |
this.shyguySprite.y, | |
this.shyguySprite.width, | |
this.shyguySprite.height | |
); | |
// Draw wingman | |
this.ctx.drawImage( | |
this.wingmanSprite.image, | |
this.wingmanSprite.frameX * this.wingmanSprite.width, | |
this.wingmanSprite.frameY * this.wingmanSprite.height, | |
this.wingmanSprite.width, | |
this.wingmanSprite.height, | |
this.wingmanSprite.x, | |
this.wingmanSprite.y, | |
this.wingmanSprite.width, | |
this.wingmanSprite.height | |
); | |
} | |
drawTargetSprite(sprite, target) { | |
this.ctx.drawImage(sprite.image, target.x, target.y, target.width, target.height); | |
} | |
switchView(viewName) { | |
if (viewName === this.currentView) return; | |
this.currentView = viewName; | |
// Hide all views first | |
this.introView.classList.remove("active"); | |
this.gameView.classList.remove("active"); | |
this.dialogueView.classList.remove("active"); | |
this.gameOverView.classList.remove("active"); | |
// Show the requested view | |
switch (viewName) { | |
case "intro": | |
this.introView.classList.add("active"); | |
break; | |
case "game": | |
this.gameView.classList.add("active"); | |
break; | |
case "dialogue": | |
this.dialogueView.classList.add("active"); | |
break; | |
case "gameOver": | |
this.gameOverView.classList.add("active"); | |
break; | |
} | |
} | |
enablePush() { | |
this.pushEnabled = true; | |
} | |
disablePush() { | |
this.pushEnabled = false; | |
} | |
initDebugControls() { | |
const debugControls = document.getElementById("debugControls"); | |
if (!IS_DEBUG) { | |
if (debugControls) { | |
debugControls.style.display = "none"; | |
} | |
return; | |
} | |
const targetDoorBtn = document.getElementById("targetDoorBtn"); | |
const targetGirlBtn = document.getElementById("targetGirlBtn"); | |
const targetBarBtn = document.getElementById("targetBarBtn"); | |
const targetDjBtn = document.getElementById("targetDjBtn"); | |
const targetSisterBtn = document.getElementById("targetSisterBtn"); | |
const stopNavBtn = document.getElementById("stopNavBtn"); | |
const togglePushBtn = document.getElementById("togglePushBtn"); | |
const speedBoostBtn = document.getElementById("speedBoostBtn"); | |
const toggleVoiceBtn = document.getElementById("toggleVoiceBtn"); | |
targetDoorBtn.addEventListener("click", () => this.setNewTarget(this.targets.exit)); | |
targetGirlBtn.addEventListener("click", () => this.setNewTarget(this.targets.girl)); | |
targetBarBtn.addEventListener("click", () => this.setNewTarget(this.targets.bar)); | |
targetDjBtn.addEventListener("click", () => this.setNewTarget(this.targets.dj)); | |
targetSisterBtn.addEventListener("click", () => this.setNewTarget(this.targets.sister)); | |
stopNavBtn.addEventListener("click", () => this.setNewTarget(null)); | |
// Add push mechanics toggle | |
togglePushBtn.addEventListener("click", () => { | |
if (this.pushEnabled) { | |
this.disablePush(); | |
} else { | |
this.enablePush(); | |
} | |
togglePushBtn.textContent = this.pushEnabled ? "Disable Push" : "Enable Push"; | |
}); | |
// Add speed boost toggle | |
speedBoostBtn.addEventListener("click", () => { | |
if (this.shyguySprite.speed === SHYGUY_SPEED) { | |
this.shyguySprite.setSpeed(10); | |
speedBoostBtn.textContent = "Normal Speed"; | |
} else { | |
this.shyguySprite.setSpeed(SHYGUY_SPEED); | |
speedBoostBtn.textContent = "Speed Boost"; | |
} | |
}); | |
// Add voice toggle handler | |
toggleVoiceBtn.addEventListener("click", () => { | |
this.voiceEnabled = !this.voiceEnabled; | |
toggleVoiceBtn.textContent = this.voiceEnabled ? "Disable Voice" : "Enable Voice"; | |
}); | |
} | |
// Update status text | |
updateStatus(message) { | |
const statusText = document.getElementById("statusText"); | |
if (statusText) { | |
statusText.textContent = message; | |
} | |
} | |
clearChat(container) { | |
if (container) { | |
container.innerHTML = ""; | |
} | |
} | |
addChatMessage(container, message, character, shyguyIsMain) { | |
if (!container) return; | |
const isMain = shyguyIsMain ? character === SHYGUY_LABEL : character !== SHYGUY_LABEL; | |
const messageDiv = document.createElement("div"); | |
messageDiv.className = `chat-message ${isMain ? "right-user" : "left-user"}`; | |
const bubble = document.createElement("div"); | |
bubble.className = "message-bubble"; | |
bubble.textContent = message; | |
messageDiv.appendChild(bubble); | |
container.appendChild(messageDiv); | |
// Auto scroll to bottom | |
container.scrollTop = container.scrollHeight; | |
} | |
resolveAction(action) { | |
// TODO: resolve the action | |
switch (action) { | |
case "stay_idle": | |
this.setNewTarget(null); | |
break; | |
case "go_bar": | |
this.setNewTarget(this.targets.bar); | |
break; | |
case "go_dj": | |
this.setNewTarget(this.targets.dj); | |
break; | |
case "go_sister": | |
this.setNewTarget(this.targets.sister); | |
break; | |
case "go_girl": | |
this.setNewTarget(this.targets.girl); | |
break; | |
case "go_home": | |
this.setNewTarget(this.targets.exit); | |
break; | |
default: | |
break; | |
} | |
} | |
async sendMessageToShyguy(message) { | |
this.addChatMessage(this.gameChatContainer, message, WINGMAN_LABEL, false); | |
this.messageInput.value = ""; | |
this.shyguyLLM.getShyGuyResponse(message).then(async (response) => { | |
const dialogue = response.dialogue; | |
const action = response.action; | |
this.addChatMessage(this.gameChatContainer, dialogue, SHYGUY_LABEL, false); | |
// Only play audio if voice is enabled | |
if (this.voiceEnabled) { | |
this.disableGameInput(); | |
this.lowerMusicVolumeALot(); | |
await this.elevenLabsClient.playAudioForCharacter(SHYGUY_LABEL, dialogue); | |
this.enableGameInput(); | |
this.restoreMusicVolume(); | |
} | |
// TODO: save conversation history | |
await this.shyguy.learnFromWingman(message); | |
console.log("[ShyguyLLM]: Next action: ", action); | |
this.resolveAction(action); | |
}); | |
} | |
async handleSendMessage() { | |
const message = this.messageInput.value.trim(); | |
if (message.length === 0) return; | |
this.sendMessageToShyguy(message); | |
} | |
async run() { | |
// wait for 16ms | |
await new Promise((resolve) => setTimeout(resolve, 16)); | |
await this.update(); | |
this.draw(); | |
if (this.shouldContinue) { | |
requestAnimationFrame(this.run); | |
} | |
} | |
handlePlayAgain() { | |
this.clearChat(this.gameChatContainer); | |
this.resetGame(); | |
this.switchView("game"); | |
} | |
async handleMicrophone() { | |
if (!this.isRecording) { | |
// Start recording | |
this.isRecording = true; | |
this.microphoneButton.classList.add("recording"); | |
this.microphoneButton.innerHTML = '<i class="fas fa-stop"></i>'; | |
// Lower music volume while recording | |
this.lowerMusicVolumeALot(); | |
await this.speechToTextClient.startRecording(); | |
} else { | |
// Stop recording | |
this.isRecording = false; | |
this.microphoneButton.classList.remove("recording"); | |
this.microphoneButton.innerHTML = '<i class="fas fa-microphone"></i>'; | |
const result = await this.speechToTextClient.stopRecording(); | |
// Restore music volume after recording | |
this.restoreMusicVolume(); | |
this.sendMessageToShyguy(result.text); | |
} | |
} | |
showContinueButton() { | |
this.dialogueContinueButton.style.display = "block"; | |
} | |
hideContinueButton() { | |
this.dialogueContinueButton.style.display = "none"; | |
} | |
setGameOver(fromExit) { | |
this.stopBackgroundMusic(); | |
if (this.gameSuccessful) { | |
this.gameOverImage.src = "assets/assets/victory.png"; | |
this.playVictoryMusic(); | |
} else { | |
this.gameOverImage.src = "assets/assets/game-over.png"; | |
this.playGameOverMusic(); | |
} | |
if (fromExit) { | |
this.gameOverText.textContent = "You lost! Shyguy ran away!"; | |
return; | |
} | |
this.gameOverText.textContent = this.gameSuccessful | |
? "You won! Shyguy got a date!" | |
: "You lost! Shyguy got rejected!"; | |
} | |
handleDialogueContinue() { | |
this.clearChat(this.dialogueChatContainer); | |
// Hide character images | |
const leftCharacterImg = document.getElementById("leftCharacterImg"); | |
const rightCharacterImg = document.getElementById("rightCharacterImg"); | |
if (leftCharacterImg) { | |
leftCharacterImg.style.display = "none"; | |
} | |
if (rightCharacterImg) { | |
rightCharacterImg.style.display = "none"; | |
} | |
// decide if game is over | |
if (this.gameOver) { | |
this.setGameOver(false); | |
this.switchView("gameOver"); | |
return; | |
} | |
// Enable push if shyguy has had at least one beer | |
if (this.shyguy.num_beers > 0) { | |
this.enablePush(); | |
} | |
this.switchView("game"); | |
this.shyguyLLM.getShyGuyResponse("").then((response) => { | |
const next_action = response.action; | |
this.resolveAction(next_action); | |
}); | |
} | |
disableGameInput() { | |
this.sendButton.setAttribute("disabled", ""); | |
this.microphoneButton.setAttribute("disabled", ""); | |
this.messageInput.setAttribute("disabled", ""); | |
} | |
enableGameInput() { | |
this.sendButton.removeAttribute("disabled"); | |
this.microphoneButton.removeAttribute("disabled"); | |
this.messageInput.removeAttribute("disabled"); | |
} | |
playBackgroundMusic() { | |
this.backgroundMusic.play().catch((error) => { | |
console.error("Error playing background music:", error); | |
}); | |
} | |
stopBackgroundMusic() { | |
this.backgroundMusic.pause(); | |
this.backgroundMusic.currentTime = 0; | |
} | |
playGameOverMusic() { | |
this.gameOverMusic.play().catch((error) => { | |
console.error("Error playing game over music:", error); | |
}); | |
} | |
playVictoryMusic() { | |
this.victoryMusic.play().catch((error) => { | |
console.error("Error playing victory music:", error); | |
}); | |
} | |
stopAllMusic() { | |
this.stopBackgroundMusic(); | |
this.gameOverMusic.pause(); | |
this.gameOverMusic.currentTime = 0; | |
this.victoryMusic.pause(); | |
this.victoryMusic.currentTime = 0; | |
} | |
lowerMusicVolume() { | |
// Store original volumes if not already stored | |
if (!this.originalVolumes) { | |
this.originalVolumes = { | |
background: this.backgroundMusic.volume, | |
gameOver: this.gameOverMusic.volume, | |
victory: this.victoryMusic.volume, | |
}; | |
} | |
// Lower all music volumes to 20% of their original values | |
this.backgroundMusic.volume = this.originalVolumes.background * 0.2; | |
this.gameOverMusic.volume = this.originalVolumes.gameOver * 0.2; | |
this.victoryMusic.volume = this.originalVolumes.victory * 0.2; | |
} | |
lowerMusicVolumeALot() { | |
// Store original volumes if not already stored | |
if (!this.originalVolumes) { | |
this.originalVolumes = { | |
background: this.backgroundMusic.volume, | |
gameOver: this.gameOverMusic.volume, | |
victory: this.victoryMusic.volume, | |
}; | |
} | |
// Lower all music volumes to 20% of their original values | |
this.backgroundMusic.volume = this.originalVolumes.background * 0.01; | |
this.gameOverMusic.volume = this.originalVolumes.gameOver * 0.01; | |
this.victoryMusic.volume = this.originalVolumes.victory * 0.01; | |
} | |
restoreMusicVolume() { | |
// Restore original volumes if they exist | |
if (this.originalVolumes) { | |
this.backgroundMusic.volume = this.originalVolumes.background * 0.2; | |
this.gameOverMusic.volume = this.originalVolumes.gameOver * 0.2; | |
this.victoryMusic.volume = this.originalVolumes.victory * 0.2; | |
} | |
} | |
} | |