228 lines
8.0 KiB
JavaScript
228 lines
8.0 KiB
JavaScript
import config from "./config.js";
|
|
|
|
const hashText = text => {
|
|
let value = 2166136261;
|
|
for (let index = 0; index < text.length; index++) {
|
|
value ^= text.charCodeAt(index);
|
|
value = Math.imul(value, 16777619);
|
|
}
|
|
return value >>> 0;
|
|
};
|
|
|
|
export const nextRandom = run => {
|
|
let value = Number(run.randomState || run.seed || Date.now()) >>> 0;
|
|
value += 0x6d2b79f5;
|
|
run.randomState = value >>> 0;
|
|
let result = value;
|
|
result = Math.imul(result ^ result >>> 15, result | 1);
|
|
result ^= result + Math.imul(result ^ result >>> 7, result | 61);
|
|
return ((result ^ result >>> 14) >>> 0) / 4294967296;
|
|
};
|
|
|
|
export const randomGet = (run, list) => list[Math.floor(nextRandom(run) * list.length)];
|
|
|
|
const weightedType = (run, weights) => {
|
|
const entries = Object.entries(weights);
|
|
const total = entries.reduce((sum, entry) => sum + entry[1], 0);
|
|
let cursor = nextRandom(run) * total;
|
|
for (const [type, weight] of entries) {
|
|
cursor -= weight;
|
|
if (cursor <= 0) return type;
|
|
}
|
|
return entries[0][0];
|
|
};
|
|
|
|
const connectFloors = (run, edges, currentFloor, nextFloor) => {
|
|
const addEdge = (source, target) => {
|
|
if (!edges.some(edge => edge[0] == source.id && edge[1] == target.id)) {
|
|
edges.push([source.id, target.id]);
|
|
}
|
|
};
|
|
currentFloor.forEach(source => {
|
|
const targets = nextFloor.slice().sort((left, right) => {
|
|
const distance = Math.abs(left.routeIndex - source.routeIndex) - Math.abs(right.routeIndex - source.routeIndex);
|
|
return distance || left.routeIndex - right.routeIndex;
|
|
});
|
|
addEdge(source, targets[0]);
|
|
if (targets.length > 1 && Math.abs(targets[1].routeIndex - source.routeIndex) <= 1 && nextRandom(run) < 0.45) {
|
|
addEdge(source, targets[1]);
|
|
}
|
|
});
|
|
nextFloor.forEach(target => {
|
|
if (!edges.some(edge => edge[1] == target.id)) {
|
|
const source = currentFloor.slice().sort((left, right) => {
|
|
const distance = Math.abs(left.routeIndex - target.routeIndex) - Math.abs(right.routeIndex - target.routeIndex);
|
|
return distance || left.routeIndex - right.routeIndex;
|
|
})[0];
|
|
addEdge(source, target);
|
|
}
|
|
});
|
|
};
|
|
|
|
const getFloorRoutes = (run, act, floor, lastFloor) => {
|
|
const routeCount = Math.max(1, config.routeCount);
|
|
if (floor == 0 || floor == lastFloor) return [(routeCount - 1) / 2];
|
|
const maximum = Math.max(1, Math.min(routeCount, act.floorNodes[floor] || routeCount));
|
|
const configuredMinimum = act.minFloorNodes?.[floor] ?? Math.max(2, maximum - 1);
|
|
const minimum = Math.max(1, Math.min(maximum, configuredMinimum));
|
|
const count = minimum + Math.floor(nextRandom(run) * (maximum - minimum + 1));
|
|
const routes = Array.from({ length: routeCount }, (_, index) => index);
|
|
for (let index = routes.length - 1; index > 0; index--) {
|
|
const target = Math.floor(nextRandom(run) * (index + 1));
|
|
[routes[index], routes[target]] = [routes[target], routes[index]];
|
|
}
|
|
return routes.slice(0, count).sort((left, right) => left - right);
|
|
};
|
|
|
|
export const generateActMap = (run, actIndex) => {
|
|
const act = config.acts[actIndex];
|
|
const nodes = [];
|
|
const edges = [];
|
|
const floors = [];
|
|
const lastFloor = act.floorNodes.length - 1;
|
|
act.floorNodes.forEach((count, floor) => {
|
|
const floorNodes = [];
|
|
const routes = getFloorRoutes(run, act, floor, lastFloor);
|
|
routes.forEach((routeIndex, index) => {
|
|
let type;
|
|
if (floor == lastFloor) type = "boss";
|
|
else if (floor == 0) type = "battle";
|
|
else type = weightedType(run, act.nodeWeights);
|
|
const id = `${act.id}_f${floor}_n${index}_${Math.floor(nextRandom(run) * 1e6)}`;
|
|
const node = {
|
|
id,
|
|
floor,
|
|
type,
|
|
routeIndex,
|
|
x: Math.round((routeIndex + 1) * 100 / (config.routeCount + 1)),
|
|
y: Math.round(90 - floor * 80 / Math.max(1, lastFloor)),
|
|
completed: false,
|
|
};
|
|
nodes.push(node);
|
|
floorNodes.push(node);
|
|
});
|
|
floors.push(floorNodes);
|
|
});
|
|
for (let floor = 0; floor < floors.length - 1; floor++) {
|
|
connectFloors(run, edges, floors[floor], floors[floor + 1]);
|
|
}
|
|
return {
|
|
layoutVersion: config.mapLayoutVersion,
|
|
routeCount: config.routeCount,
|
|
actIndex,
|
|
actId: act.id,
|
|
nodes,
|
|
edges,
|
|
currentNodeId: null,
|
|
completedNodeIds: [],
|
|
};
|
|
};
|
|
|
|
const createDeck = () => config.startingDeck.map((entry, index) => ({
|
|
id: `mengsan_card_${Date.now()}_${index}`,
|
|
suit: entry[0],
|
|
number: entry[1],
|
|
name: entry[2],
|
|
nature: entry[3] || null,
|
|
affixes: entry[4] ? entry[4].slice() : [],
|
|
upgrade: 0,
|
|
}));
|
|
|
|
export const createRun = character => {
|
|
const seed = (Date.now() ^ Math.floor(Math.random() * 0x7fffffff)) >>> 0;
|
|
const run = {
|
|
schemaVersion: config.schemaVersion,
|
|
runId: `mengsan_${Date.now()}`,
|
|
seed,
|
|
randomState: seed,
|
|
status: "running",
|
|
revision: 0,
|
|
actIndex: 0,
|
|
player: {
|
|
id: "player_1",
|
|
ownerId: "local",
|
|
character,
|
|
hp: null,
|
|
maxHp: null,
|
|
gold: 0,
|
|
deck: createDeck(),
|
|
permanentSkills: [],
|
|
temporarySkills: [],
|
|
items: [],
|
|
handLimitBonus: 0,
|
|
},
|
|
statistics: {
|
|
completedNodes: 0,
|
|
defeatedEnemies: 0,
|
|
goldEarned: 0,
|
|
},
|
|
};
|
|
run.map = generateActMap(run, 0);
|
|
return run;
|
|
};
|
|
|
|
export const getSelectableNodes = run => {
|
|
const map = run.map;
|
|
if (!map.currentNodeId) {
|
|
return map.nodes.filter(node => node.floor == 0 && !node.completed);
|
|
}
|
|
const ids = map.edges.filter(edge => edge[0] == map.currentNodeId).map(edge => edge[1]);
|
|
return map.nodes.filter(node => ids.includes(node.id) && !node.completed);
|
|
};
|
|
|
|
export const completeNode = (run, nodeId) => {
|
|
const node = run.map.nodes.find(current => current.id == nodeId);
|
|
if (!node || node.completed) return false;
|
|
node.completed = true;
|
|
run.map.currentNodeId = node.id;
|
|
run.map.completedNodeIds.push(node.id);
|
|
run.statistics.completedNodes++;
|
|
run.revision++;
|
|
return true;
|
|
};
|
|
|
|
export const insertStoryNode = (run, sourceId) => {
|
|
const map = run.map;
|
|
const source = map.nodes.find(node => node.id == sourceId);
|
|
const edgeIndex = map.edges.findIndex(edge => edge[0] == sourceId);
|
|
if (!source || edgeIndex < 0) return null;
|
|
const oldEdge = map.edges[edgeIndex];
|
|
const target = map.nodes.find(node => node.id == oldEdge[1]);
|
|
if (!target) return null;
|
|
const node = {
|
|
id: `story_${hashText(`${run.runId}_${sourceId}_${run.revision}`)}`,
|
|
floor: source.floor + 0.45,
|
|
type: "story",
|
|
x: Math.max(8, Math.min(92, Math.round((source.x + target.x) / 2 + (nextRandom(run) - 0.5) * 16))),
|
|
y: Math.round((source.y + target.y) / 2),
|
|
completed: false,
|
|
dynamic: true,
|
|
};
|
|
map.edges.splice(edgeIndex, 1, [source.id, node.id], [node.id, target.id]);
|
|
map.nodes.push(node);
|
|
return node;
|
|
};
|
|
|
|
export const enterNextAct = run => {
|
|
run.actIndex++;
|
|
if (run.actIndex >= config.acts.length) {
|
|
run.status = "completed";
|
|
return false;
|
|
}
|
|
run.map = generateActMap(run, run.actIndex);
|
|
run.revision++;
|
|
return true;
|
|
};
|
|
|
|
export const getNodeEncounter = (run, node) => {
|
|
const act = config.acts[run.actIndex];
|
|
let pool = act.enemies;
|
|
if (node.type == "elite" || node.type == "story") pool = act.eliteEnemies?.length ? act.eliteEnemies : act.enemies;
|
|
if (node.type == "boss") return { enemy: act.boss, gold: act.baseGold * 3, boss: true };
|
|
return {
|
|
enemy: randomGet(run, pool.filter(Boolean)),
|
|
gold: node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold,
|
|
boss: false,
|
|
};
|
|
};
|