| @@ -1,7 +1,7 @@ | |||||
| <template> | <template> | ||||
| <div id="app"> | <div id="app"> | ||||
| <Header version="pre-alpha" /> | <Header version="pre-alpha" /> | ||||
| <Combat :combatants="combatants" /> | |||||
| <Combat :encounter="encounter" /> | |||||
| </div> | </div> | ||||
| </template> | </template> | ||||
| @@ -13,6 +13,7 @@ import * as Creatures from '@/game/creatures' | |||||
| import * as Items from '@/game/items' | import * as Items from '@/game/items' | ||||
| import { Creature } from '@/game/entity' | import { Creature } from '@/game/entity' | ||||
| import { ProperNoun, TheyPronouns, FemalePronouns, MalePronouns, ImproperNoun } from '@/game/language' | import { ProperNoun, TheyPronouns, FemalePronouns, MalePronouns, ImproperNoun } from '@/game/language' | ||||
| import { Encounter } from './game/combat' | |||||
| @Component({ | @Component({ | ||||
| components: { | components: { | ||||
| @@ -20,7 +21,8 @@ import { ProperNoun, TheyPronouns, FemalePronouns, MalePronouns, ImproperNoun } | |||||
| } | } | ||||
| }) | }) | ||||
| export default class App extends Vue { | export default class App extends Vue { | ||||
| combatants: Array<Creature> | |||||
| encounter: Encounter | |||||
| constructor () { | constructor () { | ||||
| super() | super() | ||||
| @@ -73,7 +75,9 @@ export default class App extends Vue { | |||||
| const kenzie = new Creatures.Kenzie() | const kenzie = new Creatures.Kenzie() | ||||
| const cafat = new Creatures.Cafat() | const cafat = new Creatures.Cafat() | ||||
| const wolf = new Creatures.Wolf() | 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> | </script> | ||||
| @@ -1,10 +1,11 @@ | |||||
| <template> | <template> | ||||
| <div class="combat-layout"> | <div class="combat-layout"> | ||||
| <div>{{ encounter.currentMove.name }}</div> | |||||
| <div @wheel="horizWheelLeft" class="stat-column" id="left-stats"> | <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> | ||||
| <div @wheel="horizWheelRight" class="stat-column" id="right-stats"> | <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> | ||||
| <div id="log"> | <div id="log"> | ||||
| </div> | </div> | ||||
| @@ -12,7 +13,7 @@ | |||||
| </div> | </div> | ||||
| <div class="left-actions"> | <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> | <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" /> | <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> | <i class="action-label fas fa-user-friends" v-if="left.validActions(right).length > 0"></i> | ||||
| @@ -26,7 +27,7 @@ | |||||
| </div> | </div> | ||||
| <div class="right-actions"> | <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> | <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" /> | <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> | <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 { LogEntry } from '@/game/interface' | ||||
| import Statblock from './Statblock.vue' | import Statblock from './Statblock.vue' | ||||
| import ActionButton from './ActionButton.vue' | import ActionButton from './ActionButton.vue' | ||||
| import { Side } from '@/game/combat' | |||||
| import { Side, Encounter } from '@/game/combat' | |||||
| @Component( | @Component( | ||||
| { | { | ||||
| @@ -55,7 +56,8 @@ import { Side } from '@/game/combat' | |||||
| data () { | data () { | ||||
| return { | return { | ||||
| left: null, | left: null, | ||||
| right: null | |||||
| right: null, | |||||
| combatants: null | |||||
| } | } | ||||
| }, | }, | ||||
| methods: { | methods: { | ||||
| @@ -72,6 +74,16 @@ import { Side } from '@/game/combat' | |||||
| if (target !== null) { | if (target !== null) { | ||||
| target.scrollBy({ top: 0, left: event.deltaY, behavior: 'smooth' }) | 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 { | export default class Combat extends Vue { | ||||
| @Prop() | @Prop() | ||||
| combatants!: Array<Creature> | |||||
| encounter!: Encounter | |||||
| Side = Side | Side = Side | ||||
| actionDescription = '' | actionDescription = '' | ||||
| created () { | 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 () { | mounted () { | ||||
| @@ -112,6 +125,23 @@ export default class Combat extends Vue { | |||||
| log.scrollTo({ top: log.scrollHeight, left: 0 }) | 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") | @Emit("executedRight") | ||||
| @@ -128,6 +158,22 @@ export default class Combat extends Vue { | |||||
| log.scrollTo({ top: log.scrollHeight, left: 0 }) | 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") | @Emit("described") | ||||
| @@ -3,6 +3,7 @@ | |||||
| <div class="statblock-shader statblock-shader-hover"></div> | <div class="statblock-shader statblock-shader-hover"></div> | ||||
| <div class="statblock-shader statblock-shader-selected"></div> | <div class="statblock-shader statblock-shader-selected"></div> | ||||
| <div class="statblock-shader statblock-shader-selected-ally"></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-dead"></div> | ||||
| <div class="statblock-shader statblock-shader-eaten"></div> | <div class="statblock-shader statblock-shader-eaten"></div> | ||||
| <div class="statblock-content"> | <div class="statblock-content"> | ||||
| @@ -13,6 +14,7 @@ | |||||
| <div class="tooltip-body">{{ subject.desc }}</div> | <div class="tooltip-body">{{ subject.desc }}</div> | ||||
| </div> | </div> | ||||
| </h2> | </h2> | ||||
| <div> Initiative: {{ (initiative).toFixed(0) }}%</div> | |||||
| <div class="statblock-status-icons"> | <div class="statblock-status-icons"> | ||||
| <i :class="status.icon" v-for="(status, index) in subject.status" :key="'status' + index"> | <i :class="status.icon" v-for="(status, index) in subject.status" :key="'status' + index"> | ||||
| <div class="statblock-status-icon-topleft">{{ status.topLeft }}</div> | <div class="statblock-status-icon-topleft">{{ status.topLeft }}</div> | ||||
| @@ -105,6 +107,9 @@ export default class Statblock extends Vue { | |||||
| @Prop({ type: Creature, required: true }) | @Prop({ type: Creature, required: true }) | ||||
| subject!: Creature | subject!: Creature | ||||
| @Prop() | |||||
| initiative!: number | |||||
| private vigorIcons = VigorIcons | private vigorIcons = VigorIcons | ||||
| private statIcons = StatIcons | private statIcons = StatIcons | ||||
| private voreStatIcons = VoreStatIcons | private voreStatIcons = VoreStatIcons | ||||
| @@ -283,6 +288,15 @@ a { | |||||
| opacity: 0.20; | 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 { | .statblock[data-dead] .statblock-shader-dead { | ||||
| background: red; | background: red; | ||||
| opacity: 0.50; | 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 | * A displayable status effect | ||||
| */ | */ | ||||
| @@ -346,38 +374,64 @@ export class ImplicitStatus implements VisibleStatus { | |||||
| /** | /** | ||||
| * This kind of status is explicitly given to a creature. | * 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) { | constructor (public name: TextLike, public desc: TextLike, public icon: string) { | ||||
| super() | |||||
| } | } | ||||
| get topLeft () { return '' } | get topLeft () { return '' } | ||||
| get bottomRight () { 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 { DynText, LiveText, TextLike, Verb, PairLine, PairLineArgs } from '../language' | ||||
| import { Entity, Creature } from '../entity' | import { Entity, Creature } from '../entity' | ||||
| import { Damage, DamageFormula, Stat, Vigor, Action } from '../combat' | 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 { VoreContainer, Container } from '../vore' | ||||
| import { CapableCondition, UserDrainedVigorCondition, TogetherCondition, EnemyCondition, SoloCondition, PairCondition, ContainsCondition, ContainedByCondition } from './conditions' | 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 { | export class AttackAction extends Action { | ||||
| protected test: StatTest | protected test: StatTest | ||||
| @@ -3,6 +3,7 @@ import { Noun, Pronoun, TextLike, POV } from './language' | |||||
| import { LogEntry, LogLine, LogLines } from './interface' | import { LogEntry, LogLine, LogLines } from './interface' | ||||
| import { Vore, VoreContainer, VoreType, Container } from './vore' | import { Vore, VoreContainer, VoreType, Container } from './vore' | ||||
| import { Item } from './items' | import { Item } from './items' | ||||
| import { PassAction } from './combat/actions' | |||||
| export interface Entity { | export interface Entity { | ||||
| name: Noun; | name: Noun; | ||||
| @@ -94,6 +95,7 @@ export class Creature extends Vore implements Combatant { | |||||
| super() | super() | ||||
| const containers = this.containers | const containers = this.containers | ||||
| this.actions.push(new PassAction()) | |||||
| Object.entries(this.maxVigors).forEach(([key, val]) => { | Object.entries(this.maxVigors).forEach(([key, val]) => { | ||||
| this.vigors[key as Vigor] = val | this.vigors[key as Vigor] = val | ||||
| }) | }) | ||||