| @@ -1,7 +1,7 @@ | |||
| <template> | |||
| <div id="app"> | |||
| <Header version="pre-alpha" /> | |||
| <Combat :combatants="combatants" /> | |||
| <Combat :encounter="encounter" /> | |||
| </div> | |||
| </template> | |||
| @@ -13,6 +13,7 @@ import * as Creatures from '@/game/creatures' | |||
| import * as Items from '@/game/items' | |||
| import { Creature } from '@/game/entity' | |||
| import { ProperNoun, TheyPronouns, FemalePronouns, MalePronouns, ImproperNoun } from '@/game/language' | |||
| import { Encounter } from './game/combat' | |||
| @Component({ | |||
| components: { | |||
| @@ -20,7 +21,8 @@ import { ProperNoun, TheyPronouns, FemalePronouns, MalePronouns, ImproperNoun } | |||
| } | |||
| }) | |||
| export default class App extends Vue { | |||
| combatants: Array<Creature> | |||
| encounter: Encounter | |||
| constructor () { | |||
| super() | |||
| @@ -73,7 +75,9 @@ export default class App extends Vue { | |||
| const kenzie = new Creatures.Kenzie() | |||
| const cafat = new Creatures.Cafat() | |||
| const wolf = new Creatures.Wolf() | |||
| this.combatants = [fighter, withers, wizard, rogue, cleric, kenzie, cafat, wolf] | |||
| const combatants = [fighter, withers, wizard, rogue, cleric, kenzie, cafat, wolf] | |||
| this.encounter = new Encounter(combatants) | |||
| console.log(this.encounter) | |||
| } | |||
| } | |||
| </script> | |||
| @@ -1,10 +1,11 @@ | |||
| <template> | |||
| <div class="combat-layout"> | |||
| <div>{{ encounter.currentMove.name }}</div> | |||
| <div @wheel="horizWheelLeft" class="stat-column" id="left-stats"> | |||
| <Statblock @selectPredator="right = combatant.containedIn.owner" @selectAlly="right = combatant" @select="left = combatant" class="left-stats" :data-active="combatant === left" :data-active-ally="combatant === right" :data-eaten="combatant.containedIn !== null" :data-dead="combatant.vigors.Health <= 0" v-for="(combatant, index) in combatants.filter(c => c.side == Side.Heroes && !c.destroyed).slice().reverse()" v-bind:key="'left-stat-' + index" :subject="combatant" /> | |||
| <Statblock @selectPredator="right = combatant.containedIn.owner" @selectAlly="right = combatant" @select="doSelectLeft(combatant)" class="left-stats" :data-disabled="encounter.currentMove.side === combatant.side && encounter.currentMove !== combatant" :data-current-turn="encounter.currentMove === combatant" :data-active="combatant === left" :data-active-ally="combatant === right" :data-eaten="combatant.containedIn !== null" :data-dead="combatant.vigors.Health <= 0" v-for="(combatant, index) in combatants.filter(c => c.side == Side.Heroes && !c.destroyed).slice().reverse()" v-bind:key="'left-stat-' + index" :subject="combatant" :initiative="encounter.initiatives.get(combatant)" /> | |||
| </div> | |||
| <div @wheel="horizWheelRight" class="stat-column" id="right-stats"> | |||
| <Statblock @selectPredator="left = combatant.containedIn.owner" @selectAlly="left = combatant" @select="right = combatant" class="right-stats" :data-active="combatant === right" :data-active-ally="combatant === left" :data-eaten="combatant.containedIn !== null" :data-dead="combatant.vigors.Health <= 0" v-for="(combatant, index) in combatants.filter(c => c.side == Side.Monsters && !c.destroyed)" v-bind:key="'right-stat-' + index" :subject="combatant" /> | |||
| <Statblock @selectPredator="left = combatant.containedIn.owner" @selectAlly="left = combatant" @select="doSelectRight(combatant)" class="right-stats" :data-disabled="encounter.currentMove.side === combatant.side && encounter.currentMove !== combatant" :data-current-turn="encounter.currentMove === combatant" :data-active="combatant === right" :data-active-ally="combatant === left" :data-eaten="combatant.containedIn !== null" :data-dead="combatant.vigors.Health <= 0" v-for="(combatant, index) in combatants.filter(c => c.side == Side.Monsters && !c.destroyed)" v-bind:key="'right-stat-' + index" :subject="combatant" :initiative="encounter.initiatives.get(combatant)" /> | |||
| </div> | |||
| <div id="log"> | |||
| </div> | |||
| @@ -12,7 +13,7 @@ | |||
| </div> | |||
| <div class="left-actions"> | |||
| <div class="vert-display"> | |||
| <div v-if="encounter.currentMove === left" class="vert-display"> | |||
| <i class="action-label fas fa-users" v-if="left.validGroupActions(combatants).length > 0"></i> | |||
| <ActionButton @described="described" @executed="executedLeft" v-for="(action, index) in left.validGroupActions(combatants)" :key="'left-' + action.name + '-' + index" :action="action" :user="left" :target="right" :combatants="combatants" /> | |||
| <i class="action-label fas fa-user-friends" v-if="left.validActions(right).length > 0"></i> | |||
| @@ -26,7 +27,7 @@ | |||
| </div> | |||
| <div class="right-actions"> | |||
| <div class="vert-display"> | |||
| <div v-if="encounter.currentMove === right" class="vert-display"> | |||
| <i class="action-label fas fa-users" v-if="right.validGroupActions(combatants).length > 0"></i> | |||
| <ActionButton @described="described" @executed="executedRight" v-for="(action, index) in right.validGroupActions(combatants)" :key="'right-' + action.name + '-' + index" :action="action" :user="right" :target="left" :combatants="combatants" /> | |||
| <i class="action-label fas fa-user-friends" v-if="right.validActions(left).length > 0"></i> | |||
| @@ -47,7 +48,7 @@ import { POV } from '@/game/language' | |||
| import { LogEntry } from '@/game/interface' | |||
| import Statblock from './Statblock.vue' | |||
| import ActionButton from './ActionButton.vue' | |||
| import { Side } from '@/game/combat' | |||
| import { Side, Encounter } from '@/game/combat' | |||
| @Component( | |||
| { | |||
| @@ -55,7 +56,8 @@ import { Side } from '@/game/combat' | |||
| data () { | |||
| return { | |||
| left: null, | |||
| right: null | |||
| right: null, | |||
| combatants: null | |||
| } | |||
| }, | |||
| methods: { | |||
| @@ -72,6 +74,16 @@ import { Side } from '@/game/combat' | |||
| if (target !== null) { | |||
| target.scrollBy({ top: 0, left: event.deltaY, behavior: 'smooth' }) | |||
| } | |||
| }, | |||
| doSelectLeft (combatant: Creature) { | |||
| if (combatant.side !== this.$props.encounter.currentMove.side) { | |||
| this.$data.left = combatant | |||
| } | |||
| }, | |||
| doSelectRight (combatant: Creature) { | |||
| if (combatant.side !== this.$props.encounter.currentMove.side) { | |||
| this.$data.right = combatant | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -79,15 +91,16 @@ import { Side } from '@/game/combat' | |||
| export default class Combat extends Vue { | |||
| @Prop() | |||
| combatants!: Array<Creature> | |||
| encounter!: Encounter | |||
| Side = Side | |||
| actionDescription = '' | |||
| created () { | |||
| this.$data.left = this.combatants.filter(x => x.side === Side.Heroes)[0] | |||
| this.$data.right = this.combatants.filter(x => x.side === Side.Monsters)[0] | |||
| this.$data.left = this.encounter.combatants.filter(x => x.side === Side.Heroes)[0] | |||
| this.$data.right = this.encounter.combatants.filter(x => x.side === Side.Monsters)[0] | |||
| this.$data.combatants = this.encounter.combatants | |||
| } | |||
| mounted () { | |||
| @@ -112,6 +125,23 @@ export default class Combat extends Vue { | |||
| log.scrollTo({ top: log.scrollHeight, left: 0 }) | |||
| } | |||
| this.encounter.nextMove() | |||
| if (this.encounter.currentMove.side === Side.Heroes) { | |||
| this.$data.left = this.encounter.currentMove | |||
| this.$el.querySelector("#left-stats ") | |||
| } else if (this.encounter.currentMove.side === Side.Monsters) { | |||
| this.$data.right = this.encounter.currentMove | |||
| } | |||
| // scroll to the newly selected creature | |||
| this.$nextTick(() => { | |||
| const creature: HTMLElement|null = this.$el.querySelector("[data-current-turn]") | |||
| if (creature !== null) { | |||
| creature.scrollIntoView() | |||
| } | |||
| }) | |||
| } | |||
| @Emit("executedRight") | |||
| @@ -128,6 +158,22 @@ export default class Combat extends Vue { | |||
| log.scrollTo({ top: log.scrollHeight, left: 0 }) | |||
| } | |||
| this.encounter.nextMove() | |||
| if (this.encounter.currentMove.side === Side.Heroes) { | |||
| this.$data.left = this.encounter.currentMove | |||
| } else if (this.encounter.currentMove.side === Side.Monsters) { | |||
| this.$data.right = this.encounter.currentMove | |||
| } | |||
| // scroll to the newly selected creature | |||
| this.$nextTick(() => { | |||
| const creature: HTMLElement|null = this.$el.querySelector("[data-current-turn]") | |||
| if (creature !== null) { | |||
| creature.scrollIntoView() | |||
| } | |||
| }) | |||
| } | |||
| @Emit("described") | |||
| @@ -3,6 +3,7 @@ | |||
| <div class="statblock-shader statblock-shader-hover"></div> | |||
| <div class="statblock-shader statblock-shader-selected"></div> | |||
| <div class="statblock-shader statblock-shader-selected-ally"></div> | |||
| <div class="statblock-shader statblock-shader-current-turn"></div> | |||
| <div class="statblock-shader statblock-shader-dead"></div> | |||
| <div class="statblock-shader statblock-shader-eaten"></div> | |||
| <div class="statblock-content"> | |||
| @@ -13,6 +14,7 @@ | |||
| <div class="tooltip-body">{{ subject.desc }}</div> | |||
| </div> | |||
| </h2> | |||
| <div> Initiative: {{ (initiative).toFixed(0) }}%</div> | |||
| <div class="statblock-status-icons"> | |||
| <i :class="status.icon" v-for="(status, index) in subject.status" :key="'status' + index"> | |||
| <div class="statblock-status-icon-topleft">{{ status.topLeft }}</div> | |||
| @@ -105,6 +107,9 @@ export default class Statblock extends Vue { | |||
| @Prop({ type: Creature, required: true }) | |||
| subject!: Creature | |||
| @Prop() | |||
| initiative!: number | |||
| private vigorIcons = VigorIcons | |||
| private statIcons = StatIcons | |||
| private voreStatIcons = VoreStatIcons | |||
| @@ -283,6 +288,15 @@ a { | |||
| opacity: 0.20; | |||
| } | |||
| .statblock[data-current-turn] .statblock-shader-current-turn { | |||
| background: #0f0; | |||
| opacity: 0.3; | |||
| } | |||
| .statblock[data-disabled] { | |||
| color: #888; | |||
| } | |||
| .statblock[data-dead] .statblock-shader-dead { | |||
| background: red; | |||
| opacity: 0.50; | |||
| @@ -320,6 +320,34 @@ export abstract class GroupAction extends Action { | |||
| } | |||
| } | |||
| /** | |||
| * Individual status effects, items, etc. should override some of these hooks. | |||
| * Some hooks just produce a log entry. | |||
| * Some hooks return results along with a log entry. | |||
| */ | |||
| export class Effective { | |||
| onApply (creature: Creature): LogEntry { return nilLog } | |||
| onRemove (creature: Creature): LogEntry { return nilLog } | |||
| preAction (creature: Creature): { prevented: boolean; log: LogEntry } { | |||
| return { | |||
| prevented: false, | |||
| log: nilLog | |||
| } | |||
| } | |||
| preDamage (creature: Creature, damage: Damage): Damage { | |||
| return damage | |||
| } | |||
| preAttack (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } { | |||
| return { | |||
| prevented: false, | |||
| log: nilLog | |||
| } | |||
| } | |||
| } | |||
| /** | |||
| * A displayable status effect | |||
| */ | |||
| @@ -346,38 +374,64 @@ export class ImplicitStatus implements VisibleStatus { | |||
| /** | |||
| * This kind of status is explicitly given to a creature. | |||
| * | |||
| * Individual status effects should override some of its hooks. | |||
| * Some hooks just produce a log entry. | |||
| * Some hooks return results along with a log entry. | |||
| */ | |||
| export abstract class StatusEffect implements VisibleStatus { | |||
| export abstract class StatusEffect extends Effective implements VisibleStatus { | |||
| constructor (public name: TextLike, public desc: TextLike, public icon: string) { | |||
| super() | |||
| } | |||
| get topLeft () { return '' } | |||
| get bottomRight () { return '' } | |||
| } | |||
| onApply (creature: Creature): LogEntry { return nilLog } | |||
| /** | |||
| * An Encounter describes a fight: who is in it and whose turn it is | |||
| */ | |||
| export class Encounter { | |||
| private initiatives: Map<Creature, number> | |||
| currentMove: Creature | |||
| turnTime = 100 | |||
| onRemove (creature: Creature): LogEntry { return nilLog } | |||
| constructor (public combatants: Creature[]) { | |||
| this.initiatives = new Map() | |||
| preAction (creature: Creature): { prevented: boolean; log: LogEntry } { | |||
| return { | |||
| prevented: false, | |||
| log: nilLog | |||
| } | |||
| } | |||
| combatants.forEach(combatant => this.initiatives.set(combatant, 0)) | |||
| this.currentMove = combatants[0] | |||
| preDamage (creature: Creature, damage: Damage): Damage { | |||
| return damage | |||
| this.nextMove() | |||
| } | |||
| preAttack (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } { | |||
| return { | |||
| prevented: false, | |||
| log: nilLog | |||
| nextMove (): void { | |||
| this.initiatives.set(this.currentMove, 0) | |||
| const times = new Map<Creature, number>() | |||
| this.combatants.forEach(combatant => { | |||
| // this should never be undefined | |||
| const currentProgress = this.initiatives.get(combatant) ?? 0 | |||
| const remaining = (this.turnTime - currentProgress) / Math.max(combatant.stats.Speed, 1) | |||
| times.set(combatant, remaining) | |||
| }) | |||
| this.currentMove = this.combatants.reduce((closest, next) => { | |||
| const closestTime = times.get(closest) ?? 0 | |||
| const nextTime = times.get(next) ?? 0 | |||
| return closestTime <= nextTime ? closest : next | |||
| }, this.combatants[0]) | |||
| const closestRemaining = (this.turnTime - (this.initiatives.get(this.currentMove) ?? 0)) / Math.max(this.currentMove.stats.Speed, 1) | |||
| this.combatants.forEach(combatant => { | |||
| // still not undefined... | |||
| const currentProgress = this.initiatives.get(combatant) ?? 0 | |||
| this.initiatives.set(combatant, currentProgress + closestRemaining * Math.max(combatant.stats.Speed, 1)) | |||
| console.log(combatant.name.toString(), currentProgress, closestRemaining) | |||
| }) | |||
| // TODO: still let the creature use drained-vigor moves | |||
| console.log(this.currentMove.name.toString()) | |||
| if (this.currentMove.disabled) { | |||
| this.nextMove() | |||
| } | |||
| } | |||
| } | |||
| @@ -2,10 +2,24 @@ import { StatTest, StatVigorTest } from './tests' | |||
| import { DynText, LiveText, TextLike, Verb, PairLine, PairLineArgs } from '../language' | |||
| import { Entity, Creature } from '../entity' | |||
| import { Damage, DamageFormula, Stat, Vigor, Action } from '../combat' | |||
| import { LogLine, LogLines, LogEntry, CompositeLog } from '../interface' | |||
| import { LogLine, LogLines, LogEntry, CompositeLog, nilLog } from '../interface' | |||
| import { VoreContainer, Container } from '../vore' | |||
| import { CapableCondition, UserDrainedVigorCondition, TogetherCondition, EnemyCondition, SoloCondition, PairCondition, ContainsCondition, ContainedByCondition } from './conditions' | |||
| export class PassAction extends Action { | |||
| execute (user: Creature, target: Creature): LogEntry { | |||
| return nilLog | |||
| } | |||
| describe (user: Creature, target: Creature): LogEntry { | |||
| return new LogLine("Do nothing.") | |||
| } | |||
| constructor () { | |||
| super("Pass", "Do nothing", [new SoloCondition()]) | |||
| } | |||
| } | |||
| export class AttackAction extends Action { | |||
| protected test: StatTest | |||
| @@ -3,6 +3,7 @@ import { Noun, Pronoun, TextLike, POV } from './language' | |||
| import { LogEntry, LogLine, LogLines } from './interface' | |||
| import { Vore, VoreContainer, VoreType, Container } from './vore' | |||
| import { Item } from './items' | |||
| import { PassAction } from './combat/actions' | |||
| export interface Entity { | |||
| name: Noun; | |||
| @@ -94,6 +95,7 @@ export class Creature extends Vore implements Combatant { | |||
| super() | |||
| const containers = this.containers | |||
| this.actions.push(new PassAction()) | |||
| Object.entries(this.maxVigors).forEach(([key, val]) => { | |||
| this.vigors[key as Vigor] = val | |||
| }) | |||