Project Categories
Project Setting | Professional |
Team Size | 4 (Web), ~15 (Overall Team) |
Role(s) | Web Developer |
Languages | HTML CSS PHP JavaScript GLSL |
Software/Tools Used | Brackets (Editor), git (Version Control), WinSCP/Filezilla (SFTP), terminal (ssh), Vue.js (library), Parallax.js (library), Matter.js (library), jQuery (library), Tippy.js (library), Cropper.js (library) |
Status | Complete |
Time Period | September 2020 - December 2020 |
About
Changeling is a VR mystery, first-person 3D platformer game built upon the idea of magical realism and a sense of unease and wonder. You play as Aurelia, a dream-walker whose gift is the ability to see through the eyes of anyone she touches. You are tasked with helping this family figure out what is wrong with their child, and as you contact each member, you see through the lens of their hopes and fears of what the child is.
Description from the presskit as of
Changeling was my first experience in a professional setting. I worked with a team of four to build a website from the ground up which represents the vision for the VR game Changeling, created by Xana Adhoc. This was an internship through RIT for the fall of 2020.
Changeling was also my first experience with Vue.js. After taking about a day to learn the ins and outs of Vue (while working on other things such as wireframes), I had helped organize the home page into components; I later used the component-based structure for the characters page, and have since adopted Vue for setting up the filtering system on my own site and began working with single-file components in an Electron experiment.
This was a remote internship due to the COVID-19 pandemic; we used Discord for communication, primarily using its voice chat functionality. Remotely, we worked as a team in our team voice channel throughout the day, did daily standups with the producer, and did weekly meetings with the entire project team.
My Work
Overall
- Ensure pages validate with semantic markup and have as few accessibility issues as possible
- Aide in making pages responsive and mobile friendly
- Setup SEO head tags such as Open Graph and meta tags, as well as robots.txt and sitemap.xml
- Setup .htaccess (to setup cache periods and .html redirects) and .htpasswd (to password lock the team member form)
- As-needed commenting with JSDoc-style documentation for methods and classes
Home Page
- Aide in setting up landing video container, including responsiveness and autoplaying
- Setup class structure and simple canvas injection for home page experiences
- Implement GLSL distortion shader sample into mother character experience
- Implement state system with transitions for mother experience
- Implement blend of Matter.js and PixiJS for father experience
- Attach forces to mouse actions to furniture pieces
- Utilize team member's state system to manipulate filters as the father's experience progresses
- Assist in perlin noise generation of trees, material usage, texture generation, UI asset creation, and model optimization for baby experience
Characters
- Outline markup and utilize Parallax.js in creating a simple single-page characters page
- Create multiple methods of navigation, from a button indicating direction to a node-like list displaying current character
- Make characters page responsive and mobile friendly, complete with swipe navigation
- Setup overall style for page
Team
- Assist team member in utilizing array math to make team page carousel dynamic
- Assist in making the team page styling more consistent with the rest of site
- Create ability for user to sort team members by name and term with option of reversing
- Add small term cards with year and sprite to member photos indicating what term a member has worked on the project
Form
- Document how team member data is to be stored: a collection of keys (usernames) and values (user data) which hold submitted form data
- Create HTML form for team members to submit their info for the team page, providing examples in placeholder text and using proper markup for form elements
- Utilize Tippy.js to provide additional context for fields
- Utilize Cropper.js to provide a simple cropping tool to allow members to upload their photo to the server
- Utilize jQuery AJAX to handle form input server-side, using PHP to save form data and compress member photo and using an XHR override to display submission progress
- Automatically load existing data to allow members to easily edit any data without re-filling out the entire form again
- Create a PHP script (runs via
cron
) which backs up team member data generated by form input
Press
- Create markup and styling accounting for typical video game presskit pages as well as mobile viewers
- Create a sticky sidebar navigation element for in-page navigation
- Make navigation element have a bit of scroll-based parallax so that it stays below the header when the header is in view and goes to the top of the page when the user has scrolled
- Add a hide button for smaller screens to hide navigation from view in case of cluttering
Samples
These are some samples from the project.
Videos
Demonstration video as of , with comments on the side matching the above list to different segments in the video
Demonstration video as of to quickly show updates since the previous video
Code
Below is the solution I came up with for the team carousel (note that not the entire template is mine, but rather what I will explain). Originally, there were multiple carousel-item
containers, with a v-if
checking if teamMember.id (used to be a hardcoded number in the testing arrays which conflicted with how team members were going to be stored) was within the proper range (1-3, 4-6, etc.), and the first carousel-item
was marked "active" in the template. A team mate was struggling for a week or two trying to set it up to be dynamic. I thought about it during lunch, and I proposed dividing the array length by 3 (with some extra necessary math to account for 0-index) and using Array.slice()
to condense the template into a single dynamic carousel-item
. The lower-level team-member containers required a bit of tricky math since the index had to be re-converted back into something that accounts for the higher-level division. After a few tests, I discovered that teamMembers.slice(0+(n*3-3), 3+(n*3-3))
worked perfectly. The only other thing that needed handled was marking the first as active, which I moved to Vue.mounted
. After about an hour, the team page required no more maintained hard-coding.
<!-- in team carousel template -->
<div class="carousel-item" v-for="n in teamRows">
<div class="row">
<div v-for="teamMember in teamMembers.slice(0+(n*3-3), 3+(n*3-3))" class="col-sm-4 col-text" tabindex="-1">
<img :id="teamMember.modalId" @click="teamMember.showModal=true" role="button" label="team member modal button" tabindex=0 v-on:keyup.enter="teamMember.showModal=true" @error="imgOnError" class="team-img" :src="teamMember.img" alt="team member photo">
<p>{{teamMember.name}}</p>
<p>{{teamMember.roles}}</p>
</div>
</div>
</div>
// in team carousel Vue component definition
computed: {
teamRows() {
return Math.floor((this.teamMembers.length - 1) / 3) + 1;
},
// in Vue({ mounted: })
document.querySelectorAll('.carousel-inner .carousel-item:nth-of-type(1)').forEach(e => {
e.classList.add('active');
});
The overall Changeling team data is stored in an array of member objects. However, the teams page expects members to be organized into groups of teams. Some members are on more than one team, so we wanted them to show up in the teams they were a part of. In order to do this, I used Object.entries.filter
to extract members from the provided teamID
and put that within Object.fromEntries
so that we could also attach some missing information within Object.keys.map
such as a showModal bool, the member id, and a src to the member's photo (if they uploaded one; photos have an error
handler that defaults to a "No Photo" image).
/**
* Filters out teams from project team data
* @param {object} data The original JSON data being filtered from
* @param {string} teamID The team name
* @returns {Array.<object>} Array of team members where key = their user ID from the form
*/
function teamFromData(data, teamID) {
let team = Object.fromEntries(Object.entries(data).filter(([key, value]) => value.teams.includes(teamID) && key !== "testadmin"));
return Object.keys(team).map(key => {
data[key].id = key;
data[key].img = String.format("data/{0}.jpg", key);
data[key].showModal = false;
return data[key];
});
}
Below is the system used for sorting members on the team page. Team members have an array of strings called terms
, as well as a string called name
. These two are used for sorting. Before sorting, members were displayed based on the order that they filled out their information on the form page, from oldest to newest. The user has the ability to select from a dropdown whether to sort by first / last name, whether to sort by first completed term / most recent term, and whether to reverse either of these. Term order is determined by year then season, concatenating the two to produce a numerical order for comparison. Preferred name is split and the first name ([0]
) or the last name ([length - 1]
) is used for determining order. All sorting methods use genericComparison(a, b)
to follow DRY
. They are also found in a collection called sortingMethods
as shown below for organizational purposes. When the user changes a sorting option, an event callback is called, changing the sorting preference variable it's tied to and then calling onSortingChanged
, which is where the actual sorting takes place. The callbacks are under Vue's methods object in order to simplify event attachment (using @change
in the markup)
// Converts seasons to numbers
const seasons = {
spring: 0,
summer: 1,
fall: 2
}
/**
* Determines term order based off of year / season
* @param {string} s The string of the term being ordered, e.g. Summer2020, Fall2020, etc.
* @returns {number} A number indicating the order of the team member
*/
function getTermOrder(s) {
let str = s.replace(/[^0-9](?=[0-9])/g, '$& ').split(' ');
return str[1] + seasons[str[0].toLowerCase()];
}
// Collection of sorting methods to sort team members with
const sortingMethods = {
/**
* Generic comparison method; for use by the remaining methods only
* @param {number|string} a item a to compare
* @param {number|string} b item b to compare
* @returns {number} sort order
*/
genericComparison: function(a, b) {
if (a > b) return 1;
if (a < b) return -1;
return 0;
},
/**
* Compares two people by the term they most recently completed
* @param {object} a member a to compare
* @param {object} b member b to compare
* @returns {number} sort order
*/
mostRecentTerm: function(a, b) {
let seasonA = getTermOrder(a.terms[a.terms.length - 1]);
let seasonB = getTermOrder(b.terms[b.terms.length - 1]);
return sortingMethods.genericComparison(seasonA, seasonB) * -1;
},
// (continues for a few more methods),
// showing how names is done without showing doc + preferredNameFirst
preferredNameLast: function(a, b) {
let splitA = a.name.split(' ');
let splitB = b.name.split(' ');
return sortingMethods.genericComparison(splitA[splitA.length - 1], splitB[splitB.length - 1]);
}
}
// in Vue({ methods: ... })
// multiple event callbacks precede the below one, simply showing the bottommost for example
/**
* Callback for changing the invertTeamSort toggle
* @param {object} evt The input that called this event
*/
onTermSortReverseChanged: function(evt) {
invertTermSort = evt.target.checked;
this.onSortingChanged();
},
/**
* Generic call for when sorting has been changed
*/
// add'l contextual note: data is a part of Vue's reactive data, and is an object of teams holding arrays for each team
onSortingChanged: function() {
Object.keys(data).forEach(key => {
data[key].sort(function(a, b) {
if (invertNameSort) return nameSort(a, b) * -1;
else return nameSort(a, b);
});
data[key].sort(function(a, b) {
if (invertTermSort) return termSort(a, b) * -1;
else return termSort(a, b);
})
});
}
Places
Home Page | changelingvr.com |