Spaces:
Running
Running
| 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; | |
| } | |
| } | |
| } | |