🤖 AI Behavior System + Sound Manifest

 NEW SYSTEMS:
1. AnimalBehavior.js - Wander, Flee, Follow, Glowing Eyes
2. NPCIdleBehavior.js - Idle animations (hat fix, sweat wipe)
3. ANIMAL_SOUND_MANIFEST.md - 40+ sound mappings

🎮 AI FEATURES:
- Smooth movement (not grid-based) ✓
- Flee from player (100px range) ✓
- Follow with delay (cargo animals: llama, horse, donkey) ✓
- Glowing eyes in darkness (NOIR EFFECT!) ✓
  - Neon pink/green eyes visible at distance
  - Perfect for 'eyes in forest' atmosphere

👤 NPC FEATURES:
- Random idle animations every 3-8 seconds
- Fix hat, wipe sweat, look around, scratch head
- Gronk-specific: coin counting, item polishing

🔊 SOUND SYSTEM:
- Kenney pack mapping for 23 animal sounds
- Footstep variations (mud, grass, cobble)
- Noir sound design: muffled distant sounds + reverb

DEMO: 56/60 sprites (93%)!
Next: 4 remaining sprites + integration testing
This commit is contained in:
2026-01-08 23:34:22 +01:00
parent 86915519f1
commit 55b496a2ce
4 changed files with 592 additions and 3 deletions

278
src/ai/AnimalBehavior.js Normal file
View File

