implements combat attack dialog

pull/61/head
macniel 2025-10-22 13:21:24 +02:00
parent b2d9a82af8
commit 7f18c62270
9 changed files with 539 additions and 51 deletions

View File

@ -48,7 +48,11 @@ export class SpecialAbilityDataModel extends BaseItem {
})
),
waffenLimit: new ArrayField(
new StringField(),
new SchemaField({
waffe: new StringField(),
gruppe: new StringField(),
mod: new NumberField(),
})
),
mod: new ArrayField(new SchemaField({
name: new StringField(),
@ -67,11 +71,13 @@ export class SpecialAbilityDataModel extends BaseItem {
}
}
isActive() { // TODO also handle Waffenlimit
isActive(options) { // TODO also handle Waffenlimit
const requirements = this.#getRequirements()
let passes = false
let mod = 0
const flatActor = foundry.utils.flattenObject(this.parent.actor.system)
for (let requirement of requirements) {
@ -90,11 +96,55 @@ export class SpecialAbilityDataModel extends BaseItem {
passes = targetField <= requirement.maxValue
}
if (requirement["compare"]) {
const {ownAttribute, operation, targetAttribute} = requirement["compare"]
if (options.target) {
const flatTarget = foundry.utils.flattenObject(options.target.system)
const foreignTargetField = flatTarget[targetAttribute]
const ourTargetField = flatActor[ownAttribute]
switch (operation) {
case "lt":
passes = ourTargetField < foreignTargetField;
break;
case "lte":
passes = ourTargetField <= foreignTargetField;
break;
case "eq":
passes = ourTargetField == foreignTargetField;
break;
case "neq":
passes = ourTargetField != foreignTargetField;
break;
case "gte":
passes = ourTargetField >= foreignTargetField;
break;
case "gt":
passes = ourTargetField > foreignTargetField;
break;
}
}
passes = false
}
if (!passes) {
break
}
}
return passes
if (passes) { // TODO: how are we going to communicate the malus?
this.system.waffenLimit.forEach(waff => {
if (waff.waffe) {
passes = options.weapon.name === waff.waffe
if (waff.mod) mod = waff.mod
} else if (waff.gruppe) {
passes = options.skill.name === waff.gruppe
if (waff.mod) mod = waff.mod
}
})
}
return {passes, mod}
}
}

View File

@ -0,0 +1,255 @@
import {XmlImport} from "../xml-import/xml-import.mjs";
import {ActionManager} from "../sheets/actions/action-manager.mjs";
const {ApplicationV2, HandlebarsApplicationMixin} = foundry.applications.api
/**
* @typedef TokenDistance
* @property {Number} x
* @property {Number} y
* @property {Number} d
* @property {Token} token
*/
export class CombatActionDialog extends HandlebarsApplicationMixin(ApplicationV2) {
static DEFAULT_OPTIONS = {
classes: ['dsa41', 'dialog', 'combat'],
tag: "form",
position: {
width: 320,
height: 540
},
window: {
resizable: false,
},
form: {
submitOnChange: false,
closeOnSubmit: true,
handler: CombatActionDialog.#onSubmitForm
},
actions: {
selectTarget: CombatActionDialog.#onSelectTarget,
selectWeaponAndSkill: CombatActionDialog.#onSelectWeaponAndSkill,
selectManeuver: CombatActionDialog.#onSelectManeuver,
}
}
static PARTS = {
form: {
template: 'systems/DSA_4-1/templates/dialog/combat-action.hbs',
}
}
/**
* @type {Actor}
* @private
*/
_actor = null
constructor(actor) {
super();
this._actor = actor
this._targetId = null
this._skillId = null
this._weaponId = null
this._combatManeuverId = null
this._actionManager = new ActionManager(this._actor)
}
static async #onSelectTarget(event, target) {
const {targetId} = target.dataset
this._targetId = this._targetId === targetId ? null : targetId
this.render({parts: ["form"]})
}
static async #onSelectManeuver(event, target) {
const {maneuverId} = target.dataset
this._combatManeuverId = this._combatManeuverId === maneuverId ? null : maneuverId
this.render({parts: ["form"]})
}
static async #onSelectWeaponAndSkill(event, target) {
const {weaponId, skillId} = target.dataset
this._weaponId = this._weaponId === weaponId ? null : weaponId
this._skillId = this._skillId === skillId ? null : skillId
this.render({parts: ["form"]})
}
static async #onSubmitForm(event, form, formData) {
event.preventDefault()
}
_configureRenderOptions(options) {
super._configureRenderOptions(options)
if (options.window) {
options.window.title = `Mit ${this._actor.name} angreifen`
}
return options
}
#getDistanceBetween(originToken, targetToken) {
const distance = game.scenes.current.dimensions.distancePixels
// get distances between of tokens and thisTokenRepresentative
const actorOfToken = game.actors.get(targetToken.actorId)
return {
id: targetToken._id,
x: targetToken.x,
y: targetToken.y,
actor: actorOfToken,
token: targetToken,
d: ((Math.sqrt(Math.pow(targetToken.x - originToken.x, 2) + Math.pow(targetToken.y - originToken.y, 2))) / distance).toFixed(2),
}
}
#evaluateDistances() {
// get tokens in range on the current scene
const scene = game.scenes.current
const tokens = scene.tokens
const tokenId = this._actor.getActiveTokens()[0].id
const thisTokenRepresentative = tokens.get(tokenId)
/**
*
* @type {[TokenDistance]}
*/
return tokens.map(token => {
return {
isSelected: this._targetId === token.id,
...this.#getDistanceBetween(thisTokenRepresentative, token)
}
})
}
#evaluateWeapons() {
// get equipped weapons and adjust AT/PA values by basis values from actor TODO: and W/M of weapons
const equips = this._actor.system.heldenausruestung[this._actor.system.setEquipped]
const weapons = []
const equippedWeapons = ["links", "rechts", "fernkampf"]
const baseAt = {
links: this._actor.system.at.links.aktuell, // TODO hook Beidhändigerkampf/linkhand
rechts: this._actor.system.at.rechts.aktuell,
fernkampf: this._actor.system.fk.aktuell,
}
const basePa = {
links: this._actor.system.pa.links.aktuell, // TODO hook Beidhändigerkampf/linkhand
rechts: this._actor.system.pa.rechts.aktuell,
fernkampf: 0,
}
equippedWeapons.forEach(slot => {
const equip = equips[slot]
const weapon = this._actor.itemTypes["Equipment"].find(p => p._id === equip)
if (weapon) {
const variantWeaponSkills = [...weapon.system.rangedSkills, ...weapon.system.meleeSkills]
variantWeaponSkills.forEach(weaponSkill => {
const skill = this._actor.itemTypes["Skill"].find(p => p.name === weaponSkill)
if (skill) {
const skillAt = skill.system.at
const skillPa = skill.system.pa
weapons.push({
isSelected: this._skillId === skill._id && this._weaponId === weapon._id,
weaponId: weapon._id,
skillId: skill._id,
name: weapon.name,
skillName: skill.name,
img: weapon.img,
combatStatistics: {
at: baseAt[slot] + weapon.system.attackModifier + skillAt,
pa: basePa[slot] + weapon.system.parryModifier + skillPa
}
})
}
})
}
})
this._weapons = weapons
return this._weapons
}
#evaluateManeuvers() {
const manager = this._actionManager
const weapon = this._actor.itemTypes["Equipment"].find(p => p._id === this._weaponId)
const skill = this._actor.itemTypes["Skill"].find(p => p._id === this._skillId)
const target = game.actors.get(this._targetId)
this._maneuvers = manager.evaluate({
target,
weapon,
skill
}).filter(p => p.type === ActionManager.ATTACK).map(action => {
return {
isSelected: this._combatManeuverId === action.name,
id: action.name,
name: action.name,
type: action.type,
source: action.source,
cost: action.cost,
mod: action.mod,
}
})
return this._maneuvers
}
async _prepareContext(options) {
const context = await super._prepareContext(options)
context.actor = this._actor
context.distanceUnit = game.scenes.current.grid.units
if (this._actor.getActiveTokens()[0]?.id) {
context.tokenDistances = this.#evaluateDistances()
context.weapons = this.#evaluateWeapons()
if (this._targetId && this._weaponId && this._skillId) {
context.maneuver = this.#evaluateManeuvers()
}
const maneuver = this._maneuvers?.find(p => p.id === this._combatManeuverId)
if (maneuver) {
context.canMod = maneuver.mod != undefined
}
context.targetNumber = context.weapons.find(p => p.weaponId === this._weaponId && p.skillId === this._skillId)?.combatStatistics.at
// TODO get W/M of weapon NOW
context.ready = this._targetId && this._weaponId && this._skillId && this._combatManeuverId
return context
} else {
ui.notifications.error(`Feature funktioniert nur wenn der Akteur ein Token auf der aktuellen Szene hat`);
}
}
_onRender(context, options) {
const target = this.element.querySelector(".actions button .value")
this.element.querySelectorAll('[name="mod"], [name="malus"]').forEach(e => e.addEventListener('change', (event) => {
const at = Number(context.targetNumber)
const malus = Number(this.element.querySelector('[name="malus"]').value)
const mod = Number(this.element.querySelector('[name="mod"]').value)
const maneuver = this._maneuvers?.find(p => p.id === this._combatManeuverId)
const result = at + (maneuver.mod?.(mod) ?? 0) + malus
target.textContent = `(${result})`
}))
}
}

