diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-11 16:33:59 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-11 16:33:59 +0200 |
| commit | c5aed370a73f2873c94c5b31b306c50e8ba8adb6 (patch) | |
| tree | 78bb8c9089eb351f4889affd74eb0d4de65f0a67 | |
| parent | end game screen, fix occlusion (diff) | |
| parent | add enemy AI: chase, patrol, attack, and movement animation (diff) | |
| download | dungeon-c5aed370a73f2873c94c5b31b306c50e8ba8adb6.tar.gz dungeon-c5aed370a73f2873c94c5b31b306c50e8ba8adb6.zip | |
merge enemy AI
| -rw-r--r-- | src/game.rs | 362 | ||||
| -rw-r--r-- | src/renderer.rs | 117 |
2 files changed, 420 insertions, 59 deletions
diff --git a/src/game.rs b/src/game.rs index f66ca40..992023e 100644 --- a/src/game.rs +++ b/src/game.rs | |||
| @@ -614,6 +614,16 @@ const TURN_DURATION: f64 = 0.12; // seconds for a 90° turn | |||
| 614 | const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement | 614 | const ENEMY_ANIM_DURATION: f64 = 0.10; // seconds for enemy movement |
| 615 | const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge | 615 | const ATTACK_DURATION: f64 = 0.12; // seconds for attack lunge |
| 616 | 616 | ||
| 617 | /// An enemy movement queued for animation. | ||
| 618 | #[derive(Clone)] | ||
| 619 | struct EnemyMove { | ||
| 620 | from_x: usize, | ||
| 621 | from_y: usize, | ||
| 622 | to_x: usize, | ||
| 623 | to_y: usize, | ||
| 624 | entity: Entity, | ||
| 625 | } | ||
| 626 | |||
| 617 | pub const MAX_HP: u32 = 5; | 627 | pub const MAX_HP: u32 = 5; |
| 618 | pub const MAX_SANITY: u32 = 100; | 628 | pub const MAX_SANITY: u32 = 100; |
| 619 | pub const MAX_WEAKNESS: u32 = 100; | 629 | pub const MAX_WEAKNESS: u32 = 100; |
| @@ -664,6 +674,10 @@ pub struct Game { | |||
| 664 | 674 | ||
| 665 | // ---- animation: enemies ---- | 675 | // ---- animation: enemies ---- |
| 666 | enemy_anim_t: f64, | 676 | enemy_anim_t: f64, |
| 677 | enemy_moves: Vec<EnemyMove>, | ||
| 678 | |||
| 679 | /// Enemies that already retaliated this turn (skip their independent attack). | ||
| 680 | acted_this_turn: Vec<(usize, usize)>, | ||
| 667 | 681 | ||
| 668 | // ---- animation: attack ---- | 682 | // ---- animation: attack ---- |
| 669 | pub attacking: bool, | 683 | pub attacking: bool, |
| @@ -724,6 +738,8 @@ impl Game { | |||
| 724 | turn_delta: 0.0, | 738 | turn_delta: 0.0, |
| 725 | turn_t: 0.0, | 739 | turn_t: 0.0, |
| 726 | enemy_anim_t: 0.0, | 740 | enemy_anim_t: 0.0, |
| 741 | enemy_moves: Vec::new(), | ||
| 742 | acted_this_turn: Vec::new(), | ||
| 727 | attacking: false, | 743 | attacking: false, |
| 728 | attack_t: 0.0, | 744 | attack_t: 0.0, |
| 729 | attack_base_x: 0.0, | 745 | attack_base_x: 0.0, |
| @@ -808,10 +824,19 @@ impl Game { | |||
| 808 | } | 824 | } |
| 809 | 825 | ||
| 810 | if done { | 826 | if done { |
| 811 | self.state = GameState::ResolvingTurn; | 827 | let enemies_moved = self.compute_enemy_turns(); |
| 812 | self.resolve_turn(); | 828 | if self.state == GameState::GameOver { |
| 813 | if self.state != GameState::GameOver { | 829 | return true; |
| 814 | self.state = GameState::WaitingForInput; | 830 | } |
| 831 | if enemies_moved { | ||
| 832 | self.state = GameState::AnimatingEnemies; | ||
| 833 | self.enemy_anim_t = 0.0; | ||
| 834 | } else { | ||
| 835 | self.state = GameState::ResolvingTurn; | ||
| 836 | self.resolve_turn(); | ||
| 837 | if self.state != GameState::GameOver { | ||
| 838 | self.state = GameState::WaitingForInput; | ||
| 839 | } | ||
| 815 | } | 840 | } |
| 816 | return true; | 841 | return true; |
| 817 | } | 842 | } |
| @@ -821,6 +846,7 @@ impl Game { | |||
| 821 | self.enemy_anim_t += dt; | 846 | self.enemy_anim_t += dt; |
| 822 | if self.enemy_anim_t >= ENEMY_ANIM_DURATION { | 847 | if self.enemy_anim_t >= ENEMY_ANIM_DURATION { |
| 823 | self.enemy_anim_t = 0.0; | 848 | self.enemy_anim_t = 0.0; |
| 849 | self.apply_enemy_moves(); | ||
| 824 | self.state = GameState::ResolvingTurn; | 850 | self.state = GameState::ResolvingTurn; |
| 825 | self.resolve_turn(); | 851 | self.resolve_turn(); |
| 826 | if self.state != GameState::GameOver { | 852 | if self.state != GameState::GameOver { |
| @@ -866,6 +892,7 @@ impl Game { | |||
| 866 | // Blocked by real enemy — waste the turn, take damage. | 892 | // Blocked by real enemy — waste the turn, take damage. |
| 867 | self.set_message("Something is blocking your way."); | 893 | self.set_message("Something is blocking your way."); |
| 868 | self.hp = self.hp.saturating_sub(damage); | 894 | self.hp = self.hp.saturating_sub(damage); |
| 895 | self.acted_this_turn.push((tx, ty)); | ||
| 869 | self.advance_turn(); | 896 | self.advance_turn(); |
| 870 | if self.hp == 0 { | 897 | if self.hp == 0 { |
| 871 | self.state = GameState::GameOver; | 898 | self.state = GameState::GameOver; |
| @@ -996,6 +1023,7 @@ impl Game { | |||
| 996 | } | 1023 | } |
| 997 | }); | 1024 | }); |
| 998 | self.hp = self.hp.saturating_sub(damage); | 1025 | self.hp = self.hp.saturating_sub(damage); |
| 1026 | self.acted_this_turn.push((fx, fy)); | ||
| 999 | self.set_message(&format!( | 1027 | self.set_message(&format!( |
| 1000 | "You hit the enemy for {} dmg. It strikes back!", | 1028 | "You hit the enemy for {} dmg. It strikes back!", |
| 1001 | total_dmg | 1029 | total_dmg |
| @@ -1175,15 +1203,27 @@ impl Game { | |||
| 1175 | 1203 | ||
| 1176 | /// Advance the turn counter and state without animation. | 1204 | /// Advance the turn counter and state without animation. |
| 1177 | fn advance_turn(&mut self) { | 1205 | fn advance_turn(&mut self) { |
| 1178 | self.state = GameState::ResolvingTurn; | 1206 | let enemies_moved = self.compute_enemy_turns(); |
| 1179 | self.resolve_turn(); | 1207 | if self.state == GameState::GameOver { |
| 1180 | if self.state != GameState::GameOver { | 1208 | return; |
| 1181 | self.state = GameState::WaitingForInput; | 1209 | } |
| 1210 | if enemies_moved { | ||
| 1211 | self.state = GameState::AnimatingEnemies; | ||
| 1212 | self.enemy_anim_t = 0.0; | ||
| 1213 | } else { | ||
| 1214 | self.state = GameState::ResolvingTurn; | ||
| 1215 | self.resolve_turn(); | ||
| 1216 | if self.state != GameState::GameOver { | ||
| 1217 | self.state = GameState::WaitingForInput; | ||
| 1218 | } | ||
| 1182 | } | 1219 | } |
| 1183 | } | 1220 | } |
| 1184 | 1221 | ||
| 1185 | /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). | 1222 | /// Apply all per-turn effects (weakness, heartbeat, HP drain, message decay). |
| 1186 | fn resolve_turn(&mut self) { | 1223 | fn resolve_turn(&mut self) { |
| 1224 | // Clear acted-this-turn tracking (enemy AI already consumed it). | ||
| 1225 | self.acted_this_turn.clear(); | ||
| 1226 | |||
| 1187 | // Weakness grows every 3 turns, not every turn. | 1227 | // Weakness grows every 3 turns, not every turn. |
| 1188 | if self.heartbeat_count % 3 == 0 { | 1228 | if self.heartbeat_count % 3 == 0 { |
| 1189 | self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); | 1229 | self.weakness = (self.weakness + 1).min(MAX_WEAKNESS); |
| @@ -1211,9 +1251,20 @@ impl Game { | |||
| 1211 | if !self.can_act() { | 1251 | if !self.can_act() { |
| 1212 | return; | 1252 | return; |
| 1213 | } | 1253 | } |
| 1214 | self.state = GameState::AnimatingEnemies; | 1254 | let enemies_moved = self.compute_enemy_turns(); |
| 1215 | self.enemy_anim_t = 0.0; | 1255 | if self.state == GameState::GameOver { |
| 1216 | // TODO Phase 7: enemy movement happens here | 1256 | return; |
| 1257 | } | ||
| 1258 | if enemies_moved { | ||
| 1259 | self.state = GameState::AnimatingEnemies; | ||
| 1260 | self.enemy_anim_t = 0.0; | ||
| 1261 | } else { | ||
| 1262 | self.state = GameState::ResolvingTurn; | ||
| 1263 | self.resolve_turn(); | ||
| 1264 | if self.state != GameState::GameOver { | ||
| 1265 | self.state = GameState::WaitingForInput; | ||
| 1266 | } | ||
| 1267 | } | ||
| 1217 | } | 1268 | } |
| 1218 | 1269 | ||
| 1219 | /// Set a HUD message that persists for 3 turns. | 1270 | /// Set a HUD message that persists for 3 turns. |
| @@ -1225,6 +1276,295 @@ impl Game { | |||
| 1225 | // ---- cardinal-direction helpers ---- | 1276 | // ---- cardinal-direction helpers ---- |
| 1226 | 1277 | ||
| 1227 | /// Which grid direction the player faces: (dx, dy) ∈ {±1, 0}. | 1278 | /// Which grid direction the player faces: (dx, dy) ∈ {±1, 0}. |
| 1279 | // ---- enemy AI --------------------------------------------------------- | ||
| 1280 | |||
| 1281 | /// DDA line-of-sight check from grid cell (ex,ey) to player position (px,py). | ||
| 1282 | fn has_line_of_sight(&self, ex: usize, ey: usize, px: f64, py: f64) -> bool { | ||
| 1283 | let sx = ex as f64 + 0.5; | ||
| 1284 | let sy = ey as f64 + 0.5; | ||
| 1285 | let dx = px - sx; | ||
| 1286 | let dy = py - sy; | ||
| 1287 | let dist = (dx * dx + dy * dy).sqrt(); | ||
| 1288 | if dist < 0.01 { | ||
| 1289 | return true; | ||
| 1290 | } | ||
| 1291 | // Walk the grid line; step size is half a cell for accuracy. | ||
| 1292 | let steps = (dist * 2.0).ceil() as usize; | ||
| 1293 | for i in 1..steps { | ||
| 1294 | let t = i as f64 / steps as f64; | ||
| 1295 | let cx = (sx + dx * t) as usize; | ||
| 1296 | let cy = (sy + dy * t) as usize; | ||
| 1297 | // Reached player cell — LOS confirmed. | ||
| 1298 | if cy == py as usize && cx == px as usize { | ||
| 1299 | return true; | ||
| 1300 | } | ||
| 1301 | if self.maze.is_wall(cy, cx) { | ||
| 1302 | return false; | ||
| 1303 | } | ||
| 1304 | } | ||
| 1305 | true | ||
| 1306 | } | ||
| 1307 | |||
| 1308 | /// Try to move the enemy at (ex,ey) one tile toward the player. | ||
| 1309 | /// Queues the move in `enemy_moves` and removes the entity from the grid. | ||
| 1310 | /// `occupied` tracks cells that are already taken (by other enemy moves or static entities). | ||
| 1311 | fn try_move_enemy_toward(&mut self, ex: usize, ey: usize, occupied: &mut Vec<bool>) { | ||
| 1312 | let px = self.player.x.floor() as isize; | ||
| 1313 | let py = self.player.y.floor() as isize; | ||
| 1314 | |||
| 1315 | // Four cardinal neighbours, sorted by Manhattan distance to player. | ||
| 1316 | let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; | ||
| 1317 | let mut best: Option<(usize, usize, isize)> = None; | ||
| 1318 | |||
| 1319 | for (dx, dy) in dirs { | ||
| 1320 | let nx = ex as isize + dx; | ||
| 1321 | let ny = ey as isize + dy; | ||
| 1322 | if nx < 0 || ny < 0 { | ||
| 1323 | continue; | ||
| 1324 | } | ||
| 1325 | let nx = nx as usize; | ||
| 1326 | let ny = ny as usize; | ||
| 1327 | if nx >= self.maze.width() || ny >= self.maze.height() { | ||
| 1328 | continue; | ||
| 1329 | } | ||
| 1330 | // Must be a passage and not occupied. | ||
| 1331 | if !self | ||
| 1332 | .maze | ||
| 1333 | .is_passage_at_float(nx as f64 + 0.5, ny as f64 + 0.5) | ||
| 1334 | { | ||
| 1335 | continue; | ||
| 1336 | } | ||
| 1337 | if occupied[ny * self.maze.width() + nx] { | ||
| 1338 | continue; | ||
| 1339 | } | ||
| 1340 | let dist = manhattan(nx as isize, ny as isize, px, py); | ||
| 1341 | let current = manhattan(ex as isize, ey as isize, px, py); | ||
| 1342 | if dist < current { | ||
| 1343 | match best { | ||
| 1344 | None => best = Some((nx, ny, dist)), | ||
| 1345 | Some((_, _, best_dist)) if dist < best_dist => best = Some((nx, ny, dist)), | ||
| 1346 | _ => {} | ||
| 1347 | } | ||
| 1348 | } | ||
| 1349 | } | ||
| 1350 | |||
| 1351 | if let Some((tx, ty, _)) = best { | ||
| 1352 | let entity = self.maze.take_entity(ex, ey).unwrap(); | ||
| 1353 | occupied[ty * self.maze.width() + tx] = true; | ||
| 1354 | self.enemy_moves.push(EnemyMove { | ||
| 1355 | from_x: ex, | ||
| 1356 | from_y: ey, | ||
| 1357 | to_x: tx, | ||
| 1358 | to_y: ty, | ||
| 1359 | entity, | ||
| 1360 | }); | ||
| 1361 | } | ||
| 1362 | } | ||
| 1363 | |||
| 1364 | /// Try to move the enemy at (ex,ey) to a random adjacent passage cell (patrol). | ||
| 1365 | fn try_patrol_enemy(&mut self, ex: usize, ey: usize, occupied: &mut Vec<bool>) { | ||
| 1366 | let mut rng = rand::rng(); | ||
| 1367 | let dirs: [(isize, isize); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)]; | ||
| 1368 | // Shuffle directions. | ||
| 1369 | let mut indices = [0usize, 1, 2, 3]; | ||
| 1370 | for i in (1..4).rev() { | ||
| 1371 | let j = rng.random_range(0..=i); | ||
| 1372 | indices.swap(i, j); | ||
| 1373 | } | ||
| 1374 | |||
| 1375 | for &idx in &indices { | ||
| 1376 | let (dx, dy) = dirs[idx]; | ||
| 1377 | let nx = ex as isize + dx; | ||
| 1378 | let ny = ey as isize + dy; | ||
| 1379 | if nx < 0 || ny < 0 { | ||
| 1380 | continue; | ||
| 1381 | } | ||
| 1382 | let nx = nx as usize; | ||
| 1383 | let ny = ny as usize; | ||
| 1384 | if nx >= self.maze.width() || ny >= self.maze.height() { | ||
| 1385 | continue; | ||
| 1386 | } | ||
| 1387 | if !self | ||
| 1388 | .maze | ||
| 1389 | .is_passage_at_float(nx as f64 + 0.5, ny as f64 + 0.5) | ||
| 1390 | { | ||
| 1391 | continue; | ||
| 1392 | } | ||
| 1393 | if occupied[ny * self.maze.width() + nx] { | ||
| 1394 | continue; | ||
| 1395 | } | ||
| 1396 | let entity = self.maze.take_entity(ex, ey).unwrap(); | ||
| 1397 | occupied[ny * self.maze.width() + nx] = true; | ||
| 1398 | self.enemy_moves.push(EnemyMove { | ||
| 1399 | from_x: ex, | ||
| 1400 | from_y: ey, | ||
| 1401 | to_x: nx, | ||
| 1402 | to_y: ny, | ||
| 1403 | entity, | ||
| 1404 | }); | ||
| 1405 | return; | ||
| 1406 | } | ||
| 1407 | } | ||
| 1408 | |||
| 1409 | /// Compute actions for every enemy/hallucination on the level. | ||
| 1410 | /// Attacks are applied immediately; moves are queued in `enemy_moves`. | ||
| 1411 | /// Returns true if any enemy moved (needs animation). | ||
| 1412 | fn compute_enemy_turns(&mut self) -> bool { | ||
| 1413 | self.enemy_moves.clear(); | ||
| 1414 | |||
| 1415 | let w = self.maze.width(); | ||
| 1416 | let h = self.maze.height(); | ||
| 1417 | |||
| 1418 | // Occupied mask: cells that are taken (by static entities or queued moves). | ||
| 1419 | let mut occupied = vec![false; w * h]; | ||
| 1420 | for y in 0..h { | ||
| 1421 | for x in 0..w { | ||
| 1422 | if self.maze.entity_at(x, y).is_some() { | ||
| 1423 | occupied[y * w + x] = true; | ||
| 1424 | } | ||
| 1425 | } | ||
| 1426 | } | ||
| 1427 | // Also mark the player's cell as occupied so enemies don't walk into it. | ||
| 1428 | let pcx_usize = self.player.x.floor() as usize; | ||
| 1429 | let pcy_usize = self.player.y.floor() as usize; | ||
| 1430 | if pcx_usize < w && pcy_usize < h { | ||
| 1431 | occupied[pcy_usize * w + pcx_usize] = true; | ||
| 1432 | } | ||
| 1433 | |||
| 1434 | // Gather all enemy/hallucination positions (snapshot before mutation). | ||
| 1435 | let mut enemy_positions: Vec<(usize, usize)> = Vec::new(); | ||
| 1436 | for y in 0..h { | ||
| 1437 | for x in 0..w { | ||
| 1438 | match self.maze.entity_at(x, y) { | ||
| 1439 | Some(Entity::Enemy { .. }) | Some(Entity::Hallucination { .. }) => { | ||
| 1440 | enemy_positions.push((x, y)); | ||
| 1441 | } | ||
| 1442 | _ => {} | ||
| 1443 | } | ||
| 1444 | } | ||
| 1445 | } | ||
| 1446 | |||
| 1447 | let px = self.player.x; | ||
| 1448 | let py = self.player.y; | ||
| 1449 | let pcx = px.floor() as isize; | ||
| 1450 | let pcy = py.floor() as isize; | ||
| 1451 | let is_patrol_turn = self.heartbeat_count % 3 == 0; | ||
| 1452 | |||
| 1453 | // Drain acted-this-turn list for fast lookups. | ||
| 1454 | let acted: Vec<(usize, usize)> = self.acted_this_turn.drain(..).collect(); | ||
| 1455 | |||
| 1456 | for (ex, ey) in enemy_positions { | ||
| 1457 | // Entity may have been removed by a previous enemy's action. | ||
| 1458 | let entity = match self.maze.entity_at(ex, ey) { | ||
| 1459 | Some(e) => e.clone(), | ||
| 1460 | None => continue, | ||
| 1461 | }; | ||
| 1462 | |||
| 1463 | let is_hallucination = matches!(entity, Entity::Hallucination { .. }); | ||
| 1464 | |||
| 1465 | // ---- hallucination behaviour gating ---- | ||
| 1466 | if is_hallucination { | ||
| 1467 | if self.sanity >= 40 { | ||
| 1468 | // Static: hallucinations don't move or attack. | ||
| 1469 | continue; | ||
| 1470 | } | ||
| 1471 | // Sanity 20-39%: patrol only. | ||
| 1472 | // Sanity < 20%: chase (handled below via has_line_of_sight). | ||
| 1473 | } | ||
| 1474 | |||
| 1475 | // ---- adjacent → attack ---- | ||
| 1476 | if manhattan(ex as isize, ey as isize, pcx, pcy) == 1 { | ||
| 1477 | // Skip if this enemy already acted this turn (retaliated or blocked). | ||
| 1478 | if acted.contains(&(ex, ey)) { | ||
| 1479 | // Still let it patrol/move if applicable. | ||
| 1480 | } else { | ||
| 1481 | if is_hallucination { | ||
| 1482 | self.sanity = self.sanity.saturating_sub(5); | ||
| 1483 | self.set_message("A hallucination claws at your mind!"); | ||
| 1484 | self.flash_color = 0xFF_8833CC; | ||
| 1485 | self.flash_alpha = 0.35; | ||
| 1486 | } else { | ||
| 1487 | let damage = match &entity { | ||
| 1488 | Entity::Enemy { damage, .. } => *damage, | ||
| 1489 | _ => 1, | ||
| 1490 | }; | ||
| 1491 | self.hp = self.hp.saturating_sub(damage); | ||
| 1492 | self.set_message(&format!("An enemy strikes you for {} damage!", damage)); | ||
| 1493 | self.flash_color = 0xFF_CC3333; | ||
| 1494 | self.flash_alpha = 0.4; | ||
| 1495 | if self.hp == 0 { | ||
| 1496 | self.state = GameState::GameOver; | ||
| 1497 | return false; | ||
| 1498 | } | ||
| 1499 | } | ||
| 1500 | continue; | ||
| 1501 | } | ||
| 1502 | } | ||
| 1503 | |||
| 1504 | // ---- line-of-sight → move toward player ---- | ||
| 1505 | let has_los = self.has_line_of_sight(ex, ey, px, py); | ||
| 1506 | |||
| 1507 | if has_los { | ||
| 1508 | // Real enemies always chase; hallucinations chase only at sanity < 20%. | ||
| 1509 | if !is_hallucination { | ||
| 1510 | self.try_move_enemy_toward(ex, ey, &mut occupied); | ||
| 1511 | continue; | ||
| 1512 | } else if self.sanity < 20 { | ||
| 1513 | self.try_move_enemy_toward(ex, ey, &mut occupied); | ||
| 1514 | continue; | ||
| 1515 | } | ||
| 1516 | } | ||
| 1517 | |||
| 1518 | // ---- patrol ---- | ||
| 1519 | if is_patrol_turn { | ||
| 1520 | self.try_patrol_enemy(ex, ey, &mut occupied); | ||
| 1521 | } | ||
| 1522 | } | ||
| 1523 | |||
| 1524 | !self.enemy_moves.is_empty() | ||
| 1525 | } | ||
| 1526 | |||
| 1527 | /// After enemy animation completes, place entities in their target cells. | ||
| 1528 | fn apply_enemy_moves(&mut self) { | ||
| 1529 | for m in self.enemy_moves.drain(..) { | ||
| 1530 | let idx = m.to_y * self.maze.width() + m.to_x; | ||
| 1531 | self.maze.entities[idx] = Some(m.entity); | ||
| 1532 | } | ||
| 1533 | } | ||
| 1534 | |||
| 1535 | /// Interpolation offset for an enemy during movement animation. | ||
| 1536 | /// Returns (dx, dy) in world units; (0, 0) when not animating. | ||
| 1537 | #[allow(dead_code)] | ||
| 1538 | pub fn enemy_offset(&self, cell_x: usize, cell_y: usize) -> (f64, f64) { | ||
| 1539 | if self.state != GameState::AnimatingEnemies { | ||
| 1540 | return (0.0, 0.0); | ||
| 1541 | } | ||
| 1542 | for m in &self.enemy_moves { | ||
| 1543 | if m.from_x == cell_x && m.from_y == cell_y { | ||
| 1544 | let t = smoothstep((self.enemy_anim_t / ENEMY_ANIM_DURATION).min(1.0)); | ||
| 1545 | return ( | ||
| 1546 | (m.to_x as f64 - m.from_x as f64) * t, | ||
| 1547 | (m.to_y as f64 - m.from_y as f64) * t, | ||
| 1548 | ); | ||
| 1549 | } | ||
| 1550 | } | ||
| 1551 | (0.0, 0.0) | ||
| 1552 | } | ||
| 1553 | |||
| 1554 | /// Entities that should be rendered during enemy animation. | ||
| 1555 | /// Returns (world_x, world_y, entity) for mid-air enemies. | ||
| 1556 | pub fn enemy_render_entities(&self) -> Vec<(f64, f64, Entity)> { | ||
| 1557 | self.enemy_moves | ||
| 1558 | .iter() | ||
| 1559 | .map(|m| { | ||
| 1560 | let t = smoothstep((self.enemy_anim_t / ENEMY_ANIM_DURATION).min(1.0)); | ||
| 1561 | let wx = m.from_x as f64 + 0.5 + (m.to_x as f64 - m.from_x as f64) * t; | ||
| 1562 | let wy = m.from_y as f64 + 0.5 + (m.to_y as f64 - m.from_y as f64) * t; | ||
| 1563 | (wx, wy, m.entity.clone()) | ||
| 1564 | }) | ||
| 1565 | .collect() | ||
| 1566 | } | ||
| 1567 | |||
| 1228 | fn facing_delta(&self) -> (isize, isize) { | 1568 | fn facing_delta(&self) -> (isize, isize) { |
| 1229 | let q = (self.player.angle / std::f64::consts::FRAC_PI_2).round() as i32 & 3; | 1569 | let q = (self.player.angle / std::f64::consts::FRAC_PI_2).round() as i32 & 3; |
| 1230 | match q { | 1570 | match q { |
diff --git a/src/renderer.rs b/src/renderer.rs index aa87b54..885e5de 100644 --- a/src/renderer.rs +++ b/src/renderer.rs | |||
| @@ -74,6 +74,18 @@ pub struct Renderer { | |||
| 74 | floor_dist_lut: Vec<f64>, | 74 | floor_dist_lut: Vec<f64>, |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | // ---- entity sprites --------------------------------------------------- | ||
| 78 | |||
| 79 | /// Screen-space info for an entity sprite to render. | ||
| 80 | struct Sprite { | ||
| 81 | sx: isize, | ||
| 82 | sy: isize, | ||
| 83 | scale: f64, | ||
| 84 | ch: char, | ||
| 85 | color: u32, | ||
| 86 | cam_z: f64, | ||
| 87 | } | ||
| 88 | |||
| 77 | impl Renderer { | 89 | impl Renderer { |
| 78 | pub fn new(screen_w: usize, screen_h: usize) -> Self { | 90 | pub fn new(screen_w: usize, screen_h: usize) -> Self { |
| 79 | let half_h = screen_h as f64 / 2.0; | 91 | let half_h = screen_h as f64 / 2.0; |
| @@ -306,19 +318,9 @@ impl Renderer { | |||
| 306 | ) { | 318 | ) { |
| 307 | let mw = game.maze.width(); | 319 | let mw = game.maze.width(); |
| 308 | let mh = game.maze.height(); | 320 | let mh = game.maze.height(); |
| 309 | |||
| 310 | // Collect visible entities with their screen info. | ||
| 311 | struct Sprite { | ||
| 312 | sx: isize, | ||
| 313 | sy: isize, | ||
| 314 | scale: f64, | ||
| 315 | ch: char, | ||
| 316 | color: u32, | ||
| 317 | cam_z: f64, | ||
| 318 | } | ||
| 319 | |||
| 320 | let mut sprites: Vec<Sprite> = Vec::new(); | 321 | let mut sprites: Vec<Sprite> = Vec::new(); |
| 321 | 322 | ||
| 323 | // Static entities on the grid. | ||
| 322 | for y in 0..mh { | 324 | for y in 0..mh { |
| 323 | for x in 0..mw { | 325 | for x in 0..mw { |
| 324 | let entity = match game.maze.entity_at(x, y) { | 326 | let entity = match game.maze.entity_at(x, y) { |
| @@ -329,46 +331,15 @@ impl Renderer { | |||
| 329 | let ex = x as f64 + 0.5; | 331 | let ex = x as f64 + 0.5; |
| 330 | let ey = y as f64 + 0.5; | 332 | let ey = y as f64 + 0.5; |
| 331 | 333 | ||
| 332 | let dx = ex - game.player.x; | 334 | self.push_entity_sprite(game, ex, ey, entity, fov, half_h, &mut sprites); |
| 333 | let dy = ey - game.player.y; | ||
| 334 | |||
| 335 | let fx = game.player.angle.cos(); | ||
| 336 | let fy = game.player.angle.sin(); | ||
| 337 | let rx = -fy; | ||
| 338 | let ry = fx; | ||
| 339 | |||
| 340 | let cam_z = dx * fx + dy * fy; | ||
| 341 | if cam_z <= 0.1 { | ||
| 342 | continue; | ||
| 343 | } | ||
| 344 | |||
| 345 | let cam_x = dx * rx + dy * ry; | ||
| 346 | |||
| 347 | let half_fov_tan = (fov / 2.0).tan(); | ||
| 348 | let screen_x_norm = cam_x / (cam_z * half_fov_tan); | ||
| 349 | if screen_x_norm < -1.0 || screen_x_norm > 1.0 { | ||
| 350 | continue; | ||
| 351 | } | ||
| 352 | |||
| 353 | let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; | ||
| 354 | |||
| 355 | let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; | ||
| 356 | let sy = (half_h - entity_h as f64 * 0.3) as isize; | ||
| 357 | |||
| 358 | let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); | ||
| 359 | let (ch, color) = entity_char_and_color(entity); | ||
| 360 | |||
| 361 | sprites.push(Sprite { | ||
| 362 | sx, | ||
| 363 | sy, | ||
| 364 | scale, | ||
| 365 | ch, | ||
| 366 | color, | ||
| 367 | cam_z, | ||
| 368 | }); | ||
| 369 | } | 335 | } |
| 370 | } | 336 | } |
| 371 | 337 | ||
| 338 | // Animating enemies (mid-move, not on the grid). | ||
| 339 | for (wx, wy, entity) in game.enemy_render_entities() { | ||
| 340 | self.push_entity_sprite(game, wx, wy, &entity, fov, half_h, &mut sprites); | ||
| 341 | } | ||
| 342 | |||
| 372 | // Sort farthest first so nearby entities draw on top. | 343 | // Sort farthest first so nearby entities draw on top. |
| 373 | sprites.sort_by(|a, b| b.cam_z.partial_cmp(&a.cam_z).unwrap()); | 344 | sprites.sort_by(|a, b| b.cam_z.partial_cmp(&a.cam_z).unwrap()); |
| 374 | 345 | ||
| @@ -387,6 +358,56 @@ impl Renderer { | |||
| 387 | } | 358 | } |
| 388 | } | 359 | } |
| 389 | 360 | ||
| 361 | /// Project a world-space entity position and push a Sprite if on screen. | ||
| 362 | fn push_entity_sprite( | ||
| 363 | &self, | ||
| 364 | game: &Game, | ||
| 365 | ex: f64, | ||
| 366 | ey: f64, | ||
| 367 | entity: &Entity, | ||
| 368 | fov: f64, | ||
| 369 | half_h: f64, | ||
| 370 | sprites: &mut Vec<Sprite>, | ||
| 371 | ) { | ||
| 372 | let dx = ex - game.player.x; | ||
| 373 | let dy = ey - game.player.y; | ||
| 374 | |||
| 375 | let fx = game.player.angle.cos(); | ||
| 376 | let fy = game.player.angle.sin(); | ||
| 377 | let rx = -fy; | ||
| 378 | let ry = fx; | ||
| 379 | |||
| 380 | let cam_z = dx * fx + dy * fy; | ||
| 381 | if cam_z <= 0.1 { | ||
| 382 | return; | ||
| 383 | } | ||
| 384 | |||
| 385 | let cam_x = dx * rx + dy * ry; | ||
| 386 | |||
| 387 | let half_fov_tan = (fov / 2.0).tan(); | ||
| 388 | let screen_x_norm = cam_x / (cam_z * half_fov_tan); | ||
| 389 | if screen_x_norm < -1.0 || screen_x_norm > 1.0 { | ||
| 390 | return; | ||
| 391 | } | ||
| 392 | |||
| 393 | let sx = ((screen_x_norm + 1.0) * 0.5 * self.screen_w as f64) as isize; | ||
| 394 | |||
| 395 | let entity_h = (self.screen_h as f64 / cam_z * 0.4) as isize; | ||
| 396 | let sy = (half_h - entity_h as f64 * 0.3) as isize; | ||
| 397 | |||
| 398 | let scale = (entity_h.max(6) as f64 / LETTER_H as f64).max(0.5); | ||
| 399 | let (ch, color) = entity_char_and_color(entity); | ||
| 400 | |||
| 401 | sprites.push(Sprite { | ||
| 402 | sx, | ||
| 403 | sy, | ||
| 404 | scale, | ||
| 405 | ch, | ||
| 406 | color, | ||
| 407 | cam_z, | ||
| 408 | }); | ||
| 409 | } | ||
| 410 | |||
| 390 | fn draw_char_scaled( | 411 | fn draw_char_scaled( |
| 391 | &self, | 412 | &self, |
| 392 | buffer: &mut [u32], | 413 | buffer: &mut [u32], |
