Skip to content

Instantly share code, notes, and snippets.

@cerebralpolicy
Created February 1, 2023 22:14
Show Gist options
  • Save cerebralpolicy/cf907ab509a862f400e5d05d36c2d2af to your computer and use it in GitHub Desktop.
Save cerebralpolicy/cf907ab509a862f400e5d05d36c2d2af to your computer and use it in GitHub Desktop.
Persona 5 SMS
<div id="chat"></div>
<script id="chat-thread" type="x-template">
<div v-if="window.devicePixelRatio === 1">
<br/>
<ChatMessage
v-for="message in messages"
ref="chatMessages"
:message="message.text"
:remote="message.remote"/>
</div>
<div v-else style="margin: 20px; color: white">
<h1>Oops, my bad...</h1>
<p>
Your <code>window.devicePixelRatio</code> does not equal <code>1</code>. This means that the ratio between physical pixels and logical pixels don't have a 1-to-1 correspondence on your display device. I have yet to (and probably never will) implement pixel scaling for these ratios. If you're on a mobile device, try viewing this on a desktop computer; but not on a Retina or HiDPI display.
</p>
<p>- ナッティ (nutty7t)</p>
</div>
</script>
<script id="chat-message" type="x-template">
<svg xmlns="http://www.w3.org/2000/svg" :viewBox="`0 0 500 ${viewBoxHeight}`" :style="style">
<!--
HACK: This invisible <text> element is used to figure out
the bounding box of a line of text without it being painted.
-->
<text
ref="hackText"
visibility="hidden"
:style="{ fontSize: fontSize + 'px' }">{{ hackText }}</text>
<!-- Buddy Avatar -->
<polygon
v-if="remote"
points="-10,-5 62,5 70,55 5,63"
:transform="`translate(30,${containerHeight / 2 + messageBox.origin.y - 25})`"
style="fill: black"/>
<polygon
v-if="remote"
points="0,0 60,10 70,50 10,60"
:transform="`translate(30,${containerHeight / 2 + messageBox.origin.y - 25})`"
style="fill: white"/>
<clipPath id="avatarClipPath">
<polygon points="2,-10 62,-10 100,42 10,58"/>
</clipPath>
<image
clip-path="url(#avatarClipPath)"
v-if="remote"
x="-10"
y="-10"
xlink:href="https://static.wikia.nocookie.net/megamitensei/images/c/c9/Sumire_Text_Icon.png"
:transform="`translate(30,${containerHeight / 2 + messageBox.origin.y - 25})`"
width="80px"/>
<!-- Message Text Container Border -->
<polygon
:points="containerBorderPoints"
:style="{ fill: primaryColor }"
:class="{ flipX: !remote }"/>
<!-- Message Text Container Tail Border -->
<polygon
:points="containerTailBorderPoints"
:style="{ fill: primaryColor }"
:class="{ flipX: !remote }"/>
<!-- Message Text Container Tail -->
<polygon
:points="containerTailPoints"
:style="{ fill: secondaryColor }"
:class="{ flipX: !remote }"/>
<!-- Message Text Container -->
<polygon
:points="containerPoints"
:style="{ fill: secondaryColor }"
:class="{ flipX: !remote }"/>
<!-- Message Text -->
<text :y="textOffset.y" :style="{ fontSize: fontSize + 'px' }">
<tspan
v-for="line of wrappedMessage"
:x="remote ? messageBox.origin.x + textOffset.x : 500 - messageBox.origin.x - messageBox.centerWidth"
:dy="`${lineHeight}em`"
:style="{ fill: primaryColor }">
{{ line.text }}
</tspan>
</text>
</svg>
</script>
const ChatMessage = {
template: '#chat-message',
props: {
message: {
type: String,
required: true
},
remote: {
// Does the message originate
// from a remote source?
type: Boolean,
default: false
},
fontSize: {
type: Number,
default: 14
},
lineHeight: {
type: Number,
default: 1.5 // em
}
},
data () {
return {
hackText: '',
style: {
opacity: 0
}
}
},
computed: {
// ------------------------------------------
// Message Box (remote: true)
// ------------------------------------------
//
// origin x - right width
// \ [ ---- center width ---- ] |
// x----------------------- x --- x
// / | <message text> | /
// / | | /
// x --- x ---------------------- x
// |
// + - left width
//
messageBox () {
return {
origin: {
x: this.remote ? 130 : 60,
y: 20
},
centerWidth: 300,
leftWidth: 10,
rightWidth: 20,
slantHeight: 5,
border: {
normal: 4,
left: 15,
right: 35
}
}
},
textOffset () {
return {
// Left padding.
x: 15,
// Adjust for top/bottom padding.
y: this.messageBox.origin.y + this.fontSize * this.lineHeight / 4
}
},
containerPoints () {
return [
{
x: this.messageBox.origin.x,
y: this.messageBox.origin.y
},
{
x: this.messageBox.origin.x + this.messageBox.centerWidth + this.messageBox.rightWidth,
y: this.messageBox.origin.y
},
{
x: this.messageBox.origin.x + this.messageBox.centerWidth,
y: this.messageBox.origin.y + this.containerHeight + this.messageBox.slantHeight
},
{
x: this.messageBox.origin.x - this.messageBox.leftWidth,
y: this.messageBox.origin.y + this.containerHeight
}
].map(p => `${p.x},${p.y}`).join(' ')
},
containerBorderPoints () {
return [
{
x: this.messageBox.origin.x - this.messageBox.border.normal,
y: this.messageBox.origin.y - this.messageBox.border.normal
},
{
x: this.messageBox.origin.x + this.messageBox.centerWidth + this.messageBox.border.right,
y: this.messageBox.origin.y - this.messageBox.border.normal
},
{
x: this.messageBox.origin.x + this.messageBox.centerWidth + this.messageBox.border.normal,
y: this.messageBox.origin.y + this.containerHeight + this.messageBox.border.normal + this.messageBox.slantHeight
},
{
x: this.messageBox.origin.x - this.messageBox.border.left,
y: this.messageBox.origin.y + this.containerHeight + this.messageBox.border.normal
}
].map(p => `${p.x},${p.y}`).join(' ')
},
containerTailPoints () {
return [
{
x: this.messageBox.origin.x - 33,
y: this.messageBox.origin.y + this.containerHeight / 2 + 8
},
{
x: this.messageBox.origin.x - 17,
y: this.messageBox.origin.y + this.containerHeight / 2 - 10
},
{
x: this.messageBox.origin.x - 12,
y: this.messageBox.origin.y + this.containerHeight / 2 - 4
},
{
x: this.messageBox.origin.x,
y: this.messageBox.origin.y + this.containerHeight / 2 - 10
},
{
x: this.messageBox.origin.x,
y: this.messageBox.origin.y + this.containerHeight / 2 + 5
},
{
x: this.messageBox.origin.x - 18,
y: this.messageBox.origin.y + this.containerHeight / 2 + 10
},
{
x: this.messageBox.origin.x - 22,
y: this.messageBox.origin.y + this.containerHeight / 2 + 5
}
].map(p => `${p.x},${p.y}`).join(' ')
},
containerTailBorderPoints () {
return [
{
x: this.messageBox.origin.x - 40,
y: this.messageBox.origin.y + this.containerHeight / 2 + 12
},
{
x: this.messageBox.origin.x - 15,
y: this.messageBox.origin.y + this.containerHeight / 2 - 16
},
{
x: this.messageBox.origin.x - 12,
y: this.messageBox.origin.y + this.containerHeight / 2 - 10
},
{
x: this.messageBox.origin.x,
y: this.messageBox.origin.y + this.containerHeight / 2 - 15
},
{
x: this.messageBox.origin.x,
y: this.messageBox.origin.y + this.containerHeight / 2 + 10
},
{
x: this.messageBox.origin.x - 20,
y: this.messageBox.origin.y + this.containerHeight / 2 + 15
},
{
x: this.messageBox.origin.x - 24,
y: this.messageBox.origin.y + this.containerHeight / 2 + 10
}
].map(p => `${p.x},${p.y}`).join(' ')
},
containerHeight () {
// Compute how much vertical space the message text takes up by
// multiplying the line height by the number of lines in the message.
let height = this.fontSize * this.lineHeight * this.wrappedMessage.length
// Now, we need to add some extra bottom padding otherwise the
// descenders (the part of the characters beneath the baseline)
// will get clipped. I don't know the exact height of the descender,
// but I figure that 1/2 em should be fine. And then we'll add another
// 1/4 em for top and bottom paddings (1/2 em in total).
//
// ---
// | top padding (1/4 em)
// ---
// | text height (line height * # of lines)
// ---
// | descender padding (1/2 em)
// ---
// . | slanted bottom edge (this.messageBox.slantHeight)
// ---
// | bottom padding (1/4 em)
// ---
//
return height + this.fontSize * this.lineHeight
},
viewBoxHeight () {
//
// ---
// | border width
// ---
// | container height
// ---
// | border width
// ---
//
return this.containerHeight + this.messageBox.origin.y * 2
},
primaryColor () {
return this.remote ? 'white' : 'black'
},
secondaryColor () {
return this.remote ? 'black' : 'white'
}
},
asyncComputed: {
wrappedMessage: {
async get () {
// Kind of a hacky way of implementing word wrapping
// on SVG <text> elements. Not quite sure how to go
// about determining the bounding box of some text,
// without actually rendering it on the DOM.
const words = this.message.split(/\s+/)
const lines = []
let line = []
while (words.length > 0) {
line.push(words.shift())
this.hackText = line.join(' ')
if (await this.hackTextWidth() > this.messageBox.centerWidth) {
words.unshift(line.pop())
lines.push({ text: line.join(' ') })
line = []
}
}
lines.push({ text: line.join(' ') })
if (lines.length === 1) {
// Messages that are only one line have a fluid width.
this.messageBox.centerWidth = await this.hackTextWidth() + this.textOffset.x * 2
}
return lines
},
default: []
}
},
methods: {
async hackTextWidth () {
// Wait until #hackText is rendered in the DOM.
while (!this.$refs.hackText) {
await Vue.nextTick()
}
// Wait for Vue to update the innerHTML of #hackText.
await Vue.nextTick()
if (this.$refs.hackText.innerHTML === this.hackText) {
return this.$refs.hackText.clientWidth
} else {
console.log(
`[error] hackText does not have expected text\n` +
` expected: "${this.hackText}"\n` +
` actual: "${this.$refs.hackText.innerHTML}"`
)
return 0
}
}
},
mounted () {
TweenMax.to(this.style, 1, {
opacity: 1,
ease: Power3.easeOut
})
}
}
const ChatThread = new Vue({
el: '#chat',
template: '#chat-thread',
components: { ChatMessage },
data () {
return {
messages: [],
queue: [
{
text: 'Hello world!',
remote: true
},
{
text: 'I am thou, thou art I... Thou hast acquired a new vow. It shall become the wings of rebellion that breaketh thy chains of captivity. With the birth of the Nutty Persona, I have obtained the winds of blessing that shall lead to freedom and new power...',
remote: true
},
{
text: 'Whoooaaa! Looking cool, Joker!',
remote: false
},
{
text: 'Want to head over to the palace today?',
remote: false
},
{
text: 'Sure!',
remote: true
},
{
text: 'Kamoshida needs to atone for his actions...',
remote: true
},
{
text: 'If we steal his treasure, his heart will change in the real world. We can make him confess his sins.',
remote: false
}
],
interval: undefined
}
},
watch: {
async messages (newMessages, oldMessages) {
if (this.queue.length === 0) {
clearInterval(this.interval)
}
await Vue.nextTick()
const messages = this.$refs.chatMessages
const lastMessage = messages[messages.length - 1]
// TODO: Draw the message trail.
if (document.body !== null) {
TweenMax.to(window, 1, { scrollTo: { y: document.body.scrollHeight }, ease: Power3.easeOut });
}
}
},
mounted () {
this.interval = setInterval(() => {
this.messages.push(this.queue.shift())
}, 2000)
}
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.10/vue.min.js"></script>
<script src="https://unpkg.com/vue-async-computed@3.7.0"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/2.1.3/TweenMax.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/latest/plugins/ScrollToPlugin.min.js"></script>
@import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro&display=swap');
html {
background: #cc0000;
font-family: 'Source Sans Pro', sans-serif;
font-weight: 700;
}
body {
max-width: 600px;
margin: auto;
}
.flipX {
transform: scaleX(-1);
transform-origin: center;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment