吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1166|回复: 33
上一主题 下一主题
收起左侧

[其他原创] 我做了个《凡人修仙传》同人修仙模拟器:随机出生,活完一生

  [复制链接]
跳转到指定楼层
楼主
sakura521 发表于 2026-10-29 09:26 回帖奖励
本帖最后由 sakura521 于 2026-10-29 09:28 编辑

项目说明与地址

《一介凡人》是一个以《凡人修仙传》世界观为背景的非官方同人人生模拟项目。它不包含原作文本、图片或商业素材,原作及相关权利归原作者所有;本项目仅作个人技术交流,不提供付费内容。

页面预览

首页

一介凡人首页

降生与推演

降生与推演页面

图鉴

图鉴页面

一、项目边界

这是一个纯静态的“人生模拟器”:

  • 无构建、无框架、无 npm 运行时依赖;
  • 打开 index.html 即可运行;
  • 一局由若干“年/阶段”推进,玩家在关键节点做选择;
  • 默认数据保存在浏览器 localStorage;
  • 后端是可选的:只有留言、账号、云存档等跨设备能力才需要 Python + SQLite。

技术栈:

HTML / CSS / 原生 JavaScript
可选:Python stdlib ThreadingHTTPServer + SQLite

架构示意:

零构建人生模拟器架构图


二、核心状态:一个可序列化的对象

引擎不把状态藏在 DOM 或闭包里,而是集中到一个普通对象中。这样存档、回放、自动测试都可以直接复用。

const initialState = {
  seed: 123456789,
  age: 16,
  tier: 1,              // 当前境界
  cult: 0,              // 当前境界修为 0-100
  mood: 60,
  insight: 20,
  injury: 0,            // 0-3,影响修炼与破境
  traits: { kind: 0, ruthless: 0, curious: 0, bold: 0, calm: 0 },
  flags: {},            // 一次性事件、剧情标记
  pending: null,        // 当前等待玩家处理的抉择
  log: [],
};

几个原则:

  1. 状态必须可 JSON 序列化,不保存函数、DOM 节点和类实例。
  2. pending 是唯一的阻塞点:有 pending 时主循环不能继续推进。
  3. 事件、UI、存档都只读写状态,不直接修改彼此。

三、主循环:时间推进与事件中断

主循环做三件事:推进年份、结算年度变化、检查是否触发事件。

step() {
  const s = this.state;
  if (!s.alive) return { status: "dead" };
  if (s.pending) return { status: "pending" };

  const realm = this.realm();
  let years = realm.per || 1;
  if (s.age + years > s.maxAge) {
    years = Math.max(1, s.maxAge - s.age);
  }

  for (let i = 0; i < years; i += 1) {
    s.age += 1;
    this.yearTick(i === 0);
    if (!s.alive || s.pending) break;
  }

  if (s.alive && !s.pending) {
    this.afterStep();
  }

  return { status: s.alive ? (s.pending ? "pending" : "ok") : "dead" };
}

afterStep() 只负责“触发什么”,不负责“怎么渲染”:

afterStep() {
  const s = this.state;

  if (this.maybeMainline()) return;
  if (this.maybeChain()) return;

  if (!s.pending && s.cult >= 100 && this.canBreak()) {
    s.pending = { kind: "break", chance: this.breakChance() };
    return;
  }

  if (!s.pending && this.rng.chance(this.eventChance())) {
    const event = this.pickEvent();
    if (event) this.trigger(event.id);
  }
}

这种写法有一个很实用的好处:自动播放和手动操作共用同一套状态机,只是 UI 层决定什么时候调用 step()、什么时候展示 pending。


四、事件池:用数据描述,而不是写死剧情

事件不是一长串 if/else,而是数据对象:

const EVENTS = [
  {
    id: "market_choice",
    once: true,
    weight: 4,
    when: (s) => s.age >= 16 && s.stones >= 10,
    text: (s) => `坊市里有人向你兜售一件旧物。`,
    choices: [
      {
        label: "买下它",
        effects: { stones: -10, insight: 2, trait: { curious: 1 } },
        outcome: "你把旧物收进袖中,决定回去慢慢研究。",
      },
      {
        label: "转身离开",
        effects: { mood: 1 },
        outcome: "你没有回头。",
      },
    ],
  },
];

选择器只负责筛选:

pickEvent() {
  const s = this.state;

  let pool = EVENTS.filter((event) => {
    if (event.once && s.seen[event.id]) return false;
    if (event.stage && ![].concat(event.stage).includes(this.stage())) return false;
    return !event.when || event.when(s);
  });

  const recent = new Set((s.recentEvents || []).slice(-12));
  const fresh = pool.filter((event) => !recent.has(event.id));
  if (fresh.length) pool = fresh;

  return this.rng.weighted(pool, (event) => {
    const trait = event.tone ? s.traits[event.tone] || 0 : 0;
    return (event.weight || 1) * (1 + Math.min(0.75, trait * 0.06));
  });
}

关键点:

  • when 做硬条件过滤,权重只影响“出现概率”,不负责禁止;
  • once 与 sceneKey 防止同一事件连续重复;
  • 事件只产出 pending.choices,不直接操作界面;
  • 选项的 effects 是纯数据,方便做日志、回放和自动测试。

五、抉择校验:把限制放在数据里

选项的 requires 不写死在 UI 中,而是在引擎里统一校验:

choose(index, options) {
  const s = this.state;
  const choice = s.pending && s.pending.choices[index];
  if (!choice) return null;

  const req = choice.requires || {};

  if (req.trait) {
    for (const key of Object.keys(req.trait)) {
      if ((Number(s.traits[key]) || 0) < Number(req.trait[key])) {
        return { status: "blocked", reason: "trait" };
      }
    }
  }

  if (req.karmaMin != null && s.karma < Number(req.karmaMin)) {
    return { status: "blocked", reason: "karma" };
  }

  this.apply(choice.effects || {});
  this.pushLog("choice", choice.outcome || choice.label);
  s.pending = null;
  return { status: "ok" };
}

这样做有三个好处:

  1. 以后加“性格门槛”“因果门槛”“道统门槛”时,不需要改一堆 UI 代码;
  2. 自动模式和手动模式校验一致;
  3. 测试可以直接构造状态验证边界,而不需要点 DOM。

六、突破概率:把隐藏随机变成可见成本

破境不要只返回一个数字,而是返回完整拆解:

breakForecast() {
  const s = this.state;

  const base =
    0.78 -
    s.tier * 0.04 +
    s.mood * 0.0015 +
    s.insight * 0.0006 +
    (this.rootPower() - 1) * 0.04 +
    (s.physique?.breakBonus || 0) +
    this.artifactBreakBonus() +
    Math.min(s.breakBonus, 25) / 100 -
    (s.flags.demon ? 0.1 : 0) -
    (s.flags.heart_scar ? 0.05 : 0);

  const prep = Math.min(3, Math.max(0, s.breakPrep || 0)) * 0.03;
  const injury = Math.min(3, Math.max(0, s.injury || 0)) * 0.03;
  const chance = clamp(base + prep - injury, 0.12, 0.94);

  return { base, prep, injury, chance };
}

UI 可以把各项直接展示给玩家:

基础成算 47%
根基准备 +9%
伤势 -6%
最终成算 50%

设计上的取舍:

  • 不设隐藏默认加成,玩家看到的数字就是实际掷骰使用的数字;
  • 准备和伤势都进入同一个概率池,避免“系统暗补”;
  • 失败后保留部分准备、增加伤势,让失败产生后续决策,而不是单纯重开;
  • 修为速度通过境界需求和资质曲线调节,不使用“年龄硬门槛”强行卡人。

七、存档与可选后端

前端存档只保存可序列化快照:

saveRun() {
  const snapshot = {
    version: 1,
    seed: this.state.seed,
    state: this.state,
  };

  localStorage.setItem("demo.run.v1", JSON.stringify(snapshot));
}

恢复时做三件事:

  1. 校验版本号;
  2. 把缺失字段补默认值;
  3. 丢弃非预期字段,避免旧存档污染新逻辑。

如果要做跨设备同步,可以加一个可选后端:

# 脱敏示意,不是完整服务
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/api/save":
            self._json(200, load_snapshot(self.current_user()))

后端只在需要账号、留言、云存档时启用;静态页面本身仍然可以离线运行。前端通过 fetch 连接,请求失败时继续使用 localStorage,不让后端成为单点故障。


八、测试与平衡

这个项目目前有 400 多项断言和多种随机种子回归,重点覆盖:

  • 主循环不会在 pending 状态下继续推进;
  • 同一随机种子得到同一结果;
  • 事件条件、一次性事件和场景去重;
  • 破境概率各项相加等于最终概率;
  • 失败、伤势、资源扣除不会出现负数或 NaN;
  • 自动模式与手动模式使用同一套状态机。

