Feast 2.0!
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 
 

751 行
20 KiB

  1. import { Creature } from "./creature"
  2. import { TextLike, DynText, ToBe, LiveText, PairLineArgs, PairLine } from './language'
  3. import { LogEntry, LogLines, FAElem, LogLine, FormatEntry, FormatOpt, PropElem, nilLog, Newline } from './interface'
  4. import { Resistances } from './entity'
  5. import { World } from './world'
  6. import { TestCategory } from './combat/tests'
  7. import { VoreContainer } from './vore'
  8. export enum DamageType {
  9. Pierce = "Pierce",
  10. Slash = "Slash",
  11. Crush = "Crush",
  12. Acid = "Acid",
  13. Seduction = "Seduction",
  14. Dominance = "Dominance",
  15. Heal = "Heal",
  16. Pure = "Pure"
  17. }
  18. export interface DamageInstance {
  19. type: DamageType;
  20. amount: number;
  21. target: Vigor | Stat;
  22. }
  23. export enum Vigor {
  24. Health = "Health",
  25. Stamina = "Stamina",
  26. Resolve = "Resolve"
  27. }
  28. export const VigorIcons: {[key in Vigor]: string} = {
  29. Health: "fas fa-heart",
  30. Stamina: "fas fa-bolt",
  31. Resolve: "fas fa-brain"
  32. }
  33. export const VigorDescs: {[key in Vigor]: string} = {
  34. Health: "How much damage you can take",
  35. Stamina: "How much energy you have",
  36. Resolve: "How much dominance you can resist"
  37. }
  38. export type Vigors = {[key in Vigor]: number}
  39. export enum Stat {
  40. Toughness = "Toughness",
  41. Power = "Power",
  42. Reflexes = "Reflexes",
  43. Agility = "Agility",
  44. Willpower = "Willpower",
  45. Charm = "Charm"
  46. }
  47. export type Stats = {[key in Stat]: number}
  48. export const StatToVigor: {[key in Stat]: Vigor} = {
  49. Toughness: Vigor.Health,
  50. Power: Vigor.Health,
  51. Reflexes: Vigor.Stamina,
  52. Agility: Vigor.Stamina,
  53. Willpower: Vigor.Resolve,
  54. Charm: Vigor.Resolve
  55. }
  56. export const StatIcons: {[key in Stat]: string} = {
  57. Toughness: 'fas fa-heartbeat',
  58. Power: 'fas fa-fist-raised',
  59. Reflexes: 'fas fa-stopwatch',
  60. Agility: 'fas fa-feather',
  61. Willpower: 'fas fa-book',
  62. Charm: 'fas fa-comments'
  63. }
  64. export const StatDescs: {[key in Stat]: string} = {
  65. Toughness: 'Your brute resistance',
  66. Power: 'Your brute power',
  67. Reflexes: 'Your ability to dodge',
  68. Agility: 'Your ability to move quickly',
  69. Willpower: 'Your mental resistance',
  70. Charm: 'Your mental power'
  71. }
  72. export enum VoreStat {
  73. Mass = "Mass",
  74. Bulk = "Bulk",
  75. Prey = "Prey"
  76. }
  77. export type VoreStats = {[key in VoreStat]: number}
  78. export const VoreStatIcons: {[key in VoreStat]: string} = {
  79. [VoreStat.Mass]: "fas fa-weight",
  80. [VoreStat.Bulk]: "fas fa-weight-hanging",
  81. [VoreStat.Prey]: "fas fa-utensils"
  82. }
  83. export const VoreStatDescs: {[key in VoreStat]: string} = {
  84. [VoreStat.Mass]: "How much you weigh",
  85. [VoreStat.Bulk]: "Your weight, plus the weight of your prey",
  86. [VoreStat.Prey]: "How many creatures you've got inside of you"
  87. }
  88. export interface CombatTest {
  89. test: (user: Creature, target: Creature) => boolean;
  90. odds: (user: Creature, target: Creature) => number;
  91. explain: (user: Creature, target: Creature) => LogEntry;
  92. fail: (user: Creature, target: Creature) => LogEntry;
  93. }
  94. /**
  95. * An instance of damage. Contains zero or more [[DamageInstance]] objects
  96. */
  97. export class Damage {
  98. readonly damages: DamageInstance[]
  99. constructor (...damages: DamageInstance[]) {
  100. this.damages = damages
  101. }
  102. scale (factor: number): Damage {
  103. const results: Array<DamageInstance> = []
  104. this.damages.forEach(damage => {
  105. results.push({
  106. type: damage.type,
  107. amount: damage.amount * factor,
  108. target: damage.target
  109. })
  110. })
  111. return new Damage(...results)
  112. }
  113. // TODO make this combine damage instances when appropriate
  114. combine (other: Damage): Damage {
  115. return new Damage(...this.damages.concat(other.damages))
  116. }
  117. toString (): string {
  118. return this.damages.map(damage => damage.amount + " " + damage.type).join("/")
  119. }
  120. render (): LogEntry {
  121. return new LogLine(...this.damages.flatMap(instance => {
  122. if (instance.target in Vigor) {
  123. return [instance.amount.toString(), new FAElem(VigorIcons[instance.target as Vigor]), " " + instance.type]
  124. } else if (instance.target in Stat) {
  125. return [instance.amount.toString(), new FAElem(StatIcons[instance.target as Stat]), " " + instance.type]
  126. } else {
  127. // this should never happen!
  128. return []
  129. }
  130. }))
  131. }
  132. // TODO is there a way to do this that will satisfy the typechecker?
  133. renderShort (): LogEntry {
  134. /* eslint-disable-next-line */
  135. const vigorTotals: Vigors = Object.keys(Vigor).reduce((total: any, key) => { total[key] = 0; return total }, {})
  136. /* eslint-disable-next-line */
  137. const statTotals: Stats = Object.keys(Stat).reduce((total: any, key) => { total[key] = 0; return total }, {})
  138. this.damages.forEach(instance => {
  139. const factor = instance.type === DamageType.Heal ? -1 : 1
  140. if (instance.target in Vigor) {
  141. vigorTotals[instance.target as Vigor] += factor * instance.amount
  142. } else if (instance.target in Stat) {
  143. statTotals[instance.target as Stat] += factor * instance.amount
  144. }
  145. })
  146. const vigorEntries = Object.keys(Vigor).flatMap(key => vigorTotals[key as Vigor] === 0 ? [] : [new PropElem(key as Vigor, vigorTotals[key as Vigor]), ' '])
  147. const statEntries = Object.keys(Stat).flatMap(key => statTotals[key as Stat] === 0 ? [] : [new PropElem(key as Stat, statTotals[key as Stat]), ' '])
  148. return new FormatEntry(new LogLine(...vigorEntries.concat(statEntries)), FormatOpt.DamageInst)
  149. }
  150. }
  151. /**
  152. * Computes damage given the source and target of the damage.
  153. */
  154. export interface DamageFormula {
  155. calc (user: Creature, target: Creature): Damage;
  156. describe (user: Creature, target: Creature): LogEntry;
  157. explain (user: Creature): LogEntry;
  158. }
  159. export class CompositeDamageFormula implements DamageFormula {
  160. constructor (private formulas: DamageFormula[]) {
  161. }
  162. calc (user: Creature, target: Creature): Damage {
  163. return this.formulas.reduce((total: Damage, next: DamageFormula) => total.combine(next.calc(user, target)), new Damage())
  164. }
  165. describe (user: Creature, target: Creature): LogEntry {
  166. return new LogLines(...this.formulas.map(formula => formula.describe(user, target)))
  167. }
  168. explain (user: Creature): LogEntry {
  169. return new LogLines(...this.formulas.map(formula => formula.explain(user)))
  170. }
  171. }
  172. /**
  173. * Simply returns the damage it was given.
  174. */
  175. export class ConstantDamageFormula implements DamageFormula {
  176. constructor (private damage: Damage) {
  177. }
  178. calc (user: Creature, target: Creature): Damage {
  179. return this.damage
  180. }
  181. describe (user: Creature, target: Creature): LogEntry {
  182. return this.explain(user)
  183. }
  184. explain (user: Creature): LogEntry {
  185. return new LogLine('Deal ', this.damage.renderShort())
  186. }
  187. }
  188. /**
  189. * Randomly scales the damage it was given with a factor of (1-x) to (1+x)
  190. */
  191. export class UniformRandomDamageFormula implements DamageFormula {
  192. constructor (private damage: Damage, private variance: number) {
  193. }
  194. calc (user: Creature, target: Creature): Damage {
  195. return this.damage.scale(Math.random() * this.variance * 2 - this.variance + 1)
  196. }
  197. describe (user: Creature, target: Creature): LogEntry {
  198. return this.explain(user)
  199. }
  200. explain (user: Creature): LogEntry {
  201. return new LogLine('Deal between ', this.damage.scale(1 - this.variance).renderShort(), ' and ', this.damage.scale(1 + this.variance).renderShort(), '.')
  202. }
  203. }
  204. /**
  205. * A [[DamageFormula]] that uses the attacker's stats
  206. */
  207. export class StatDamageFormula implements DamageFormula {
  208. constructor (private factors: Array<{ stat: Stat|VoreStat; fraction: number; type: DamageType; target: Vigor|Stat }>) {
  209. }
  210. calc (user: Creature, target: Creature): Damage {
  211. const instances: Array<DamageInstance> = this.factors.map(factor => {
  212. if (factor.stat in Stat) {
  213. return {
  214. amount: factor.fraction * user.stats[factor.stat as Stat],
  215. target: factor.target,
  216. type: factor.type
  217. }
  218. } else if (factor.stat in VoreStat) {
  219. return {
  220. amount: factor.fraction * user.voreStats[factor.stat as VoreStat],
  221. target: factor.target,
  222. type: factor.type
  223. }
  224. } else {
  225. // should be impossible; .stat is Stat|VoreStat
  226. return {
  227. amount: 0,
  228. target: Vigor.Health,
  229. type: DamageType.Heal
  230. }
  231. }
  232. })
  233. return new Damage(...instances)
  234. }
  235. describe (user: Creature, target: Creature): LogEntry {
  236. return new LogLine(
  237. this.explain(user),
  238. `, for a total of `,
  239. this.calc(user, target).renderShort()
  240. )
  241. }
  242. explain (user: Creature): LogEntry {
  243. return new LogLine(
  244. `Deal `,
  245. ...this.factors.map(factor => new LogLine(
  246. `${factor.fraction * 100}% of your `,
  247. new PropElem(factor.stat),
  248. ` as `,
  249. new PropElem(factor.target)
  250. )).joinGeneral(new LogLine(`, `), new LogLine(` and `))
  251. )
  252. }
  253. }
  254. /**
  255. * Deals a percentage of the target's current vigors/stats
  256. */
  257. export class FractionDamageFormula implements DamageFormula {
  258. constructor (private factors: Array<{ fraction: number; target: Vigor|Stat; type: DamageType }>) {
  259. }
  260. calc (user: Creature, target: Creature): Damage {
  261. const instances: Array<DamageInstance> = this.factors.map(factor => {
  262. if (factor.target in Stat) {
  263. return {
  264. amount: Math.max(0, factor.fraction * target.stats[factor.target as Stat]),
  265. target: factor.target,
  266. type: factor.type
  267. }
  268. } else if (factor.target in Vigor) {
  269. return {
  270. amount: Math.max(factor.fraction * target.vigors[factor.target as Vigor]),
  271. target: factor.target,
  272. type: factor.type
  273. }
  274. } else {
  275. // should be impossible; .target is Stat|Vigor
  276. return {
  277. amount: 0,
  278. target: Vigor.Health,
  279. type: DamageType.Heal
  280. }
  281. }
  282. })
  283. return new Damage(...instances)
  284. }
  285. describe (user: Creature, target: Creature): LogEntry {
  286. return this.explain(user)
  287. }
  288. explain (user: Creature): LogEntry {
  289. return new LogLine(
  290. `Deal damage equal to `,
  291. ...this.factors.map(factor => new LogLine(
  292. `${factor.fraction * 100}% of your target's `,
  293. new PropElem(factor.target)
  294. )).joinGeneral(new LogLine(`, `), new LogLine(` and `))
  295. )
  296. }
  297. }
  298. export enum Side {
  299. Heroes,
  300. Monsters
  301. }
  302. /**
  303. * A Combatant has a list of possible actions to take, as well as a side.
  304. */
  305. export interface Combatant {
  306. actions: Array<Action>;
  307. groupActions: Array<GroupAction>;
  308. side: Side;
  309. }
  310. /**
  311. * An Action is anything that can be done by a [[Creature]] to a [[Creature]].
  312. */
  313. export abstract class Action {
  314. constructor (
  315. public name: TextLike,
  316. public desc: TextLike,
  317. public conditions: Array<Condition> = [],
  318. public tests: Array<CombatTest> = []
  319. ) {
  320. }
  321. allowed (user: Creature, target: Creature): boolean {
  322. return this.conditions.every(cond => cond.allowed(user, target))
  323. }
  324. toString (): string {
  325. return this.name.toString()
  326. }
  327. try (user: Creature, target: Creature): LogEntry {
  328. const failReason = this.tests.find(test => !test.test(user, target))
  329. if (failReason !== undefined) {
  330. return failReason.fail(user, target)
  331. } else {
  332. return this.execute(user, target)
  333. }
  334. }
  335. describe (user: Creature, target: Creature, verbose = true): LogEntry {
  336. return new LogLines(
  337. ...(verbose ? this.conditions.map(condition => condition.explain(user, target)).concat([new Newline()]) : []),
  338. new LogLine(
  339. `Success chance: ${(this.odds(user, target) * 100).toFixed(0)}%`
  340. ),
  341. new Newline(),
  342. ...this.tests.map(test => test.explain(user, target))
  343. )
  344. }
  345. odds (user: Creature, target: Creature): number {
  346. return this.tests.reduce((total, test) => total * test.odds(user, target), 1)
  347. }
  348. abstract execute (user: Creature, target: Creature): LogEntry
  349. }
  350. export class CompositionAction extends Action {
  351. public consequences: Array<Consequence>;
  352. constructor (
  353. name: TextLike,
  354. desc: TextLike,
  355. properties: {
  356. conditions?: Array<Condition>;
  357. consequences?: Array<Consequence>;
  358. tests?: Array<CombatTest>;
  359. }
  360. ) {
  361. super(name, desc, properties.conditions ?? [], properties.tests ?? [])
  362. this.consequences = properties.consequences ?? []
  363. }
  364. execute (user: Creature, target: Creature): LogEntry {
  365. return new LogLines(
  366. ...this.consequences.filter(consequence => consequence.applicable(user, target)).map(consequence => consequence.apply(user, target))
  367. )
  368. }
  369. describe (user: Creature, target: Creature): LogEntry {
  370. return new LogLines(
  371. ...this.consequences.map(consequence => consequence.describe(user, target)).concat(
  372. new Newline(),
  373. super.describe(user, target)
  374. )
  375. )
  376. }
  377. }
  378. /**
  379. * A Condition describes whether or not something is permissible between two [[Creature]]s
  380. */
  381. export interface Condition {
  382. allowed: (user: Creature, target: Creature) => boolean;
  383. explain: (user: Creature, target: Creature) => LogEntry;
  384. }
  385. export interface Actionable {
  386. actions: Array<Action>;
  387. }
  388. export abstract class GroupAction extends Action {
  389. constructor (name: TextLike, desc: TextLike, conditions: Array<Condition>) {
  390. super(name, desc, conditions)
  391. }
  392. allowedGroup (user: Creature, targets: Array<Creature>): Array<Creature> {
  393. return targets.filter(target => this.allowed(user, target))
  394. }
  395. executeGroup (user: Creature, targets: Array<Creature>): LogEntry {
  396. return new LogLines(...targets.map(target => this.execute(user, target)))
  397. }
  398. abstract describeGroup (user: Creature, targets: Array<Creature>): LogEntry
  399. }
  400. /**
  401. * Individual status effects, items, etc. should override some of these hooks.
  402. * Some hooks just produce a log entry.
  403. * Some hooks return results along with a log entry.
  404. */
  405. export class Effective {
  406. /**
  407. * Executes when the effect is initially applied
  408. */
  409. onApply (creature: Creature): LogEntry { return nilLog }
  410. /**
  411. * Executes when the effect is removed
  412. */
  413. onRemove (creature: Creature): LogEntry { return nilLog }
  414. /**
  415. * Executes before the creature tries to perform an action
  416. */
  417. preAction (creature: Creature): { prevented: boolean; log: LogEntry } {
  418. return {
  419. prevented: false,
  420. log: nilLog
  421. }
  422. }
  423. /**
  424. * Executes before another creature tries to perform an action that targets this creature
  425. */
  426. preReceiveAction (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } {
  427. return {
  428. prevented: false,
  429. log: nilLog
  430. }
  431. }
  432. /**
  433. * Executes before the creature receives damage (or healing)
  434. */
  435. preDamage (creature: Creature, damage: Damage): Damage {
  436. return damage
  437. }
  438. /**
  439. * Executes before the creature is attacked
  440. */
  441. preAttack (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } {
  442. return {
  443. prevented: false,
  444. log: nilLog
  445. }
  446. }
  447. /**
  448. * Executes when a creature's turn starts
  449. */
  450. preTurn (creature: Creature): { prevented: boolean; log: LogEntry } {
  451. return {
  452. prevented: false,
  453. log: nilLog
  454. }
  455. }
  456. /**
  457. * Modifies the effective resistance to a certain damage type
  458. */
  459. modResistance (type: DamageType, factor: number): number {
  460. return factor
  461. }
  462. /**
  463. * Called when a test is about to resolve. Decides if the creature should automatically fail.
  464. */
  465. failTest (creature: Creature, opponent: Creature): { failed: boolean; log: LogEntry } {
  466. return {
  467. failed: false,
  468. log: nilLog
  469. }
  470. }
  471. /**
  472. * Changes a creature's size. This represents the change in *mass*
  473. */
  474. scale (scale: number): number {
  475. return scale
  476. }
  477. /**
  478. * Additively modifies a creature's score for an offensive test
  479. */
  480. modTestOffense (attacker: Creature, defender: Creature, kind: TestCategory): number {
  481. return 0
  482. }
  483. /**
  484. * Additively modifies a creature's score for a defensive test
  485. */
  486. modTestDefense (defender: Creature, attacker: Creature, kind: TestCategory): number {
  487. return 0
  488. }
  489. /**
  490. * Affects digestion damage
  491. */
  492. modDigestionDamage (predator: Creature, prey: Creature, container: VoreContainer, damage: Damage): Damage {
  493. return damage
  494. }
  495. /**
  496. * Affects a stat
  497. */
  498. modStat (creature: Creature, stat: Stat, current: number): number {
  499. return current
  500. }
  501. /**
  502. * Provides actions
  503. */
  504. actions (user: Creature): Array<Action> {
  505. return []
  506. }
  507. }
  508. /**
  509. * A displayable status effect
  510. */
  511. export interface VisibleStatus {
  512. name: TextLike;
  513. desc: TextLike;
  514. icon: TextLike;
  515. topLeft: string;
  516. bottomRight: string;
  517. }
  518. /**
  519. * This kind of status is never explicitly applied to an entity -- e.g., a dead entity will show
  520. * a status indicating that it is dead, but entities cannot be "given" the dead effect
  521. */
  522. export class ImplicitStatus implements VisibleStatus {
  523. topLeft = ''
  524. bottomRight = ''
  525. constructor (public name: TextLike, public desc: TextLike, public icon: string) {
  526. }
  527. }
  528. /**
  529. * This kind of status is explicitly given to a creature.
  530. */
  531. export abstract class StatusEffect extends Effective implements VisibleStatus {
  532. constructor (public name: TextLike, public desc: TextLike, public icon: string) {
  533. super()
  534. }
  535. get topLeft () { return '' }
  536. get bottomRight () { return '' }
  537. }
  538. export type EncounterDesc = {
  539. name: TextLike;
  540. intro: (world: World) => LogEntry;
  541. }
  542. /**
  543. * An Encounter describes a fight: who is in it and whose turn it is
  544. */
  545. export class Encounter {
  546. initiatives: Map<Creature, number>
  547. currentMove: Creature
  548. turnTime = 100
  549. constructor (public desc: EncounterDesc, public combatants: Creature[]) {
  550. this.initiatives = new Map()
  551. combatants.forEach(combatant => this.initiatives.set(combatant, 0))
  552. this.currentMove = combatants[0]
  553. this.nextMove()
  554. }
  555. nextMove (totalTime = 0): LogEntry {
  556. this.initiatives.set(this.currentMove, 0)
  557. const times = new Map<Creature, number>()
  558. this.combatants.forEach(combatant => {
  559. // this should never be undefined
  560. const currentProgress = this.initiatives.get(combatant) ?? 0
  561. const remaining = (this.turnTime - currentProgress) / Math.sqrt(Math.max(combatant.stats.Agility, 1))
  562. times.set(combatant, remaining)
  563. })
  564. this.currentMove = this.combatants.reduce((closest, next) => {
  565. const closestTime = times.get(closest) ?? 0
  566. const nextTime = times.get(next) ?? 0
  567. return closestTime <= nextTime ? closest : next
  568. }, this.combatants[0])
  569. const closestRemaining = (this.turnTime - (this.initiatives.get(this.currentMove) ?? 0)) / Math.sqrt(Math.max(this.currentMove.stats.Agility, 1))
  570. this.combatants.forEach(combatant => {
  571. // still not undefined...
  572. const currentProgress = this.initiatives.get(combatant) ?? 0
  573. this.initiatives.set(combatant, currentProgress + closestRemaining * Math.sqrt(Math.max(combatant.stats.Agility, 1)))
  574. })
  575. // TODO: still let the creature use drained-vigor moves
  576. if (this.currentMove.disabled) {
  577. return this.nextMove(closestRemaining + totalTime)
  578. } else {
  579. // applies digestion every time combat advances
  580. const tickResults = this.combatants.flatMap(
  581. combatant => combatant.containers.map(
  582. container => container.tick(5 * (closestRemaining + totalTime))
  583. )
  584. )
  585. const effectResults = this.currentMove.effects.map(effect => effect.preTurn(this.currentMove)).filter(effect => effect.prevented)
  586. if (effectResults.some(result => result.prevented)) {
  587. const parts = effectResults.map(result => result.log).concat([this.nextMove()])
  588. return new LogLines(
  589. ...parts,
  590. ...tickResults
  591. )
  592. } else {
  593. return new LogLines(
  594. ...tickResults
  595. )
  596. }
  597. }
  598. return nilLog
  599. }
  600. /**
  601. * Combat is won once one side is completely disabled
  602. */
  603. get winner (): null|Side {
  604. const remaining: Set<Side> = new Set(this.combatants.filter(combatant => !combatant.disabled).map(combatant => combatant.side))
  605. if (remaining.size === 1) {
  606. return Array.from(remaining)[0]
  607. } else {
  608. return null
  609. }
  610. }
  611. /**
  612. * Combat is completely won once one side is completely destroyed
  613. */
  614. get totalWinner (): null|Side {
  615. const remaining: Set<Side> = new Set(this.combatants.filter(combatant => !combatant.destroyed).map(combatant => combatant.side))
  616. if (remaining.size === 1) {
  617. return Array.from(remaining)[0]
  618. } else {
  619. return null
  620. }
  621. }
  622. }
  623. export abstract class Consequence {
  624. constructor (public conditions: Condition[]) {
  625. }
  626. applicable (user: Creature, target: Creature): boolean {
  627. return this.conditions.every(cond => cond.allowed(user, target))
  628. }
  629. abstract describe (user: Creature, target: Creature): LogEntry
  630. abstract apply (user: Creature, target: Creature): LogEntry
  631. }