猫武士 · 演替兴衰(族群版)
by EpicCoder882470 lines88.7 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>猫武士 · 演替兴衰(族群版)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; font-family: system-ui; }
body {
background: #111;
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
overflow: hidden;
touch-action: none;
user-select: none;
height: 100vh;
}
canvas {
border: 2px solid #444;
border-radius: 6px;
touch-action: none;
max-width: 100%;
max-height: 70vh;
background: #477c42;
display: block;
}
.panel {
margin-top: 8px;
width: 100%;
max-width: 900px;
background: #1a1a1a;
border: 2px solid #444;
border-radius: 6px;
padding: 8px 12px;
color: #ddd;
font-size: 13px;
line-height: 1.5;
}
.panel strong { color: #fff; }
#detailPanel {
margin-top: 4px;
border-top: 1px solid #444;
padding-top: 4px;
font-size: 12px;
color: #aaa;
min-height: 1.2em;
}
.controls {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.controls button {
background: #333;
color: #ddd;
border: 1px solid #555;
border-radius: 4px;
padding: 4px 10px;
font-size: 13px;
cursor: pointer;
}
.controls button.active {
background: #5a7a5a;
border-color: #8aa88a;
}
.controls .reset-btn {
background: #5a3a3a;
border-color: #8a5a5a;
}
#timeDisplay {
color: #ffd700;
font-weight: bold;
margin-left: 6px;
font-size: 14px;
}
.info {
font-size: 11px;
color: #888;
margin-top: 3px;
}
#resetNotice {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: #ffd700;
padding: 8px 20px;
border-radius: 8px;
font-size: 18px;
font-weight: bold;
pointer-events: none;
opacity: 0;
transition: opacity 0.5s;
z-index: 999;
}
#clanInfo {
margin-top: 4px;
font-size: 12px;
color: #88ff88;
max-height: 80px;
overflow-y: auto;
border-top: 1px solid #333;
padding-top: 4px;
}
</style>
</head>
<body>
<div id="resetNotice">演替已经重新开启</div>
<canvas id="worldCanvas" width="900" height="600"></canvas>
<div class="panel">
<div><strong>生态实时面板</strong></div>
<div id="populationPanel">猎物:0 / 草:0 / 成年猫:0 / 幼猫:0 / 跟随:无</div>
<div id="detailPanel">点击一只猫查看详情</div>
<div class="controls">
<span style="color:#888; font-size:13px;">速度:</span>
<button id="speed1x" class="active" onclick="setSpeed(1)">1×</button>
<button id="speed2x" onclick="setSpeed(2)">2×</button>
<button id="speed5x" onclick="setSpeed(5)">5×</button>
<button class="reset-btn" onclick="resetWorld()">重置</button>
<span id="timeDisplay">☀️ 白天</span>
</div>
<div class="info">
点击猫跟随|拖拽平移|双指缩放|红圈=饥饿|💤=睡觉|粉色圈=吃饱且可繁殖|族群自动形成(带颜色边界)
</div>
<div id="clanInfo"></div>
</div>
<script>
// ============================================================
// 猫武士 · 演替兴衰 族群自组织版
// 新增:熟悉度系统、空间聚类形成族群、角色分配、合作捕猎、领地驱逐
// ============================================================
const canvas = document.getElementById("worldCanvas");
const ctx = canvas.getContext("2d");
const populationPanel = document.getElementById("populationPanel");
const detailPanel = document.getElementById("detailPanel");
const timeDisplay = document.getElementById("timeDisplay");
const resetNotice = document.getElementById("resetNotice");
const clanInfo = document.getElementById("clanInfo");
// ---------- 名字库 ----------
const PREFIXES = [
"虎", "鹰", "鸦", "松鸦", "香薇", "冬青", "蕨", "玫瑰", "火", "云", "雾", "风",
"灰", "蓝", "斑", "长", "短", "亮", "暗", "雨", "雪", "霜", "露", "溪", "河",
"湖", "星", "月", "日", "影", "光", "叶", "花", "石", "岩", "荆棘", "芦苇",
"柳", "橡", "枫", "桦", "松", "杉", "白", "黑", "红", "金", "银", "铜"
];
const SUFFIXES = [
"尾", "羽", "花", "毛", "爪", "足", "心", "风", "叶", "霜", "云", "星",
"光", "月", "溪", "河", "湖", "石", "岩", "棘", "苇", "柳", "橡", "枫",
"桦", "松", "杉", "飞", "跃", "奔", "步", "啸", "嚎", "眼", "耳", "鼻",
"须", "掌", "腿", "腹", "背", "额", "面", "斑", "纹", "环"
];
const CLAN_NAMES = ["雷族", "影族", "风族", "河族", "天族", "夜族", "日族", "雾族", "云族", "霜族"];
// ---------- 配置 ----------
const GRID_SIZE = 60;
const GRID_ALPHA = 0.35;
const DASH_PATTERN = [8,6];
const GRID_COLOR = "#ffffff";
const GRASS_COLOR = "#477c42";
const TREE_COLOR = "#6b4423";
const TREE_RADIUS = 22;
const TREE_COLLIDE_RADIUS = 28;
const TREE_BLOCK_RAY_RADIUS = 26;
const TREE_MIN_DISTANCE = 110;
const TREE_TOTAL_COUNT = 40;
const HERB_COLOR = "#1c4d19";
const HERB_SIZE = 10;
const HERB_MAX_COUNT = 160;
const HERB_MIN_SPACE = 35;
const HERB_RESPAWN_INTERVAL = 160;
const PREY_RADIUS = 8;
const PREY_COUNT = 60;
const PREY_MAX_COUNT = 150;
const PREY_SPEED = 1.1;
const PREY_FLEE_SPEED = 3.0;
const PREY_VIEW_RANGE = 170;
const PREY_VIEW_ANGLE = Math.PI / 3;
const PREY_FEED_RANGE = 16;
const BREED_DISTANCE = 35;
const FEED_REQUIRE = 3;
const BABY_COUNT = 4;
const MAX_HUNGER_TIME = 2500;
const CAT_COLOR = "#f2a65a";
const CAT_RADIUS = 14;
const CAT_SPEED = 1.4;
const CAT_VIEW_RANGE = 220;
const CAT_VIEW_ANGLE = Math.PI / 3;
const CAT_HUNT_RANGE = 25;
const REWARD_CATCH = 10;
const PENALTY_ESCAPE = -6;
const CAT_BREED_REQUIRE = 3;
const CAT_BREED_DISTANCE = 40;
const CAT_BREED_COOLDOWN = 800;
const CAT_LITTER_SIZE = 2;
const CAT_GROW_TIME = 1800;
const CAT_HUNGER_MAX = 5000;
const CAT_HUNGER_STATE = 600;
const CAT_FULL_THRESHOLD = 2000;
const MAX_CATS = 200; // 增加上限
// 族群聚类参数
const CLUSTER_EPSILON = 150;
const CLUSTER_MIN_PTS = 3;
const CLUSTER_INTERVAL = 300; // 每300帧检测一次
// 熟悉度参数
const FAMILIARITY_INCREASE = 0.5; // 每帧靠近增加
const FAMILIARITY_DECAY = 0.01; // 每帧衰减
const FAMILIARITY_THRESHOLD = 30; // 形成族群所需平均熟悉度
// 昼夜时长(帧数)
const DAY_LENGTH = 2400;
const NIGHT_LENGTH = 1800;
// 灌木和荆棘
const BUSH_COUNT = 80;
const BUSH_RADIUS = 30;
const THORN_COUNT = 50;
const THORN_RADIUS = 22;
// 窝相关
const NEST_CHECK_RADIUS = 80;
const NEST_PREFER_RADIUS = 120;
const MALE_SYMBOL = "♂";
const FEMALE_SYMBOL = "♀";
// 大世界边界
const WORLD_BOUND = {
left: -600,
top: -400,
right: 3000,
bottom: 2000
};
// ---------- 相机 ----------
let offsetX = 0, offsetY = 0, scale = 0.6;
const MIN_SCALE = 0.2, MAX_SCALE = 2.5;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let startOffsetX = 0, startOffsetY = 0;
let lastTouchDist = 0;
let followedCat = null;
let pointerDownX = 0, pointerDownY = 0;
let pointerDownTime = 0;
let isClick = false;
let speedMultiplier = 1;
// ---------- 实体容器 ----------
let trees = [], herbs = [], preys = [], cats = [];
let bushes = [], thorns = [], nests = [];
let herbRespawnTimer = 0;
let floatingTexts = [];
let isDay = true;
let dayTimer = 0;
let nightTimer = 0;
let resetNoticeTimer = 0;
const RESET_NOTICE_DURATION = 180;
// ---------- 族群系统 ----------
let clans = [];
let clanIdCounter = 0;
let clusterTimer = 0;
// 预设颜色 (用于族群边界)
const CLAN_COLORS = [
"#ff4444", "#44ff44", "#4444ff", "#ffff44", "#ff44ff", "#44ffff",
"#ff8800", "#88ff00", "#0088ff", "#ff0088", "#8800ff", "#00ff88"
];
// ---------- 工具函数 ----------
function isPointInWorld(x, y) {
return x >= WORLD_BOUND.left && x <= WORLD_BOUND.right &&
y >= WORLD_BOUND.top && y <= WORLD_BOUND.bottom;
}
function screenToWorld(screenX, screenY) {
return { x: (screenX - offsetX) / scale, y: (screenY - offsetY) / scale };
}
function getCanvasCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
return {
x: (clientX - rect.left) * (canvas.width / rect.width),
y: (clientY - rect.top) * (canvas.height / rect.height)
};
}
function getPixelRatio() {
const rect = canvas.getBoundingClientRect();
return canvas.width / rect.width;
}
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function pointInCircle(px, py, cx, cy, r) {
return Math.hypot(px - cx, py - cy) <= r;
}
function lineIntersectsCircle(x1, y1, x2, y2, cx, cy, r) {
const dx = x2 - x1, dy = y2 - y1;
const fx = x1 - cx, fy = y1 - cy;
const a = dx*dx + dy*dy;
const b = 2*(fx*dx + fy*dy);
const c = fx*fx + fy*fy - r*r;
let disc = b*b - 4*a*c;
if (disc < 0) return false;
disc = Math.sqrt(disc);
const t1 = (-b - disc) / (2*a);
const t2 = (-b + disc) / (2*a);
return (t1 >= 0 && t1 <= 1) || (t2 >= 0 && t2 <= 1);
}
// ---------- 碰撞与视线 ----------
function checkCollision(x, y, radius) {
for (const tree of trees) {
if (distance({x,y}, tree) < TREE_COLLIDE_RADIUS + radius) return true;
}
for (const thorn of thorns) {
if (distance({x,y}, thorn) < THORN_RADIUS + radius) return true;
}
return false;
}
function isLineBlocked(x1, y1, x2, y2) {
for (const tree of trees) {
if (lineIntersectsCircle(x1, y1, x2, y2, tree.x, tree.y, TREE_BLOCK_RAY_RADIUS)) return true;
}
for (const bush of bushes) {
if (pointInCircle(x1, y1, bush.x, bush.y, BUSH_RADIUS)) continue;
if (pointInCircle(x2, y2, bush.x, bush.y, BUSH_RADIUS)) return true;
if (lineIntersectsCircle(x1, y1, x2, y2, bush.x, bush.y, BUSH_RADIUS)) return true;
}
return false;
}
function safeMove(entity, moveX, moveY, radius) {
let newX = entity.x + moveX;
let newY = entity.y + moveY;
const left = WORLD_BOUND.left + radius;
const right = WORLD_BOUND.right - radius;
const top = WORLD_BOUND.top + radius;
const bottom = WORLD_BOUND.bottom - radius;
let canX = (newX >= left && newX <= right);
let canY = (newY >= top && newY <= bottom);
let finalX = entity.x, finalY = entity.y;
if (canX && canY) {
finalX = newX; finalY = newY;
} else if (canX && !canY) {
finalX = newX; finalY = entity.y;
} else if (!canX && canY) {
finalX = entity.x; finalY = newY;
} else {
entity.stuckTimer = (entity.stuckTimer || 0) + 1;
if (entity.stuckTimer > 25) {
entity.x -= Math.cos(entity.angle) * 18;
entity.y -= Math.sin(entity.angle) * 18;
entity.angle += (Math.random() > 0.5 ? 1 : -1) * (Math.PI/2 + Math.random()*Math.PI/3);
entity.stuckTimer = 0;
} else {
entity.angle += (Math.random() > 0.5 ? 0.4 : -0.4);
}
entity.x = Math.max(left, Math.min(right, entity.x));
entity.y = Math.max(top, Math.min(bottom, entity.y));
return;
}
if (checkCollision(finalX, finalY, radius)) {
if (!checkCollision(finalX, entity.y, radius)) {
finalY = entity.y;
} else if (!checkCollision(entity.x, finalY, radius)) {
finalX = entity.x;
} else {
entity.stuckTimer = (entity.stuckTimer || 0) + 1;
if (entity.stuckTimer > 25) {
entity.x -= Math.cos(entity.angle) * 18;
entity.y -= Math.sin(entity.angle) * 18;
entity.angle += (Math.random() > 0.5 ? 1 : -1) * (Math.PI/2 + Math.random()*Math.PI/3);
entity.stuckTimer = 0;
} else {
entity.angle += (Math.random() > 0.5 ? 0.4 : -0.4);
}
entity.x = Math.max(left, Math.min(right, entity.x));
entity.y = Math.max(top, Math.min(bottom, entity.y));
return;
}
}
entity.x = Math.max(left, Math.min(right, finalX));
entity.y = Math.max(top, Math.min(bottom, finalY));
entity.stuckTimer = 0;
}
// ---------- 学习系统 ----------
function createLearning() {
return {
hunt: {
hungry: { attempts: 0, success: 0 },
normal: { attempts: 0, success: 0 },
full: { attempts: 0, success: 0 }
},
rescue: { attempts: 0, success: 0 },
mate: { attempts: 0, success: 0 },
nest: { attempts: 0, success: 0 },
cover: { attempts: 0, success: 0 }
};
}
function updateLearning(cat, category, subKey, success = true) {
if (!cat.learning) return;
if (subKey !== undefined) {
cat.learning[category][subKey].attempts++;
if (success) cat.learning[category][subKey].success++;
} else {
cat.learning[category].attempts++;
if (success) cat.learning[category].success++;
}
}
function getUtility(success, attempts, baseWeight = 0.5) {
if (attempts === 0) return baseWeight + 0.2 * Math.random();
const rate = success / attempts;
const exploration = 1 / (1 + attempts * 0.05);
return rate * (1 - exploration * 0.3) + baseWeight * exploration * 0.3 + 0.1 * Math.random();
}
function getHuntUtility(cat) {
const state = cat.hungerTimer < CAT_HUNGER_STATE ? 'hungry' :
cat.hungerTimer > CAT_FULL_THRESHOLD ? 'full' : 'normal';
const exp = cat.learning.hunt[state];
let baseWeight = 0.9;
if (state === 'hungry') baseWeight = 1.0;
else if (state === 'full') baseWeight = 0.2;
return getUtility(exp.success, exp.attempts, baseWeight);
}
function getRescueUtility(cat) {
const exp = cat.learning.rescue;
return getUtility(exp.success, exp.attempts, 0.6);
}
function getMateUtility(cat) {
if (cat.huntCount < CAT_BREED_REQUIRE) return 0;
if (cat.hungerTimer < CAT_FULL_THRESHOLD) return 0;
const exp = cat.learning.mate;
return getUtility(exp.success, exp.attempts, 0.4);
}
function getNestUtility(cat) {
if (cat.nest) return 0;
if (cat.hungerTimer < CAT_FULL_THRESHOLD) return 0;
const exp = cat.learning.nest;
return getUtility(exp.success, exp.attempts, 0.9);
}
function getWanderUtility() {
return 0.2 + 0.1 * Math.random();
}
// ---------- 熟悉度系统 ----------
function initFamiliarity(cat) {
if (!cat.familiarity) cat.familiarity = {};
}
function updateFamiliarities() {
// 遍历所有猫对,增加熟悉度
for (let i = 0; i < cats.length; i++) {
const a = cats[i];
initFamiliarity(a);
// 衰减(所有熟悉度缓慢降低)
for (const key in a.familiarity) {
a.familiarity[key] = Math.max(0, a.familiarity[key] - FAMILIARITY_DECAY);
}
for (let j = i+1; j < cats.length; j++) {
const b = cats[j];
initFamiliarity(b);
const d = distance(a, b);
if (d < 100) { // 靠近
// 检查视野(不遮挡)
if (!isLineBlocked(a.x, a.y, b.x, b.y)) {
const inc = FAMILIARITY_INCREASE * (1 - d/100);
a.familiarity[b.id] = Math.min(100, (a.familiarity[b.id] || 0) + inc);
b.familiarity[a.id] = Math.min(100, (b.familiarity[a.id] || 0) + inc);
}
}
}
}
}
// ---------- 族群聚类 ----------
function clusterCats() {
// 仅对无族群的猫进行聚类
const loners = cats.filter(c => c.clanId === null && c.growTimer >= CAT_GROW_TIME);
if (loners.length < CLUSTER_MIN_PTS) return;
// 简单DBSCAN
const clusters = [];
const visited = new Set();
const eps = CLUSTER_EPSILON;
const minPts = CLUSTER_MIN_PTS;
for (let i = 0; i < loners.length; i++) {
if (visited.has(i)) continue;
const p = loners[i];
const neighbors = [];
for (let j = 0; j < loners.length; j++) {
if (i === j) continue;
if (distance(p, loners[j]) <= eps) {
neighbors.push(j);
}
}
if (neighbors.length < minPts - 1) {
visited.add(i);
continue;
}
// 形成新族群
const cluster = [i];
visited.add(i);
const queue = [...neighbors];
while (queue.length > 0) {
const idx = queue.shift();
if (visited.has(idx)) continue;
visited.add(idx);
const q = loners[idx];
const qNeighbors = [];
for (let k = 0; k < loners.length; k++) {
if (visited.has(k)) continue;
if (distance(q, loners[k]) <= eps) {
qNeighbors.push(k);
}
}
if (qNeighbors.length >= minPts - 1) {
for (const n of qNeighbors) {
if (!visited.has(n)) {
queue.push(n);
}
}
}
cluster.push(idx);
}
if (cluster.length >= minPts) {
clusters.push(cluster);
}
}
// 为每个聚类创建族群
for (const cluster of clusters) {
const members = cluster.map(idx => loners[idx]);
// 计算平均熟悉度
let totalFam = 0;
let pairs = 0;
for (let i = 0; i < members.length; i++) {
for (let j = i+1; j < members.length; j++) {
const a = members[i];
const b = members[j];
const fam = (a.familiarity[b.id] || 0);
totalFam += fam;
pairs++;
}
}
const avgFam = pairs > 0 ? totalFam / pairs : 0;
if (avgFam < FAMILIARITY_THRESHOLD) continue; // 熟悉度不够
// 创建族群
const clanId = ++clanIdCounter;
const clanName = CLAN_NAMES[clanId % CLAN_NAMES.length] + (clanId > 10 ? Math.floor(clanId/10) : '');
const color = CLAN_COLORS[clanId % CLAN_COLORS.length];
const clan = {
id: clanId,
name: clanName,
color: color,
members: [],
leader: null,
deputy: null,
center: { x: 0, y: 0 },
radius: 0,
preyScore: 0
};
// 分配clanId
for (const cat of members) {
cat.clanId = clanId;
cat.role = 'warrior';
clan.members.push(cat);
}
// 计算中心和半径
updateClanTerritory(clan);
// 分配角色
assignRoles(clan);
clans.push(clan);
addFloatingText(clan.center.x, clan.center.y-20, `新族群: ${clanName} (${clan.members.length}猫)`, color);
}
}
function updateClanTerritory(clan) {
if (clan.members.length === 0) return;
let cx = 0, cy = 0;
for (const cat of clan.members) {
cx += cat.x;
cy += cat.y;
}
cx /= clan.members.length;
cy /= clan.members.length;
clan.center = { x: cx, y: cy };
let maxDist = 0;
for (const cat of clan.members) {
const d = distance(clan.center, cat);
if (d > maxDist) maxDist = d;
}
clan.radius = Math.max(maxDist, 80);
}
function assignRoles(clan) {
if (clan.members.length === 0) return;
// 按捕猎+声望排序
const sorted = [...clan.members].sort((a,b) => (b.huntCount + b.reputation) - (a.huntCount + a.reputation));
// 首领
const leader = sorted[0];
leader.role = 'leader';
clan.leader = leader;
// 副首领
if (sorted.length > 1) {
sorted[1].role = 'deputy';
clan.deputy = sorted[1];
}
// 猫后(哺乳母猫)
for (const cat of clan.members) {
if (cat.isLactating && cat.role === 'warrior') {
cat.role = 'queen';
}
}
// 幼崽(成长中的猫)
for (const cat of clan.members) {
if (cat.growTimer < CAT_GROW_TIME) {
cat.role = 'kit';
}
}
}
// ---------- 合作捕猎与领地驱逐 ----------
function handleCooperativeHunt(cat, prey) {
// 如果猫有族群,且同族群成员也在追同一猎物,则加速
if (cat.clanId !== null) {
const clan = clans.find(c => c.id === cat.clanId);
if (clan) {
// 检查同族其他猫是否也锁定同一猎物
let allies = 0;
for (const other of clan.members) {
if (other === cat) continue;
if (other.targetPrey === prey) allies++;
}
if (allies > 0) {
// 速度加成
const bonus = 1 + allies * 0.05;
// 在捕猎中应用(在捕猎移动时)
cat.cooperativeBonus = bonus;
}
}
}
// 捕获后分享
// 在捕猎成功处,如果猎物被捕获,附近同族猫恢复少量饥饿
}
function handleTerritoryDefense() {
// 对每个族群,检查是否有其他族群的猫进入领地核心区域(半径0.6*radius)
for (const clan of clans) {
if (clan.members.length === 0) continue;
const coreRadius = clan.radius * 0.6;
for (const cat of cats) {
if (cat.clanId === clan.id) continue;
if (cat.isSleeping || cat.isHelping) continue;
const d = distance(cat, clan.center);
if (d < coreRadius) {
// 驱赶:领地成员会追逐入侵者
// 找最近的领地成员去追逐
let defender = null;
let minDist = Infinity;
for (const member of clan.members) {
if (member.isSleeping || member.isHelping || member.isLactating) continue;
const dd = distance(member, cat);
if (dd < minDist) {
minDist = dd;
defender = member;
}
}
if (defender) {
// 设置追逐目标
defender.targetIntruder = cat;
defender.defenseMode = true;
}
}
}
}
}
// ---------- 生成函数 ----------
function generateTrees() {
trees = [];
let tries = 0;
while (trees.length < TREE_TOTAL_COUNT && tries < TREE_TOTAL_COUNT*20) {
tries++;
const x = WORLD_BOUND.left + 50 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 100);
const y = WORLD_BOUND.top + 50 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 100);
let ok = true;
for (const t of trees) if (distance({x,y}, t) < TREE_MIN_DISTANCE) { ok = false; break; }
if (ok) trees.push({x,y});
}
if (trees.length === 0) {
const fallback = [
[0,0], [300,200], [-100,100], [500,-50], [200,400],
[-400,-200], [600,300], [-200,500], [800,0], [400,-300]
];
for (const [x,y] of fallback) trees.push({x,y});
}
}
function trySpawnHerb() {
if (herbs.length >= HERB_MAX_COUNT) return false;
for (let i=0; i<30; i++) {
const x = WORLD_BOUND.left + 20 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 40);
const y = WORLD_BOUND.top + 20 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 40);
let ok = true;
for (const t of trees) if (distance({x,y}, t) < TREE_COLLIDE_RADIUS + 10) { ok = false; break; }
if (!ok) continue;
for (const h of herbs) if (distance({x,y}, h) < HERB_MIN_SPACE) { ok = false; break; }
if (ok) { herbs.push({x,y}); return true; }
}
return false;
}
function generateHerbs() {
herbs = [];
let count = 0;
while (count < HERB_MAX_COUNT && count < 200) {
if (trySpawnHerb()) count++;
else break;
}
if (herbs.length < 20) {
for (let i=0; i<40; i++) {
herbs.push({
x: WORLD_BOUND.left + 30 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 60),
y: WORLD_BOUND.top + 30 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 60)
});
}
}
}
function createPrey(x, y) {
return {
x, y,
angle: Math.random()*2*Math.PI,
wanderTimer: Math.random()*100,
targetHerb: null,
targetMate: null,
eatenHerbs: 0,
breedCooldown: 0,
hungerTimer: MAX_HUNGER_TIME,
fleeTarget: null,
stuckTimer: 0,
isSleeping: false
};
}
function generatePreys() {
preys = [];
let tries = 0;
while (preys.length < PREY_COUNT && tries < PREY_COUNT*30) {
tries++;
const x = WORLD_BOUND.left + 20 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 40);
const y = WORLD_BOUND.top + 20 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 40);
let ok = true;
for (const t of trees) if (distance({x,y}, t) < TREE_COLLIDE_RADIUS + PREY_RADIUS) { ok = false; break; }
if (ok) preys.push(createPrey(x,y));
}
if (preys.length < 10) {
for (let i=0; i<20; i++) {
preys.push(createPrey(
WORLD_BOUND.left + 40 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 80),
WORLD_BOUND.top + 40 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 80)
));
}
}
}
function generateBushes() {
bushes = [];
let tries = 0;
while (bushes.length < BUSH_COUNT && tries < BUSH_COUNT*30) {
tries++;
const x = WORLD_BOUND.left + 20 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 40);
const y = WORLD_BOUND.top + 20 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 40);
let ok = true;
for (const t of trees) if (distance({x,y}, t) < TREE_COLLIDE_RADIUS + BUSH_RADIUS) { ok = false; break; }
if (!ok) continue;
for (const b of bushes) if (distance({x,y}, b) < BUSH_RADIUS*1.8) { ok = false; break; }
if (ok) bushes.push({x,y});
}
if (bushes.length < 10) {
for (let i=0; i<20; i++) {
bushes.push({
x: WORLD_BOUND.left + 30 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 60),
y: WORLD_BOUND.top + 30 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 60)
});
}
}
}
function generateThorns() {
thorns = [];
let tries = 0;
while (thorns.length < THORN_COUNT && tries < THORN_COUNT*30) {
tries++;
const x = WORLD_BOUND.left + 20 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 40);
const y = WORLD_BOUND.top + 20 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 40);
let ok = true;
for (const t of trees) if (distance({x,y}, t) < TREE_COLLIDE_RADIUS + THORN_RADIUS) { ok = false; break; }
if (!ok) continue;
for (const th of thorns) if (distance({x,y}, th) < THORN_RADIUS*2) { ok = false; break; }
if (ok) thorns.push({x,y});
}
if (thorns.length < 5) {
for (let i=0; i<10; i++) {
thorns.push({
x: WORLD_BOUND.left + 30 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 60),
y: WORLD_BOUND.top + 30 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 60)
});
}
}
}
function randomGender() { return Math.random() < 0.5 ? "male" : "female"; }
function generateName() {
return PREFIXES[Math.floor(Math.random()*PREFIXES.length)] +
SUFFIXES[Math.floor(Math.random()*SUFFIXES.length)];
}
// 给猫分配唯一ID
let catIdCounter = 0;
function createCat(x, y, isAdult = true) {
const id = ++catIdCounter;
return {
id: id,
x, y,
angle: Math.random()*2*Math.PI,
wanderTimer: Math.random()*120,
targetPrey: null,
targetMate: null,
lastTarget: null,
policyScore: 0,
tryFlank: false,
stuckTimer: 0,
huntCount: isAdult ? 2 : 0,
breedCooldown: 0,
growTimer: isAdult ? CAT_GROW_TIME : 0,
gender: randomGender(),
hungerTimer: CAT_HUNGER_MAX,
mate: null,
parents: { father: null, mother: null },
kittens: [],
isLactating: false,
milkSupply: 0,
carryingPrey: null,
targetMateLocation: null,
name: generateName(),
helpingTarget: null,
waitingForHelp: false,
helpTargetPos: null,
reputation: 0,
isHelping: false,
helpAttemptTimer: 0,
nest: null,
isSleeping: false,
sleepPenalty: 0,
buildNestCooldown: 0,
learning: createLearning(),
huntMode: null,
targetBush: null,
coverPhase: null,
coverStartPos: null,
// 族群相关
clanId: null,
role: 'loner',
familiarity: {},
// 防御
targetIntruder: null,
defenseMode: false,
cooperativeBonus: 1
};
}
function createKitten(x, y, father, mother) {
const cat = createCat(x, y, false);
cat.growTimer = 0;
cat.huntCount = 0;
cat.parents.father = father;
cat.parents.mother = mother;
cat.name = generateName();
cat.learning = createLearning();
return cat;
}
function generateCats() {
cats = [];
catIdCounter = 0;
let tries = 0;
while (cats.length < 10 && tries < 10*30) {
tries++;
const x = WORLD_BOUND.left + 20 + Math.random()*(WORLD_BOUND.right - WORLD_BOUND.left - 40);
const y = WORLD_BOUND.top + 20 + Math.random()*(WORLD_BOUND.bottom - WORLD_BOUND.top - 40);
let ok = true;
for (const t of trees) if (distance({x,y}, t) < TREE_COLLIDE_RADIUS + CAT_RADIUS) { ok = false; break; }
if (ok) cats.push(createCat(x,y));
}
if (cats.length < 2) {
const fallback = [[0,0], [300,300]];
for (const [x,y] of fallback) cats.push(createCat(x,y));
}
}
// ---------- 交配 ----------
function performMating(father, mother) {
if (cats.length >= MAX_CATS) return false;
if (father.gender === mother.gender) return false;
if (father.growTimer < CAT_GROW_TIME || mother.growTimer < CAT_GROW_TIME) return false;
if (father.breedCooldown > 0 || mother.breedCooldown > 0) return false;
if (father.huntCount < CAT_BREED_REQUIRE || mother.huntCount < CAT_BREED_REQUIRE) return false;
father.mate = mother;
mother.mate = father;
const kittens = [];
for (let k=0; k<CAT_LITTER_SIZE; k++) {
let sx = (father.x + mother.x)/2 + (Math.random()-0.5)*45;
let sy = (father.y + mother.y)/2 + (Math.random()-0.5)*45;
if (checkCollision(sx, sy, CAT_RADIUS*0.4)) { sx += 35; sy += 35; }
kittens.push(createKitten(sx, sy, father, mother));
}
cats.push(...kittens);
mother.isLactating = true;
mother.milkSupply = 20;
father.kittens.push(...kittens);
mother.kittens.push(...kittens);
father.huntCount = 0;
mother.huntCount = 0;
father.breedCooldown = CAT_BREED_COOLDOWN;
mother.breedCooldown = CAT_BREED_COOLDOWN;
father.targetMate = null;
mother.targetMate = null;
updateLearning(father, 'mate', undefined, true);
updateLearning(mother, 'mate', undefined, true);
// 如果父母有族群,新生幼崽继承族群
if (father.clanId !== null && father.clanId === mother.clanId) {
const clan = clans.find(c => c.id === father.clanId);
if (clan) {
for (const kitten of kittens) {
kitten.clanId = clan.id;
kitten.role = 'kit';
clan.members.push(kitten);
// 增加熟悉度
kitten.familiarity[father.id] = 50;
kitten.familiarity[mother.id] = 50;
father.familiarity[kitten.id] = 50;
mother.familiarity[kitten.id] = 50;
}
}
}
return true;
}
// ---------- 浮动文字 ----------
function addFloatingText(x, y, text, color = "#ffd700") {
floatingTexts.push({ x, y, text, color, life: 60, maxLife: 60, alpha: 1.0 });
}
function updateFloatingTexts() {
for (let i=floatingTexts.length-1; i>=0; i--) {
const ft = floatingTexts[i];
ft.life -= 1;
ft.alpha = ft.life / ft.maxLife;
if (ft.life <= 0) floatingTexts.splice(i,1);
}
}
// ---------- 昼夜管理 ----------
function updateDayNight() {
if (isDay) {
dayTimer++;
if (dayTimer >= DAY_LENGTH) {
isDay = false;
dayTimer = 0;
nightTimer = 0;
handleNightStart();
timeDisplay.textContent = "🌙 夜晚";
}
} else {
nightTimer++;
if (nightTimer >= NIGHT_LENGTH) {
isDay = true;
nightTimer = 0;
dayTimer = 0;
handleDayStart();
timeDisplay.textContent = "☀️ 白天";
}
}
}
function handleNightStart() {
for (const cat of cats) {
if (cat.isSleeping) continue;
const progress = cat.growTimer / CAT_GROW_TIME;
if (progress < 0.33) continue;
const hungry = cat.hungerTimer < CAT_HUNGER_STATE;
if (hungry && Math.random() < 0.3) {
cat.isSleeping = false;
continue;
}
if (cat.nest && !hungry) {
cat.isSleeping = true;
updateLearning(cat, 'nest', undefined, true);
} else if (!cat.nest) {
cat.isSleeping = true;
cat.sleepPenalty = (cat.sleepPenalty || 0) + 1;
updateLearning(cat, 'nest', undefined, false);
cat.reputation = Math.max(0, cat.reputation - 1);
} else {
cat.isSleeping = true;
}
}
for (const prey of preys) {
prey.isSleeping = Math.random() < 0.8;
}
}
function handleDayStart() {
for (const cat of cats) {
cat.isSleeping = false;
}
for (const prey of preys) {
prey.isSleeping = false;
}
}
// ---------- 筑巢 ----------
function tryBuildNest(cat) {
if (cat.hungerTimer < CAT_FULL_THRESHOLD) return false;
if (cat.isHelping || cat.targetMate || cat.isSleeping) return false;
if (cat.buildNestCooldown > 0) return false;
if (cat.nest) return false;
let treeCount = 0, thornCount = 0;
for (const tree of trees) {
if (distance(cat, tree) < NEST_CHECK_RADIUS) treeCount++;
}
for (const thorn of thorns) {
if (distance(cat, thorn) < NEST_CHECK_RADIUS) thornCount++;
}
if (treeCount < 1 || thornCount < 1) {
updateLearning(cat, 'nest', undefined, false);
return false;
}
let bestX = cat.x, bestY = cat.y;
let bestScore = 0;
for (let attempt=0; attempt<20; attempt++) {
const angle = Math.random()*2*Math.PI;
const dist = 20 + Math.random()*40;
const tx = cat.x + Math.cos(angle)*dist;
const ty = cat.y + Math.sin(angle)*dist;
if (!isPointInWorld(tx, ty)) continue;
if (checkCollision(tx, ty, CAT_RADIUS)) continue;
let score = 0;
for (const nest of nests) {
const d = distance({x:tx,y:ty}, nest);
if (d < NEST_PREFER_RADIUS) score += (NEST_PREFER_RADIUS - d)/NEST_PREFER_RADIUS;
}
score += Math.random()*0.5;
if (score > bestScore) {
bestScore = score;
bestX = tx; bestY = ty;
}
}
const nest = { x: bestX, y: bestY, owner: cat };
nests.push(nest);
cat.nest = nest;
cat.buildNestCooldown = 600;
addFloatingText(bestX, bestY-20, cat.name+" 建了窝", "#88dd88");
updateLearning(cat, 'nest', undefined, true);
return true;
}
// ---------- 更新猎物 ----------
function updateBreeding() {
if (preys.length >= PREY_MAX_COUNT) return;
for (let i=0; i<preys.length; i++) {
const a = preys[i];
if (a.breedCooldown > 0 || a.eatenHerbs < FEED_REQUIRE) continue;
for (let j=i+1; j<preys.length; j++) {
const b = preys[j];
if (b.breedCooldown > 0 || b.eatenHerbs < FEED_REQUIRE) continue;
if (distance(a,b) <= BREED_DISTANCE) {
const cx = (a.x+b.x)/2, cy = (a.y+b.y)/2;
for (let k=0; k<BABY_COUNT; k++) {
let sx = cx + (Math.random()-0.5)*40;
let sy = cy + (Math.random()-0.5)*40;
if (checkCollision(sx, sy, PREY_RADIUS)) { sx += 30; sy += 30; }
if (preys.length < PREY_MAX_COUNT) preys.push(createPrey(sx, sy));
}
a.eatenHerbs = 0; b.eatenHerbs = 0;
a.breedCooldown = 600; b.breedCooldown = 600;
break;
}
}
}
}
function updatePreys() {
for (let i=preys.length-1; i>=0; i--) {
const prey = preys[i];
if (prey.isSleeping) continue;
prey.hungerTimer -= 1;
if (prey.hungerTimer <= 0) { preys.splice(i,1); continue; }
prey.wanderTimer -= 1;
if (prey.breedCooldown > 0) prey.breedCooldown--;
prey.targetHerb = null;
prey.targetMate = null;
prey.fleeTarget = null;
let nearestCat = null, minCatDist = Infinity;
for (const cat of cats) {
if (cat.isSleeping) continue;
const d = distance(prey, cat);
if (d > PREY_VIEW_RANGE) continue;
const angleToCat = Math.atan2(cat.y - prey.y, cat.x - prey.x);
let delta = angleToCat - prey.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= PREY_VIEW_ANGLE/2 && !isLineBlocked(prey.x, prey.y, cat.x, cat.y)) {
if (d < minCatDist) { minCatDist = d; nearestCat = cat; }
}
}
let moveX = 0, moveY = 0;
if (nearestCat) {
const dxAway = prey.x - nearestCat.x, dyAway = prey.y - nearestCat.y;
const distToCat = Math.hypot(dxAway, dyAway);
let fleeAngle = Math.atan2(dyAway, dxAway);
prey.angle = fleeAngle;
const testX = prey.x + Math.cos(fleeAngle)*30;
const testY = prey.y + Math.sin(fleeAngle)*30;
if (!isPointInWorld(testX, testY)) {
fleeAngle += (Math.random()>0.5 ? 1 : -1) * Math.PI/4;
prey.angle = fleeAngle;
}
let speedScale = 1;
if (distToCat < 160) speedScale = 1 + (160 - distToCat)/160 * 0.45;
const speed = PREY_FLEE_SPEED * speedScale;
moveX = Math.cos(fleeAngle) * speed;
moveY = Math.sin(fleeAngle) * speed;
} else {
if (prey.eatenHerbs >= FEED_REQUIRE && prey.breedCooldown <= 0) {
let nearestMate = null, minDist = Infinity;
for (const other of preys) {
if (other === prey || other.eatenHerbs < FEED_REQUIRE || other.breedCooldown > 0 || other.isSleeping) continue;
const d = distance(prey, other);
if (d > PREY_VIEW_RANGE || isLineBlocked(prey.x, prey.y, other.x, other.y)) continue;
const angleToTarget = Math.atan2(other.y - prey.y, other.x - prey.x);
let delta = angleToTarget - prey.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= PREY_VIEW_ANGLE/2 && d < minDist) {
minDist = d;
nearestMate = other;
}
}
prey.targetMate = nearestMate;
}
if (!prey.targetMate) {
let nearestHerb = null, minDist = Infinity;
for (const herb of herbs) {
const d = distance(prey, herb);
if (d > PREY_VIEW_RANGE || isLineBlocked(prey.x, prey.y, herb.x, herb.y)) continue;
const angleToTarget = Math.atan2(herb.y - prey.y, herb.x - prey.x);
let delta = angleToTarget - prey.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= PREY_VIEW_ANGLE/2 && d < minDist) {
minDist = d;
nearestHerb = herb;
}
}
prey.targetHerb = nearestHerb;
}
if (prey.targetMate) {
const d = distance(prey, prey.targetMate);
prey.angle = Math.atan2(prey.targetMate.y - prey.y, prey.targetMate.x - prey.x);
if (d > BREED_DISTANCE) {
moveX = (prey.targetMate.x - prey.x)/d * PREY_SPEED;
moveY = (prey.targetMate.y - prey.y)/d * PREY_SPEED;
}
} else if (prey.targetHerb) {
const d = distance(prey, prey.targetHerb);
prey.angle = Math.atan2(prey.targetHerb.y - prey.y, prey.targetHerb.x - prey.x);
if (d > PREY_FEED_RANGE) {
moveX = (prey.targetHerb.x - prey.x)/d * PREY_SPEED;
moveY = (prey.targetHerb.y - prey.y)/d * PREY_SPEED;
} else {
const idx = herbs.indexOf(prey.targetHerb);
if (idx > -1) herbs.splice(idx,1);
prey.eatenHerbs += 1;
prey.hungerTimer = MAX_HUNGER_TIME;
prey.targetHerb = null;
}
} else {
if (prey.wanderTimer <= 0) {
prey.angle = Math.random()*2*Math.PI;
prey.wanderTimer = 90 + Math.random()*140;
}
moveX = Math.cos(prey.angle) * PREY_SPEED * 0.65;
moveY = Math.sin(prey.angle) * PREY_SPEED * 0.65;
}
}
safeMove(prey, moveX, moveY, PREY_RADIUS);
}
updateBreeding();
}
// ---------- 更新猫(整合族群逻辑) ----------
function updateCats() {
// 睡眠回窝
for (const cat of cats) {
if (cat.isSleeping && cat.nest) {
const d = distance(cat, cat.nest);
if (d > 10) {
const moveX = (cat.nest.x - cat.x)/d * CAT_SPEED * 0.5;
const moveY = (cat.nest.y - cat.y)/d * CAT_SPEED * 0.5;
safeMove(cat, moveX, moveY, CAT_RADIUS);
}
if (cat.hungerTimer < CAT_HUNGER_MAX) {
cat.hungerTimer += 0.2;
if (cat.hungerTimer > CAT_HUNGER_STATE + 200) cat.hungerTimer = CAT_HUNGER_STATE + 200;
}
continue;
}
}
for (let i=cats.length-1; i>=0; i--) {
const cat = cats[i];
if (cat.isSleeping) continue;
// 等待救助时饥饿暂停
if (cat.waitingForHelp) {
let helperAlive = false;
for (const other of cats) {
if (other.helpingTarget === cat) { helperAlive = true; break; }
}
if (!helperAlive) {
cat.waitingForHelp = false;
} else {
continue;
}
}
cat.hungerTimer -= 1;
if (cat.hungerTimer <= 0) {
if (cat.waitingForHelp) {
for (const other of cats) {
if (other.helpingTarget === cat) {
other.helpingTarget = null;
other.isHelping = false;
other.carryingPrey = null;
other.helpAttemptTimer = 0;
}
}
}
cats.splice(i,1);
continue;
}
if (cat.growTimer < CAT_GROW_TIME) cat.growTimer += 1;
if (cat.breedCooldown > 0) cat.breedCooldown--;
if (cat.buildNestCooldown > 0) cat.buildNestCooldown--;
const progress = cat.growTimer / CAT_GROW_TIME;
let stage = 2;
if (progress < 0.33) stage = 0;
else if (progress < 0.66) stage = 1;
const scaleFactor = stage===0 ? 0.4 : (stage===1 ? 0.7 : 1.0);
const currentRadius = CAT_RADIUS * scaleFactor;
// 哺乳
if (cat.isLactating && cat.kittens.length > 0) {
if (cat.milkSupply > 0) {
for (const kitten of cat.kittens) {
if (distance(cat, kitten) < 40) {
cat.milkSupply -= 0.5;
kitten.growTimer += 2;
}
}
if (cat.milkSupply <= 0) cat.isLactating = false;
} else {
cat.isLactating = false;
}
cat.targetMate = null;
cat.targetPrey = null;
continue;
}
// 送货
if (cat.carryingPrey && cat.mate) {
const target = cat.mate;
const d = distance(cat, target);
cat.angle = Math.atan2(target.y - cat.y, target.x - cat.x);
if (d > 30) {
const moveX = (target.x - cat.x)/d * CAT_SPEED * 1.2;
const moveY = (target.y - cat.y)/d * CAT_SPEED * 1.2;
safeMove(cat, moveX, moveY, currentRadius);
} else {
target.milkSupply += 30;
target.isLactating = true;
cat.carryingPrey = null;
cat.targetMateLocation = null;
}
cat.targetMate = null;
cat.targetPrey = null;
continue;
}
const hungry = cat.hungerTimer < CAT_HUNGER_STATE;
const full = cat.hungerTimer > CAT_FULL_THRESHOLD;
const canMate = (stage===2) && cat.huntCount >= CAT_BREED_REQUIRE && cat.breedCooldown === 0;
// ----- 救助行为(最高优先级)-----
if (cat.isHelping) {
if (hungry) {
if (cat.helpingTarget) {
cat.helpingTarget.waitingForHelp = false;
cat.helpingTarget = null;
}
cat.isHelping = false;
cat.carryingPrey = null;
cat.helpAttemptTimer = 0;
updateLearning(cat, 'rescue', undefined, false);
} else {
if (cat.carryingPrey) {
const targetPos = cat.helpTargetPos;
if (targetPos) {
const d = distance(cat, targetPos);
cat.angle = Math.atan2(targetPos.y - cat.y, targetPos.x - cat.x);
if (d > 30) {
const moveX = (targetPos.x - cat.x)/d * CAT_SPEED * 1.2;
const moveY = (targetPos.y - cat.y)/d * CAT_SPEED * 1.2;
safeMove(cat, moveX, moveY, currentRadius);
} else {
if (cat.helpingTarget && cat.helpingTarget.waitingForHelp) {
cat.helpingTarget.hungerTimer = CAT_HUNGER_MAX;
cat.helpingTarget.waitingForHelp = false;
cat.reputation += 2;
cat.carryingPrey = null;
addFloatingText(cat.x, cat.y-30, "喂食成功", "#8f8");
updateLearning(cat, 'rescue', undefined, true);
}
cat.helpingTarget = null;
cat.isHelping = false;
cat.helpTargetPos = null;
cat.helpAttemptTimer = 0;
}
} else {
cat.isHelping = false;
cat.carryingPrey = null;
cat.helpingTarget = null;
cat.helpAttemptTimer = 0;
updateLearning(cat, 'rescue', undefined, false);
}
} else {
// 救助过程中捕猎
cat.targetPrey = null;
let nearestPrey = null, minDist = Infinity;
for (const prey of preys) {
if (prey.isSleeping) continue;
const d = distance(cat, prey);
if (d > CAT_VIEW_RANGE || isLineBlocked(cat.x, cat.y, prey.x, prey.y)) continue;
const angleToTarget = Math.atan2(prey.y - cat.y, prey.x - cat.x);
let delta = angleToTarget - cat.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= CAT_VIEW_ANGLE/2 && d < minDist) {
minDist = d;
nearestPrey = prey;
}
}
if (nearestPrey) {
cat.targetPrey = nearestPrey;
const d = distance(cat, nearestPrey);
cat.angle = Math.atan2(nearestPrey.y - cat.y, nearestPrey.x - cat.x);
if (d > CAT_HUNT_RANGE) {
let moveX, moveY;
if (cat.tryFlank) {
const flankOffset = cat.angle + (Math.random()>0.5 ? 0.6 : -0.6);
moveX = Math.cos(flankOffset) * CAT_SPEED;
moveY = Math.sin(flankOffset) * CAT_SPEED;
} else {
moveX = (nearestPrey.x - cat.x)/d * CAT_SPEED;
moveY = (nearestPrey.y - cat.y)/d * CAT_SPEED;
}
safeMove(cat, moveX, moveY, currentRadius);
} else {
const idx = preys.indexOf(nearestPrey);
if (idx > -1) {
const prey = preys.splice(idx,1)[0];
cat.huntCount += 1;
cat.hungerTimer = CAT_HUNGER_MAX;
cat.carryingPrey = prey;
cat.tryFlank = true;
cat.targetPrey = null;
const state = cat.hungerTimer < CAT_HUNGER_STATE ? 'hungry' :
cat.hungerTimer > CAT_FULL_THRESHOLD ? 'full' : 'normal';
updateLearning(cat, 'hunt', state, true);
}
}
} else {
cat.helpAttemptTimer += 1;
if (cat.helpAttemptTimer > 300) {
if (cat.helpingTarget) {
cat.helpingTarget.waitingForHelp = false;
cat.helpingTarget = null;
}
cat.isHelping = false;
cat.helpAttemptTimer = 0;
updateLearning(cat, 'rescue', undefined, false);
} else {
cat.wanderTimer -= 1;
if (cat.wanderTimer <= 0) {
cat.angle = Math.random()*2*Math.PI;
cat.wanderTimer = 100 + Math.random()*180;
}
const moveX = Math.cos(cat.angle) * CAT_SPEED * 0.7;
const moveY = Math.sin(cat.angle) * CAT_SPEED * 0.7;
safeMove(cat, moveX, moveY, currentRadius);
}
}
}
}
continue;
}
// ============================================================
// 未吃饱强制捕猎(含掩护学习)
// ============================================================
const isNotFull = !full;
if (isNotFull && stage !== 0 && !cat.isHelping && !cat.carryingPrey) {
// 1. 搜索视野内最近的猎物
let nearestPrey = null;
let minDist = Infinity;
for (const prey of preys) {
if (prey.isSleeping) continue;
const d = distance(cat, prey);
if (d > CAT_VIEW_RANGE || isLineBlocked(cat.x, cat.y, prey.x, prey.y)) continue;
const angleToTarget = Math.atan2(prey.y - cat.y, prey.x - cat.x);
let delta = angleToTarget - cat.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= CAT_VIEW_ANGLE/2 && d < minDist) {
minDist = d;
nearestPrey = prey;
}
}
// 如果当前没有锁定的猎物,但找到了新猎物
if (!cat.targetPrey && nearestPrey) {
// 评估是否使用掩护
const bestBush = findBestCoverBush(cat, nearestPrey);
const directRate = cat.learning.cover.attempts > 0 ?
cat.learning.cover.success / cat.learning.cover.attempts : 0.5;
const explore = Math.random() < 0.15;
let useCover = false;
if (bestBush && (directRate > 0.4 || explore)) {
useCover = true;
}
if (useCover) {
cat.huntMode = 'cover';
cat.targetBush = bestBush;
cat.coverPhase = 'approach';
cat.coverStartPos = { x: cat.x, y: cat.y };
cat.targetPrey = nearestPrey;
updateLearning(cat, 'cover', undefined, false);
addFloatingText(cat.x, cat.y-20, '🌿 利用掩护', '#88dd88');
} else {
cat.huntMode = 'direct';
cat.targetPrey = nearestPrey;
updateLearning(cat, 'hunt', 'normal', false);
}
}
// 如果猫正在掩护模式
if (cat.huntMode === 'cover' && cat.targetPrey) {
const prey = cat.targetPrey;
const bush = cat.targetBush;
if (!bush || !prey.alive) {
cat.huntMode = null;
cat.targetPrey = null;
cat.targetBush = null;
cat.coverPhase = null;
continue;
}
const dToBush = distance(cat, bush);
const dToPrey = distance(cat, prey);
switch (cat.coverPhase) {
case 'approach':
if (dToBush > 5) {
const angleToBush = Math.atan2(bush.y - cat.y, bush.x - cat.x);
cat.x += Math.cos(angleToBush) * CAT_SPEED * 0.8;
cat.y += Math.sin(angleToBush) * CAT_SPEED * 0.8;
cat.angle = angleToBush;
} else {
cat.coverPhase = 'stalk';
addFloatingText(cat.x, cat.y-20, '🤫 潜行中', '#88dd88');
}
break;
case 'stalk':
const angleToPrey = Math.atan2(prey.y - cat.y, prey.x - cat.x);
cat.angle = angleToPrey;
if (dToPrey < CAT_HUNT_RANGE + 20) {
cat.coverPhase = 'attack';
} else {
cat.x += Math.cos(angleToPrey) * CAT_SPEED * 0.5;
cat.y += Math.sin(angleToPrey) * CAT_SPEED * 0.5;
}
break;
case 'attack':
const attackAngle = Math.atan2(prey.y - cat.y, prey.x - cat.x);
cat.x += Math.cos(attackAngle) * CAT_SPEED * 1.8;
cat.y += Math.sin(attackAngle) * CAT_SPEED * 1.8;
cat.angle = attackAngle;
if (dToPrey < CAT_HUNT_RANGE) {
const idx = preys.indexOf(prey);
if (idx > -1) {
preys.splice(idx, 1);
cat.huntCount += 1;
cat.hungerTimer = CAT_HUNGER_MAX;
updateLearning(cat, 'cover', undefined, true);
updateLearning(cat, 'hunt', 'normal', true);
addFloatingText(cat.x, cat.y-30, '✅ 掩护捕猎成功!', '#8f8');
// 合作分享
if (cat.clanId !== null) {
const clan = clans.find(c => c.id === cat.clanId);
if (clan) {
for (const member of clan.members) {
if (member !== cat && distance(member, cat) < 120) {
member.hungerTimer = Math.min(CAT_HUNGER_MAX, member.hungerTimer + 200);
}
}
}
}
cat.huntMode = null;
cat.targetPrey = null;
cat.targetBush = null;
cat.coverPhase = null;
}
} else if (dToPrey > 150) {
updateLearning(cat, 'cover', undefined, false);
addFloatingText(cat.x, cat.y-30, '❌ 掩护失败', '#f88');
cat.huntMode = null;
cat.targetPrey = null;
cat.targetBush = null;
cat.coverPhase = null;
}
break;
}
cat.x = Math.max(WORLD_BOUND.left+20, Math.min(WORLD_BOUND.right-20, cat.x));
cat.y = Math.max(WORLD_BOUND.top+20, Math.min(WORLD_BOUND.bottom-20, cat.y));
continue;
}
// 直接追击模式
if (cat.huntMode === 'direct' && cat.targetPrey) {
const prey = cat.targetPrey;
if (!prey.alive) {
cat.huntMode = null;
cat.targetPrey = null;
continue;
}
const d = distance(cat, prey);
const angleToPrey = Math.atan2(prey.y - cat.y, prey.x - cat.x);
cat.angle = angleToPrey;
if (d > CAT_HUNT_RANGE) {
let moveX, moveY;
if (cat.tryFlank) {
const flankOffset = cat.angle + (Math.random()>0.5 ? 0.6 : -0.6);
moveX = Math.cos(flankOffset) * CAT_SPEED;
moveY = Math.sin(flankOffset) * CAT_SPEED;
} else {
moveX = Math.cos(angleToPrey) * CAT_SPEED;
moveY = Math.sin(angleToPrey) * CAT_SPEED;
}
safeMove(cat, moveX, moveY, currentRadius);
} else {
const idx = preys.indexOf(prey);
if (idx > -1) {
preys.splice(idx, 1);
cat.huntCount += 1;
cat.hungerTimer = CAT_HUNGER_MAX;
updateLearning(cat, 'hunt', 'normal', true);
addFloatingText(cat.x, cat.y-30, '✅ 直接捕猎成功!', '#8f8');
// 合作分享
if (cat.clanId !== null) {
const clan = clans.find(c => c.id === cat.clanId);
if (clan) {
for (const member of clan.members) {
if (member !== cat && distance(member, cat) < 120) {
member.hungerTimer = Math.min(CAT_HUNGER_MAX, member.hungerTimer + 200);
}
}
}
}
}
cat.huntMode = null;
cat.targetPrey = null;
}
continue;
}
// 闲逛
if (!cat.targetPrey && !cat.huntMode) {
cat.wanderTimer -= 1;
if (cat.wanderTimer <= 0) {
cat.angle = Math.random()*2*Math.PI;
cat.wanderTimer = 100 + Math.random()*180;
}
const moveX = Math.cos(cat.angle) * CAT_SPEED * 0.7;
const moveY = Math.sin(cat.angle) * CAT_SPEED * 0.7;
safeMove(cat, moveX, moveY, currentRadius);
}
continue;
}
// ===== 吃饱后的决策(优先求偶) =====
if (full) {
if (canMate) {
let nearestMate = null;
let minMateDist = Infinity;
for (const other of cats) {
if (other === cat) continue;
if (other.gender === cat.gender) continue;
if (other.growTimer < CAT_GROW_TIME) continue;
if (other.breedCooldown > 0) continue;
if (other.huntCount < CAT_BREED_REQUIRE) continue;
if (other.isSleeping) continue;
const d = distance(cat, other);
if (d > CAT_VIEW_RANGE || isLineBlocked(cat.x, cat.y, other.x, other.y)) continue;
const angleToOther = Math.atan2(other.y - cat.y, other.x - cat.x);
let delta = angleToOther - cat.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= CAT_VIEW_ANGLE/2 && d < minMateDist) {
minMateDist = d;
nearestMate = other;
}
}
if (nearestMate) {
cat.targetMate = nearestMate;
const d = distance(cat, nearestMate);
cat.angle = Math.atan2(nearestMate.y - cat.y, nearestMate.x - cat.x);
if (d > CAT_BREED_DISTANCE) {
const moveX = (nearestMate.x - cat.x)/d * CAT_SPEED;
const moveY = (nearestMate.y - cat.y)/d * CAT_SPEED;
safeMove(cat, moveX, moveY, currentRadius);
} else {
const father = cat.gender==="male" ? cat : nearestMate;
const mother = cat.gender==="female" ? cat : nearestMate;
const success = performMating(father, mother);
cat.targetMate = null;
if (cat.mate) cat.mate.targetMate = null;
if (!success) {
cat.breedCooldown = 60;
updateLearning(cat, 'mate', undefined, false);
}
}
continue;
}
}
// 救助
let rescueTarget = null;
let rescueDist = Infinity;
for (const other of cats) {
if (other === cat) continue;
if (other.hungerTimer >= CAT_HUNGER_STATE) continue;
if (other.waitingForHelp) continue;
const d = distance(cat, other);
if (d > CAT_VIEW_RANGE || isLineBlocked(cat.x, cat.y, other.x, other.y)) continue;
const angleToOther = Math.atan2(other.y - cat.y, other.x - cat.x);
let delta = angleToOther - cat.angle;
while (delta > Math.PI) delta -= 2*Math.PI;
while (delta < -Math.PI) delta += 2*Math.PI;
if (Math.abs(delta) <= CAT_VIEW_ANGLE/2 && d < rescueDist) {
rescueDist = d;
rescueTarget = other;
}
}
if (rescueTarget) {
cat.isHelping = true;
cat.helpingTarget = rescueTarget;
cat.helpTargetPos = { x: rescueTarget.x, y: rescueTarget.y };
cat.helpAttemptTimer = 0;
rescueTarget.waitingForHelp = true;
addFloatingText(cat.x, cat.y-30, "在这等着。", "#ffd700");
continue;
}
// 筑巢
if (!cat.nest && stage===2) {
if (Math.random() < 0.5) {
if (tryBuildNest(cat)) {
continue;
}
}
}
// 闲逛
cat.wanderTimer -= 1;
if (cat.wanderTimer <= 0) {
cat.angle = Math.random()*2*Math.PI;
cat.wanderTimer = 100 + Math.random()*180;
}
const moveX = Math.cos(cat.angle) * CAT_SPEED * 0.7;
const moveY = Math.sin(cat.angle) * CAT_SPEED * 0.7;
safeMove(cat, moveX, moveY, currentRadius);
}
}
// ----- 族群维护(定时执行) -----
clusterTimer++;
if (clusterTimer >= CLUSTER_INTERVAL) {
clusterTimer = 0;
// 更新熟悉度
updateFamiliarities();
// 聚类形成新族群
clusterCats();
// 更新现有族群领地和角色
for (const clan of clans) {
// 移除已死亡的成员
clan.members = clan.members.filter(c => cats.includes(c));
if (clan.members.length === 0) {
// 族群消失
addFloatingText(clan.center.x, clan.center.y, `族群 ${clan.name} 消亡`, "#ff4444");
clans = clans.filter(c => c !== clan);
continue;
}
updateClanTerritory(clan);
assignRoles(clan);
}
// 领地驱逐
handleTerritoryDefense();
}
// 处理追逐入侵者
for (const cat of cats) {
if (cat.defenseMode && cat.targetIntruder) {
const intruder = cat.targetIntruder;
if (!cats.includes(intruder) || intruder.isSleeping) {
cat.defenseMode = false;
cat.targetIntruder = null;
continue;
}
const d = distance(cat, intruder);
if (d < 20) {
// 驱赶成功,入侵者逃跑
const fleeAngle = Math.atan2(cat.y - intruder.y, cat.x - intruder.x);
intruder.x += Math.cos(fleeAngle) * 10;
intruder.y += Math.sin(fleeAngle) * 10;
cat.defenseMode = false;
cat.targetIntruder = null;
} else {
// 追逐
const angleToIntruder = Math.atan2(intruder.y - cat.y, intruder.x - cat.x);
cat.angle = angleToIntruder;
const moveX = Math.cos(angleToIntruder) * CAT_SPEED * 1.1;
const moveY = Math.sin(angleToIntruder) * CAT_SPEED * 1.1;
safeMove(cat, moveX, moveY, CAT_RADIUS);
}
}
}
}
// ---------- 寻找最佳掩护灌木 ----------
function findBestCoverBush(catPos, preyPos) {
let best = null;
let bestScore = -Infinity;
for (const bush of bushes) {
const dCat = distance(catPos, bush);
const dPrey = distance(bush, preyPos);
if (dPrey > 90 || dPrey < 20) continue;
if (dCat > 250) continue;
if (isLineBlocked(bush.x, bush.y, preyPos.x, preyPos.y)) continue;
const score = 60 - dPrey + (80 - dCat)*0.3;
if (score > bestScore) {
bestScore = score;
best = bush;
}
}
return best;
}
// ---------- 重置检测 ----------
function checkReset() {
if (preys.length === 0 || cats.length === 0) {
if (resetNoticeTimer === 0) {
resetWorld();
}
}
}
// ---------- 主更新 ----------
function updateWorld() {
herbRespawnTimer++;
if (herbRespawnTimer >= HERB_RESPAWN_INTERVAL) {
herbRespawnTimer = 0;
trySpawnHerb();
}
updateFloatingTexts();
updateDayNight();
if (resetNoticeTimer > 0) {
resetNoticeTimer--;
if (resetNoticeTimer === 0) {
resetNotice.style.opacity = '0';
}
}
}
// ---------- 面板 ----------
function updatePopulationPanel() {
let adult = 0, kid = 0;
for (const c of cats) {
if (c.growTimer >= CAT_GROW_TIME) adult++;
else kid++;
}
let followText = "无";
if (followedCat) {
const genderText = followedCat.gender === "male" ? "公猫" : "母猫";
followText = `${genderText} | 捕猎:${followedCat.huntCount}`;
}
populationPanel.textContent = `猎物:${preys.length} / 草:${herbs.length} / 成年猫:${adult} / 幼猫:${kid} / 跟随:${followText}`;
}
function updateDetailPanel() {
if (followedCat && !cats.includes(followedCat)) {
followedCat = null;
}
if (!followedCat) {
detailPanel.textContent = "点击一只猫查看详情";
return;
}
const c = followedCat;
const progress = c.growTimer / CAT_GROW_TIME;
let stageStr = "成年";
if (progress < 0.33) stageStr = "幼崽";
else if (progress < 0.66) stageStr = "幼猫";
const hungerStr = c.hungerTimer < CAT_HUNGER_STATE ? "饥饿" : (c.hungerTimer > CAT_FULL_THRESHOLD ? "吃饱" : "未吃饱");
const genderStr = c.gender === "male" ? "公" : "母";
const statusParts = [];
if (c.isLactating) statusParts.push("哺乳中");
if (c.carryingPrey) statusParts.push("携带猎物");
if (c.mate) statusParts.push("有配偶");
if (c.isHelping) statusParts.push("救助中");
if (c.waitingForHelp) statusParts.push("等待救助");
if (c.isSleeping) statusParts.push("💤 睡觉");
if (c.nest) statusParts.push("有窝");
if (c.clanId !== null) {
const clan = clans.find(cl => cl.id === c.clanId);
statusParts.push(`族群: ${clan ? clan.name : '未知'}`);
}
if (c.role) statusParts.push(`角色: ${c.role}`);
const statusStr = statusParts.length ? statusParts.join("、") : "无特殊";
const learn = c.learning;
const huntRate = (h) => {
const e = learn.hunt[h];
return e.attempts > 0 ? (e.success/e.attempts*100).toFixed(0) : 'N/A';
};
const huntInfo = `捕猎率: ${huntRate('hungry')}%/${huntRate('normal')}%/${huntRate('full')}%`;
const coverInfo = learn.cover.attempts > 0 ?
`掩护率: ${(learn.cover.success/learn.cover.attempts*100).toFixed(0)}%` :
'掩护: 无经验';
const rescueInfo = learn.rescue.attempts > 0 ? `救助率: ${(learn.rescue.success/learn.rescue.attempts*100).toFixed(0)}%` : '救助: 无经验';
const mateInfo = learn.mate.attempts > 0 ? `求偶率: ${(learn.mate.success/learn.mate.attempts*100).toFixed(0)}%` : '求偶: 无经验';
const nestInfo = learn.nest.attempts > 0 ? `筑巢率: ${(learn.nest.success/learn.nest.attempts*100).toFixed(0)}%` : '筑巢: 无经验';
const detail = `姓名:${c.name} | 性别:${genderStr} | 年龄:${stageStr} | 饥饿:${hungerStr} | 捕猎:${c.huntCount} | 幼崽:${c.kittens.length} | 声望:${c.reputation} | 状态:${statusStr} | ${huntInfo} | ${coverInfo} | ${rescueInfo} | ${mateInfo} | ${nestInfo}`;
detailPanel.textContent = detail;
}
// ---------- 渲染 ----------
function renderMap() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
if (followedCat) {
const targetX = canvas.width/2 - followedCat.x * scale;
const targetY = canvas.height/2 - followedCat.y * scale;
offsetX += (targetX - offsetX) * 0.08;
offsetY += (targetY - offsetY) * 0.08;
}
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
// 地面
ctx.fillStyle = GRASS_COLOR;
ctx.fillRect(-3000, -3000, 6000, 6000);
// 网格
ctx.beginPath();
ctx.setLineDash(DASH_PATTERN);
ctx.strokeStyle = GRID_COLOR;
ctx.globalAlpha = GRID_ALPHA;
ctx.lineWidth = 1;
const p1 = screenToWorld(0, 0);
const p2 = screenToWorld(canvas.width, canvas.height);
const startX = Math.floor(p1.x / GRID_SIZE) * GRID_SIZE;
const startY = Math.floor(p1.y / GRID_SIZE) * GRID_SIZE;
for (let x = startX; x <= p2.x; x += GRID_SIZE) {
ctx.moveTo(x, p1.y);
ctx.lineTo(x, p2.y);
}
for (let y = startY; y <= p2.y; y += GRID_SIZE) {
ctx.moveTo(p1.x, y);
ctx.lineTo(p2.x, y);
}
ctx.stroke();
ctx.globalAlpha = 1;
ctx.setLineDash([]);
// 绘制族群领地(半透明圆圈)
for (const clan of clans) {
if (clan.members.length === 0) continue;
ctx.beginPath();
ctx.arc(clan.center.x, clan.center.y, clan.radius, 0, 2*Math.PI);
ctx.strokeStyle = clan.color || '#ffffff';
ctx.lineWidth = 2;
ctx.setLineDash([10,10]);
ctx.stroke();
ctx.setLineDash([]);
// 族群名称
ctx.fillStyle = clan.color || '#ffffff';
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(clan.name, clan.center.x, clan.center.y - clan.radius - 8);
}
// 植物
ctx.fillStyle = HERB_COLOR;
for (const herb of herbs) {
ctx.beginPath();
ctx.moveTo(herb.x, herb.y - HERB_SIZE);
ctx.lineTo(herb.x - HERB_SIZE*0.7, herb.y + HERB_SIZE*0.6);
ctx.lineTo(herb.x + HERB_SIZE*0.7, herb.y + HERB_SIZE*0.6);
ctx.closePath();
ctx.fill();
}
// 灌木
ctx.fillStyle = "rgba(0,80,0,0.3)";
for (const bush of bushes) {
ctx.beginPath();
ctx.arc(bush.x, bush.y, BUSH_RADIUS, 0, 2*Math.PI);
ctx.fill();
ctx.strokeStyle = "rgba(0,200,0,0.4)";
ctx.lineWidth = 1;
ctx.setLineDash([4,4]);
ctx.stroke();
ctx.setLineDash([]);
}
// 荆棘
ctx.fillStyle = "#1a4a1a";
for (const thorn of thorns) {
ctx.beginPath();
ctx.arc(thorn.x, thorn.y, THORN_RADIUS, 0, 2*Math.PI);
ctx.fill();
ctx.strokeStyle = "#000";
ctx.lineWidth = 3;
const off = THORN_RADIUS * 0.6;
ctx.beginPath();
ctx.moveTo(thorn.x - off, thorn.y - off);
ctx.lineTo(thorn.x + off, thorn.y + off);
ctx.moveTo(thorn.x + off, thorn.y - off);
ctx.lineTo(thorn.x - off, thorn.y + off);
ctx.stroke();
}
// 树木
ctx.fillStyle = TREE_COLOR;
for (const tree of trees) {
ctx.beginPath();
ctx.arc(tree.x, tree.y, TREE_RADIUS, 0, 2*Math.PI);
ctx.fill();
}
// 猎物
for (const prey of preys) {
const color = prey.eatenHerbs >= FEED_REQUIRE ? (prey.targetMate ? "#555" : "#222") : "#111";
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(prey.x, prey.y, PREY_RADIUS, 0, 2*Math.PI);
ctx.fill();
}
// 猫本体
for (const cat of cats) {
const progress = Math.min(1, cat.growTimer / CAT_GROW_TIME);
let sf = 1;
if (progress < 0.33) sf = 0.4;
else if (progress < 0.66) sf = 0.7;
else sf = 1.0;
const r = CAT_RADIUS * sf;
const isAdult = progress >= 1;
const full = cat.hungerTimer > CAT_FULL_THRESHOLD;
const canBreed = isAdult && cat.huntCount >= CAT_BREED_REQUIRE && cat.breedCooldown === 0 && full;
if (cat === followedCat) {
ctx.beginPath();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 3;
ctx.arc(cat.x, cat.y, r + 10, 0, 2*Math.PI);
ctx.stroke();
}
if (canBreed) {
ctx.beginPath();
ctx.strokeStyle = "#ff88bb";
ctx.lineWidth = 2;
ctx.arc(cat.x, cat.y, r + 6, 0, 2*Math.PI);
ctx.stroke();
}
if (cat.hungerTimer < CAT_HUNGER_STATE) {
ctx.beginPath();
ctx.strokeStyle = "rgba(255,0,0,0.6)";
ctx.lineWidth = 2;
ctx.arc(cat.x, cat.y, r + 4, 0, 2*Math.PI);
ctx.stroke();
}
let color = CAT_COLOR;
if (cat.carryingPrey) color = "#3a86ff";
if (cat.waitingForHelp) color = "#ffaa44";
if (cat.isSleeping) color = "#555555";
// 根据族群上色
if (cat.clanId !== null) {
const clan = clans.find(c => c.id === cat.clanId);
if (clan) {
// 用族群的浅色
color = clan.color || CAT_COLOR;
}
}
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cat.x, cat.y, r, 0, 2*Math.PI);
ctx.fill();
ctx.fillStyle = "#995511";
ctx.beginPath();
ctx.arc(cat.x + Math.cos(cat.angle)*(r-3), cat.y + Math.sin(cat.angle)*(r-3), 3, 0, 2*Math.PI);
ctx.fill();
}
// 窝
for (const nest of nests) {
ctx.setLineDash([5,5]);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(nest.x, nest.y, CAT_RADIUS + 4, 0, 2*Math.PI);
ctx.stroke();
ctx.setLineDash([]);
}
// 边界
ctx.strokeStyle = "#000";
ctx.lineWidth = 4;
ctx.strokeRect(WORLD_BOUND.left, WORLD_BOUND.top,
WORLD_BOUND.right - WORLD_BOUND.left,
WORLD_BOUND.bottom - WORLD_BOUND.top);
ctx.restore();
// 夜晚覆盖层
if (!isDay) {
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
// 视野
for (const cat of cats) {
if (cat.isSleeping) continue;
const progress = Math.min(1, cat.growTimer / CAT_GROW_TIME);
let sf = 1;
if (progress < 0.33) sf = 0.4;
else if (progress < 0.66) sf = 0.7;
else sf = 1.0;
const viewRange = CAT_VIEW_RANGE * sf * scale;
const sx = cat.x * scale + offsetX;
const sy = cat.y * scale + offsetY;
ctx.save();
ctx.beginPath();
ctx.moveTo(sx, sy);
const startAngle = cat.angle - CAT_VIEW_ANGLE/2;
const endAngle = cat.angle + CAT_VIEW_ANGLE/2;
ctx.arc(sx, sy, viewRange, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = "rgba(242, 166, 90, 0.12)";
ctx.fill();
ctx.strokeStyle = "rgba(242, 166, 90, 0.35)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
for (const prey of preys) {
if (prey.isSleeping) continue;
const viewRange = PREY_VIEW_RANGE * scale;
const sx = prey.x * scale + offsetX;
const sy = prey.y * scale + offsetY;
ctx.save();
ctx.beginPath();
ctx.moveTo(sx, sy);
const startAngle = prey.angle - PREY_VIEW_ANGLE/2;
const endAngle = prey.angle + PREY_VIEW_ANGLE/2;
ctx.arc(sx, sy, viewRange, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = "rgba(0, 0, 0, 0.07)";
ctx.fill();
ctx.strokeStyle = "rgba(0, 0, 0, 0.14)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
// 文字
for (const cat of cats) {
const sx = cat.x * scale + offsetX;
const sy = cat.y * scale + offsetY;
const progress = Math.min(1, cat.growTimer / CAT_GROW_TIME);
let sf = 1;
if (progress < 0.33) sf = 0.4;
else if (progress < 0.66) sf = 0.7;
else sf = 1.0;
const r = CAT_RADIUS * sf * scale;
const symbol = cat.gender === "male" ? MALE_SYMBOL : FEMALE_SYMBOL;
const text = `(${symbol}) ${cat.name}`;
ctx.save();
ctx.font = "bold 14px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#fff";
ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 6;
ctx.fillText(text, sx, sy - r - 6);
ctx.restore();
}
for (const nest of nests) {
const sx = nest.x * scale + offsetX;
const sy = nest.y * scale + offsetY;
ctx.save();
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#ddd";
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 4;
ctx.fillText(nest.owner.name + " 的窝", sx, sy - (CAT_RADIUS+4)*scale - 6);
ctx.restore();
}
for (const ft of floatingTexts) {
const sx = ft.x * scale + offsetX;
const sy = ft.y * scale + offsetY;
ctx.save();
ctx.globalAlpha = ft.alpha;
ctx.font = "bold 20px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 8;
ctx.fillStyle = ft.color || "#ffd700";
ctx.fillText(ft.text, sx, sy);
ctx.restore();
}
for (const prey of preys) {
if (prey.isSleeping) {
const sx = prey.x * scale + offsetX;
const sy = prey.y * scale + offsetY;
ctx.save();
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#ccc";
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 4;
ctx.fillText("Z", sx, sy - PREY_RADIUS*scale - 2);
ctx.restore();
}
}
for (const cat of cats) {
if (cat.isSleeping) {
const sx = cat.x * scale + offsetX;
const sy = cat.y * scale + offsetY;
const progress = Math.min(1, cat.growTimer / CAT_GROW_TIME);
let sf = 1;
if (progress < 0.33) sf = 0.4;
else if (progress < 0.66) sf = 0.7;
else sf = 1.0;
const r = CAT_RADIUS * sf * scale;
ctx.save();
ctx.font = "18px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillStyle = "#aaddff";
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 4;
ctx.fillText("💤", sx, sy - r - 6);
ctx.restore();
}
}
}
// ---------- 交互 ----------
function getMouseScreenPos(e) {
if (e.touches) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
return { x: e.clientX, y: e.clientY };
}
function handlePointerDown(e) {
const pos = getMouseScreenPos(e);
pointerDownX = pos.x;
pointerDownY = pos.y;
pointerDownTime = Date.now();
isClick = true;
}
function handlePointerUp(e) {
if (!isClick) return;
const pos = getMouseScreenPos(e);
const dx = pos.x - pointerDownX;
const dy = pos.y - pointerDownY;
const dist = Math.hypot(dx, dy);
const elapsed = Date.now() - pointerDownTime;
if (dist < 10 && elapsed < 300) {
const canvasPos = getCanvasCoords(pos.x, pos.y);
const world = screenToWorld(canvasPos.x, canvasPos.y);
const pixelRatio = getPixelRatio();
let clicked = null;
for (const cat of cats) {
const progress = Math.min(1, cat.growTimer / CAT_GROW_TIME);
let sf = 1;
if (progress < 0.33) sf = 0.4;
else if (progress < 0.66) sf = 0.7;
else sf = 1.0;
const r = CAT_RADIUS * sf;
const hitRadius = Math.max(30, 30 / pixelRatio);
if (Math.hypot(world.x - cat.x, world.y - cat.y) <= r + hitRadius) {
clicked = cat;
break;
}
}
if (clicked) {
followedCat = clicked;
isDragging = false;
return;
} else {
followedCat = null;
}
}
}
function handlePointerMove(e) {
const pos = getMouseScreenPos(e);
if (isClick) {
const dx = pos.x - pointerDownX;
const dy = pos.y - pointerDownY;
if (Math.hypot(dx, dy) > 10) {
isClick = false;
isDragging = true;
dragStartX = pointerDownX;
dragStartY = pointerDownY;
startOffsetX = offsetX;
startOffsetY = offsetY;
}
}
if (isDragging) {
const pixelRatio = getPixelRatio();
const dx = (pos.x - dragStartX) * pixelRatio;
const dy = (pos.y - dragStartY) * pixelRatio;
offsetX = startOffsetX + dx;
offsetY = startOffsetY + dy;
}
}
function handlePointerCancel() {
isDragging = false;
isClick = false;
}
canvas.addEventListener("mousedown", handlePointerDown);
window.addEventListener("mousemove", handlePointerMove);
window.addEventListener("mouseup", handlePointerUp);
canvas.addEventListener("touchstart", (e) => {
if (e.touches.length === 1) {
const touch = e.touches[0];
handlePointerDown({ clientX: touch.clientX, clientY: touch.clientY });
} else if (e.touches.length === 2) {
lastTouchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY);
isDragging = false;
isClick = false;
}
}, { passive: true });
canvas.addEventListener("touchmove", (e) => {
e.preventDefault();
if (e.touches.length === 1) {
const touch = e.touches[0];
handlePointerMove({ clientX: touch.clientX, clientY: touch.clientY });
} else if (e.touches.length === 2) {
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY);
const cx = (e.touches[0].clientX + e.touches[1].clientX)/2;
const cy = (e.touches[0].clientY + e.touches[1].clientY)/2;
const canvasPos = getCanvasCoords(cx, cy);
const wBefore = screenToWorld(canvasPos.x, canvasPos.y);
const ratio = dist / lastTouchDist;
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale * ratio));
lastTouchDist = dist;
const wAfter = screenToWorld(canvasPos.x, canvasPos.y);
offsetX += (wAfter.x - wBefore.x) * scale;
offsetY += (wAfter.y - wBefore.y) * scale;
}
}, { passive: false });
canvas.addEventListener("touchend", (e) => {
if (e.touches.length === 0) {
const touch = e.changedTouches[0];
if (touch) handlePointerUp({ clientX: touch.clientX, clientY: touch.clientY });
else handlePointerUp({ clientX: pointerDownX, clientY: pointerDownY });
}
});
canvas.addEventListener("touchcancel", handlePointerCancel);
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const pos = { x: e.clientX, y: e.clientY };
const canvasPos = getCanvasCoords(pos.x, pos.y);
const wBefore = screenToWorld(canvasPos.x, canvasPos.y);
const zoom = e.deltaY > 0 ? -0.12 : 0.12;
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale + zoom));
const wAfter = screenToWorld(canvasPos.x, canvasPos.y);
offsetX += (wAfter.x - wBefore.x) * scale;
offsetY += (wAfter.y - wBefore.y) * scale;
}, { passive: false });
// ---------- 控制 ----------
function setSpeed(s) {
speedMultiplier = s;
document.querySelectorAll('.controls button').forEach(btn => btn.classList.remove('active'));
const map = {1:'speed1x', 2:'speed2x', 5:'speed5x'};
const el = document.getElementById(map[s]);
if (el) el.classList.add('active');
}
window.setSpeed = setSpeed;
function resetWorld() {
trees.length = 0;
herbs.length = 0;
preys.length = 0;
cats.length = 0;
bushes.length = 0;
thorns.length = 0;
nests.length = 0;
floatingTexts = [];
followedCat = null;
isDay = true;
dayTimer = 0;
nightTimer = 0;
timeDisplay.textContent = "☀️ 白天";
offsetX = 0;
offsetY = 0;
scale = 0.6;
clans = [];
clanIdCounter = 0;
clusterTimer = 0;
generateTrees();
generateHerbs();
generatePreys();
generateBushes();
generateThorns();
generateCats();
resetNotice.style.opacity = '1';
resetNoticeTimer = RESET_NOTICE_DURATION;
clanInfo.innerHTML = '';
}
window.resetWorld = resetWorld;
// ---------- 主循环 ----------
function gameLoop() {
checkReset();
for (let t=0; t<speedMultiplier; t++) {
updateWorld();
updatePreys();
updateCats();
}
updatePopulationPanel();
updateDetailPanel();
renderMap();
// 更新族群信息面板
let info = '';
for (const clan of clans) {
info += `<span style="color:${clan.color};">${clan.name}</span> (${clan.members.length}) `;
}
clanInfo.textContent = info || '暂无族群';
requestAnimationFrame(gameLoop);
}
// ---------- 初始化 ----------
generateTrees();
generateHerbs();
generatePreys();
generateBushes();
generateThorns();
generateCats();
gameLoop();
</script>
</body>
</html>Game Source: 猫武士 · 演替兴衰(族群版)
Creator: EpicCoder88
Libraries: none
Complexity: complex (2470 lines, 88.7 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-epiccoder88-mslu5gj0" to link back to the original. Then publish at arcadelab.ai/publish.