574
edits
m (rename) |
|||
Line 1: | Line 1: | ||
# | Inspired by the crash system in the [[Mikoyan-Gurevich_MiG-15|Mig-15]], this system is meant to become generic and usable for all aircraft by adding just a few lines. For now you have to add a file with code though. | ||
== Features so far== | |||
* Impact detection and tied into the new failure manager. | |||
* Over-G detection for wing stress. | |||
* Sounds. | |||
== Planned features== | |||
Feel free to suggest some in the forum topic linked to at the end of this article. | |||
== How to install the current system on an aircraft == | |||
Copy this code into your aircrafts Nasal folder as crash-and-stress.nas file: | |||
<syntaxhighlight lang="nasal"> | |||
# | |||
# A Flightgear JSBSim crash and stress damage system. | |||
# | |||
# Inspired by the crash system in Mig15 by Slavutinsky Victor. And by Hvengel's formula for wingload stress. | |||
# | |||
# Authors: Slavutinsky Victor, Nikolai V. Chr. (Necolatis) | |||
# | |||
# | |||
# Version 0.11 | |||
# | |||
# License: | |||
# GPL 2.0 | |||
var TRUE = 1; | |||
var FALSE = 0; | |||
var CrashAndStress = { | |||
# pattern singleton | |||
_instance: nil, | |||
# Get the instance | |||
new: func (gears, stressLimit = nil, wingsFailureModes = nil) { | |||
var m = nil; | |||
if(me._instance == nil) { | |||
me._instance = {}; | |||
me._instance["parents"] = [CrashAndStress]; | |||
m = me._instance; | |||
m.inService = FALSE; | |||
m.repairing = FALSE; | |||
m.exploded = FALSE; | |||
m.wingsAttached = TRUE; | |||
m.wingLoadLimitUpper = nil; | |||
m.wingLoadLimitLower = nil; | |||
m._looptimer = maketimer(0, m, m._loop); | |||
m.input = { | |||
# trembleOn: "damage/g-tremble-on", | |||
# trembleMax: "damage/g-tremble-max", | |||
replay: "sim/replay/replay-state", | |||
lat: "position/latitude-deg", | |||
lon: "position/longitude-deg", | |||
alt: "position/altitude-ft", | |||
altAgl: "position/altitude-agl-ft", | |||
elev: "position/ground-elev-ft", | |||
crackOn: "damage/sounds/crack-on", | |||
creakOn: "damage/sounds/creaking-on", | |||
crackVol: "damage/sounds/crack-volume", | |||
creakVol: "damage/sounds/creaking-volume", | |||
wCrashOn: "damage/sounds/water-crash-on", | |||
crashOn: "damage/sounds/crash-on", | |||
detachOn: "damage/sounds/detach-on", | |||
explodeOn: "damage/sounds/explode-on", | |||
}; | |||
foreach(var ident; keys(m.input)) { | |||
m.input[ident] = props.globals.getNode(m.input[ident], 1); | |||
} | |||
m.fdm = nil; | |||
if(getprop("sim/flight-model") == "jsb") { | |||
m.fdm = jsbSimProp; | |||
} elsif(getprop("sim/flight-model") == "yasim") { | |||
m.fdm = yaSimProp; | |||
} else { | |||
return nil; | |||
} | |||
m.fdm.convert(); | |||
m.wowStructure = []; | |||
m.wowGear = []; | |||
m.lastMessageTime = 0; | |||
m._identifyGears(gears); | |||
m.setStressLimit(stressLimit); | |||
m.setWingsFailureModes(wingsFailureModes); | |||
m._startImpactListeners(); | |||
} else { | |||
m = me._instance; | |||
} | |||
return m; | |||
}, | |||
# start the system | |||
start: func () { | |||
me.inService = TRUE; | |||
}, | |||
# stop the system | |||
stop: func () { | |||
me.inService = FALSE; | |||
}, | |||
# return TRUE if in progress | |||
isStarted: func () { | |||
return me.inService; | |||
}, | |||
# repair the aircaft | |||
repair: func () { | |||
var failure_modes = FailureMgr._failmgr.failure_modes; | |||
var mode_list = keys(failure_modes); | |||
foreach(var failure_mode_id; mode_list) { | |||
FailureMgr.set_failure_level(failure_mode_id, 0); | |||
} | |||
me.wingsAttached = TRUE; | |||
me.exploded = FALSE; | |||
me.lastMessageTime = 0; | |||
me.repairing = TRUE; | |||
var timer = maketimer(10, me, me._finishRepair); | |||
timer.start(); | |||
}, | |||
# accepts a vector with failure mode IDs, they will fail when wings break off. | |||
setWingsFailureModes: func (modes) { | |||
if(modes == nil) { | |||
modes = []; | |||
} | |||
## | |||
# Returns an actuator object that will set the serviceable property at | |||
# the given node to zero when the level of failure is > 0. | |||
# it will also fail additionally failure modes. | |||
var set_unserviceable_cascading = func(path, casc_paths) { | |||
var prop = path ~ "/serviceable"; | |||
if (props.globals.getNode(prop) == nil) { | |||
props.globals.initNode(prop, TRUE, "BOOL"); | |||
} else { | |||
props.globals.getNode(prop).setValue(TRUE);#in case this gets initialized empty from a recorder signal or MP alias. | |||
} | |||
return { | |||
parents: [FailureMgr.FailureActuator], | |||
mode_paths: casc_paths, | |||
set_failure_level: func(level) { | |||
setprop(prop, level > 0 ? 0 : 1); | |||
foreach(var mode_path ; me.mode_paths) { | |||
FailureMgr.set_failure_level(mode_path, level); | |||
} | |||
}, | |||
get_failure_level: func { getprop(prop) ? 0 : 1 } | |||
} | |||
} | |||
var prop = me.fdm.wingsFailureID; | |||
var actuator_wings = set_unserviceable_cascading(prop, modes); | |||
FailureMgr.add_failure_mode(prop, "Main wings", actuator_wings); | |||
}, | |||
# set the stresslimit for the main wings | |||
setStressLimit: func (stressLimit = nil) { | |||
if (stressLimit != nil) { | |||
var wingloadMax = stressLimit['wingloadMaxLbs']; | |||
var wingloadMin = stressLimit['wingloadMinLbs']; | |||
var maxG = stressLimit['maxG']; | |||
var minG = stressLimit['minG']; | |||
var weight = stressLimit['weightLbs']; | |||
if(wingloadMax != nil) { | |||
me.wingLoadLimitUpper = wingloadMax; | |||
} elsif (maxG != nil and weight != nil) { | |||
me.wingLoadLimitUpper = maxG * weight; | |||
} | |||
if(wingloadMin != nil) { | |||
me.wingLoadLimitLower = wingloadMin; | |||
} elsif (minG != nil and weight != nil) { | |||
me.wingLoadLimitLower = minG * weight; | |||
} elsif (me.wingLoadLimitUpper != nil) { | |||
me.wingLoadLimitLower = -me.wingLoadLimitUpper * 0.4;#estimate for when lower is not specified | |||
} | |||
me._looptimer.start(); | |||
} else { | |||
me._looptimer.stop(); | |||
} | |||
}, | |||
_identifyGears: func (gears) { | |||
var contacts = props.globals.getNode("/gear").getChildren("gear"); | |||
foreach(var contact; contacts) { | |||
var index = contact.getIndex(); | |||
var isGear = me._contains(gears, index); | |||
var wow = contact.getChild("wow"); | |||
if (isGear == TRUE) { | |||
append(me.wowGear, wow); | |||
} else { | |||
append(me.wowStructure, wow); | |||
} | |||
} | |||
}, | |||
_finishRepair: func () { | |||
me.repairing = FALSE; | |||
}, | |||
_isStructureInContact: func () { | |||
foreach(var structure; me.wowStructure) { | |||
if (structure.getBoolValue() == TRUE) { | |||
return TRUE; | |||
} | |||
} | |||
return FALSE; | |||
}, | |||
_isGearInContact: func () { | |||
foreach(var gear; me.wowGear) { | |||
if (gear.getBoolValue() == TRUE) { | |||
return TRUE; | |||
} | |||
} | |||
return FALSE; | |||
}, | |||
_contains: func (vector, content) { | |||
foreach(var vari; vector) { | |||
if (vari == content) { | |||
return TRUE; | |||
} | |||
} | |||
return FALSE; | |||
}, | |||
_startImpactListeners: func () { | |||
ImpactStructureListener.crash = me; | |||
foreach(var structure; me.wowStructure) { | |||
setlistener(structure, func {call(ImpactStructureListener.run, nil, ImpactStructureListener, ImpactStructureListener)},0,0); | |||
} | |||
}, | |||
_isRunning: func () { | |||
if (me.inService == FALSE or me.input.replay.getBoolValue() == TRUE or me.repairing == TRUE) { | |||
return FALSE; | |||
} | |||
var time = me.fdm.input.simTime.getValue(); | |||
if (time != nil and time > 1) { | |||
return TRUE; | |||
} | |||
return FALSE; | |||
}, | |||
_calcGroundSpeed: func () { | |||
var horzSpeed = me.fdm.input.vgFps.getValue(); | |||
var vertSpeed = me.fdm.input.downFps.getValue(); | |||
var realSpeed = math.sqrt((horzSpeed * horzSpeed) + (vertSpeed * vertSpeed)); | |||
realSpeed = me.fdm.fps2kt(realSpeed); | |||
return realSpeed; | |||
}, | |||
_impactDamage: func () { | |||
var lat = me.input.lat.getValue(); | |||
var lon = me.input.lon.getValue(); | |||
var info = geodinfo(lat, lon); | |||
var solid = info[1] == nil?TRUE:info[1].solid; | |||
var speed = me._calcGroundSpeed(); | |||
if (me.exploded == FALSE) { | |||
var failure_modes = FailureMgr._failmgr.failure_modes; | |||
var mode_list = keys(failure_modes); | |||
var probability = speed / 200.0;# 200kt will fail everything, 0kt will fail nothing. | |||
# test for explosion | |||
if(probability > 1.0 and me.fdm.input.fuel.getValue() > 2500) { | |||
# 200kt+ and fuel in tanks will explode the aircraft on impact. | |||
me._explodeBegin(); | |||
return; | |||
} | |||
foreach(var failure_mode_id; mode_list) { | |||
if(rand() < probability) { | |||
FailureMgr.set_failure_level(failure_mode_id, 1); | |||
} | |||
} | |||
var str = "Aircraft hit "~info[1].names[size(info[1].names)-1]~"."; | |||
me._output(str); | |||
} elsif (solid == TRUE) { | |||
var pos= geo.Coord.new().set_latlon(lat, lon); | |||
wildfire.ignite(pos, 1); | |||
} | |||
if(solid == TRUE) { | |||
#print("solid"); | |||
me._impactSoundBegin(speed); | |||
} else { | |||
#print("water"); | |||
me._impactSoundWaterBegin(speed); | |||
} | |||
}, | |||
_impactSoundWaterBegin: func (speed) { | |||
if (speed > 5) {#check if sound already running? | |||
me.input.wCrashOn.setValue(1); | |||
var timer = maketimer(3, me, me._impactSoundWaterEnd); | |||
timer.start(); | |||
} | |||
}, | |||
_impactSoundWaterEnd: func () { | |||
me.input.wCrashOn.setValue(0); | |||
}, | |||
_impactSoundBegin: func (speed) { | |||
if (speed > 5) { | |||
me.input.crashOn.setValue(1); | |||
var timer = maketimer(3, me, me._impactSoundEnd); | |||
timer.start(); | |||
} | |||
}, | |||
_impactSoundEnd: func () { | |||
me.input.crashOn.setValue(0); | |||
}, | |||
_explodeBegin: func() { | |||
me.input.explodeOn.setValue(1); | |||
me.exploded = TRUE; | |||
var failure_modes = FailureMgr._failmgr.failure_modes; | |||
var mode_list = keys(failure_modes); | |||
foreach(var failure_mode_id; mode_list) { | |||
FailureMgr.set_failure_level(failure_mode_id, 1); | |||
} | |||
me._output("Aircraft exploded.", TRUE); | |||
var timer = maketimer(3, me, me._explodeEnd); | |||
timer.start(); | |||
}, | |||
_explodeEnd: func () { | |||
me.input.explodeOn.setValue(0); | |||
}, | |||
_stressDamage: func (str) { | |||
me._output("Aircraft damaged: Wings broke off, due to "~str~" G forces."); | |||
me.input.detachOn.setValue(1); | |||
FailureMgr.set_failure_level(me.fdm.wingsFailureID, 1); | |||
me.wingsAttached = FALSE; | |||
var timer = maketimer(3, me, me._stressDamageEnd); | |||
timer.start(); | |||
}, | |||
_stressDamageEnd: func () { | |||
me.input.detachOn.setValue(0); | |||
}, | |||
_output: func (str, override = FALSE) { | |||
var time = me.fdm.input.simTime.getValue(); | |||
if (override == TRUE or (time - me.lastMessageTime) > 3) { | |||
me.lastMessageTime = time; | |||
print(str); | |||
screen.log.write(str, 0.7098, 0.5372, 0.0);# solarized yellow | |||
} | |||
}, | |||
_loop: func () { | |||
me._testStress(); | |||
me._testWaterImpact(); | |||
}, | |||
_testWaterImpact: func () { | |||
if(me.input.altAgl.getValue() < 0) { | |||
var lat = me.input.lat.getValue(); | |||
var lon = me.input.lon.getValue(); | |||
var info = geodinfo(lat, lon); | |||
var solid = info[1] == nil?TRUE:info[1].solid; | |||
if(solid == FALSE) { | |||
me._impactDamage(); | |||
} | |||
} | |||
}, | |||
_testStress: func () { | |||
if (me._isRunning() == TRUE and me.wingsAttached == TRUE) { | |||
var gForce = me.fdm.input.Nz.getValue() == nil?1:me.fdm.input.Nz.getValue(); | |||
var weight = me.fdm.input.weight.getValue(); | |||
var wingload = gForce * weight; | |||
#print("wingload: "~wingload~" max: "~me.wingLoadLimitUpper); | |||
var broken = FALSE; | |||
if(wingload < 0) { | |||
broken = me._testWingload(-wingload, -me.wingLoadLimitLower); | |||
if(broken == TRUE) { | |||
me._stressDamage("negative"); | |||
} | |||
} else { | |||
broken = me._testWingload(wingload, me.wingLoadLimitUpper); | |||
if(broken == TRUE) { | |||
me._stressDamage("positive"); | |||
} | |||
} | |||
} else { | |||
me.input.crackOn.setValue(0); | |||
me.input.creakOn.setValue(0); | |||
#me.input.trembleOn.setValue(0); | |||
} | |||
}, | |||
_testWingload: func (wingload, wingLoadLimit) { | |||
if (wingload > (wingLoadLimit * 0.5)) { | |||
#me.input.trembleOn.setValue(1); | |||
var tremble_max = math.sqrt((wingload - (wingLoadLimit * 0.5)) / (wingLoadLimit * 0.5)); | |||
#me.input.trembleMax.setValue(1); | |||
if (wingload > (wingLoadLimit * 0.75)) { | |||
#tremble_max = math.sqrt((wingload - (wingLoadLimit * 0.5)) / (wingLoadLimit * 0.5)); | |||
me.input.creakVol.setValue(tremble_max); | |||
me.input.creakOn.setValue(1); | |||
if (wingload > (wingLoadLimit * 0.90)) { | |||
me.input.crackOn.setValue(1); | |||
me.input.crackVol.setValue(tremble_max); | |||
if (wingload > wingLoadLimit) { | |||
me.input.crackVol.setValue(1); | |||
me.input.creakVol.setValue(1); | |||
#me.input.trembleMax.setValue(1); | |||
return TRUE; | |||
} | |||
} else { | |||
me.input.crackOn.setValue(0); | |||
} | |||
} else { | |||
me.input.creakOn.setValue(0); | |||
} | |||
} else { | |||
me.input.crackOn.setValue(0); | |||
me.input.creakOn.setValue(0); | |||
#me.input.trembleOn.setValue(0); | |||
} | |||
return FALSE; | |||
}, | |||
}; | |||
var ImpactStructureListener = { | |||
crash: nil, | |||
run: func () { | |||
if (crash._isRunning() == TRUE) { | |||
var wow = crash._isStructureInContact(); | |||
if (wow == TRUE) { | |||
crash._impactDamage(); | |||
} | |||
} | |||
}, | |||
}; | |||
# static class | |||
var fdmProperties = { | |||
input: {}, | |||
convert: func () { | |||
foreach(var ident; keys(me.input)) { | |||
me.input[ident] = props.globals.getNode(me.input[ident], 1); | |||
} | |||
}, | |||
fps2kt: func (fps) { | |||
return fps * 0.5924838; | |||
}, | |||
wingsFailureID: nil, | |||
}; | |||
var jsbSimProp = { | |||
parents: [fdmProperties], | |||
input: { | |||
weight: "fdm/jsbsim/inertia/weight-lbs", | |||
fuel: "fdm/jsbsim/propulsion/total-fuel-lbs", | |||
simTime: "fdm/jsbsim/simulation/sim-time-sec", | |||
vgFps: "fdm/jsbsim/velocities/vg-fps", | |||
downFps: "velocities/down-relground-fps", | |||
Nz: "fdm/jsbsim/accelerations/Nz", | |||
}, | |||
wingsFailureID: "fdm/jsbsim/structural/wings", | |||
}; | |||
var yaSimProp = { | |||
parents: [fdmProperties], | |||
input: { | |||
weight: "yasim/gross-weight-lbs", | |||
fuel: "consumables/fuel/total-fuel-lbs", | |||
simTime: "sim/time/elapsed-sec", | |||
vgFps: "velocities/groundspeed-kt", | |||
Nz: "accelerations/n-z-cg-fps_sec", | |||
}, | |||
convert: func () { | |||
call(fdmProperties.convert, [], me); | |||
me.input.downFps = props.Node.new().setValue(0); | |||
}, | |||
fps2kt: func (fps) { | |||
return fps; | |||
}, | |||
wingsFailureID: "structural/wings", | |||
}; | |||
# TODO: | |||
# | |||
# Loss of inertia if impacting/sliding? Or should the jsb groundcontacts take care of that alone? | |||
# If gears hit something at too high speed the gears should be damaged? | |||
# Make property to control if system active, or method enough? | |||
# Explosion depending on bumpiness and speed when sliding? | |||
# Tie in with damage from Bombable? | |||
# Use galvedro's UpdateLoop framework when it gets merged | |||
# example uses: | |||
# | |||
# var crashCode = CrashAndStress.new([0,1,2]; | |||
# | |||
# var crashCode = CrashAndStress.new([0,1,2], {"weightLbs":30000, "maxG": 12}); | |||
# | |||
# var crashCode = CrashAndStress.new([0,1,2,3], {"weightLbs":20000, "maxG": 11, "minG": -5}); | |||
# | |||
# var crashCode = CrashAndStress.new([0,1,2], {"wingloadMaxLbs": 90000, "wingloadMinLbs": -45000}, ["controls/flight/aileron", "controls/flight/elevator", "controls/flight/flaps"]); | |||
# | |||
# var crashCode = CrashAndStress.new([0,1,2], {"wingloadMaxLbs":90000}, ["controls/flight/aileron", "controls/flight/elevator", "controls/flight/flaps"]); | |||
# | |||
# Gears parameter must be defined. | |||
# Stress parameter is optional. If minimum wing stress is not defined it will be set to -40% of max wingload stress if that is defined. | |||
# The last optional parameter is a list of failure mode IDs that shall fail when wings detach. They must be defined in the FailureMgr. | |||
# | |||
# | |||
# Remember to add sounds and to add the sound properties as custom signals to the replay recorder. | |||
# use: | |||
var crashCode = CrashAndStress.new([0,1,2], {"weightLbs":30000, "maxG": 12}, ["controls/gear1", "controls/gear2", "controls/flight/aileron", "controls/flight/elevator", "consumables/fuel/wing-tanks"]); | |||
crashCode.start(); | |||
# test: | |||
var repair = func { | |||
crashCode.repair(); | |||
}; | |||
</syntaxhighlight> | |||
Notice that you should edit the line underneath '''# use''' to fit your aircraft requirements. | |||
In your -set.xml file under nasal tags add | |||
<syntaxhighlight lang="xml"> | |||
<crash> | |||
<file>Aircraft/[aircraft name here]/Nasal/crash-and-stress.nas</file> | |||
</crash> | |||
</syntaxhighlight> | |||
Add this to your sounds file: | |||
<syntaxhighlight lang="xml"> | |||
<aircraft-explode> | |||
<name>aircraft-explode</name> | |||
<path>Sounds/aircraft-explode.wav</path> | |||
<mode>once</mode> | |||
<condition> | |||
<or> | |||
<equals> | |||
<property>damage/sounds/explode-on</property> | |||
<value>1</value> | |||
</equals> | |||
<equals> | |||
<property>damage/sounds/detach-on</property> | |||
<value>1</value> | |||
</equals> | |||
</or> | |||
</condition> | |||
<volume> | |||
<factor>1</factor> | |||
</volume> | |||
</aircraft-explode> | |||
<aircraft-crash> | |||
<name>aircraft-crash</name> | |||
<path>Sounds/aircraft-crash.wav</path> | |||
<mode>once</mode> | |||
<condition> | |||
<equals> | |||
<property>damage/sounds/crash-on</property> | |||
<value>1</value> | |||
</equals> | |||
</condition> | |||
<volume> | |||
<factor>1</factor> | |||
</volume> | |||
</aircraft-crash> | |||
<aircraft-water-crash> | |||
<name>aircraft-water-crash</name> | |||
<path>Sounds/aircraft-water-crash.wav</path> | |||
<mode>once</mode> | |||
<condition> | |||
<equals> | |||
<property>damage/sounds/water-crash-on</property> | |||
<value>1</value> | |||
</equals> | |||
</condition> | |||
<volume> | |||
<factor>1</factor> | |||
</volume> | |||
</aircraft-water-crash> | |||
<aircraft-crack> | |||
<name>aircraft-crack</name> | |||
<path>Sounds/aircraft-crack.wav</path> | |||
<mode>once</mode> | |||
<condition> | |||
<property>sim/current-view/internal</property> | |||
<equals> | |||
<property>damage/sounds/crack-on</property> | |||
<value>1</value> | |||
</equals> | |||
</condition> | |||
<volume> | |||
<property>damage/sounds/crack-volume</property> | |||
<factor>1</factor> | |||
</volume> | |||
</aircraft-crack> | |||
<aircraft-creaking> | |||
<name>aircraft-creaking</name> | |||
<path>Sounds/aircraft-creaking.wav</path> | |||
<mode>looped</mode> | |||
<condition> | |||
<property>sim/current-view/internal</property> | |||
<equals> | |||
<property>damage/sounds/creaking-on</property> | |||
<value>1</value> | |||
</equals> | |||
</condition> | |||
<volume> | |||
<property>damage/sounds/creaking-volume</property> | |||
<factor>1</factor> | |||
</volume> | |||
</aircraft-creaking> | |||
</syntaxhighlight> | |||
Add some sounds to the aircraft sound folder that corresponds to the file names used in the above. (you can use those in the Mig15 or Ja-37 folders) | |||
Optionally add these signals to the replay recorder: | |||
<syntaxhighlight lang="xml"> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">damage/sounds/explode-on</property> | |||
</signal> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">damage/sounds/crash-on</property> | |||
</signal> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">damage/sounds/water-crash-on</property> | |||
</signal> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">damage/sounds/crack-on</property> | |||
</signal> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">damage/sounds/creaking-on</property> | |||
</signal> | |||
<signal> | |||
<type>float</type> | |||
<property type="string">damage/sounds/crack-volume</property> | |||
</signal> | |||
<signal> | |||
<type>float</type> | |||
<property type="string">damage/sounds/creaking-volume</property> | |||
</signal> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">fdm/jsbsim/structural/wings/serviceable</property> | |||
</signal> | |||
</syntaxhighlight> | |||
Notice that the last signal is jsbsim specific, the corresponding yasim signal is: | |||
<syntaxhighlight lan="nasal"> | |||
<signal> | |||
<type>bool</type> | |||
<property type="string">structural/wings/serviceable</property> | |||
</signal> | |||
</syntaxhighlight> | |||
That property is also what you would use to animate wings being detached and/or changes in the aerodynamics. | |||
== Performance == | |||
As it is now you should feel no performance degradation at all from using it. | |||
== People volunteering to work on this == | |||
* Bomber | |||
* Necolatis | |||
== Aircrafts that use this system == | |||
[[Saab JA-37 Viggen]] (version 2.83+) | |||
== Related content == | |||
* [http://forum.flightgear.org/viewtopic.php?f=4&t=24901 Generic crash system for JSBSim aircraft] – Forum topic on the official FlightGear forum. | |||
[[Category:Aircraft enhancement]] |
edits