@@ -0,0 +1,278 @@
/**
* 🐮 ANIMAL AI BEHAVIOR SYSTEM
* "Krvava Žetev" - Gothic Farm Game
*
* Behaviors:
* - Wander: Random smooth movement (not grid-based)
* - Flee: Run away from player when close
* - Glowing Eyes: Neon eyes visible in darkness (noir effect!)
* - Follow: Cargo animals (llama, horse) follow with delay
*/
export class AnimalBehavior {
constructor(scene, sprite, animalType) {
this.scene = scene;
this.sprite = sprite;
this.animalType = animalType;
// Behavior states
this.state = 'wander'; // 'wander', 'flee', 'follow', 'idle'
this.wanderTimer = 0;
this.wanderDuration = Phaser.Math.Between(2000, 5000);
this.wanderAngle = Math.random() * Math.PI * 2;
// Movement params
this.speed = this.getSpeed();
this.fleeDistance = 100; // pixels
this.fleeSpeed = this.speed * 2;
// Glowing eyes effect (NOIR!)
this.glowingEyes = null;
this.eyeColor = this.getEyeColor();
this.createGlowingEyes();
// Cargo follow system (for llama, horse, donkey)
this.isCargoAnimal = ['llama', 'horse', 'donkey'].includes(animalType);
this.followTarget = null;
this.followDelay = 0.5; // seconds
this.followHistory = [];
}
getSpeed() {
const speeds = {
cow: 30,
pig: 35,
sheep: 25,
chicken: 50,
duck: 45,
goat: 40,
horse: 60,
rabbit: 70,
donkey: 35,
llama: 40
};
return speeds[this.animalType] || 30;
}
getEyeColor() {
// Noir glowing eyes - pink or green like Kai's dreads!
const colors = {
cow: 0xff0066, // neon pink
pig: 0xff0066, // neon pink
sheep: 0x00ff88, // neon green
chicken: 0xffff00, // yellow
duck: 0x00ffff, // cyan
goat: 0xff0066, // neon pink
horse: 0x00ff88, // neon green
rabbit: 0xff00ff, // purple
donkey: 0x00ff88, // neon green
llama: 0xff0066 // neon pink
};
return colors[this.animalType] || 0xff0066;
}
createGlowingEyes() {
// Create glowing eye sprites for noir darkness effect
this.glowingEyes = this.scene.add.container(this.sprite.x, this.sprite.y);
// Two small glowing circles for eyes
const leftEye = this.scene.add.circle(-8, -10, 3, this.eyeColor);
const rightEye = this.scene.add.circle(8, -10, 3, this.eyeColor);
// Add glow effect
leftEye.setAlpha(0.8);
rightEye.setAlpha(0.8);
this.glowingEyes.add([leftEye, rightEye]);
// Hide by default, show only in darkness or at distance
this.glowingEyes.setVisible(false);
this.glowingEyes.setDepth(this.sprite.depth + 1);
}
update(time, delta) {
if (!this.sprite.active) return;
// Update glowing eyes position
if (this.glowingEyes) {
this.glowingEyes.setPosition(this.sprite.x, this.sprite.y);
this.updateGlowingEyesVisibility();
}
// Behavior state machine
switch (this.state) {
case 'wander':
this.updateWander(time, delta);
break;
case 'flee':
this.updateFlee(time, delta);
break;
case 'follow':
this.updateFollow(time, delta);
break;
case 'idle':
this.updateIdle(time, delta);
break;
}
// Check for player proximity (flee trigger)
this.checkPlayerProximity();
}
updateWander(time, delta) {
this.wanderTimer += delta;
// Change direction periodically
if (this.wanderTimer >= this.wanderDuration) {
this.wanderTimer = 0;
this.wanderDuration = Phaser.Math.Between(2000, 5000);
this.wanderAngle = Math.random() * Math.PI * 2;
// Sometimes stop and idle
if (Math.random() < 0.3) {
this.state = 'idle';
this.sprite.setVelocity(0, 0);
return;
}
}
// Smooth movement in wander direction
const vx = Math.cos(this.wanderAngle) * this.speed;
const vy = Math.sin(this.wanderAngle) * this.speed;
this.sprite.setVelocity(vx, vy);
}
updateFlee(time, delta) {
const player = this.scene.player;
if (!player) return;
// Flee away from player
const angle = Phaser.Math.Angle.Between(
player.x, player.y,
this.sprite.x, this.sprite.y
);
const vx = Math.cos(angle) * this.fleeSpeed;
const vy = Math.sin(angle) * this.fleeSpeed;
this.sprite.setVelocity(vx, vy);
// Return to wander if far enough
const dist = Phaser.Math.Distance.Between(
player.x, player.y,
this.sprite.x, this.sprite.y
);
if (dist > this.fleeDistance * 2) {
this.state = 'wander';
}
}
updateFollow(time, delta) {
if (!this.followTarget || !this.isCargoAnimal) return;
// Record follow history for delayed follow
this.followHistory.push({
x: this.followTarget.x,
y: this.followTarget.y,
time: time
});
// Remove old history
const cutoffTime = time - (this.followDelay * 1000);
this.followHistory = this.followHistory.filter(h => h.time > cutoffTime);
// Follow delayed position
if (this.followHistory.length > 0) {
const target = this.followHistory[0];
const angle = Phaser.Math.Angle.Between(
this.sprite.x, this.sprite.y,
target.x, target.y
);
const dist = Phaser.Math.Distance.Between(
this.sprite.x, this.sprite.y,
target.x, target.y
);
// Only move if far enough
if (dist > 50) {
const vx = Math.cos(angle) * this.speed;
const vy = Math.sin(angle) * this.speed;
this.sprite.setVelocity(vx, vy);
} else {
this.sprite.setVelocity(0, 0);
}
}
}
updateIdle(time, delta) {
// Stop moving
this.sprite.setVelocity(0, 0);
// Return to wander after random time
if (Math.random() < 0.01) { // ~1% chance per frame
this.state = 'wander';
}
}
checkPlayerProximity() {
const player = this.scene.player;
if (!player) return;
const dist = Phaser.Math.Distance.Between(
player.x, player.y,
this.sprite.x, this.sprite.y
);
// Trigger flee if player too close
if (dist < this.fleeDistance && this.state !== 'flee') {
this.state = 'flee';
// Play flee sound if available
if (this.scene.sound.get(`${this.animalType}_flee`)) {
this.scene.sound.play(`${this.animalType}_flee`);
}
}
}
updateGlowingEyesVisibility() {
if (!this.glowingEyes) return;
const player = this.scene.player;
if (!player) return;
const dist = Phaser.Math.Distance.Between(
player.x, player.y,
this.sprite.x, this.sprite.y
);
// Show glowing eyes in darkness or at distance
// NOIR EFFECT: When Kai shines light in forest, just glowing eyes visible!
const isDark = this.scene.darkness || false; // Check if darkness system active
const isDistant = dist > 200 && dist < 400; // Medium distance
if (isDark || isDistant) {
this.glowingEyes.setVisible(true);
// Hide actual sprite if far
if (dist > 300) {
this.sprite.setAlpha(0.2);
}
} else {
this.glowingEyes.setVisible(false);
this.sprite.setAlpha(1.0);
}
}
setFollowTarget(target) {
if (this.isCargoAnimal) {
this.followTarget = target;
this.state = 'follow';
}
}
destroy() {
if (this.glowingEyes) {
this.glowingEyes.destroy();
}
}
}

