diff --git a/.eslintrc.js b/.eslintrc.js index b5422a6..b422f1e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -13,6 +13,8 @@ module.exports = { }, rules: { 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', - 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-useless-constructor': 'off', + '@typescript-eslint/no-unused-vars': 'off' } } diff --git a/src/App.vue b/src/App.vue index a803d06..583fb9b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,7 @@ diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue index fefe413..9a45482 100644 --- a/src/components/HelloWorld.vue +++ b/src/components/HelloWorld.vue @@ -1,42 +1,49 @@ diff --git a/src/game/combat.ts b/src/game/combat.ts new file mode 100644 index 0000000..8f90e17 --- /dev/null +++ b/src/game/combat.ts @@ -0,0 +1,250 @@ +import { Creature, POV } from './entity' +import { POVActionPicker } from './language' +import { Container } from './vore' +import { LogEntry, LogLines, CompositeLog } from './interface' + +export enum DamageType {Pierce, Slash, Crush, Acid} + +export interface DamageInstance { + type: DamageType; + amount: number; +} + +export class Damage { + readonly damages: DamageInstance[] + + constructor (...damages: DamageInstance[]) { + this.damages = damages + } + + scale (factor: number): Damage { + const results: Array = [] + + this.damages.forEach(damage => { + results.push({ + type: damage.type, + amount: damage.amount * factor + }) + }) + + return new Damage(...results) + } +} + +export enum Stat { + STR = 'Strength', + DEX = 'Dexterity', + CON = 'Constitution' +} + +export type Stats = {[key in Stat]: number} + +export enum State { + Normal, + Grappled, + Grappling, + Eaten, +} + +export interface Combatant { + actions: Array; +} + +export abstract class Action { + public name: string; + allowed (user: Creature, target: Creature) { + return this.userStates.has(user.state) && this.targetStates.has(target.state) + } + + abstract execute(user: Creature, target: Creature): LogEntry + + constructor (name: string, public userStates: Set, public targetStates: Set) { + this.name = name + } + + toString (): string { + return this.name + } +} + +abstract class SelfAction extends Action { + allowed (user: Creature, target: Creature) { + if (user === target) { + return super.allowed(user, target) + } else { + return false + } + } +} + +abstract class PairAction extends Action { + allowed (user: Creature, target: Creature) { + if (user !== target) { + return super.allowed(user, target) + } else { + return false + } + } +} + +export class AttackAction extends PairAction { + protected lines: POVActionPicker = { + [POV.First]: { + [POV.First]: (user, target) => new LogLines('You bite...yourself?'), + [POV.Third]: (user, target) => new LogLines('You bite ' + target.name) + }, + [POV.Third]: { + [POV.First]: (user, target) => new LogLines(user.name.capital + ' bites you'), + [POV.Third]: (user, target) => new LogLines(user.name.capital + ' bites ' + target.name) + } + } + + constructor (protected damage: Damage) { + super('Attack', new Set([State.Normal]), new Set([State.Normal])) + } + + execute (user: Creature, target: Creature): LogEntry { + target.takeDamage(this.damage) + return this.lines[user.perspective][target.perspective](user, target) + } +} + +export class DevourAction extends PairAction { + protected lines: POVActionPicker = { + [POV.First]: { + [POV.First]: (user, target) => new LogLines('You devour...yourself?'), + [POV.Third]: (user, target) => new LogLines('You devour ' + target.name) + }, + [POV.Third]: { + [POV.First]: (user, target) => new LogLines(user.name.capital + ' devours you'), + [POV.Third]: (user, target) => new LogLines(user.name.capital + ' devours ' + target.name) + } + } + + constructor (protected container: Container) { + super('Devour', new Set([State.Normal]), new Set([State.Normal])) + } + + execute (user: Creature, target: Creature): LogEntry { + target.state = State.Eaten + return new CompositeLog(this.lines[user.perspective][target.perspective](user, target), this.container.consume(target)) + } +} + +export class StruggleAction extends PairAction { + protected lines: POVActionPicker = { + [POV.First]: { + [POV.First]: (user, target) => new LogLines('You escape from...yourself?'), + [POV.Third]: (user, target) => new LogLines('You escape from ' + target.name) + }, + [POV.Third]: { + [POV.First]: (user, target) => new LogLines(user.name.capital + ' escapes from you'), + [POV.Third]: (user, target) => new LogLines(user.name.capital + ' escapes from ' + target.name) + } + } + + constructor () { + super('Struggle', new Set([State.Eaten]), new Set([State.Normal])) + } + + execute (user: Creature, target: Creature): LogEntry { + if (user.containedIn) { return new CompositeLog(this.lines[user.perspective][target.perspective](user, target), user.containedIn.release(user)) } else { return new LogLines("The prey wasn't actually eaten...") } + } +} + +export class DigestAction extends SelfAction { + protected lines: POVActionPicker = { + [POV.First]: { + [POV.First]: (user, target) => new LogLines('You rub your stomach'), + [POV.Third]: (user, target) => new LogLines("You can't digest for other people...") + }, + [POV.Third]: { + [POV.First]: (user, target) => new LogLines("Other people can't digest for you..."), + [POV.Third]: (user, target) => new LogLines(user.name.capital + ' rubs ' + user.pronouns.possessive + ' gut.') + } + } + + allowed (user: Creature, target: Creature) { + if (Array.from(user.containers).some(container => { + return container.contents.size > 0 + })) { + return super.allowed(user, target) + } else { + return false + } + } + + constructor () { + super('Digest', new Set([State.Normal]), new Set([State.Normal])) + } + + execute (user: Creature, target: Creature): LogEntry { + const results = new CompositeLog(...Array.from(user.containers).map(container => container.tick(60))) + return new CompositeLog(this.lines[user.perspective][target.perspective](user, target), results) + } +} + +export interface CombatTest { + test: (user: Creature, target: Creature) => boolean; + odds: (user: Creature, target: Creature) => number; + explain: (user: Creature, target: Creature) => LogEntry; +} + +function logistic (x0: number, L: number, k: number): (x: number) => number { + return (x: number) => { + return L / (1 + Math.exp(-k * (x - x0))) + } +} + +abstract class RandomTest implements CombatTest { + test (user: Creature, target: Creature): boolean { + return Math.random() < this.odds(user, target) + } + + abstract odds(user: Creature, target: Creature): number + abstract explain(user: Creature, target: Creature): LogEntry +} + +export class StatTest extends RandomTest { + private f: (x: number) => number + + constructor (public readonly stat: Stat, k = 0.1) { + super() + this.f = logistic(0, 1, k) + } + + odds (user: Creature, target: Creature): number { + return this.f(user.stats[this.stat] - target.stats[this.stat]) + } + + explain (user: Creature, target: Creature): LogEntry { + const delta: number = user.stats[this.stat] - target.stats[this.stat] + let result: string + + if (delta === 0) { + result = 'You and the target have the same ' + this.stat + '.' + } else if (delta < 0) { + result = 'You have ' + delta + ' less ' + this.stat + ' than your foe.' + } else { + result = 'You have ' + delta + ' more ' + this.stat + ' than you foe.' + } + + result += ' Your odds of success are ' + (100 * this.odds(user, target)) + '%' + + return new LogLines(result) + } +} + +export class ChanceTest extends RandomTest { + constructor (public readonly chance: number) { + super() + } + + odds (user: Creature, target: Creature): number { + return this.chance + } + + explain (user: Creature, target: Creature): LogEntry { + return new LogLines('You have a flat ' + (100 * this.chance) + '% chance.') + } +} diff --git a/src/game/creatures.ts b/src/game/creatures.ts new file mode 100644 index 0000000..fd240f2 --- /dev/null +++ b/src/game/creatures.ts @@ -0,0 +1,4 @@ +import { Wolf } from './creatures/wolf' +import { Player } from './creatures/player' + +export { Wolf, Player } diff --git a/src/game/creatures/player.ts b/src/game/creatures/player.ts new file mode 100644 index 0000000..0fa2717 --- /dev/null +++ b/src/game/creatures/player.ts @@ -0,0 +1,18 @@ +import { Creature, POV } from '../entity' +import { ProperNoun, TheyPronouns } from '../language' +import { Stat, Damage, AttackAction, DevourAction, DamageType } from '../combat' +import { Stomach, VoreType } from '../vore' + +export class Player extends Creature { + constructor () { + super(new ProperNoun('The Dude'), TheyPronouns, { [Stat.STR]: 20, [Stat.DEX]: 20, [Stat.CON]: 20 }, new Set([VoreType.Oral]), new Set([VoreType.Oral]), 50) + this.actions.push(new AttackAction(new Damage({ type: DamageType.Pierce, amount: 20 }))) + + const stomach = new Stomach(this, 100, new Damage({ amount: 10, type: DamageType.Acid }, { amount: 10, type: DamageType.Crush })) + + this.containers.add(stomach) + this.actions.push(new DevourAction(stomach)) + + this.perspective = POV.First + } +} diff --git a/src/game/creatures/wolf.ts b/src/game/creatures/wolf.ts new file mode 100644 index 0000000..1622ba6 --- /dev/null +++ b/src/game/creatures/wolf.ts @@ -0,0 +1,28 @@ +import { Creature, POV } from '../entity' +import { Stat, Damage, DamageType, AttackAction, DevourAction, StruggleAction, DigestAction } from '../combat' +import { MalePronouns, ImproperNoun } from '../language' +import { VoreType, Stomach } from '../vore' +import { LogLines } from '../interface' + +class BiteAction extends AttackAction { + constructor () { + super(new Damage({ amount: 10, type: DamageType.Pierce })) + this.lines[POV.First][POV.Third] = (user, target) => new LogLines('You sink your fangs into ' + target.name) + this.lines[POV.Third][POV.First] = (user, target) => new LogLines(user.name.capital + ' bites you. This bite is easily the top 1% of bites produced by ' + user.name.plural.all + '.') + this.lines[POV.Third][POV.Third] = (user, target) => new LogLines(user.name.capital + ' chomps ' + target.name) + } +} + +export class Wolf extends Creature { + constructor () { + super(new ImproperNoun('wolf', 'wolves'), MalePronouns, { [Stat.STR]: 10, [Stat.DEX]: 10, [Stat.CON]: 10 }, new Set([VoreType.Oral]), new Set([VoreType.Oral]), 25) + this.actions.push(new BiteAction()) + + const stomach = new Stomach(this, 50, new Damage({ amount: 5, type: DamageType.Acid }, { amount: 5, type: DamageType.Crush })) + + this.containers.add(stomach) + this.actions.push(new DevourAction(stomach)) + this.actions.push(new StruggleAction()) + this.actions.push(new DigestAction()) + } +} diff --git a/src/game/entity.ts b/src/game/entity.ts new file mode 100644 index 0000000..bc06f5d --- /dev/null +++ b/src/game/entity.ts @@ -0,0 +1,54 @@ +import { DamageType, Damage, Combatant, Stats, State, Action } from './combat' +import { Noun, Pronoun } from './language' + +import { Pred, Prey, Container, VoreType } from './vore' + +export enum POV {First, Third} + +export interface Entity { + name: Noun; + pronouns: Pronoun; + perspective: POV; +} + +export interface Mortal extends Entity { + health: number; + resistances: Map; + takeDamage: (damage: Damage) => void; + stats: Stats; +} + +export class Creature implements Mortal, Pred, Prey, Combatant { + health = 100 + resistances: Map = new Map() + perspective: POV = POV.Third + state: State = State.Normal + containers: Set = new Set(); + actions: Array = []; + containedIn: Container|null = null; + + constructor (public name: Noun, public pronouns: Pronoun, public stats: Stats, public preyPrefs: Set, public predPrefs: Set, public bulk: number) { + + } + + toString (): string { + return this.name + ': ' + this.health + ' HP' + } + + takeDamage (damage: Damage): void { + damage.damages.forEach(instance => { + if (this.resistances.has(instance.type)) { + this.health -= instance.amount * this.resistances.get(instance.type)! + } else { + this.health -= instance.amount + } + }) + } + + validActions (target: Creature): Array { + console.log(this) + return this.actions.filter(action => { + return action.allowed(this, target) + }) + } +} diff --git a/src/game/feast.ts b/src/game/feast.ts new file mode 100644 index 0000000..311a96e --- /dev/null +++ b/src/game/feast.ts @@ -0,0 +1,16 @@ +import { Wolf } from './creatures/wolf' +import { POV } from './entity' +import { CompositeLog, LogLines, log } from './interface' + +const wolf = new Wolf() +const player = new Wolf() + +player.perspective = POV.First + +log(new CompositeLog(...player.validActions(wolf).map(action => new LogLines(action.toString())))) +log(new CompositeLog(...player.validActions(player).map(action => new LogLines(action.toString())))) + +log(player.actions[1].execute(player, wolf)) + +log(new CompositeLog(...player.validActions(wolf).map(action => new LogLines(action.toString())))) +log(new CompositeLog(...player.validActions(player).map(action => new LogLines(action.toString())))) diff --git a/src/game/interface.ts b/src/game/interface.ts new file mode 100644 index 0000000..77aaf32 --- /dev/null +++ b/src/game/interface.ts @@ -0,0 +1,38 @@ +export interface LogEntry { + render: () => HTMLElement[]; +} + +export class LogLines implements LogEntry { + lines: string[] + + constructor (...lines: string[]) { + this.lines = lines + } + + render (): HTMLElement[] { + const p = document.createElement('p') + this.lines.forEach(line => { + const div = document.createElement('div') + div.innerText = line + p.appendChild(div) + }) + + return [p] + } +} + +export class CompositeLog implements LogEntry { + entries: LogEntry[] + + constructor (...entries: LogEntry[]) { + this.entries = entries + } + + render (): HTMLElement[] { + return this.entries.map(entry => entry.render()).reduce((results: HTMLElement[], next: HTMLElement[]) => results.concat(next), []) + } +} + +export function log (entry: LogEntry): void { + entry.render().forEach(elem => document.body.appendChild(elem)) +} diff --git a/src/game/language.ts b/src/game/language.ts new file mode 100644 index 0000000..a3f9af7 --- /dev/null +++ b/src/game/language.ts @@ -0,0 +1,209 @@ +import { Creature, POV } from './entity' +import { LogEntry } from './interface' + +export type POVActionPicker = { [key in POV]: { [key in POV]: (user: Creature, target: Creature) => LogEntry } } + +enum NounKind { + Specific, + Nonspecific, + All +} + +enum VowelSound { + Default, + Vowel, + NonVowel +} + +interface WordOptions { + plural: boolean; + capital: boolean; + proper: boolean; + kind: NounKind; + vowel: VowelSound; + count: boolean; +} + +export class Noun { + constructor (private singularNoun: string, private pluralNoun: string|null = null, private options: WordOptions = { plural: false, capital: false, proper: false, kind: NounKind.Specific, vowel: VowelSound.Default, count: true }) { + + } + + get capital (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.capital = true + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get plural (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.plural = true + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get proper (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.proper = true + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get improper (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.proper = false + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get specific (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.kind = NounKind.Specific + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get nonspecific (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.kind = NounKind.Nonspecific + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get all (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.kind = NounKind.All + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + get uncountable (): Noun { + const opts: WordOptions = Object.assign({}, this.options) + opts.count = false + return new Noun(this.singularNoun, this.pluralNoun, opts) + } + + toString (): string { + let result: string + + if (this.options.plural) { + if (this.pluralNoun === null) { + result = this.singularNoun + } else { + result = (this.pluralNoun as string) + } + } else { + result = this.singularNoun + } + + if (!this.options.proper) { + if (this.options.kind === NounKind.Nonspecific && this.options.count) { + if (this.options.plural) { + result = 'some ' + result + } else { + if (this.options.vowel === VowelSound.Default) { + if ('aeiouAEIOU'.indexOf(result.slice(0, 1)) >= 0) { + result = 'an ' + result + } else { + result = 'a ' + result + } + } else if (this.options.vowel === VowelSound.Vowel) { + result = 'an ' + result + } else if (this.options.vowel === VowelSound.NonVowel) { + result = 'a ' + result + } + } + } else if (this.options.kind === NounKind.Specific) { + result = 'the ' + result + } + } + + if (this.options.capital) { + result = result.slice(0, 1).toUpperCase() + result.slice(1) + } + + return result + } +} + +export class ImproperNoun extends Noun { + constructor (singularNoun: string, pluralNoun: string) { + super(singularNoun, pluralNoun, { plural: false, capital: false, proper: false, kind: NounKind.Specific, vowel: VowelSound.Default, count: true }) + } +} + +export class ProperNoun extends Noun { + constructor (singularNoun: string) { + super(singularNoun, null, { plural: false, capital: false, proper: true, kind: NounKind.Specific, vowel: VowelSound.Default, count: true }) + } +} + +interface PronounDict { + subjective: string; + objective: string; + possessive: string; + reflexive: string; +} + +export class Pronoun { + constructor (private pronouns: PronounDict, private capitalize: boolean = false) { + + } + + get capital (): Pronoun { + return new Pronoun(this.pronouns, true) + } + + get subjective (): string { + return this.caps(this.pronouns.subjective) + } + + get objective (): string { + return this.caps(this.pronouns.objective) + } + + get possessive (): string { + return this.caps(this.pronouns.possessive) + } + + get reflexive (): string { + return this.caps(this.pronouns.reflexive) + } + + private caps (input: string): string { + if (this.capitalize) { + return input.slice(0, 1).toUpperCase() + input.slice(1) + } else { + return input + } + } +} + +export const MalePronouns = new Pronoun({ + subjective: 'he', + objective: 'him', + possessive: 'his', + reflexive: 'himself' +}) + +export const FemalePronouns = new Pronoun({ + subjective: 'she', + objective: 'her', + possessive: 'her', + reflexive: 'herself' +}) + +export const TheyPronouns = new Pronoun({ + subjective: 'they', + objective: 'them', + possessive: 'their', + reflexive: 'themself' +}) + +export const TheyPluralPronouns = new Pronoun({ + subjective: 'they', + objective: 'them', + possessive: 'their', + reflexive: 'themselves' +}) + +export const ObjectPronouns = new Pronoun({ + subjective: 'it', + objective: 'it', + possessive: 'its', + reflexive: 'itself' +}) diff --git a/src/game/vore.ts b/src/game/vore.ts new file mode 100644 index 0000000..1eb4f9c --- /dev/null +++ b/src/game/vore.ts @@ -0,0 +1,172 @@ +import { Entity, Mortal, POV } from './entity' +import { Damage } from './combat' +import { LogLines, LogEntry, CompositeLog } from './interface' +export enum VoreType {Oral} + +export interface Prey extends Mortal { + preyPrefs: Set; + bulk: number; + containedIn: Container | null; +} + +export interface Pred extends Entity { + predPrefs: Set; + containers: Set; +} + +export interface Container { + name: string; + voreTypes: Set; + contents: Set; + capacity: number; + fullness: number; + canTake: (prey: Prey) => boolean; + consume: (prey: Prey) => LogEntry; + release: (prey: Prey) => LogEntry; + struggle: (prey: Prey) => LogEntry; + tick: (dt: number) => LogEntry; + describe: () => LogEntry; + digest: (prey: Prey) => LogEntry; + absorb: (prey: Prey) => LogEntry; + dispose: (preys: Prey[]) => LogEntry; +} + +abstract class NormalContainer implements Container { + contents: Set + + get fullness (): number { + return Array.from(this.contents.values()).reduce((total: number, prey: Prey) => total + prey.bulk, 0) + } + + canTake (prey: Prey): boolean { + const fits = this.capacity - this.fullness >= prey.bulk + + const permitted = Array.from(this.voreTypes).every(voreType => { + return prey.preyPrefs.has(voreType) + }) + + return fits && permitted + } + + consume (prey: Prey): LogEntry { + this.contents.add(prey) + prey.containedIn = this + return new LogLines('MUNCH') + } + + release (prey: Prey): LogEntry { + prey.containedIn = null + return new LogLines('ANTI-MUNCH') + } + + struggle (prey: Prey): LogEntry { + return new LogLines('Slosh!') + } + + tick (dt: number): LogEntry { + const digested: Array = [] + const absorbed: Array = [] + + this.contents.forEach(prey => { + const start = prey.health + prey.takeDamage(this.damage.scale(dt / 3600)) + const end = prey.health + if (start > 0 && end <= 0) { + digested.push(prey) + } else if (start > -100 && end <= -100) { + absorbed.push(prey) + } + }) + + const digestedEntries = new CompositeLog(...digested.map(prey => this.digest(prey))) + const absorbedEntries = new CompositeLog(...digested.map(prey => this.absorb(prey))) + + this.contents = new Set(Array.from(this.contents.values()).filter(prey => { + return prey.health > 0 + })) + + return new CompositeLog(digestedEntries, absorbedEntries) + } + + describe (): LogEntry { + const lines: Array = [] + + this.contents.forEach(prey => { + lines.push(prey.toString()) + }) + + return new LogLines(...lines) + } + + digest (prey: Prey): LogEntry { + return new LogLines('Glorp!') + } + + absorb (prey: Prey): LogEntry { + return new LogLines('Glorp...') + } + + dispose (preys: Prey[]): LogEntry { + return new LogLines('GLORP') + } + + constructor (public name: string, protected owner: Pred, public voreTypes: Set, public capacity: number, private damage: Damage) { + this.contents = new Set() + } +} + +export class Stomach extends NormalContainer { + constructor (owner: Pred, capacity: number, damage: Damage) { + super('Stomach', owner, new Set([VoreType.Oral]), capacity, damage) + } + + consume (prey: Prey): LogEntry { + super.consume(prey) + + const predPOV = this.owner.perspective + const preyPOV = prey.perspective + if (predPOV === POV.First && preyPOV === POV.Third) { + return new LogLines(prey.name.capital + ' slides down into your stomach') + } else if (predPOV === POV.Third && preyPOV === POV.First) { + return new LogLines(this.owner.name.capital + "'s guts swell as you slush down into " + this.owner.pronouns.possessive + ' stomach') + } else if (predPOV === POV.Third && preyPOV === POV.Third) { + return new LogLines(this.owner.name.capital + "'s belly fills with the struggling form of " + prey.name) + } else { + return new LogLines('FIX ME!') + } + } + + digest (prey: Prey): LogEntry { + super.digest(prey) + + const predPOV = this.owner.perspective + const preyPOV = prey.perspective + + if (predPOV === POV.First && preyPOV === POV.Third) { + return new LogLines('Your stomach finishes off ' + prey.name) + } else if (predPOV === POV.Third && preyPOV === POV.First) { + return new LogLines(this.owner.name.capital + ' digests you') + } else if (predPOV === POV.Third && preyPOV === POV.Third) { + return new LogLines(this.owner.name.capital + ' finishes digesting ' + prey.name) + } else { + return new LogLines('FIX ME!') + } + } + + absorb (prey: Prey): LogEntry { + super.absorb(prey) + + const predPOV = this.owner.perspective + const preyPOV = prey.perspective + + if (predPOV === POV.First && preyPOV === POV.Third) { + return new LogLines("Your stomach melts down what's left of " + prey.name) + } else if (predPOV === POV.Third && preyPOV === POV.First) { + return new LogLines(this.owner.name.capital + ' finishes absorbing you') + } else if (predPOV === POV.Third && preyPOV === POV.Third) { + return new LogLines(this.owner.name.capital + ' fully absorbs ' + prey.name) + } else { + return new LogLines('FIX ME!') + } + } +}