560 lines
18 KiB
JavaScript
560 lines
18 KiB
JavaScript
import * as THREE from 'three';
|
|
|
|
// Lightweight articulated characters; all coordinates below are in the group.
|
|
// createFamily() builds the father and child; createFamily({robot: true}) builds the Robot Market guide alone.
|
|
/**
|
|
* Creates family resources for the game.
|
|
* @param {*} options - Operation options.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
export function createFamily({ robot: robotOnly = false } = {}) {
|
|
const build = {
|
|
robotOnly,
|
|
};
|
|
for (const [name, method] of Object.entries(FAMILY_METHODS))
|
|
build[name] = (...args) => method(build, ...args);
|
|
for (const initialize of FAMILY_STEPS) initialize(build);
|
|
return build.result;
|
|
}
|
|
/**
|
|
* Runs the family shape operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} parent - parent.
|
|
* @param {*} geometry - geometry.
|
|
* @param {*} material - material.
|
|
* @param {*} position - position.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familyShape(build, parent, geometry, material, position) {
|
|
const m = new THREE.Mesh(geometry, material);
|
|
m.position.set(...position);
|
|
m.castShadow = true;
|
|
m.receiveShadow = true;
|
|
parent.add(m);
|
|
return m;
|
|
}
|
|
/**
|
|
* Runs the family sphere operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} parent - parent.
|
|
* @param {*} r - r.
|
|
* @param {*} material - material.
|
|
* @param {*} pos - pos.
|
|
* @param {*} scale - scale.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familySphere(build, parent, r, material, pos, scale = [1, 1, 1]) {
|
|
const m = build.shape(parent, new THREE.SphereGeometry(r, 16, 12), material, pos);
|
|
m.scale.set(...scale);
|
|
return m;
|
|
}
|
|
/**
|
|
* Runs the family segment operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} parent - parent.
|
|
* @param {*} a - a.
|
|
* @param {*} b - b.
|
|
* @param {*} r - r.
|
|
* @param {*} material - material.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familySegment(build, parent, a, b, r, material) {
|
|
const mesh = build.shape(parent, new THREE.CylinderGeometry(r, r, 1, 10), material, [0, 0, 0]);
|
|
return {
|
|
mesh,
|
|
a,
|
|
b,
|
|
};
|
|
}
|
|
/**
|
|
* Runs the family update segment operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} bone - bone.
|
|
* @param {*} a - a.
|
|
* @param {*} b - b.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familyUpdateSegment(build, bone, a, b) {
|
|
const delta = b.clone().sub(a);
|
|
bone.mesh.position.copy(a).add(b).multiplyScalar(0.5);
|
|
bone.mesh.scale.y = delta.length();
|
|
bone.mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
|
|
}
|
|
|
|
// Procedural Robot Market mascot: a hovering guide that follows the father and child.
|
|
/**
|
|
* Runs the family make robot operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} z - z.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familyMakeRobot(build, z) {
|
|
const scale = 0.69,
|
|
root = new THREE.Group(),
|
|
torso = new THREE.Group();
|
|
root.name = 'robot-market-mascot';
|
|
root.position.z = z;
|
|
build.group.add(root);
|
|
root.add(torso);
|
|
const teal = new THREE.MeshStandardMaterial({
|
|
color: '#079f98',
|
|
metalness: 0.4,
|
|
roughness: 0.28,
|
|
});
|
|
const white = new THREE.MeshStandardMaterial({
|
|
color: '#edf6f4',
|
|
metalness: 0.22,
|
|
roughness: 0.32,
|
|
});
|
|
const joint = new THREE.MeshStandardMaterial({
|
|
color: '#102b30',
|
|
metalness: 0.5,
|
|
roughness: 0.35,
|
|
});
|
|
const glow = new THREE.MeshStandardMaterial({
|
|
color: '#c6f7ff',
|
|
emissive: '#23bcff',
|
|
emissiveIntensity: 2.5,
|
|
roughness: 0.2,
|
|
});
|
|
const visor = new THREE.MeshStandardMaterial({
|
|
color: '#020d16',
|
|
metalness: 0.5,
|
|
roughness: 0.16,
|
|
});
|
|
build.sphere(torso, 0.25, teal, [0, 0.76, 0], [1, 0.65, 0.75]);
|
|
build.sphere(torso, 0.31, white, [0, 0.97, 0], [1, 1.05, 0.72]);
|
|
for (const side of [-1, 1])
|
|
build.sphere(torso, 0.17, teal, [side * 0.24, 1.03, -0.04], [0.55, 1, 1]);
|
|
build.shape(torso, new THREE.CylinderGeometry(0.09, 0.09, 0.12, 12), joint, [0, 1.27, 0]);
|
|
const head = new THREE.Group();
|
|
head.position.y = 1.61;
|
|
torso.add(head);
|
|
build.sphere(head, 0.43, teal, [0, 0, 0], [1.12, 0.91, 0.88]);
|
|
build.sphere(head, 0.4, white, [0, -0.015, 0.12], [1.12, 0.86, 0.77]);
|
|
build.sphere(head, 0.35, visor, [0, -0.015, 0.255], [1.13, 0.83, 0.48]);
|
|
const eyes = [];
|
|
/**
|
|
* Runs the arc operation for this subsystem.
|
|
* @param {*} parent - parent.
|
|
* @param {*} radius - radius.
|
|
* @param {*} thickness - thickness.
|
|
* @param {*} pos - pos.
|
|
* @param {*} rotation - rotation.
|
|
* @param {*} material - material.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function arc(parent, radius, thickness, pos, rotation = 0, material = glow) {
|
|
const line = build.shape(
|
|
parent,
|
|
new THREE.TorusGeometry(radius, thickness, 8, 24, Math.PI),
|
|
material,
|
|
pos,
|
|
);
|
|
line.rotation.z = rotation;
|
|
return line;
|
|
}
|
|
for (const side of [-1, 1]) {
|
|
eyes.push(arc(head, 0.065, 0.016, [side * 0.16, 0.025, 0.423]));
|
|
const ear = build.shape(head, new THREE.CylinderGeometry(0.14, 0.14, 0.085, 24), joint, [
|
|
side * 0.47,
|
|
0,
|
|
0,
|
|
]);
|
|
ear.rotation.z = Math.PI / 2;
|
|
const ring = build.shape(head, new THREE.TorusGeometry(0.105, 0.012, 8, 24), glow, [
|
|
side * 0.518,
|
|
0,
|
|
0,
|
|
]);
|
|
ring.rotation.y = Math.PI / 2;
|
|
build.sphere(head, 0.12, teal, [side * 0.49, 0, 0], [0.4, 1, 1]);
|
|
}
|
|
arc(head, 0.075, 0.016, [0, -0.085, 0.425], Math.PI);
|
|
build.sphere(head, 0.1, teal, [0, 0.38, 0], [1, 0.55, 1]);
|
|
build.shape(head, new THREE.CylinderGeometry(0.017, 0.022, 0.18, 10), teal, [0, 0.49, 0]);
|
|
build.sphere(head, 0.055, teal, [0, 0.6, 0]);
|
|
// Draw the chest mark as a texture so it remains attached to the 3D body.
|
|
if (typeof document !== 'undefined') {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 256;
|
|
const c = canvas.getContext('2d');
|
|
if (c) {
|
|
c.strokeStyle = '#102b30';
|
|
c.fillStyle = '#102b30';
|
|
c.lineWidth = 12;
|
|
c.lineJoin = 'round';
|
|
c.beginPath();
|
|
c.moveTo(65, 40);
|
|
c.lineTo(82, 40);
|
|
c.lineTo(99, 110);
|
|
c.lineTo(170, 110);
|
|
c.lineTo(186, 58);
|
|
c.lineTo(89, 58);
|
|
c.stroke();
|
|
for (const x of [111, 162]) {
|
|
c.beginPath();
|
|
c.arc(x, 130, 9, 0, Math.PI * 2);
|
|
c.fill();
|
|
}
|
|
c.textAlign = 'center';
|
|
c.font = 'bold 34px sans-serif';
|
|
c.fillText('ROBOT', 128, 181);
|
|
c.fillText('MARKET', 128, 218);
|
|
build.shape(
|
|
torso,
|
|
new THREE.PlaneGeometry(0.31, 0.31),
|
|
new THREE.MeshBasicMaterial({
|
|
map: new THREE.CanvasTexture(canvas),
|
|
transparent: true,
|
|
depthWrite: false,
|
|
}),
|
|
[0, 0.99, 0.224],
|
|
);
|
|
}
|
|
}
|
|
const legs = [-1, 1].map((side) => {
|
|
const hip = new THREE.Group();
|
|
hip.position.set(side * 0.15, 0.65, 0);
|
|
root.add(hip);
|
|
build.sphere(hip, 0.09, joint, [0, 0, 0]);
|
|
build.shape(hip, new THREE.CapsuleGeometry(0.095, 0.12, 4, 12), white, [0, -0.12, 0]);
|
|
const knee = new THREE.Group();
|
|
knee.position.y = -0.25;
|
|
hip.add(knee);
|
|
build.sphere(knee, 0.105, teal, [0, 0, 0.035]);
|
|
build.shape(knee, new THREE.CapsuleGeometry(0.1, 0.12, 4, 12), white, [0, -0.13, 0]);
|
|
const foot = build.sphere(knee, 0.13, teal, [0, -0.29, 0.07], [1, 0.65, 1.6]);
|
|
build.sphere(knee, 0.13, joint, [0, -0.34, 0.07], [1.06, 0.22, 1.65]);
|
|
return {
|
|
hip,
|
|
knee,
|
|
foot,
|
|
};
|
|
});
|
|
const arms = [-1, 1].map(() => ({
|
|
upper: build.segment(build.group, null, null, 0.09, teal),
|
|
lower: build.segment(build.group, null, null, 0.085, white),
|
|
hand: build.sphere(build.group, 0.09, joint, [0, 0, 0], [1, 1.1, 1]),
|
|
}));
|
|
return {
|
|
root,
|
|
torso,
|
|
head,
|
|
legs,
|
|
arms,
|
|
scale,
|
|
z,
|
|
eyes,
|
|
};
|
|
}
|
|
/**
|
|
* Runs the family make person operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} isChild - is Child.
|
|
* @param {*} z - z.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familyMakePerson(build, isChild, z) {
|
|
const scale = isChild ? 0.69 : 1.05,
|
|
root = new THREE.Group();
|
|
root.position.z = z;
|
|
build.group.add(root);
|
|
const torso = new THREE.Group();
|
|
root.add(torso);
|
|
const cloth = build.mat(isChild ? '#d98150' : '#e4c695'),
|
|
pants = build.mat(isChild ? '#527463' : '#496474');
|
|
// Tapered jacket, hips and neck replace the single rounded toy-like body.
|
|
build.shape(
|
|
torso,
|
|
new THREE.CylinderGeometry(0.255 * scale, 0.22 * scale, 0.62 * scale, 16),
|
|
cloth,
|
|
[0, 1.2 * scale, 0],
|
|
).scale.z = 0.72;
|
|
build.sphere(torso, 0.23, pants, [0, 0.88 * scale, 0], [scale, 0.55 * scale, 0.72 * scale]);
|
|
build.shape(
|
|
torso,
|
|
new THREE.CylinderGeometry(0.075 * scale, 0.09 * scale, 0.18 * scale, 12),
|
|
build.skin,
|
|
[0, 1.59 * scale, 0],
|
|
);
|
|
build.shape(
|
|
torso,
|
|
new THREE.BoxGeometry(0.014 * scale, 0.5 * scale, 0.014 * scale),
|
|
build.mat('#bda77f'),
|
|
[0, 1.2 * scale, 0.19 * scale],
|
|
);
|
|
for (const side of [-1, 1]) {
|
|
const collar = build.shape(
|
|
torso,
|
|
new THREE.BoxGeometry(0.12 * scale, 0.09 * scale, 0.025 * scale),
|
|
cloth,
|
|
[side * 0.07 * scale, 1.49 * scale, 0.15 * scale],
|
|
);
|
|
collar.rotation.z = side * 0.4;
|
|
}
|
|
const head = new THREE.Group();
|
|
head.position.y = 1.77 * scale;
|
|
torso.add(head);
|
|
head.scale.setScalar(isChild ? 0.9 : 0.77);
|
|
const eyes = [];
|
|
build.sphere(head, 0.27 * scale, build.skin, [0, 0, 0]);
|
|
build.sphere(head, 0.275 * scale, build.hair, [0, 0.11 * scale, -0.035 * scale], [1, 0.65, 1]);
|
|
build.sphere(head, 0.058 * scale, build.skin, [0, -0.02 * scale, 0.25 * scale]);
|
|
for (const side of [-1, 1]) {
|
|
build.sphere(head, 0.06 * scale, build.skin, [side * 0.26 * scale, 0, 0]);
|
|
build.sphere(
|
|
head,
|
|
0.037 * scale,
|
|
build.mat('#f6f1e7'),
|
|
[side * 0.09 * scale, 0.03 * scale, 0.241 * scale],
|
|
[1, 0.65, 0.5],
|
|
);
|
|
const eye = build.sphere(
|
|
head,
|
|
0.022 * scale,
|
|
build.shoes,
|
|
[side * 0.09 * scale, 0.03 * scale, 0.258 * scale],
|
|
[0.8, 1, 0.45],
|
|
);
|
|
eyes.push(eye);
|
|
const brow = build.shape(
|
|
head,
|
|
new THREE.BoxGeometry(0.075 * scale, 0.015 * scale, 0.015 * scale),
|
|
build.hair,
|
|
[side * 0.09 * scale, 0.087 * scale, 0.237 * scale],
|
|
);
|
|
brow.rotation.z = side * -0.08;
|
|
build.sphere(head, 0.04 * scale, build.mat('#d79379'), [
|
|
side * 0.15 * scale,
|
|
-0.055 * scale,
|
|
0.215 * scale,
|
|
]);
|
|
}
|
|
const smile = build.shape(
|
|
head,
|
|
new THREE.TorusGeometry(0.068 * scale, 0.012 * scale, 6, 14, Math.PI),
|
|
build.shoes,
|
|
[0, -0.075 * scale, 0.252 * scale],
|
|
);
|
|
smile.rotation.z = Math.PI;
|
|
if (isChild) {
|
|
build.sphere(head, 0.29 * scale, build.mat('#e8b857'), [0, 0.18 * scale, 0], [1, 0.5, 1]);
|
|
build.shape(
|
|
head,
|
|
new THREE.BoxGeometry(0.55 * scale, 0.04 * scale, 0.34 * scale),
|
|
build.mat('#e8b857'),
|
|
[0, 0.13 * scale, 0.12 * scale],
|
|
);
|
|
} else {
|
|
build.sphere(torso, 0.22, build.mat('#a77950'), [0, 1.2 * scale, -0.24 * scale], [1, 1.2, 0.5]);
|
|
}
|
|
const legs = [-1, 1].map((side) => {
|
|
const hip = new THREE.Group();
|
|
hip.position.set(side * 0.15 * scale, 0.83 * scale, 0);
|
|
root.add(hip);
|
|
build.shape(hip, new THREE.CapsuleGeometry(0.09 * scale, 0.23 * scale, 4, 10), pants, [
|
|
0,
|
|
-0.16 * scale,
|
|
0,
|
|
]);
|
|
const knee = new THREE.Group();
|
|
knee.position.y = -0.34 * scale;
|
|
hip.add(knee);
|
|
build.shape(knee, new THREE.CapsuleGeometry(0.08 * scale, 0.25 * scale, 4, 10), pants, [
|
|
0,
|
|
-0.16 * scale,
|
|
0,
|
|
]);
|
|
const foot = build.shape(knee, new THREE.SphereGeometry(0.12 * scale, 12, 8), build.shoes, [
|
|
0,
|
|
-0.32 * scale,
|
|
0.065 * scale,
|
|
]);
|
|
foot.scale.set(0.85, 0.65, 1.5);
|
|
return {
|
|
hip,
|
|
knee,
|
|
foot,
|
|
};
|
|
});
|
|
const arms = [-1, 1].map(() => ({
|
|
upper: build.segment(build.group, null, null, 0.072 * scale, cloth),
|
|
lower: build.segment(build.group, null, null, 0.061 * scale, build.skin),
|
|
hand: build.sphere(build.group, 0.075 * scale, build.skin, [0, 0, 0]),
|
|
}));
|
|
return {
|
|
root,
|
|
torso,
|
|
head,
|
|
legs,
|
|
arms,
|
|
scale,
|
|
z,
|
|
eyes,
|
|
};
|
|
}
|
|
// The group faces +X; `facing` is the actors' own yaw inside it (π/2 = along +X).
|
|
// `wave` raises the robot's right hand, `point` stretches it forward.
|
|
/**
|
|
* Runs the family update operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @param {*} options1 - Operation options.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familyUpdate(
|
|
build,
|
|
{
|
|
time,
|
|
gait,
|
|
moving,
|
|
facing = Math.PI / 2,
|
|
shopping,
|
|
delivery,
|
|
finished,
|
|
talking = false,
|
|
wave = false,
|
|
point = false,
|
|
dt = 1 / 60,
|
|
},
|
|
) {
|
|
build.actors.forEach((actor, index) => {
|
|
const blend = 1 - Math.exp(-dt * 7),
|
|
isRobot = actor === build.robot;
|
|
actor.root.position.x = THREE.MathUtils.lerp(
|
|
actor.root.position.x,
|
|
finished ? (index === 0 ? -0.55 : 0.55) : shopping && !isRobot ? [-0.55, 0.25][index] : 0,
|
|
blend,
|
|
);
|
|
// The robot hovers; it bobs faster while it is speaking.
|
|
const hover = isRobot ? 0.35 + Math.sin(time * (talking ? 6 : 2.2)) * 0.07 : 0;
|
|
actor.root.position.y = THREE.MathUtils.lerp(
|
|
actor.root.position.y,
|
|
finished ? -0.6 * actor.scale : hover,
|
|
isRobot ? 1 : blend,
|
|
);
|
|
actor.root.position.z = finished ? 0 : actor.z;
|
|
actor.root.rotation.y = facing;
|
|
const bob = moving ? Math.abs(Math.sin(gait)) * 0.022 : Math.sin(time * 2) * 0.006;
|
|
actor.torso.position.y = bob;
|
|
actor.torso.rotation.z = moving ? Math.sin(gait) * 0.018 : 0;
|
|
const blinking = Math.sin(time * 1.3 + index) > 0.993;
|
|
actor.eyes.forEach((eye) => (eye.scale.y = blinking ? 0.12 : 1));
|
|
actor.head.rotation.y = shopping
|
|
? -0.65
|
|
: isRobot
|
|
? Math.sin(time * 0.9) * 0.35
|
|
: Math.sin(time * 0.7) * 0.06;
|
|
actor.torso.rotation.x = delivery && actor === build.child ? 0.22 : 0;
|
|
actor.legs.forEach((leg, i) => {
|
|
const phase = gait + i * Math.PI;
|
|
const stride = isRobot ? 0.08 : 0.34;
|
|
leg.hip.rotation.x = finished ? -1.4 : moving ? Math.sin(phase) * stride : 0;
|
|
leg.foot.rotation.x = moving ? Math.sin(phase + 0.5) * 0.12 : 0;
|
|
leg.knee.rotation.x = finished
|
|
? 2.5
|
|
: isRobot
|
|
? 0.25
|
|
: moving
|
|
? Math.max(0, -Math.sin(phase)) * 0.5
|
|
: 0.05;
|
|
});
|
|
});
|
|
build.group.updateMatrixWorld(true);
|
|
/**
|
|
* Runs the to family operation for this subsystem.
|
|
* @param {*} actor - actor.
|
|
* @param {*} point - point.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
const toFamily = (actor, point) => point.applyMatrix4(actor.root.matrix).clone();
|
|
/**
|
|
* Runs the shoulder operation for this subsystem.
|
|
* @param {*} actor - actor.
|
|
* @param {*} side - side.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
const shoulder = (actor, side) =>
|
|
toFamily(
|
|
actor,
|
|
new THREE.Vector3(
|
|
side * 0.29 * actor.scale,
|
|
(actor === build.robot ? 1.12 : 1.43) * actor.scale,
|
|
0,
|
|
),
|
|
);
|
|
// With characters facing +X, father's left and child's right hands meet.
|
|
const contact = new THREE.Vector3(0, 1.06, 0);
|
|
build.actors.forEach((actor) =>
|
|
actor.arms.forEach((arm, i) => {
|
|
const side = i === 0 ? -1 : 1,
|
|
inside = actor === build.father ? side === -1 : actor === build.child && side === 1;
|
|
const a = shoulder(actor, side);
|
|
let target;
|
|
if (inside && !shopping && !finished) target = contact.clone();
|
|
else if (actor === build.child && shopping && side === -1)
|
|
target = new THREE.Vector3(0.5, delivery ? 0.65 : 1.1, -0.15);
|
|
else if (actor === build.robot && side === 1 && wave)
|
|
target = toFamily(actor, new THREE.Vector3(0.42, 1.62 + Math.sin(time * 9) * 0.07, 0.12));
|
|
else if (actor === build.robot && side === 1 && point)
|
|
target = toFamily(actor, new THREE.Vector3(0.3, 1.25, 0.62));
|
|
else
|
|
target = toFamily(
|
|
actor,
|
|
new THREE.Vector3(
|
|
side * 0.34 * actor.scale,
|
|
0.88 * actor.scale,
|
|
moving ? Math.sin(gait + (i ? Math.PI : 0)) * 0.2 : 0,
|
|
),
|
|
);
|
|
const elbow = a
|
|
.clone()
|
|
.lerp(target, 0.5)
|
|
.add(new THREE.Vector3(0.07, 0, inside ? 0 : 0.07));
|
|
build.updateSegment(arm.upper, a, elbow);
|
|
build.updateSegment(arm.lower, elbow, target);
|
|
arm.hand.position.copy(target);
|
|
}),
|
|
);
|
|
}
|
|
/**
|
|
* Runs the family build group operation for this subsystem.
|
|
* @param {*} build - build.
|
|
* @returns {*} Operation result, when applicable.
|
|
*/
|
|
function familyBuildGroup(build) {
|
|
build.group = new THREE.Group();
|
|
build.mat = (color) =>
|
|
new THREE.MeshStandardMaterial({
|
|
color,
|
|
roughness: 0.82,
|
|
});
|
|
build.skin = build.mat('#edbd98');
|
|
build.hair = build.mat('#49382d');
|
|
build.shoes = build.mat('#273c36');
|
|
build.father = build.robotOnly ? null : build.makePerson(false, -0.45);
|
|
build.child = build.robotOnly ? null : build.makePerson(true, 0.45);
|
|
build.robot = build.robotOnly ? build.makeRobot(0) : null;
|
|
build.actors = build.robotOnly ? [build.robot] : [build.father, build.child];
|
|
build.update({
|
|
time: 0,
|
|
gait: 0,
|
|
moving: false,
|
|
});
|
|
build.result = {
|
|
group: build.group,
|
|
update: build.update,
|
|
};
|
|
}
|
|
const FAMILY_METHODS = {
|
|
shape: familyShape,
|
|
sphere: familySphere,
|
|
segment: familySegment,
|
|
updateSegment: familyUpdateSegment,
|
|
makeRobot: familyMakeRobot,
|
|
makePerson: familyMakePerson,
|
|
update: familyUpdate,
|
|
};
|
|
const FAMILY_STEPS = [familyBuildGroup];
|