- Added proper boundary margins (60px) for whale size and bobbing - Clamps whale position to bounds when hitting edges - Uses Math.abs() for reliable direction reversal - Properly flips whale sprite when bouncing - Bounds: X: 60-740, Y: 120-550 - Whale can no longer escape off screen 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
620 lines
19 KiB
JavaScript
620 lines
19 KiB
JavaScript
import Phaser from 'phaser';
|
|
|
|
export default class HuntingScene extends Phaser.Scene {
|
|
constructor() {
|
|
super({ key: 'HuntingScene' });
|
|
}
|
|
|
|
init(data) {
|
|
this.inventory = data.inventory || { whaleOil: 0, fuel: 100, penguins: 0 };
|
|
this.whalesHunted = 0;
|
|
this.harpoons = [];
|
|
this.currentWhale = null;
|
|
this.crosshairX = 400;
|
|
this.crosshairY = 300;
|
|
this.crosshairSpeed = 5;
|
|
this.crosshairShakeAmount = 2; // Pixels of shake
|
|
this.crosshairShakeTime = 0; // Timer for smooth shake oscillation
|
|
this.useKeyboard = false; // Toggle between mouse and keyboard
|
|
}
|
|
|
|
create() {
|
|
// Ocean background
|
|
this.add.rectangle(400, 300, 800, 600, 0x1e5a8e);
|
|
this.createOceanWaves();
|
|
|
|
// UI elements
|
|
this.createHUD();
|
|
this.createCrosshair();
|
|
this.createInstructions();
|
|
|
|
// Spawn single whale
|
|
this.spawnWhale();
|
|
|
|
// Input setup
|
|
this.setupInput();
|
|
|
|
// Setup ocean movement (camera sway)
|
|
this.setupOceanMovement();
|
|
|
|
// Message display
|
|
this.showMessage('Hunt whales! Click or press SPACE to shoot harpoon.');
|
|
}
|
|
|
|
update() {
|
|
// Update crosshair position based on input mode
|
|
if (this.useKeyboard) {
|
|
this.updateCrosshairKeyboard();
|
|
} else {
|
|
this.updateCrosshairMouse();
|
|
}
|
|
|
|
// Update crosshair sprite position with smooth shake
|
|
this.crosshairShakeTime += 0.15; // Increment for smooth oscillation
|
|
const shakeX = Math.sin(this.crosshairShakeTime * 3) * this.crosshairShakeAmount;
|
|
const shakeY = Math.cos(this.crosshairShakeTime * 2.5) * this.crosshairShakeAmount;
|
|
this.crosshair.setPosition(this.crosshairX + shakeX, this.crosshairY + shakeY);
|
|
|
|
// Update harpoons
|
|
this.updateHarpoons();
|
|
|
|
// Update whale
|
|
this.updateWhale();
|
|
|
|
// Check collisions
|
|
this.checkCollisions();
|
|
}
|
|
|
|
setupInput() {
|
|
// Keyboard controls
|
|
this.cursors = this.input.keyboard.createCursorKeys();
|
|
this.wasd = this.input.keyboard.addKeys({
|
|
up: Phaser.Input.Keyboard.KeyCodes.W,
|
|
down: Phaser.Input.Keyboard.KeyCodes.S,
|
|
left: Phaser.Input.Keyboard.KeyCodes.A,
|
|
right: Phaser.Input.Keyboard.KeyCodes.D
|
|
});
|
|
this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
|
|
this.tabKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.TAB);
|
|
|
|
// Mouse click to shoot
|
|
this.input.on('pointerdown', (pointer) => {
|
|
if (!this.useKeyboard) {
|
|
this.shootHarpoon();
|
|
}
|
|
});
|
|
|
|
// Space to shoot when using keyboard
|
|
this.spaceKey.on('down', () => {
|
|
this.shootHarpoon();
|
|
});
|
|
|
|
// Tab to toggle control mode
|
|
this.tabKey.on('down', () => {
|
|
this.useKeyboard = !this.useKeyboard;
|
|
this.controlModeText.setText(`Controls: ${this.useKeyboard ? 'KEYBOARD' : 'MOUSE'} (TAB to switch)`);
|
|
if (this.useKeyboard) {
|
|
this.input.setDefaultCursor('default');
|
|
} else {
|
|
this.input.setDefaultCursor('none');
|
|
}
|
|
});
|
|
|
|
// Start with mouse control (hide cursor)
|
|
this.input.setDefaultCursor('none');
|
|
}
|
|
|
|
updateCrosshairMouse() {
|
|
this.crosshairX = this.input.x;
|
|
this.crosshairY = this.input.y;
|
|
}
|
|
|
|
updateCrosshairKeyboard() {
|
|
// Arrow keys or WASD
|
|
if (this.cursors.left.isDown || this.wasd.left.isDown) {
|
|
this.crosshairX -= this.crosshairSpeed;
|
|
}
|
|
if (this.cursors.right.isDown || this.wasd.right.isDown) {
|
|
this.crosshairX += this.crosshairSpeed;
|
|
}
|
|
if (this.cursors.up.isDown || this.wasd.up.isDown) {
|
|
this.crosshairY -= this.crosshairSpeed;
|
|
}
|
|
if (this.cursors.down.isDown || this.wasd.down.isDown) {
|
|
this.crosshairY += this.crosshairSpeed;
|
|
}
|
|
|
|
// Keep within bounds
|
|
this.crosshairX = Phaser.Math.Clamp(this.crosshairX, 0, 800);
|
|
this.crosshairY = Phaser.Math.Clamp(this.crosshairY, 0, 600);
|
|
}
|
|
|
|
createCrosshair() {
|
|
const graphics = this.add.graphics();
|
|
graphics.lineStyle(3, 0xff0000, 1);
|
|
|
|
// Crosshair lines
|
|
graphics.beginPath();
|
|
graphics.moveTo(-20, 0);
|
|
graphics.lineTo(-5, 0);
|
|
graphics.moveTo(5, 0);
|
|
graphics.lineTo(20, 0);
|
|
graphics.moveTo(0, -20);
|
|
graphics.lineTo(0, -5);
|
|
graphics.moveTo(0, 5);
|
|
graphics.lineTo(0, 20);
|
|
graphics.strokePath();
|
|
|
|
// Center circle
|
|
graphics.lineStyle(2, 0xff0000, 1);
|
|
graphics.strokeCircle(0, 0, 3);
|
|
|
|
// Convert to texture and create sprite
|
|
graphics.generateTexture('crosshair', 40, 40);
|
|
graphics.destroy();
|
|
|
|
this.crosshair = this.add.sprite(400, 300, 'crosshair');
|
|
this.crosshair.setDepth(100);
|
|
this.crosshair.setScrollFactor(0); // Fixed to screen, ignores camera movement
|
|
}
|
|
|
|
createOceanWaves() {
|
|
const graphics = this.add.graphics();
|
|
graphics.lineStyle(2, 0x4a90c4, 0.5);
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
const y = 50 + i * 50;
|
|
graphics.beginPath();
|
|
for (let x = 0; x < 800; x += 20) {
|
|
const waveY = y + Math.sin((x + i * 30) * 0.02) * 10;
|
|
if (x === 0) {
|
|
graphics.moveTo(x, waveY);
|
|
} else {
|
|
graphics.lineTo(x, waveY);
|
|
}
|
|
}
|
|
graphics.strokePath();
|
|
}
|
|
}
|
|
|
|
createHUD() {
|
|
// HUD background (fixed to screen, ignores camera movement)
|
|
const hudBg = this.add.rectangle(400, 30, 780, 50, 0x000000, 0.7);
|
|
hudBg.setStrokeStyle(2, 0xffffff);
|
|
hudBg.setScrollFactor(0);
|
|
|
|
// Stats (fixed to screen)
|
|
this.statsText = this.add.text(20, 15, '', {
|
|
fontSize: '16px',
|
|
fill: '#fff'
|
|
});
|
|
this.statsText.setScrollFactor(0);
|
|
|
|
// Control mode indicator (fixed to screen)
|
|
this.controlModeText = this.add.text(400, 15, 'Controls: MOUSE (TAB to switch)', {
|
|
fontSize: '16px',
|
|
fill: '#ffff00'
|
|
}).setOrigin(0.5, 0);
|
|
this.controlModeText.setScrollFactor(0);
|
|
|
|
// Return button (fixed to screen)
|
|
const returnBtn = this.add.rectangle(750, 30, 80, 35, 0x8B0000);
|
|
returnBtn.setInteractive({ useHandCursor: true });
|
|
returnBtn.setStrokeStyle(2, 0xffffff);
|
|
returnBtn.setScrollFactor(0);
|
|
|
|
const returnText = this.add.text(750, 30, 'RETURN', {
|
|
fontSize: '14px',
|
|
fill: '#fff'
|
|
}).setOrigin(0.5);
|
|
returnText.setScrollFactor(0);
|
|
|
|
returnBtn.on('pointerdown', () => {
|
|
this.returnToMap();
|
|
});
|
|
|
|
this.updateStats();
|
|
}
|
|
|
|
updateStats() {
|
|
this.statsText.setText([
|
|
`Fuel: ${this.inventory.fuel}/100`,
|
|
`Oil: ${this.inventory.whaleOil}/50`,
|
|
`Whales: ${this.whalesHunted}`
|
|
]);
|
|
}
|
|
|
|
createInstructions() {
|
|
this.messageText = this.add.text(400, 570, '', {
|
|
fontSize: '16px',
|
|
fill: '#fff',
|
|
backgroundColor: '#000000',
|
|
padding: { x: 10, y: 5 }
|
|
}).setOrigin(0.5);
|
|
this.messageText.setScrollFactor(0); // Fixed to screen
|
|
}
|
|
|
|
showMessage(text) {
|
|
this.messageText.setText(text);
|
|
}
|
|
|
|
spawnWhale() {
|
|
// Don't spawn if there's already a whale
|
|
if (this.currentWhale && this.currentWhale.getData('alive')) {
|
|
return;
|
|
}
|
|
|
|
// Spawn randomly all over the screen
|
|
const x = 100 + Math.random() * 600; // 100-700 (across screen with margins)
|
|
const y = 150 + Math.random() * 350; // 150-500 (across screen with margins)
|
|
const direction = Math.random() > 0.5 ? 1 : -1;
|
|
|
|
// Create whale body
|
|
const whale = this.add.container(x, y);
|
|
|
|
// Whale shape
|
|
const body = this.add.ellipse(0, 0, 80, 40, 0x2a2a2a);
|
|
const tail = this.add.triangle(
|
|
direction > 0 ? -45 : 45, 0,
|
|
0, -15,
|
|
0, 15,
|
|
direction > 0 ? -20 : 20, 0,
|
|
0x1a1a1a
|
|
);
|
|
const fin = this.add.triangle(
|
|
direction > 0 ? 20 : -20, -5,
|
|
direction > 0 ? 10 : -10, -25,
|
|
direction > 0 ? 30 : -30, -5,
|
|
0x1a1a1a
|
|
);
|
|
|
|
// Health bar background
|
|
const healthBg = this.add.rectangle(0, -35, 60, 6, 0x000000);
|
|
// Health bar (will be updated as whale takes damage)
|
|
const healthBar = this.add.rectangle(0, -35, 60, 6, 0x00ff00);
|
|
|
|
whale.add([body, tail, fin, healthBg, healthBar]);
|
|
whale.setData('direction', direction);
|
|
whale.setData('alive', true);
|
|
whale.setData('health', 3); // Requires 3 hits
|
|
whale.setData('maxHealth', 3);
|
|
whale.setData('bodyWidth', 80);
|
|
whale.setData('bodyHeight', 40);
|
|
whale.setData('healthBar', healthBar);
|
|
whale.setData('bobTime', 0); // Timer for bobbing animation
|
|
|
|
// Swimming movement
|
|
whale.setData('swimSpeedX', direction * (0.2 + Math.random() * 0.3)); // 0.2-0.5 speed
|
|
whale.setData('swimSpeedY', (Math.random() - 0.5) * 0.15); // Slight vertical drift
|
|
|
|
// Diving mechanics
|
|
whale.setData('diving', false);
|
|
whale.setData('diveTimer', 0);
|
|
whale.setData('nextDiveTime', 3 + Math.random() * 4); // Dive every 3-7 seconds
|
|
|
|
// Flip if moving right
|
|
if (direction > 0) {
|
|
whale.setScale(-1, 1);
|
|
}
|
|
|
|
this.currentWhale = whale;
|
|
}
|
|
|
|
updateWhale() {
|
|
if (!this.currentWhale) {
|
|
return;
|
|
}
|
|
|
|
const whale = this.currentWhale;
|
|
|
|
if (!whale.getData('alive')) {
|
|
return;
|
|
}
|
|
|
|
// Update dive timer
|
|
let diveTimer = whale.getData('diveTimer');
|
|
diveTimer += 0.016; // ~60fps
|
|
whale.setData('diveTimer', diveTimer);
|
|
|
|
// Handle diving behavior
|
|
const diving = whale.getData('diving');
|
|
const nextDiveTime = whale.getData('nextDiveTime');
|
|
|
|
if (!diving && diveTimer >= nextDiveTime) {
|
|
// Start diving
|
|
this.startDive(whale);
|
|
} else if (diving && diveTimer >= nextDiveTime + 2) {
|
|
// Resurface after 2 seconds
|
|
this.endDive(whale);
|
|
}
|
|
|
|
// Swimming movement
|
|
let swimSpeedX = whale.getData('swimSpeedX');
|
|
let swimSpeedY = whale.getData('swimSpeedY');
|
|
|
|
// Update position with swimming
|
|
whale.x += swimSpeedX;
|
|
whale.y += swimSpeedY;
|
|
|
|
// Bobbing animation (on top of swimming)
|
|
let bobTime = whale.getData('bobTime');
|
|
bobTime += 0.02;
|
|
whale.setData('bobTime', bobTime);
|
|
|
|
const bobOffsetX = Math.sin(bobTime) * 15;
|
|
const bobOffsetY = Math.cos(bobTime * 0.7) * 10;
|
|
|
|
whale.x += bobOffsetX;
|
|
whale.y += bobOffsetY;
|
|
|
|
// Keep whale within visible bounds
|
|
const margin = 60; // Account for whale size and bobbing
|
|
const minX = margin;
|
|
const maxX = 800 - margin;
|
|
const minY = 120;
|
|
const maxY = 550;
|
|
|
|
// Bounce off screen edges and clamp position
|
|
if (whale.x < minX) {
|
|
whale.x = minX;
|
|
whale.setData('swimSpeedX', Math.abs(swimSpeedX)); // Swim right
|
|
whale.setScale(-Math.abs(whale.scaleX), whale.scaleY); // Face right
|
|
} else if (whale.x > maxX) {
|
|
whale.x = maxX;
|
|
whale.setData('swimSpeedX', -Math.abs(swimSpeedX)); // Swim left
|
|
whale.setScale(Math.abs(whale.scaleX), whale.scaleY); // Face left
|
|
}
|
|
|
|
if (whale.y < minY) {
|
|
whale.y = minY;
|
|
whale.setData('swimSpeedY', Math.abs(swimSpeedY)); // Swim down
|
|
} else if (whale.y > maxY) {
|
|
whale.y = maxY;
|
|
whale.setData('swimSpeedY', -Math.abs(swimSpeedY)); // Swim up
|
|
}
|
|
|
|
// Update opacity based on diving
|
|
if (diving) {
|
|
whale.alpha = Math.max(0.2, whale.alpha - 0.05);
|
|
}
|
|
}
|
|
|
|
startDive(whale) {
|
|
whale.setData('diving', true);
|
|
this.showMessage('Whale is diving!');
|
|
}
|
|
|
|
endDive(whale) {
|
|
whale.setData('diving', false);
|
|
whale.setData('diveTimer', 0);
|
|
whale.setData('nextDiveTime', 3 + Math.random() * 4); // Next dive in 3-7 seconds
|
|
whale.alpha = 1; // Full opacity
|
|
this.showMessage('Whale surfaced!');
|
|
}
|
|
|
|
shootHarpoon() {
|
|
// Create harpoon at crosshair position (fixed to screen)
|
|
const harpoon = this.add.rectangle(this.crosshairX, this.crosshairY, 4, 20, 0x8B4513);
|
|
harpoon.setData('speed', 8);
|
|
harpoon.setData('active', true);
|
|
harpoon.setScrollFactor(0); // Fixed to screen
|
|
|
|
this.harpoons.push(harpoon);
|
|
|
|
// Sound effect simulation (flash) - fixed to screen
|
|
const flash = this.add.circle(this.crosshairX, this.crosshairY, 10, 0xffff00, 0.8);
|
|
flash.setScrollFactor(0);
|
|
this.tweens.add({
|
|
targets: flash,
|
|
alpha: 0,
|
|
scale: 2,
|
|
duration: 200,
|
|
onComplete: () => flash.destroy()
|
|
});
|
|
}
|
|
|
|
updateHarpoons() {
|
|
for (let i = this.harpoons.length - 1; i >= 0; i--) {
|
|
const harpoon = this.harpoons[i];
|
|
|
|
if (!harpoon.getData('active')) {
|
|
continue;
|
|
}
|
|
|
|
// Move harpoon up
|
|
harpoon.y -= harpoon.getData('speed');
|
|
|
|
// Remove if off screen
|
|
if (harpoon.y < -20) {
|
|
harpoon.destroy();
|
|
this.harpoons.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
checkCollisions() {
|
|
if (!this.currentWhale || !this.currentWhale.getData('alive')) {
|
|
return;
|
|
}
|
|
|
|
const whale = this.currentWhale;
|
|
|
|
// Can't hit whale while diving
|
|
if (whale.getData('diving')) {
|
|
return;
|
|
}
|
|
|
|
for (let h = this.harpoons.length - 1; h >= 0; h--) {
|
|
const harpoon = this.harpoons[h];
|
|
|
|
if (!harpoon.getData('active')) {
|
|
continue;
|
|
}
|
|
|
|
// Collision detection accounting for camera offset
|
|
// Harpoon is in screen space (scrollFactor 0)
|
|
// Whale is in world space, so convert to screen space
|
|
const whaleScreenX = whale.x - this.cameras.main.scrollX;
|
|
const whaleScreenY = whale.y - this.cameras.main.scrollY;
|
|
|
|
const distance = Phaser.Math.Distance.Between(
|
|
harpoon.x, harpoon.y,
|
|
whaleScreenX, whaleScreenY
|
|
);
|
|
|
|
if (distance < 40) {
|
|
// Hit!
|
|
this.hitWhale(whale, harpoon);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
hitWhale(whale, harpoon) {
|
|
harpoon.setData('active', false);
|
|
harpoon.destroy();
|
|
|
|
// Reduce whale health
|
|
let health = whale.getData('health');
|
|
health -= 1;
|
|
whale.setData('health', health);
|
|
|
|
// Update health bar
|
|
const healthBar = whale.getData('healthBar');
|
|
const maxHealth = whale.getData('maxHealth');
|
|
const healthPercent = health / maxHealth;
|
|
healthBar.setDisplaySize(60 * healthPercent, 6);
|
|
|
|
// Change health bar color based on health
|
|
if (healthPercent > 0.6) {
|
|
healthBar.setFillStyle(0x00ff00); // Green
|
|
} else if (healthPercent > 0.3) {
|
|
healthBar.setFillStyle(0xffff00); // Yellow
|
|
} else {
|
|
healthBar.setFillStyle(0xff0000); // Red
|
|
}
|
|
|
|
// Hit flash effect
|
|
const hitFlash = this.add.circle(whale.x, whale.y, 50, 0xff0000, 0.6);
|
|
this.tweens.add({
|
|
targets: hitFlash,
|
|
alpha: 0,
|
|
scale: 1.5,
|
|
duration: 300,
|
|
onComplete: () => hitFlash.destroy()
|
|
});
|
|
|
|
// Whale damaged animation (shake)
|
|
this.tweens.add({
|
|
targets: whale,
|
|
x: whale.x + 5,
|
|
yoyo: true,
|
|
repeat: 2,
|
|
duration: 50
|
|
});
|
|
|
|
if (health <= 0) {
|
|
// Whale is dead!
|
|
this.killWhale(whale);
|
|
} else {
|
|
// Whale is still alive
|
|
this.showMessage(`Hit! Whale health: ${health}/${maxHealth}`);
|
|
}
|
|
}
|
|
|
|
killWhale(whale) {
|
|
whale.setData('alive', false);
|
|
|
|
// Check if enough fuel to process the whale
|
|
if (this.inventory.fuel < 2) {
|
|
// Not enough fuel to cook the oil!
|
|
this.tweens.add({
|
|
targets: whale,
|
|
alpha: 0,
|
|
y: whale.y + 50,
|
|
duration: 1000,
|
|
onComplete: () => {
|
|
whale.destroy();
|
|
this.currentWhale = null;
|
|
// Spawn new whale
|
|
this.time.delayedCall(1500, () => {
|
|
this.spawnWhale();
|
|
});
|
|
}
|
|
});
|
|
|
|
this.showMessage('Whale killed but no fuel to process it! Need 2 fuel to cook whale oil.');
|
|
return;
|
|
}
|
|
|
|
// Whale death animation
|
|
this.tweens.add({
|
|
targets: whale,
|
|
alpha: 0,
|
|
y: whale.y + 50,
|
|
angle: 90,
|
|
duration: 1000,
|
|
onComplete: () => {
|
|
whale.destroy();
|
|
this.currentWhale = null;
|
|
// Spawn new whale after delay
|
|
this.time.delayedCall(1500, () => {
|
|
this.spawnWhale();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Consume fuel for processing
|
|
this.inventory.fuel -= 2;
|
|
|
|
// Reward
|
|
this.inventory.whaleOil += 1;
|
|
this.whalesHunted++;
|
|
this.updateStats();
|
|
|
|
// Show success message
|
|
this.showMessage(`Whale killed! +1 Whale Oil, -2 Fuel (Oil: ${this.inventory.whaleOil}, Fuel: ${this.inventory.fuel})`);
|
|
|
|
// Success flash (fixed to screen)
|
|
const flash = this.add.rectangle(400, 300, 800, 600, 0xffffff, 0.3);
|
|
flash.setScrollFactor(0);
|
|
this.tweens.add({
|
|
targets: flash,
|
|
alpha: 0,
|
|
duration: 300,
|
|
onComplete: () => flash.destroy()
|
|
});
|
|
}
|
|
|
|
setupOceanMovement() {
|
|
// Create continuous camera sway to simulate ocean movement
|
|
// This makes the whole scene move as if on a rocking ship
|
|
|
|
// Initial camera position offset
|
|
this.cameraTime = 0;
|
|
|
|
// Store this in the update loop by creating a timer
|
|
this.time.addEvent({
|
|
delay: 16, // ~60fps
|
|
callback: () => {
|
|
this.cameraTime += 0.1; // Faster oscillation (~1 second period)
|
|
|
|
// Smooth sine wave movement for natural ocean sway
|
|
const swayX = Math.sin(this.cameraTime) * 25; // 25 pixels horizontal sway
|
|
const swayY = Math.cos(this.cameraTime * 0.6) * 18; // 18 pixels vertical sway
|
|
|
|
// Apply to camera scroll
|
|
this.cameras.main.scrollX = swayX;
|
|
this.cameras.main.scrollY = swayY;
|
|
},
|
|
loop: true
|
|
});
|
|
}
|
|
|
|
returnToMap() {
|
|
// Restore default cursor before leaving
|
|
this.input.setDefaultCursor('default');
|
|
this.scene.start('MapScene', { inventory: this.inventory });
|
|
}
|
|
}
|