平衡调整不能只拍脑袋。比较有效的方法是写一个只读审计脚本:

for (let i = 0; i < 200; i += 1) {
  const sim = new Sim({ seed: 90000 + i * 131 });
  while (sim.state.alive) {
    if (sim.state.pending) {
      sim.choose(safeChoice(sim.state));
    } else {
      sim.step();
    }
  }
  recordAgeAtRealms(sim.state);
}

然后看各境界到达年龄的中位数、分位数和死亡原因,而不是只看“某一局感觉太快”。


九、踩过的坑

1. 不要让 UI 直接改状态

早期我在按钮回调里直接改 state,结果自动模式和手动模式各有一套逻辑,后面很难维护。正确做法是:

按钮点击 → sim.choose() → 状态变化 → 重新渲染

2. 概率不要藏默认值

风险选项不要偷偷加固定成功加成;否则玩家无法通过失败复盘。所有修正项都应该进入 breakForecast 或风险预览。

3. 文本也需要去重

同一事件文本在一局里反复出现,会立刻破坏沉浸感。除了事件 id,还要记录 sceneKey 和最近文本使用次数。

4. 本地存档要有上限和迁移

localStorage 不是数据库。我的做法是只保留最近一段人生快照,并在读取时做字段补全和版本迁移;不要在存档里塞函数或 DOM。


十、这套结构适合什么

它不只适合人生模拟器,也适合:

  • 文字冒险 / 互动小说;
  • Roguelike 事件流;
  • 养成和经营类小游戏;
  • 需要“可复现随机 + 可解释概率”的轻量模拟。

核心可以压缩成一句话:

把世界状态放进一个可序列化对象,把内容放进数据,把选择交给统一的校验器,把随机性锁在可复现的种子里。


附:目录结构(脱敏)

project/
├─ index.html
├─ play.html
├─ css/
│  ├─ tokens.css
│  ├─ base.css
│  └─ game.css
├─ js/
│  ├─ common.js      # 导航、主题、通用工具
│  ├─ data.js        # 静态配置
│  ├─ events.js      # 事件池
│  ├─ sim.js         # 状态机与规则
│  └─ game.js        # 渲染与交互
└─ server.py         # 可选后端

项目地址

免费评分

参与人数 8吾爱币 +6 热心值 +7 收起 理由
HXY499 + 1 我很赞同!
ot1686 + 1 谢谢@Thanks!
apull + 1 + 1 谢谢@Thanks!
笨笨家的唯一 + 1 我很赞同!
木林森森林木 + 1 + 1 热心回复!
52_steven + 1 + 1 鼓励转贴优秀软件安全工具和文档!
lsb2pojie + 1 + 1 我很赞同!
awdxszz + 1 + 1 我是第一个给好评的,请记住我,哈哈

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
 楼主| sakura521 发表于 2026-10-29 10:28 |楼主
yamik 发表于 2026-10-29 10:22
第一张图里的卡片是不是歪的 还是我红果看多了视力下降了

是歪的  当时专门歪的   
推荐
冥界3大法王 发表于 2026-10-29 11:31
沙发
 楼主| sakura521 发表于 2026-10-29 09:30 |楼主
之前在水漫金山被删除了   迁移到本区  之前有提到的bug可能没有看到  欢迎大家再继续提
3#
awdxszz 发表于 2026-10-29 09:33
来了兄弟~感谢分享
4#
JohnnyBear 发表于 2026-10-29 09:36
玩过一个同类型的游戏;蛮难的
5#
lsb2pojie 发表于 2026-10-29 09:39
这个思路很不错,感觉可以以这个为基础,写小说出AI漫剧
6#
3650798 发表于 2026-10-29 09:47
人界 · 化神期 · 后期 · 享年 1292 岁 · 余庆 · 第 1 世
7#
PulpF4on 发表于 2026-10-29 09:53
有点意思,正好在追凡人,感谢分享
8#
shengforever 发表于 2026-10-29 10:00
看到这,不禁回想起了当年追凡人的时候
9#
qq646577619 发表于 2026-10-29 10:01
凡人动画,现在很拉跨了 . 哎
10#
yamik 发表于 2026-10-29 10:22
第一张图里的卡片是不是歪的 还是我红果看多了视力下降了
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-29 14:26

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表