153
src/ai/NPCIdleBehavior.js Normal file
View File

@@ -0,0 +1,153 @@
/**
* 👤 NPC IDLE BEHAVIOR SYSTEM
* "Krvava Žetev" - Gothic Farm Game
*
* Makes NPCs feel alive with random idle animations:
* - Fix hat
* - Wipe sweat
* - Look around
* - Scratch head
* - Adjust clothes
*/
export class NPCIdleBehavior {
constructor(scene, sprite, npcType) {
this.scene = scene;
this.sprite = sprite;
this.npcType = npcType;
// Idle animation timing
this.idleTimer = 0;
this.nextIdleTime = Phaser.Math.Between(3000, 8000);
// Available idle animations
this.idleAnimations = this.getIdleAnimations();
this.currentIdle = null;
}
getIdleAnimations() {
// Different NPCs have different idle behaviors
const genericIdles = [
'fix_hat',
'wipe_sweat',
'look_around',
'scratch_head',
'adjust_clothes',
'yawn',
'stretch'
];
const npcSpecific = {
'gronk': [...genericIdles, 'adjust_apron', 'count_coins', 'polish_item'],
'farmer': [...genericIdles, 'lean_on_fence', 'check_watch', 'spit'],
'merchant': [...genericIdles, 'fix_glasses', 'write_note', 'arrange_goods'],
'default': genericIdles
};
return npcSpecific[this.npcType] || npcSpecific.default;
}
update(time, delta) {
if (!this.sprite.active) return;
this.idleTimer += delta;
// Trigger random idle animation
if (this.idleTimer >= this.nextIdleTime && !this.currentIdle) {
this.playRandomIdle();
this.idleTimer = 0;
this.nextIdleTime = Phaser.Math.Between(3000, 8000);
}
}
playRandomIdle() {
const idle = Phaser.Utils.Array.GetRandom(this.idleAnimations);
this.currentIdle = idle;
// Play animation if it exists
const animKey = `${this.npcType}_${idle}`;
if (this.sprite.anims && this.sprite.anims.exists(animKey)) {
this.sprite.play(animKey);
// Return to default idle after animation
this.sprite.on('animationcomplete', () => {
this.sprite.play(`${this.npcType}_idle`);
this.currentIdle = null;
}, this);
} else {
// Fallback: simple visual effects for idle
this.playFallbackIdle(idle);
}
}
playFallbackIdle(idleType) {
// Create simple tween-based idle behaviors as fallback
switch (idleType) {
case 'fix_hat':
this.scene.tweens.add({
targets: this.sprite,
angle: -5,
duration: 200,
yoyo: true,
onComplete: () => {
this.currentIdle = null;
}
});
break;
case 'wipe_sweat':
this.scene.tweens.add({
targets: this.sprite,
scaleY: 0.98,
duration: 300,
yoyo: true,
onComplete: () => {
this.currentIdle = null;
}
});
break;
case 'look_around':
this.scene.tweens.add({
targets: this.sprite,
scaleX: -Math.abs(this.sprite.scaleX),
duration: 400,
yoyo: true,
onComplete: () => {
this.currentIdle = null;
}
});
break;
case 'scratch_head':
this.scene.tweens.add({
targets: this.sprite,
y: this.sprite.y - 5,
duration: 150,
yoyo: true,
repeat: 2,
onComplete: () => {
this.currentIdle = null;
}
});
break;
default:
// Generic bob
this.scene.tweens.add({
targets: this.sprite,
y: this.sprite.y - 3,
duration: 200,
yoyo: true,
onComplete: () => {
this.currentIdle = null;
}
});
}
}
destroy() {
this.sprite = null;
}
}