View File

@ -16,8 +16,4 @@ export class SpecialAbility extends Item {
}
}
isActive() {
return true
}
}

View File

@ -23,97 +23,121 @@ export class ActionManager {
cost: ActionManager.FREE,
type: ActionManager.DEFENSE,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Rufen",
cost: ActionManager.FREE,
type: ActionManager.INTERACTION,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Sich zu Boden fallen lassen",
cost: ActionManager.FREE,
type: ActionManager.MOVEMENT,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Waffe oder Gegenstand fallen lassen",
cost: ActionManager.FREE,
type: ActionManager.INTERACTION,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "getragenes Artefakt aktivieren",
cost: ActionManager.FREE,
type: ActionManager.INTERACTION,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Schnellziehen",
cost: ActionManager.FREE,
type: ActionManager.INTERACTION,
source: ActionManager.SF,
eval: () => this.#hatSonderfertigkeit("Schnellziehen") && this.#evalSonderfertigkeitRequirements("Schnellziehen")
eval: (options) => this.#hatSonderfertigkeit("Schnellziehen", options) && this.#evalSonderfertigkeitRequirements("Schnellziehen", options)
}
]
#regularActions = [
{
name: "Angriffsaktion",
name: "Nahkampfangriff",
type: ActionManager.ATTACK,
cost: ActionManager.REGULAR,
source: ActionManager.DEFAULT,
eval: () => this.#hatWaffeinHand()
eval: (options) => this.#hatWaffeinHand() && !this.#hatFernkampfWaffeinHand()
},
{
name: "Fernkampfangriff",
type: ActionManager.ATTACK,
cost: ActionManager.REGULAR,
source: ActionManager.DEFAULT,
eval: (options) => this.#hatFernkampfWaffeinHand(),
},
{
name: "Angesagter Fernkampfangriff",
type: ActionManager.ATTACK,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: (options) => this.#hatFernkampfWaffeinHand(),
mod: (value) => -value * 2
},
{
name: "Scharfer Schuss",
type: ActionManager.ATTACK,
cost: ActionManager.CONTINUING,
source: ActionManager.SF,
eval: (options) => this.#hatFernkampfWaffeinHand() && this.#hatSonderfertigkeit("Scharfschütze", options) && this.#evalSonderfertigkeitRequirements("Scharfschütze", options),
mod: (value) => -value
},
{
name: "Schnellschuss",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => this.#hatFernkampfWaffeinHand()
eval: (options) => this.#hatFernkampfWaffeinHand()
},
{
name: "Schnellschuss (Scharfschütze)",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.SF,
eval: () => this.#hatFernkampfWaffeinHand() && this.#hatSonderfertigkeit("Scharfschütze") && this.#evalSonderfertigkeitRequirements("Scharfschütze")
eval: (options) => this.#hatFernkampfWaffeinHand() && this.#hatSonderfertigkeit("Scharfschütze", options) && this.#evalSonderfertigkeitRequirements("Scharfschütze", options)
},
{
name: "Abwehraktion",
type: ActionManager.DEFENSE,
cost: ActionManager.REGULAR,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Bewegen",
type: ActionManager.MOVEMENT,
cost: ActionManager.REGULAR,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Position",
type: ActionManager.MOVEMENT,
cost: ActionManager.REGULAR,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Finte",
type: ActionManager.ATTACK,
cost: ActionManager.REGULAR,
source: ActionManager.SF,
eval: () =>
mod: (value) => value,
eval: (options) =>
this.#hatWaffeinHand() &&
this.#hatSonderfertigkeit("Finte") &&
this.#evalSonderfertigkeitRequirements("Finte")
this.#hatSonderfertigkeit("Finte", options) &&
this.#evalSonderfertigkeitRequirements("Finte", options)
},
{
@ -121,23 +145,25 @@ export class ActionManager {
type: ActionManager.ATTACK,
cost: ActionManager.REGULAR,
source: ActionManager.DEFAULT,
eval: () => true
mod: (value) => -(value),
eval: (options) => !this.#hatFernkampfWaffeinHand()
},
{
name: "Wuchtschlag",
type: ActionManager.ATTACK,
cost: ActionManager.REGULAR,
source: ActionManager.SF,
eval: () => this.#hatSonderfertigkeit("Wuchtschlag")
&& this.#evalSonderfertigkeitRequirements("Wuchtschlag")
mod: (value) => -(value),
eval: (options) => !this.#hatFernkampfWaffeinHand() && this.#hatSonderfertigkeit("Wuchtschlag", options)
&& this.#evalSonderfertigkeitRequirements("Wuchtschlag", options)
},
{
name: "Betäubungsschlag",
type: ActionManager.ATTACK,
cost: ActionManager.REGULAR,
source: ActionManager.SF,
eval: () => this.#hatSonderfertigkeit("Betäubungsschlag")
&& this.#evalSonderfertigkeitRequirements("Betäubungsschlag")
eval: (options) => !this.#hatFernkampfWaffeinHand() && this.#hatSonderfertigkeit("Betäubungsschlag", options)
&& this.#evalSonderfertigkeitRequirements("Betäubungsschlag", options)
}
]
@ -147,76 +173,76 @@ export class ActionManager {
type: ActionManager.TALENT,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Waffe ziehen",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Sprinten",
type: ActionManager.MOVEMENT,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Gegenstand benutzen",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Schnellladen (Bogen)",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.SF,
eval: () => this.#hatMunition()
&& this.#hatFernkampfWaffeinHand("Bogen")
&& this.#hatSonderfertigkeit("Schnellladen (Bogen)")
&& this.#evalSonderfertigkeitRequirements("Schnellladen (Bogen)")
eval: (options) => this.#hatMunition()
&& this.#hatFernkampfWaffeinHand("Bogen", options)
&& this.#hatSonderfertigkeit("Schnellladen (Bogen)", options)
&& this.#evalSonderfertigkeitRequirements("Schnellladen (Bogen)", options)
},
{
name: "Schnellladen (Armbrust)",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.SF,
eval: () => this.#hatMunition()
&& this.#hatFernkampfWaffeinHand("Armbrust")
&& this.#hatSonderfertigkeit("Schnellladen (Armbrust)")
&& this.#evalSonderfertigkeitRequirements("Schnellladen (Armbrust)")
eval: (options) => this.#hatMunition()
&& this.#hatFernkampfWaffeinHand("Armbrust", options)
&& this.#hatSonderfertigkeit("Schnellladen (Armbrust)", options)
&& this.#evalSonderfertigkeitRequirements("Schnellladen (Armbrust)", options)
},
{
name: "Nachladen",
type: ActionManager.INTERACTION,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => this.#hatMunition()
eval: (options) => this.#hatMunition()
},
{
name: "Talenteinsatz",
type: ActionManager.TALENT,
cost: ActionManager.CONTINUING,
source: ActionManager.DEFAULT,
eval: () => true
eval: (options) => true
},
{
name: "Zaubern",
type: ActionManager.SPELL,
cost: ActionManager.CONTINUING,
source: ActionManager.SF,
eval: () => this.#hatSonderfertigkeitBeginnendMit("Repräsentation:")
eval: (options) => this.#hatSonderfertigkeitBeginnendMit("Repräsentation:", options)
},
{
name: "Liturgie wirken",
type: ActionManager.SPELL,
cost: ActionManager.CONTINUING,
source: ActionManager.SF,
eval: () => this.#hatSonderfertigkeitBeginnendMit("Liturgiekenntnis")
eval: (options) => this.#hatSonderfertigkeitBeginnendMit("Liturgiekenntnis", options)
}
]
@ -236,25 +262,30 @@ export class ActionManager {
return item != null
}
#hatSonderfertigkeitBeginnendMit(name) {
#hatSonderfertigkeitBeginnendMit(name, options) {
return this.actor.itemTypes["SpecialAbility"]?.find(p => p.name.startsWith(name)) != null
}
#hatSonderfertigkeit(name) {
#hatSonderfertigkeit(name, options) {
return this.actor.itemTypes["SpecialAbility"]?.find(p => p.name === name) != null
}
#evalSonderfertigkeitRequirements(nameOfSF) {
#evalSonderfertigkeitRequirements(nameOfSF, options) {
const sf = this.actor.itemTypes["SpecialAbility"].find(p => p.name === nameOfSF)
return sf.system.isActive()
return sf.system.isActive(options).passes
}
evaluate() {
/**
*
* @param {{target: String?}} options
*/
evaluate(options) {
let actionArray = [...this.#freeActions, ...this.#regularActions, ...this.#continuingActions]
console.log(this.actor, actionArray.map((action) => {
return {
...action,
eval: action.eval()
eval: action.eval(options)
}
}))
const validActions = actionArray.filter(action => action.eval())

View File

@ -7,6 +7,7 @@ import Meta from "./character/meta.mjs"
import Skills from "./character/skills.mjs"
import Social from "./character/social.mjs";
import Spells from "./character/spells.mjs"
import {CombatActionDialog} from "../dialog/combatAction.mjs";
const {HandlebarsApplicationMixin} = foundry.applications.api
const {ActorSheetV2} = foundry.applications.sheets
@ -36,6 +37,7 @@ class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
openEmbeddedDocument: CharacterSheet.#openEmbeddedDocument,
openCultureDocument: CharacterSheet.#openCultureDocument,
openSpeciesDocument: CharacterSheet.#openSpeciesDocument,
openCombatAction: CharacterSheet.#openCombatAction,
}
}
@ -143,6 +145,10 @@ class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
this.document.itemTypes["Species"]?.[0]?.sheet.render(true)
}
static #openCombatAction() {
new CombatActionDialog(this.document).render(true)
}
/**
* Handle form submission
* @this {AdvantageSheet}

View File

@ -0,0 +1,91 @@
@keyframes pulse {
0% {
text-shadow: 0 0 0 red;
}
75% {
text-shadow: 0 0 4px red;
}
100% {
text-shadow: 0 0 0 red;
}
}
.dsa41.dialog.combat {
.window-content > section {
display: flex;
flex-direction: column;
height: 100%;
fieldset {
flex: 1;
border-bottom: 0;
border-left: 0;
border-right: 0;
padding: 0;
margin: 0;
legend {
text-align: center;
padding: 0 16px;
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
text-indent: 0;
li {
height: 32px;
display: grid;
line-height: 32px;
vertical-align: middle;
grid-template-columns: 32px 1fr 83px;
grid-template-rows: 1fr;
border: 1px transparent;
margin: 8px;
gap: 0 8px;
&.selected {
background-color: rgba(255, 140, 0, 0.2);
border: 1px solid orange;
border-radius: 16px;
}
&.name-only {
display: block;
}
}
}
.malus-and-mod {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: 0 8px;
}
.actions {
flex: 0;
display: flex;
justify-content: center;
button.ready {
animation: pulse 2s infinite;
}
}
}
}

View File

@ -24,4 +24,5 @@
@use "organisms/species-sheet";
@use "organisms/profession-sheet";
@use "organisms/xml-import-dialog";
@use "organisms/combat-action-dialog";

View File

@ -0,0 +1,58 @@
<section>
<fieldset>
<legend>Ziel auswählen</legend>
<ul>
{{#each tokenDistances}}
<li class="token {{#if isSelected}}selected{{/if}}" data-action="selectTarget"
data-target-id="{{this.id}}"><img src="{{this.token.texture.src}}"
style="width: 32px; height: 32px"/><span>{{this.actor.name}}</span><span>({{this.d}} {{../distanceUnit}}
)</span></li>
{{/each}}
</ul>
</fieldset>
<fieldset>
<legend>Waffe auswählen</legend>
<ul>
{{#each weapons}}
<li class="{{#if isSelected}}selected{{/if}}" data-action="selectWeaponAndSkill"
data-weapon-id="{{this.weaponId}}" data-skill-id="{{this.skillId}}"><img src="{{this.img}}"
style="width: 32px; height: 32px"/><span>{{this.name}}
({{this.skillName}})</span><span>{{#if this.combatStatistics}}
(AT: {{this.combatStatistics.at}} PA: {{this.combatStatistics.pa}}){{/if}}</span></li>
{{/each}}
</ul>
</fieldset>
<fieldset>
<legend>Manöver auswählen</legend>
<ul>
{{#each maneuver}}
<li class="{{#if isSelected}}selected{{/if}} name-only" data-action="selectManeuver"
data-maneuver-id="{{this.id}}" class="{{this.source}}">{{this.name}}</li>
{{/each}}
</ul>
</fieldset>
<fieldset>
<legend>Erschwernisse und Ansagen</legend>
<div class="malus-and-mod">
<label>Umstände
<input name="malus" type="number">
</label>
<label>Ansage
<input name="mod" type="number" {{disabled (not canMod)}}>
</label>
</div>
</fieldset>
<div class="actions">
<button {{#if ready}}class="ready"{{/if}} type="submit"><i class="fa-solid fa-swords"></i>Angreifen <span
class="value"></span></button>
</div>
</section>

View File

@ -1,4 +1,4 @@
<div class="player-action {{this.source}} {{this.type}}">
<div class="player-action {{this.source}} {{this.type}}" data-action="openCombatAction">
<span class="name">{{this.name}}</span>
<span class="time">{{this.cost}}</span>