Files
EVEngine/examples/rpg/main.nut
T

489 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// EVEngine RPG 模块示例游戏 —— 「地牢生存」
//
// 一个最小但完整的动作 RPG 循环,用来演示 eve.RPG() 五套子系统怎么协同工作:
// 属性 attributes —— HP / MP / 耐力 / 攻击 / 防御,用 clampMin/clampMax
// modifier 把血条钳在 [0, max] 之间。
// 效果 effects —— JSON 注册的效果模板(力量姿态 / 衰弱 / 灼烧 / 治疗药水 /
// 被动回复),覆盖 instant / duration / period / stack
// 四种策略。
// 状态 status —— 运行时 buff/debuff 实例:剩余时间、叠加层数、周期 tick。
// 技能 skills —— 学习 / 冷却 / 消耗 / 读条时间 / 施放事件轮询。
// 结算 settlement —— 本例的伤害公式直接写在脚本里(见 computeDamage):
// RPG 模块的 SettlementPipeline::registerStage 是 C++
// 扩展点(未绑定到脚本),游戏项目通常会在原生插件里注册
// 自己的伤害/治疗阶段,脚本侧改用 eve.RPG().runSettlement()
// 触发;这里为了保持示例零原生依赖,改成纯脚本公式,
// 详见 docs/RPG系统设计.md。
//
// 玩法:键盘 1/2/3/4 释放技能,撑过一波波强度递增的怪物,R 重开。
// 运行: make run/macosx-debug GAME=examples/rpg (或对应平台的 run/<platform>-debug)
// ============================================================================
// ---- 软热重载状态保护:脚本被 dofile 重跑时不清空已创建的实体/UI ----
// (约定见 example/main.nut)
persist rpg = null
persist player = null
persist enemy = null
persist wave = 1
persist score = 0
persist gameOver = false
persist battleLog = []
persist hitFlash = { player = 0.0, enemy = 0.0 }
const PLAYER_MAX_HP = 100.0;
const PLAYER_MAX_MP = 50.0;
const PLAYER_MAX_STAMINA = 100.0;
// 效果 id -> 中文展示名,只用于状态栏文字。
effectNames <- {
["buff.power_stance"] = "力量姿态",
["debuff.weaken"] = "衰弱",
["dot.burn"] = "灼烧"
};
// 施法失败原因 -> 中文提示(对应 SkillSystem::canCast 的 reason 字符串)。
reasonText <- {
no_actor = "目标无效",
unknown_skill = "未知技能",
not_learned = "尚未学会",
already_casting = "正在读条",
on_cooldown = "冷却中",
insufficient_cost = "资源不足"
};
function randf(a, b) {
return a + (b - a) * (rand().tofloat() / RAND_MAX.tofloat());
}
function roundi(v) {
return floor(v + 0.5).tointeger();
}
function logLine(text) {
battleLog.push(text);
while (battleLog.len() > 6)
battleLog.remove(0);
}
// 部分构建的脚本绑定不含 isCastingSkill / getCastProgress(ECS 实体反射差异),
// 用 feature-detect 兜底,保证示例在任意构建下都能运行。
function actorCasting(actor) {
return actor != null && ("isCastingSkill" in actor) && actor.isCastingSkill();
}
function actorCastProgress(actor) {
if (actor == null || !("getCastProgress" in actor)) return 0.0;
return actor.getCastProgress();
}
// ---------------------------------------------------------------------------
// 数据驱动内容:效果 / 技能定义。注册接口是幂等的(按 id 覆盖),软重载时
// 重复调用也没问题,方便边跑边改数值。
// 内容从 data/*.json 读取——改数值不用碰脚本,保存即热重载。
// ---------------------------------------------------------------------------
function readTextFile(path) {
local handle = file(path, "r");
if (handle == null) return null;
local content = handle.read();
handle.close();
return content;
}
function registerContent() {
rpg.clearEffectDefinitions();
local effectsJson = readTextFile("data/effects.json");
local effectsLoaded = (effectsJson != null) ? rpg.registerEffectsFromJson(effectsJson) : 0;
if (effectsJson == null)
print("rpg: missing data/effects.json\n");
rpg.clearSkillDefinitions();
local skillsJson = readTextFile("data/skills.json");
local skillsLoaded = (skillsJson != null) ? rpg.registerSkillsFromJson(skillsJson) : 0;
if (skillsJson == null)
print("rpg: missing data/skills.json\n");
print(format("RPG content loaded: %d effects, %d skills\n", effectsLoaded, skillsLoaded));
}
// ---------------------------------------------------------------------------
// Actor 构建:把 health/mana/stamina 的 base 值钳在 [0, max]。
// clampMin/clampMax 是永久 modifier(source="system",高优先级),装一次
// 之后无论谁往 base 上加减,getFinalAttribute 读到的都是钳制后的值。
// ---------------------------------------------------------------------------
function setupVitals(actor, maxHp, maxMp, maxStamina) {
actor.setBaseAttribute("health", maxHp);
actor.setBaseAttribute("mana", maxMp);
actor.setBaseAttribute("stamina", maxStamina);
actor.addAttributeModifier("health", "system", "clampMin", 0.0, 900);
actor.addAttributeModifier("health", "system", "clampMax", maxHp, 901);
actor.addAttributeModifier("mana", "system", "clampMin", 0.0, 900);
actor.addAttributeModifier("mana", "system", "clampMax", maxMp, 901);
actor.addAttributeModifier("stamina", "system", "clampMin", 0.0, 900);
actor.addAttributeModifier("stamina", "system", "clampMax", maxStamina, 901);
}
function makePlayer() {
local p = rpg.newActor();
setupVitals(p, PLAYER_MAX_HP, PLAYER_MAX_MP, PLAYER_MAX_STAMINA);
p.setBaseAttribute("attack", 14.0);
p.setBaseAttribute("defense", 4.0);
p.learnSkill("player.strike");
p.learnSkill("player.fireball");
p.learnSkill("player.power_stance");
p.learnSkill("player.potion");
// 被动回复:duration 很长 + period=2s 的无限期 buff,纯靠 tick 事件在脚本里回血回蓝。
p.applyEffect("passive.regen", "system");
return p;
}
// 波次强度曲线:难度随波数线性增长,够撑十几波就会感到吃力。
function makeEnemy(w) {
local e = rpg.newActor();
local maxHp = 40.0 + w * 14.0;
setupVitals(e, maxHp, 0.0, 0.0);
e.setBaseAttribute("attack", 7.0 + w * 1.6);
e.setBaseAttribute("defense", 1.0 + w * 0.8);
e.learnSkill("enemy.claw");
e.learnSkill("enemy.roar");
return e;
}
function enemyMaxHp(w) { return 40.0 + w * 14.0; }
// ---------------------------------------------------------------------------
// 伤害结算(脚本侧公式,见文件头注释)。
// ---------------------------------------------------------------------------
function computeDamage(atk, def, critChance, critMult) {
local raw = atk - def * 0.5;
if (raw < 1.0) raw = 1.0;
local crit = randf(0.0, 1.0) < critChance;
if (crit) raw *= critMult;
return { dmg = roundi(raw), crit = crit };
}
function applyDamage(target, isPlayerTarget, dmg) {
target.modifyBaseAttribute("health", -dmg * 1.0);
if (isPlayerTarget) hitFlash.player = 0.15; else hitFlash.enemy = 0.15;
}
// ---------------------------------------------------------------------------
// 玩家输入:边沿检测(仅在“刚按下”那一帧触发),避免长按重复施法。
// ---------------------------------------------------------------------------
function keyPressed(key) {
return key_just_pressed(key);
}
function tryPlayerSkill(skillId, target, label) {
if (player.canCastSkill(skillId)) {
player.beginCastSkill(skillId, target);
} else {
local reason = player.canCastSkillReason(skillId);
local text = (reason in reasonText) ? reasonText[reason] : reason;
logLine(label + " 无法施放:" + text);
}
}
// ---------------------------------------------------------------------------
// 敌人 AI:简单的冷却+概率决策,学到的两个技能都靠 SkillSystem 管理冷却/读条。
// ---------------------------------------------------------------------------
function updateEnemyAI() {
if (actorCasting(enemy)) return;
if (enemy.canCastSkill("enemy.roar") && randf(0.0, 1.0) < 0.35) {
enemy.beginCastSkill("enemy.roar", player);
return;
}
if (enemy.canCastSkill("enemy.claw")) {
enemy.beginCastSkill("enemy.claw", player);
}
}
// ---------------------------------------------------------------------------
// 每帧轮询 RPG 模块产生的事件:技能命中结算 + 周期性 tick(灼烧/回复)。
// ---------------------------------------------------------------------------
function processEvents() {
local n = rpg.getTickEventCount();
for (local i = 0; i < n; i += 1) {
local actor = rpg.getTickEventActor(i);
local effectId = rpg.getTickEventEffectId(i);
local stacks = rpg.getTickEventStacks(i);
local isPlayer = (actor == player);
if (effectId == "dot.burn") {
local dmg = 4 * stacks;
applyDamage(actor, isPlayer, dmg);
logLine((isPlayer ? "玩家" : "敌人") + " 受到灼烧伤害 " + dmg + (stacks > 1 ? " (x" + stacks + ")" : ""));
} else if (effectId == "passive.regen") {
actor.modifyBaseAttribute("health", 3.0);
actor.modifyBaseAttribute("mana", 2.0);
actor.modifyBaseAttribute("stamina", 6.0);
}
}
local m = rpg.getCastEventCount();
for (local i = 0; i < m; i += 1) {
local caster = rpg.getCastEventCaster(i);
local target = rpg.getCastEventTarget(i);
local skillId = rpg.getCastEventSkillId(i);
local isPlayerCaster = (caster == player);
if (skillId == "player.strike" || skillId == "enemy.claw") {
local r = computeDamage(caster.getFinalAttribute("attack"), target.getFinalAttribute("defense"), 0.15, 1.6);
applyDamage(target, !isPlayerCaster, r.dmg);
logLine((isPlayerCaster ? "玩家" : "敌人") + " 普通攻击造成 " + r.dmg + " 伤害" + (r.crit ? "(暴击!)" : ""));
} else if (skillId == "player.fireball") {
local r = computeDamage(caster.getFinalAttribute("attack") + 10.0, target.getFinalAttribute("defense"), 0.2, 1.6);
applyDamage(target, !isPlayerCaster, r.dmg);
logLine("玩家 火球术造成 " + r.dmg + " 伤害" + (r.crit ? "(暴击!)" : "") + ",并点燃目标");
} else if (skillId == "player.power_stance") {
logLine("玩家 进入力量姿态,攻击力提升");
} else if (skillId == "player.potion") {
logLine("玩家 使用治疗药水,恢复生命");
} else if (skillId == "enemy.roar") {
logLine("敌人 发出怒吼,玩家防御被削弱");
}
}
}
// ---------------------------------------------------------------------------
// 胜负判定 / 波次推进
// ---------------------------------------------------------------------------
function checkOutcomes() {
if (enemy != null && enemy.getFinalAttribute("health") <= 0.0) {
score += 1;
logLine("敌人被击败!进入第 " + (wave + 1) + " 波");
enemy.release();
wave += 1;
enemy = makeEnemy(wave);
}
if (player != null && player.getFinalAttribute("health") <= 0.0 && !gameOver) {
gameOver = true;
logLine("玩家倒下了……");
showGameOver(true);
}
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
function buildUI() {
local pad = 14.0;
ui.beginBuild();
ui.beginWindow("Player", "root");
ui.text("玩家", "title");
ui.progress(1.0, "hpbar", "");
ui.text("HP 0/0", "hptext");
ui.progress(1.0, "mpbar", "");
ui.text("MP 0/0", "mptext");
ui.progress(1.0, "stbar", "");
ui.text("耐力 0/0", "sttext");
ui.text("", "pstatus");
ui.separator("sep1");
ui.text("[1] 普通攻击", "s1");
ui.text("[2] 火球术 (MP15)", "s2");
ui.text("[3] 力量姿态 (耐力10)", "s3");
ui.text("[4] 治疗药水", "s4");
ui.end();
ui.mountBuildAs("player");
ui.select("player");
ui.setHostOverlay(true);
ui.setHostPos(pad, pad, 0.0, 0.0);
ui.beginBuild();
ui.beginWindow("Enemy", "root");
ui.text("敌人 - 第 1 波", "title");
ui.progress(1.0, "ehpbar", "");
ui.text("HP 0/0", "ehptext");
ui.text("", "estatus");
ui.end();
ui.mountBuildAs("enemy");
ui.select("enemy");
ui.setHostOverlay(true);
ui.setHostPos(config.width - pad, pad, 1.0, 0.0);
ui.beginBuild();
ui.beginWindow("Log", "root");
ui.text("战斗记录:", "logtitle");
ui.text("", "log");
ui.end();
ui.mountBuildAs("log");
ui.select("log");
ui.setHostOverlay(true);
ui.setHostPos(pad, config.height - pad, 0.0, 1.0);
ui.beginBuild();
ui.beginWindow("Game Over", "root");
ui.text("玩家倒下", "msg");
ui.text("", "final");
ui.button("重新开始 (R)", "restart");
ui.end();
ui.mountBuildAs("gameover");
ui.select("gameover");
ui.setHostVisible(false);
ui.setHostPos(config.width * 0.5, config.height * 0.45, 0.5, 0.5);
}
function statusLines(actor) {
local text = "";
local n = actor.getStatusCount();
for (local i = 0; i < n; i += 1) {
local effectId = actor.getStatusEffectId(i);
local name = (effectId in effectNames) ? effectNames[effectId] : effectId;
local stacks = actor.getStatusStacks(i);
local remaining = actor.getStatusRemaining(i);
local line = name + (stacks > 1 ? " x" + stacks : "") + " " + format("%.1f", remaining) + "s";
text = (text == "") ? line : (text + "\n" + line);
}
return text;
}
function refreshHud() {
ui.select("player");
local hp = player.getFinalAttribute("health");
local mp = player.getFinalAttribute("mana");
local st = player.getFinalAttribute("stamina");
ui.setValue("hpbar", (hp / PLAYER_MAX_HP).tofloat());
ui.setText("hptext", "HP " + roundi(hp) + "/" + roundi(PLAYER_MAX_HP));
ui.setValue("mpbar", (mp / PLAYER_MAX_MP).tofloat());
ui.setText("mptext", "MP " + roundi(mp) + "/" + roundi(PLAYER_MAX_MP));
ui.setValue("stbar", (st / PLAYER_MAX_STAMINA).tofloat());
ui.setText("sttext", "耐力 " + roundi(st) + "/" + roundi(PLAYER_MAX_STAMINA));
ui.setText("pstatus", statusLines(player));
local hint1 = actorCasting(player) ? format("读条中 %.0f%%", actorCastProgress(player) * 100.0) : "";
ui.setText("s1", "[1] 普通攻击" + (player.getSkillCooldown("player.strike") > 0.0 ? format(" (CD %.1fs)", player.getSkillCooldown("player.strike")) : ""));
ui.setText("s2", "[2] 火球术 (MP15)" + (player.getSkillCooldown("player.fireball") > 0.0 ? format(" (CD %.1fs)", player.getSkillCooldown("player.fireball")) : "") + (hint1 != "" ? " " + hint1 : ""));
ui.setText("s3", "[3] 力量姿态 (耐力10)" + (player.getSkillCooldown("player.power_stance") > 0.0 ? format(" (CD %.1fs)", player.getSkillCooldown("player.power_stance")) : ""));
ui.setText("s4", "[4] 治疗药水" + (player.getSkillCooldown("player.potion") > 0.0 ? format(" (CD %.1fs)", player.getSkillCooldown("player.potion")) : ""));
ui.select("enemy");
if (enemy != null) {
local maxHp = enemyMaxHp(wave);
local ehp = enemy.getFinalAttribute("health");
ui.setText("title", "敌人 - 第 " + wave + " 波");
ui.setValue("ehpbar", (ehp / maxHp).tofloat());
ui.setText("ehptext", "HP " + roundi(ehp) + "/" + roundi(maxHp));
ui.setText("estatus", statusLines(enemy));
}
ui.select("log");
local logText = "得分 " + score + " 第 " + wave + " 波\n";
foreach (line in battleLog)
logText += line + "\n";
ui.setText("log", logText);
}
function showGameOver(show) {
ui.select("gameover");
ui.setHostVisible(show);
if (show) {
ui.setHostModal(true);
ui.setText("final", "存活 " + wave + " 波,击败 " + score + " 个敌人");
} else {
ui.setHostModal(false);
}
}
// ---------------------------------------------------------------------------
// 游戏生命周期
// ---------------------------------------------------------------------------
function startNewGame() {
if (player != null) player.release();
if (enemy != null) enemy.release();
wave = 1;
score = 0;
gameOver = false;
battleLog = [];
hitFlash.player = 0.0;
hitFlash.enemy = 0.0;
player = makePlayer();
enemy = makeEnemy(wave);
showGameOver(false);
logLine("第 1 波开始,按 1/2/3/4 施放技能");
}
eve_init = function() {
gfx.setBackgroundColor(0.06, 0.05, 0.09, 1.0);
if (rpg == null) rpg = eve.RPG();
registerContent();
buildUI();
if (player == null)
startNewGame();
else
refreshHud();
};
// 软重载后调用(仅重跑脚本时触发,不重建实体/UI)。
eve_reload <- function() {
registerContent();
};
eve_update = function(dt) {
local id = ui.consumeClick();
while (id != "") {
if (id == "gameover/restart") startNewGame();
id = ui.consumeClick();
}
if (hitFlash.player > 0.0) hitFlash.player -= dt;
if (hitFlash.enemy > 0.0) hitFlash.enemy -= dt;
if (!gameOver) {
if (keyPressed("1")) tryPlayerSkill("player.strike", enemy, "普通攻击");
if (keyPressed("2")) tryPlayerSkill("player.fireball", enemy, "火球术");
if (keyPressed("3")) tryPlayerSkill("player.power_stance", null, "力量姿态");
if (keyPressed("4")) tryPlayerSkill("player.potion", null, "治疗药水");
updateEnemyAI();
rpg.update(dt);
processEvents();
checkOutcomes();
} else if (keyPressed("R")) {
startNewGame();
}
refreshHud();
};
eve_render = function() {
gfx.clear();
// 地面
gfx.drawSolidRect(0.0, config.height - 60.0, config.width * 1.0, 60.0, 0.12, 0.1, 0.14, 1.0);
// 玩家(左侧,蓝色系)
local px = config.width * 0.28;
local py = config.height - 160.0;
local pf = hitFlash.player > 0.0 ? 0.6 : 0.0;
gfx.drawSolidRect(px - 30.0, py, 60.0, 90.0, 0.25 + pf, 0.45 + pf, 0.8 + pf, 1.0);
gfx.drawSolidRect(px - 16.0, py - 34.0, 32.0, 32.0, 0.35 + pf, 0.55 + pf, 0.85 + pf, 1.0);
if (actorCasting(player)) {
local prog = actorCastProgress(player);
gfx.drawSolidRect(px - 30.0, py - 46.0, 60.0, 6.0, 0.2, 0.2, 0.25, 1.0);
gfx.drawSolidRect(px - 30.0, py - 46.0, 60.0 * prog, 6.0, 0.95, 0.75, 0.25, 1.0);
}
// 敌人(右侧,红色系,随波数略微变大)
local ex = config.width * 0.72;
local ey = config.height - 170.0;
local ef = hitFlash.enemy > 0.0 ? 0.6 : 0.0;
local esize = 70.0 + (wave.tofloat() * 3.0);
if (esize > 130.0) esize = 130.0;
gfx.drawSolidRect(ex - esize * 0.5, ey, esize, esize, 0.75 + ef, 0.25 + ef, 0.2 + ef, 1.0);
gfx.drawSolidRect(ex - esize * 0.22, ey + esize * 0.25, esize * 0.14, esize * 0.14, 1.0, 1.0, 0.6, 1.0);
gfx.drawSolidRect(ex + esize * 0.08, ey + esize * 0.25, esize * 0.14, esize * 0.14, 1.0, 1.0, 0.6, 1.0);
if (actorCasting(enemy)) {
gfx.drawSolidRect(ex - esize * 0.5, ey - 12.0, esize, 6.0, 0.2, 0.2, 0.25, 1.0);
gfx.drawSolidRect(ex - esize * 0.5, ey - 12.0, esize * actorCastProgress(enemy), 6.0, 0.9, 0.35, 0.35, 1.0);
}
ui.beginFrameAndRender();
};
eve_quit = function() {
};